API Reference
The “look it up” page for all public methods, options, and the curve definition schema.
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
| Option | Type | Default | Description |
|---|---|---|---|
| trailLength | number | 120 | Number of points in trail |
| skeletonColor | string | '#ffffff' | Color string for the skeleton path. Use "transparent" to hide |
| trailColor | string | string[] | '#ffffff' | Color string for solid trails, or array of color strings for gradients |
| headColor | string | derived | Color string for the head dot. Omit to auto-follow trail color |
| headRadius | number | 4 | Radius of the head dot in pixels |
| trailWidth | number | 1 | Multiplier scaling the ribbon trail width. 2 = twice as thick, 0.5 = half width. Taper shape is preserved. |
| trailStyle | TrailStyle | 'default' | 'default' | 'gradient-static' | 'gradient-animated' |
| autoStart | boolean | true | Start the animation loop automatically on creation |
| initialPhase | number | undefined | Initial position along the curve (phase value). Calls seek(initialPhase) before first frame |
| pauseOnHidden | boolean | true | Pauses 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
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
| Option | Type | Default | Description |
|---|---|---|---|
cols | number | 32 | Number of dot columns |
rows | number | 32 | Number of dot rows |
roundness | number | 1 | Dot shape. 0 = sharp square, 1 = full circle. Values between give rounded rectangles. |
trailLength | number | cols x 3 | Number of trail points to keep. Larger values extend the tail further back. |
trailColor | string | string[] | '#ffffff' | Dot color. Single string for solid mode; array of 2+ strings for gradient mode. Accepts hex, rgb(), rgba(), and oklch(). |
trailStyle | TrailStyle | '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. |
autoStart | boolean | true | Start the animation loop automatically on creation |
initialPhase | number | undefined | Initial position along the curve. Calls seek(initialPhase) before the first frame. |
pauseOnHidden | boolean | true | Pauses 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.
setRenderOptionssupportstrailColorandtrailStyleonly. Fields likeheadColor,skeletonColor,headRadius, andtrailWidththrow 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. Aconsole.warnfires when points extend beyond ±2 to alert you of potential off-screen rendering. - All points identical produces a static dot. A
console.warnfires to alert you of this degenerate input. - The returned
CurveDefhasperiod: 2πand can be passed tocreateSarmal,createSarmalSVG,morphTo, or the lower-level engine constructor.
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 2π, 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();
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.
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();
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
};
}, []);
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:
| Option | Type | Default | Description |
|---|---|---|---|
clearTrail | boolean | false | Clear 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:
| Option | Type | Default | Description |
|---|---|---|---|
wrap | boolean | false | Trail wraps around period boundary for a full trail everywhere |
step | number | period / trailLength | Time 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.
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)
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:
speedmust be a finite numberdurationmust be a finite number greater than0
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:
| Option | Type | Description |
|---|---|---|
trailColor | string | string[] | Single color string for solid trails, or array of color strings for gradients |
headColor | string | null | Color string to override, or null to let it auto pick from trail color |
skeletonColor | string | Color string, or "transparent" to hide |
trailStyle | TrailStyle | 'default' | 'gradient-static' | 'gradient-animated' |
headRadius | number | Radius of the head dot. Canvas: CSS pixels auto-derived from container size by default. SVG: viewBox units (default 1.5). |
trailWidth | number | Multiplier scaling the ribbon trail width. Positive finite number. |
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:
| Option | Type | Default | Description |
|---|---|---|---|
duration | number | 300 | Duration of the morph transition in milliseconds |
morphStrategy | 'normalized' | 'raw' | 'normalized' | Strategy for lerping between curves with different periods |
easing | (t: number) => number | easeInOutCubic | Eases 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 |
align | boolean | false | When 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) * periodBsmooth for all period ratios'raw': Sametfor 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 becomescurveAfor 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 secondsparams: Animated parameters object
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
| Mode | Description |
|---|---|
'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. |
skeletonFn | Override function for computing a skeleton independent of fn. |
Not in the schema: Color, trail style, rendering options, trailLength, and scale are renderer concerns, not curve concerns.
Trail Styles
| Style | Description |
|---|---|
'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,
});
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.
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
-
Mutable trail buffer: The internal trail array is reused each frame. If you need to keep previous frame values, copy with
[...trail]. -
jump()is position-only: Updatesphasebut NOTactualTime. Clock time only advances with the internal animation loop. Useseek()if you needactualTimeto match. -
seekstep: Default isperiod / trailLength, which is deterministic and FPS-independent. Pass explicitstepto match your actual render loop delta. -
Live skeleton drift: Curves with
skeleton: 'live'drift based onactualTime. At speed ≠ 1, skeleton and trail may misalign. This is a known limitation. -
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.
-
Canvas sizing is fixed at init:
createSarmal()captures canvas dimensions at creation. If the canvas is resized after init, you will need to calldestroy()and re-create. -
Negative speed: Reverses
phasetraversal direction. Trail extends in the opposite direction. Supported but not polished. -
setSpeed(0)vspause():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. -
pause()during a speed transition: The pendingsetSpeedOver()Promise rejects with'Speed transition cancelled', butuserSpeedOverrideis not cleared. Onplay(), the animation does not resume with curve’s default, but with the interpolated speed from when it had paused.- Call
resetSpeed()afterpause()to return to the natural rhythm.
- Call
-
Post-
destroy()calls throw: Afterdestroy(), 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;. Usepause()instead ofdestroy()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;