Menu

API Reference

The “look it up” page for all public methods, options, and the curve definition schema.

Tip
This reference assumes familiarity with the core concepts. See Gotchas for common pitfalls

createSarmal

Creates a canvas-based sarmal instance that start animating automatically. Returns SarmalInstance.

import { createSarmal, curves } from "@sarmal/core";

const canvas = document.getElementById("loading-indicator") as HTMLCanvasElement;
const sarmal = createSarmal(canvas, curves.rose3);

Options

OptionTypeDefaultDescription
trailLengthnumber120Number of points in trail
skeletonColorstring'#ffffff'Color string for the skeleton path. Use "transparent" to hide
trailColorstring | string[]'#ffffff'Color string for solid trails, or array of color strings for gradients
headColorstringderivedColor string for the head dot. Omit to auto-follow trail color
headRadiusnumber4Radius of the head dot in pixels
trailWidthnumber1Multiplier scaling the ribbon trail width. 2 = twice as thick, 0.5 = half width. Taper shape is preserved.
trailStyleTrailStyle'default''default' | 'gradient-static' | 'gradient-animated'
autoStartbooleantrueStart the animation loop automatically on creation
initialPhasenumberundefinedInitial position along the curve (phase value). Calls seek(initialPhase) before first frame
pauseOnHiddenbooleantruePauses when the browser tab is hidden and resumes when visible

Initial state

By default, sarmal instances start animating immediately from phase=0. You can control this behavior:

// Start automatically from phase=0 (default behavior)
const s1 = createSarmal(canvas, curves.rose3);

// Start automatically from phase=Math.PI
const s2 = createSarmal(canvas, curves.rose5, { initialPhase: Math.PI });

// Create dormant at phase=0; call play() when ready
const s3 = createSarmal(canvas, curves.deltoid, { autoStart: false });
s3.play();

// Create dormant at phase=Math.PI
const s4 = createSarmal(canvas, curves.lissajous32, {
  autoStart: false,
  initialPhase: Math.PI
});

// Canvas shows skeleton + trail at phase=π position
s4.play();

createSarmalSVG

Same API as createSarmal. First argument is an <svg> element. The renderer takes ownership of the targeted svg element.

import { createSarmalSVG, curves } from "@sarmal/core";

const svg = document.getElementById("loading-indicator")!;
const sarmal = createSarmalSVG(svg, curves.lissajous32);
// Animation starts automatically
Info

trailStyle and palette work identically in both canvas and SVG renderers.

createSarmalDotMatrix

A canvas renderer that maps the sarmal trail onto a grid of dots instead of a ribbon. Each frame, dots near the head are fully lit, dots near the tail are dim, and off-trail dots appear as faint background points. The grid density and dot shape are configurable.

Returns SarmalInstance, which is the same interface as createSarmal and createSarmalSVG.

import { createSarmalDotMatrix, curves } from "@sarmal/core";

const canvas = document.getElementById("loading-indicator") as HTMLCanvasElement;
const sarmal = createSarmalDotMatrix(canvas, curves.lissajous43, {
  cols: 32,
  rows: 32,
  trailColor: "#2dd4bf",
});

Options

OptionTypeDefaultDescription
colsnumber32Number of dot columns
rowsnumber32Number of dot rows
roundnessnumber1Dot shape. 0 = sharp square, 1 = full circle. Values between give rounded rectangles.
trailLengthnumbercols x 3Number of trail points to keep. Larger values extend the tail further back.
trailColorstring | string[]'#ffffff'Dot color. Single string for solid mode; array of 2+ strings for gradient mode. Accepts hex, rgb(), rgba(), and oklch().
trailStyleTrailStyle'default''default' is a solid color, alpha by intensity. 'gradient-static' is color sampled from trailColor gradient per dot. 'gradient-animated' is the same but the gradient phase shifts over time. Requires trailColor array.
autoStartbooleantrueStart the animation loop automatically on creation
initialPhasenumberundefinedInitial position along the curve. Calls seek(initialPhase) before the first frame.
pauseOnHiddenbooleantruePauses when the browser tab is hidden and resumes when visible

Differences

  • No skeleton. The dot-matrix renderer draws no background path outline.
  • No ribbon trail. The trail is expressed as dot brightness, not a tapered ribbon.
  • setRenderOptions supports trailColor and trailStyle only. Fields like headColor, skeletonColor, headRadius, and trailWidth throw if passed. The dot matrix renderer has no head dot, no skeleton, and no ribbon.
// Change dot color on a live instance
sarmal.setRenderOptions({ trailColor: "#f87171" });

// Enable gradient mode: each dot's color is sampled from the palette by trail position
sarmal.setRenderOptions({ trailColor: palettes.ice, trailStyle: "gradient-static" });

drawCurve

Creates a CurveDef from drawn control points. The points form a closed loop. The last point connects back to the first, and the resulting closed Catmull-Rom spline passes smoothly through every point.

import { createSarmal, drawCurve } from "@sarmal/core";

const curve = drawCurve([
  [-0.5,  0.3],
  [ 0.2, -0.8],
  [ 0.7,  0.4],
], { name: "my-shape" }); // name is optional, defaults to "drawn"

createSarmal(canvas, curve);

Coordinate system

Points use a normalized grid ranging from -1 to 1 on both axes. (0, 0) is the exact center of whatever container you render into. (-1, -1) is the top-left corner, (1, 1) the bottom-right.

There is no fixed pixel size. The renderer scales the curve to fit any canvas or SVG element.

This is the same coordinate system the playground’s draw mode uses, so points you place by hand map directly into this function without any conversion.

Requirements

  • At least 3 points. Throws if fewer are passed.
  • Points are copied at construction. Mutating the original array afterward has no effect on the curve.
  • Points can be anywhere within the normalized range. Values outside [-1, 1] are valid but may push the curve outside the visible area. A console.warn fires when points extend beyond ±2 to alert you of potential off-screen rendering.
  • All points identical produces a static dot. A console.warn fires to alert you of this degenerate input.
  • The returned CurveDef has period: 2π and can be passed to createSarmal, createSarmalSVG, morphTo, or the lower-level engine constructor.
Tip
The built-in Artemis II curve is a real-world example of a drawCurve-based curve. It has 21 control points defining a Catmull-Rom spline. Its source in @sarmal/core/curves/artemis2 shows the pattern for making a drawn curve a reusable built-in.

evaluateCatmullRom

The underlying spline evaluator is also exported. It returns the (x, y) position at any parametric t along the closed loop, which can be useful for computing your own geometry from the same point set:

import { evaluateCatmullRom } from "@sarmal/core";

const position = evaluateCatmullRom(points, Math.PI); // halfway through the loop

t wraps modulo , so values outside that range are valid and remapped automatically.


Instance Methods

play()

Starts the animation loop (requestAnimationFrame). If already running, does nothing.

sarmal.play();
state
playing

pause()

Pauses the animation loop (cancelAnimationFrame) and cancels any speed transition. When paused, the state of t, trail, and speed are all preserved. Call play() to resume.

Info

pause() is different from setSpeed(0). pause() stops the RAF loop entirely and saves CPU. setSpeed(0) freezes the animation but keeps the loop running.

sarmal.pause();

reset()

Resets the engine and clears the trail. The next frame will start fresh from the beginning of the curve. The animation loop keeps running.

sarmal.reset();
playing
reset() clears the trail and resets t to 0. The loop keeps running.

destroy()

Permanently stops the animation and clears the visual output. Calling any instance method after destroy() throws. destroy() itself is safe to call multiple times. For temporary suspension, use pause() and continue afterwards with play().

// React example
useEffect(() => {
  const sarmal = createSarmal(canvas, curves.rose3);

  return () => {
    sarmal.destroy(); // Cleanup on unmount
  };
}, []);
playing
Permanently stops the animation and clears the visual output. Use pause() for temporary suspension.

Engine Control

jump()

Instantly moves the head to position phase. Does not update actualTime. Trail is left untouched by default, but you can use clearTrail: true to wipe it.

Use jump when you need a raw position override: morphing mid-flight, scrubbing, or situations where the existing trail context should stay intact.

// Teleport to halfway through the curve
sarmal.jump(Math.PI);

// Teleport and clear the trail for a blank start
sarmal.jump(0, { clearTrail: true });

Options:

OptionTypeDefaultDescription
clearTrailbooleanfalseClear the trail on jump

seek()

Moves to phase and reconstructs the trail as if the animation naturally arrived there from phase=0. Also updates actualTime to match.

Use seek when you want the trail to look meaningful after the move, like making a jump where keeping the trail context matters.

// Seek to t=2 with a natural-looking trail
sarmal.seek(2);

// Wrap trail around period boundary for a full trail near t=0
sarmal.seek(0.5, { wrap: true });

// Custom step size (default: period / trailLength)
sarmal.seek(1, { step: 0.1 });

Options:

OptionTypeDefaultDescription
wrapbooleanfalseTrail wraps around period boundary for a full trail everywhere
stepnumberperiod / trailLengthTime gap between trail points (deterministic, FPS-independent)

Animation Control

setSpeed()

Overrides the animation speed. Returns void. The override persists until cleared with resetSpeed() or replaced with another call.

// Slow to half speed
sarmal.setSpeed(0.5);

// Speed up to twice the speed
sarmal.setSpeed(2.0);

// Freeze in place while keeping the loop (`t`) advancing
sarmal.setSpeed(0);

// Reverse direction
sarmal.setSpeed(-1);

Validation: speed must be a finite number. 0 and negative values are valid.

Warning

setSpeed(0) freezes the animation but does not stop the loop. Use pause() if you need to cancel the requestAnimationFrame entirely and save CPU.

getSpeed()

Returns the effective speed the engine is currently using. When setSpeed() has been called, returns that value. Otherwise, returns curve.speed, which is the default value from the CurveDef.

sarmal.setSpeed(0.5);
sarmal.getSpeed(); // 0.5

sarmal.resetSpeed();
sarmal.getSpeed(); // 1 (or whatever curve.speed is)

resetSpeed()

Clears the speed override, returning to the curve’s default speed. This is the correct way to “undo” a speed change without knowing the curve’s default.

sarmal.setSpeed(2.0);
sarmal.getSpeed(); // 2.0

sarmal.resetSpeed();
sarmal.getSpeed(); // curve.speed (e.g., 1)
Info

resetSpeed() removes the override layer entirely. It defers to the curve dynamically, so future curve changes are reflected in getSpeed() automatically.

setSpeedOver()

Transitions to a new speed over a duration, returning a Promise<void> that resolves when complete. Uses linear interpolation between current and target speed.

// Decelerate to 0.2× over 500ms
await sarmal.setSpeedOver(0.2, 500);

// Then resume normal speed
await sarmal.setSpeedOver(1.0, 300);

Cancellation: Calling setSpeed(), setSpeedOver(), or pause() while a transition is in progress cancels it. The Promise rejects with Error('Speed transition cancelled'). A subsequent setSpeedOver() starts from the current interpolated speed.

To stop with a graceful deceleration, you can make use of two explicit steps:

await sarmal.setSpeedOver(0, 400); // decelerate over 400ms
sarmal.pause();                     // then pause the loop

Validation:

  • speed must be a finite number
  • duration must be a finite number greater than 0

setRenderOptions()

Changes colors and trail style on a live Sarmal instance without destroying and recreating it. Render options are independent of engine state. They do not affect and are not affected by morphing, seeking, jumping, speed changes, pause/play, or reset.

// Theme change: switch the whole palette
sarmal.setRenderOptions({ trailColor: palettes.sunset });

// Error state: flip to a solid red.
sarmal.setRenderOptions({ trailStyle: "default", trailColor: "#c0143c" });

// Override head color to make it independent of trailColor
sarmal.setRenderOptions({ headColor: "#ffffff" });

// Make head color auto inheirt from trailColor again
sarmal.setRenderOptions({ headColor: null });

// Hide skeleton
sarmal.setRenderOptions({ skeletonColor: "transparent" });

Options:

OptionTypeDescription
trailColorstring | string[]Single color string for solid trails, or array of color strings for gradients
headColorstring | nullColor string to override, or null to let it auto pick from trail color
skeletonColorstringColor string, or "transparent" to hide
trailStyleTrailStyle'default' | 'gradient-static' | 'gradient-animated'
headRadiusnumberRadius of the head dot. Canvas: CSS pixels auto-derived from container size by default. SVG: viewBox units (default 1.5).
trailWidthnumberMultiplier scaling the ribbon trail width. Positive finite number.
Info

Accepted color formats: #rrggbb, #rgb, #rrggbbaa (alpha stripped), rgb(), rgba() (alpha stripped), oklch(L C H) (CSS Color 4, bare floats only). Note that named colors ("red"), hsl(), unit suffixes on oklch (30deg), and percentage channels are not accepted.

Validation: Throws an error if any field fails validation. In case of a validation error, no fields will be mutated, so the animation continues on the previous valid state.

Morphing

morphTo()

Smooth transition between curves. Returns Promise<void> that resolves when morph completes.

// Basic morph over default duration (300ms)
await sarmal.morphTo(curves.deltoid);

// Morph with custom duration
await sarmal.morphTo(curves.rose3, { duration: 500 });

// Use 'raw' strategy for different period handling
await sarmal.morphTo(curves.lissajous32, { morphStrategy: "raw" });

// Pass a custom easing, or a linear ramp to opt out of the default ease
await sarmal.morphTo(curves.rose3, { easing: (t) => t });

// Start the target from the point nearest the current head (removes the start "snap")
await sarmal.morphTo(curves.epicycloid3, { align: true });

// Chain morphs
await sarmal.morphTo(curves.astroid);
await sarmal.morphTo(curves.rose5);
await sarmal.morphTo(curves.artemis2);

Options:

OptionTypeDefaultDescription
durationnumber300Duration of the morph transition in milliseconds
morphStrategy'normalized' | 'raw''normalized'Strategy for lerping between curves with different periods
easing(t: number) => numbereaseInOutCubicEases the morph’s progress over time. Receives raw linear progress (0 to 1) and returns the eased progress (0 to 1) that drives the blend. Pass (t) => t for a constant-rate ramp
alignbooleanfalseWhen true, the target curve starts from the point nearest the current head instead of its own phase 0

Morph strategies:

  • 'normalized' (default): tB = (t / periodA) * periodB smooth for all period ratios
  • 'raw': Same t for both curves, which can produce chaotic results with mismatched periods

Phase alignment (align):

By default the target curve begins from its own phase 0, which can make the head appear to “snap” to a distant point when the morph starts. Setting align: true instead starts the target from the point physically nearest the current head, removing that snap.

It is off by default because it is rather a situational win for asymmetric transitions. On symmetric shapes like a rose, multiple petals can be equally close, so the pick may feel arbitrary. Turn it on per-morph only when you see the head snap and the transition would benefit.

Behavior notes:

  • Calling morphTo() mid-morph resolves the previous Promise immediately. The current interpolated state becomes curveA for the new morph while avoiding a visual jump
  • The transition flow for speed and morph run independently

Curve Schema

interface CurveDef {
  name: string;                      // Required: identifier for the curve
  fn: (phase, elapsed, params) => Point;    // Required: parametric function
  period?: number;                   // Default: 2π
  speed?: number;                    // Default: 1 (radians per second)
  skeleton?: 'static' | 'live';      // Default: 'static'
  skeletonFn?: (t: number) => Point; // Optional stable-shape override
}

Curve function

  • phase: Position along curve (0 period)
  • elapsed: Actual elapsed seconds
  • params: Animated parameters object
Warning

params is reserved for future implementations and currently always uses {} internally

const myCurve: CurveDef = {
  name: "circle",
  fn: (phase, elapsed, params) => ({
    x: Math.cos(t),
    y: Math.sin(t),
  }),
  period: Math.PI * 2,
  speed: 1,
};

Skeleton modes

ModeDescription
'static'Skeleton is computed once at initialization from fn(phase, 0) and cached. Use for curves with fixed shapes.
'live'Skeleton is recomputed each frame using fn(phase, actualTime). Use for curves whose shape drifts over time.
skeletonFnOverride function for computing a skeleton independent of fn.
Info

See Concepts for detailed explanation of skeleton modes and when to use each.

Not in the schema: Color, trail style, rendering options, trailLength, and scale are renderer concerns, not curve concerns.

Trail Styles

StyleDescription
'default'Solid trail with opacity fade
'gradient-static'Head-to-tail color transition, fixed
'gradient-animated'Colors flow along trail continuously
// Solid color trail (string)
const sarmal = createSarmal(canvas, curves.rose3, {
  trailStyle: "default",
  trailColor: "#3b82f6",
});

// Static gradient from head to tail (array)
const sarmal = createSarmal(canvas, curves.deltoid, {
  trailStyle: "gradient-static",
  trailColor: palettes.ice,
});

// Animated flowing colors (array)
const sarmal = createSarmal(canvas, curves.astroid, {
  trailStyle: "gradient-animated",
  trailColor: palettes.bard,
});
Warning

Mismatched combinations (e.g. an array of colors with "default" trail style, or a color string with gradient styles) still produce a valid render, but output a console warning to inform that there is a mismatch. The selected trailStyle determines whether to use a single color, or the whole array (if available).

Built-in Palettes

These color combinations are exposed as the palettes named export. When in gradient trail style mode, pass any palette to trailColor for gradient trails.

Built-in Palettes
bard
#a855f7
#3b82f6
#14b8a6
#ec4899
carnival
#ff6b6b
#4ecdc4
#ffe66d
ocean
#1e3a8a
#06b6d4
#22d3ee
#e0f2fe
sunset
#f97316
#dc2626
#9333ea
#f472b6
ice
#1e3a8a
#67e8f9
rocketpop
#08b8cd
#ffffff
#ff001f
neon
#00e5ff
#7c3aed
#e040fb
vaporwave
#ff71ce
#01cdfe
#b967ff
pastel
#c4b5fd
#fbcfe8
#bae6fd
sakura
#fff1f2
#fda4af
#fb7185
import { createSarmal, curves, palettes } from "@sarmal/core";

// Use a preset
const sarmal = createSarmal(canvas, curves.rose3, {
  trailStyle: "gradient-animated",
  trailColor: palettes.sunset,
});

// Custom palette
const sarmal = createSarmal(canvas, curves.lissajous32, {
  trailStyle: "gradient-static",
  trailColor: ["#ff6b6b", "#ffd93d", "#6bcb77"],
});

Gotchas

  1. Mutable trail buffer: The internal trail array is reused each frame. If you need to keep previous frame values, copy with [...trail].

  2. jump() is position-only: Updates phase but NOT actualTime. Clock time only advances with the internal animation loop. Use seek() if you need actualTime to match.

  3. seek step: Default is period / trailLength, which is deterministic and FPS-independent. Pass explicit step to match your actual render loop delta.

  4. Live skeleton drift: Curves with skeleton: 'live' drift based on actualTime. At speed ≠ 1, skeleton and trail may misalign. This is a known limitation.

  5. Trail bunching/spreading: Speed changes affect point spacing immediately. Slowing down means points bunch up together; speed up means the points spread apart. This is the currently intended behavior, not a bug.

  6. Canvas sizing is fixed at init: createSarmal() captures canvas dimensions at creation. If the canvas is resized after init, you will need to call destroy() and re-create.

  7. Negative speed: Reverses phase traversal direction. Trail extends in the opposite direction. Supported but not polished.

  8. setSpeed(0) vs pause(): setSpeed(0) freezes the animation but the RAF loop keeps running, during which the sarmal remains reactive. pause() cancels the loop entirely and saves CPU. They look identical on screen but have different resource implications.

  9. pause() during a speed transition: The pending setSpeedOver() Promise rejects with 'Speed transition cancelled', but userSpeedOverride is not cleared. On play(), the animation does not resume with curve’s default, but with the interpolated speed from when it had paused.

    • Call resetSpeed() after pause() to return to the natural rhythm.
  10. Post-destroy() calls throw: After destroy(), calling any method on the instance throws an error. In component cleanup handlers, null the reference after destroying to make accidental access obvious: sarmal.destroy(); sarmal = null;. Use pause() instead of destroy() if you plan to resume the animation.

TypeScript Types

// Core types
interface Point {
  x: number;
  y: number;
}

interface CurveDef {
  name: string;
  fn: (phase: number, elapsed: number, params: Record<string, number>) => Point;
  period?: number;
  speed?: number;
  skeleton?: "static" | "live";
  skeletonFn?: (phase: number) => Point;
}

interface SarmalInstance {
  play(): void;
  pause(): void;
  reset(): void;
  /** Permanently stops the animation and clears the visual output. Idempotent. Post-destroy calls throw. */
  destroy(): void;
  jump(phase: number, options?: { clearTrail?: boolean }): void;
  seek(phase: number, options?: { wrap?: boolean; step?: number }): void;
  setSpeed(speed: number): void;
  getSpeed(): number;
  resetSpeed(): void;
  setSpeedOver(speed: number, duration: number): Promise<void>;
  morphTo(target: CurveDef, options?: {
    duration?: number;
    morphStrategy?: "normalized" | "raw";
    easing?: (t: number) => number;
    align?: boolean;
  }): Promise<void>;
  setRenderOptions(partial: RuntimeRenderOptions): void;
}

type TrailStyle = "default" | "gradient-static" | "gradient-animated";
type TrailColor = string | string[];

interface RuntimeRenderOptions {
  trailColor?: TrailColor;
  headColor?: string | null;
  skeletonColor?: string;
  trailStyle?: TrailStyle;
  headRadius?: number;
}

const palettes: {
  bard: string[];
  sunset: string[];
  ocean: string[];
  ice: string[];
  rocketpop: string[];
  neon: string[];
  carnival: string[];
  vaporwave: string[];
  pastel: string[];
  sakura: string[];
};

function drawCurve(points: [number, number][]): CurveDef;

function evaluateCatmullRom(
  points: [number, number][],
  t: number,
): Point;

interface DotMatrixSarmalOptions {
  cols?: number;           // default: 32
  rows?: number;           // default: 32
  roundness?: number;      // 0–1, default: 1 (circle)
  trailLength?: number;    // default: cols * 3
  trailColor?: string | string[];  // default: '#ffffff'
  trailStyle?: TrailStyle; // default: 'default'
  autoStart?: boolean;     // default: true
  initialPhase?: number;
  pauseOnHidden?: boolean; // default: true
}

function createSarmalDotMatrix(
  canvas: HTMLCanvasElement,
  curveDef: CurveDef,
  options?: DotMatrixSarmalOptions,
): SarmalInstance;