Menu

Frameworks

Framework integration guides for React and CDN usage.

React

The @sarmal/react package provides a thin React wrapper over @sarmal/core. It gives you a <Sarmal> component for drop-in usage and a useSarmal hook for imperative control.

Installation

npm install @sarmal/core @sarmal/react

The Component

The quickest way to get an animation on screen in React:

import { Sarmal } from "@sarmal/react";
import { rose5 } from "@sarmal/core";

function App() {
  return <Sarmal curve={rose5} />;
}

The component renders a <canvas> element. The canvas buffer dimensions must match the display size. You can either pass width and height props directly, or wrap in a parent container with explicit dimensions.

// Explicit dimensions
<Sarmal curve={curves.rose3} width={200} height={200} />

// Or a sized parent
<div style={{ width: 200, height: 200 }}>
  <Sarmal curve={curves.rose3} />
</div>

If neither is provided, the canvas falls back to 300x300 with a console warning. width and height are set during initialization. Changing them after mount destroys and recreates the instance.

Props

PropTypeDefaultDescription
curve*CurveDef-Required. The curve definition. Must be a stable reference (module-level const or useMemo).
classNamestring-Applied to the <canvas> element
styleCSSProperties-Applied to the <canvas> element
widthnumber-Canvas buffer width in CSS pixels. Init-only.
heightnumber-Canvas buffer height in CSS pixels. Init-only.
autoStartbooleantrueStart the animation loop automatically. Init-only.
initialPhasenumberundefinedInitial position along the curve. Init-only.
skeletonColorstring'#ffffff'Hex color for the skeleton path
trailColorstring | string[]'#ffffff'Hex color or array of hex colors for gradient trails
headColorstringderivedHex color for the head dot. Omit to auto-follow trail color
headRadiusnumber4Radius of the head dot in pixels. Changeable at runtime via setRenderOptions.
trailLengthnumber120Number of trail points. Init-only.
trailStyleTrailStyle'default''default' | 'gradient-static' | 'gradient-animated'
morphDurationnumber300Duration of the morph transition when curve changes (ms)
morphStrategy'normalized' | 'raw''normalized'Phase-mapping strategy for the curve-change morph
morphEasing(t: number) => numbereaseInOutCubicEases the curve-change morph. Pass (t) => t for a constant-rate ramp
morphAlignbooleanfalseStart the new curve from the point nearest the current head, removing the start “snap”
onReady(instance) => void-Fired once after the instance is created. Store the reference if you need imperative control.

Curve stability

The curve prop must be a stable reference.

Do not define it inline

The component cannot deep-compare curves because they contain functions.

Use a module-level constant or useMemo:

// [GOOD] module-level constant
import { curves } from "@sarmal/core";

<Sarmal curve={curves.rose3} />

// [GOOD] memoized custom curve
const myCurve = useMemo(
  () => ({
    name: "circle",
    fn: (phase) => ({ x: Math.cos(phase), y: Math.sin(phase) }),
  }),
  [],
);

<Sarmal curve={myCurve} />

// [BAD] new object every render, causes infinite morph loop
<Sarmal
  curve={{
    name: "circle",
    fn: (phase) => ({ x: Math.cos(phase), y: Math.sin(phase) }),
  }}
/>

Curve morphing

When the curve prop changes, it smoothly transitions using morphTo. The morphDuration prop controls how long the transition takes:

const [curve, setCurve] = useState(curves.astroid);

// Later...
setCurve(curves.deltoid); // morphs over 300ms (default)

// Custom duration:
<Sarmal curve={curve} morphDuration={800} />

Imperative control

Use the onReady callback to get the SarmalInstance for manual control:

const sarmalRef = useRef<SarmalInstance | null>(null);

<Sarmal
  curve={curves.rose3}
  onReady={(inst) => {
    sarmalRef.current = inst;
  }}
/>;

// Later:
sarmalRef.current?.pause();
sarmalRef.current?.setSpeed(0.5);

useSarmal Hook

For full control over the canvas element and instance lifecycle, you may prefer to use the hook:

import { useSarmal } from "@sarmal/react";
import { curves } from "@sarmal/core";

function MyLoader() {
  const { canvasRef, instance } = useSarmal(curves.astroid);

  return <canvas ref={canvasRef} style={{ width: 200, height: 200 }} />;
}

The hook returns:

  • canvasRef: attach to your <canvas> element
  • instance: a useRef holding the SarmalInstance. Access with instance.current for imperative calls (play, pause, setSpeed, morphTo, etc.)

Hook signature

function useSarmal(
  curve: CurveDef,
  options?: Partial<SarmalOptions>,
  init?: {
    width?: number;
    height?: number;
    trailLength?: number;
    headRadius?: number;
    autoStart?: boolean;
    initialPhase?: number;
  },
  morphOptions?: {
    morphDuration?: number;
    morphStrategy?: "normalized" | "raw";
    morphEasing?: (t: number) => number;
    morphAlign?: boolean;
  },
): {
  canvasRef: React.RefObject<HTMLCanvasElement | null>;
  instance: React.RefObject<SarmalInstance | null>;
}
Warning

Runtime visual options (trailColor, headColor, skeletonColor, trailStyle) passed in options are applied at creation but not tracked for changes.

For reactive updates after mount, call instance.current.setRenderOptions(...) directly. Init options (width, height, trailLength, autoStart, initialPhase) are tracked and trigger destroy+recreate on change. headRadius is also tracked as an init option (prop changes recreate), but can be changed at runtime with setRenderOptions without a recreate.

Server Side Rendering

@sarmal/react emits "use client" at the top of its bundle output. createSarmal is only ever called inside useEffect, which never runs on the server.

Next.js App Router: Importing @sarmal/react inside a Server Component will fail. Add "use client" to the file that imports it, or wrap the <Sarmal> usage in a dedicated ClientWrapper.tsx that is itself marked "use client".

This also applies when a curve definition is passed as a prop from a server component to a client child. CurveDef contains a function (fn) which React cannot serialize across the server/client boundary. The fix is the same: move the curve import and the component that uses it into a file that has "use client".

// ClientWrapper.tsx
"use client";

import { Sarmal } from "@sarmal/react";
import { curves } from "@sarmal/core";

export default function ClientWrapper() {
  return <Sarmal curve={curves.astroid} width={200} height={200} />;
}

SVG Output

For SVG rendering, use <SarmalSVG> and useSarmalSVG. The API mirrors the canvas variants with these differences:

  • The <svg> element has no width/height attributes set. Size it entirely with CSS.
  • The renderer sets viewBox="0 0 100 100" automatically, so coordinates are normalized.
  • className and style apply to the <svg> element.
  • All init-time props are available: trailLength, headRadius, autoStart, initialPhase.
// Component
import { SarmalSVG } from "@sarmal/react";

<SarmalSVG curve={curves.rose3} style={{ width: 200, height: 200 }} />

// Hook
import { useSarmalSVG } from "@sarmal/react";

function MyLoader() {
  const { svgRef, instance } = useSarmalSVG(curves.astroid);
  return <svg ref={svgRef} />;
}

Svelte

The @sarmal/svelte package provides a thin Svelte 5 wrapper over @sarmal/core. It ships three exports per renderer: a component, a composable, and an action.

Installation

npm install @sarmal/core @sarmal/svelte

The Component

The quickest way to get an animation on screen in Svelte 5:

<script>
  import { Sarmal } from "@sarmal/svelte";
  import { curves } from "@sarmal/core";
</script>

<Sarmal curve={curves.rose3} />

The component renders a <canvas> element. Like the React wrapper, you can pass explicit width and height props or rely on a sized parent container.

<!-- Explicit dimensions -->
<Sarmal curve={curves.rose3} width={200} height={200} />

<!-- Or a sized parent -->
<div style="width: 200px; height: 200px;">
  <Sarmal curve={curves.rose3} />
</div>

If neither is provided, the canvas falls back to 300x300 with a console warning. width and height are set during initialization. Changing them after mount destroys and recreates the instance.

Props

PropTypeDefaultDescription
curve*CurveDef-Required. The curve definition. Must be a stable reference (module-level const).
classstring-Applied to the <canvas> element
stylestring-Applied to the <canvas> element
widthnumber-Canvas buffer width in CSS pixels. Init-only.
heightnumber-Canvas buffer height in CSS pixels. Init-only.
autoStartbooleantrueStart the animation loop automatically. Init-only.
initialPhasenumberundefinedInitial position along the curve. Init-only.
skeletonColorstring'#ffffff'Hex color for the skeleton path
trailColorstring | string[]'#ffffff'Hex color or array of hex colors for gradient trails
headColorstringderivedHex color for the head dot. Omit to auto-follow trail color
headRadiusnumber4Radius of the head dot in pixels. Init-only. Changeable at runtime via setRenderOptions.
trailLengthnumber120Number of trail points. Init-only.
trailStyleTrailStyle'default''default' | 'gradient-static' | 'gradient-animated'
morphDurationnumber300Duration of the morph transition when curve changes (ms)
morphStrategy'normalized' | 'raw''normalized'Phase-mapping strategy for the curve-change morph
morphEasing(t: number) => numbereaseInOutCubicEases the curve-change morph. Pass (t) => t for a constant-rate ramp
morphAlignbooleanfalseStart the new curve from the point nearest the current head, removing the start “snap”
instanceSarmalInstance | null-Bindable. Use bind:instance to get the live SarmalInstance.
onready(instance) => void-Callback fired once after the instance is created.

Curve stability

The curve prop must be a stable reference.

Do not define it inline

The component cannot deep-compare curves because they contain functions.

Use a module-level constant:

<script>
  import { Sarmal } from "@sarmal/svelte";
  import { curves } from "@sarmal/core";

  // GOOD: module-level constant
  let curve = $state(curves.rose3);

  // Later: morph to a different curve
  curve = curves.deltoid;
</script>

<Sarmal curve={curve} />

Curve morphing

When the curve prop changes, it smoothly transitions using morphTo. The morphDuration prop controls how long the transition takes:

<script>
  import { Sarmal } from "@sarmal/svelte";
  import { curves } from "@sarmal/core";

  let curve = $state(curves.astroid);

  function switchCurve() {
    curve = curves.deltoid; // morphs over 300ms (default)
  }
</script>

<Sarmal curve={curve} morphDuration={800} />

Imperative control

Use bind:instance or the onready callback to get the SarmalInstance for manual control:

<script>
  import { Sarmal } from "@sarmal/svelte";
  import { curves } from "@sarmal/core";
  import type { SarmalInstance } from "@sarmal/core";

  let sarmal = $state<SarmalInstance | null>(null);

  function pauseIt() {
    sarmal?.pause();
  }
</script>

<Sarmal curve={curves.rose3} bind:instance={sarmal} />

The Action

The sarmal action is the most idiomatic Svelte primitive for attaching a sarmal to a <canvas> element. It preserves full access to Svelte’s own directives on the element:

<script>
  import { sarmal } from "@sarmal/svelte";
  import { curves } from "@sarmal/core";

  let options = $state({ curve: curves.rose3, trailColor: "#a78bfa" });
</script>

<canvas width={200} height={200} use:sarmal={options} />

The action responds to option changes via its update callback. Init-time options trigger destroy + recreate, the curve option triggers morphTo, and runtime visual options trigger setRenderOptions.

useSarmal Composable

For full control over the canvas element and instance lifecycle:

<script>
  import { useSarmal } from "@sarmal/svelte";
  import { curves } from "@sarmal/core";

  let canvas = $state<HTMLCanvasElement | null>(null);
  const { instance } = useSarmal(
    () => canvas,
    () => curves.rose3,
  );
</script>

<canvas bind:this={canvas} width={200} height={200} />

The composable takes getter functions so it can reactively track changes. The signature:

function useSarmal(
  canvasElement: HTMLCanvasElement | null,
  getCurve: () => CurveDef,
  getOptions?: () => Partial<SarmalOptions>,
  getInit?: () => CanvasInit,
  getMorphDuration?: () => number | undefined,
): {
  get instance(): SarmalInstance | null;
}
Warning

Runtime visual options (trailColor, headColor, skeletonColor, trailStyle) passed in getOptions are applied at creation and tracked for reactive updates via setRenderOptions. Init options (width, height, trailLength, autoStart, initialPhase) trigger destroy+recreate on change. headRadius is tracked as an init option (prop changes recreate) but can be changed at runtime with setRenderOptions without a recreate.

SVG Output

For SVG rendering, use <SarmalSVG>, useSarmalSVG, and the sarmalSVG action. The API mirrors the canvas variants:

<!-- Component -->
<script>
  import { SarmalSVG } from "@sarmal/svelte";
  import { curves } from "@sarmal/core";
</script>

<SarmalSVG curve={curves.rose3} style="width: 200px; height: 200px;" />

<!-- Action -->
<script>
  import { sarmalSVG } from "@sarmal/svelte";
  import { curves } from "@sarmal/core";

  let opts = $state({ curve: curves.astroid, trailColor: "#a78bfa" });
</script>

<svg use:sarmalSVG={opts} style="width: 200px; height: 200px;" />

<!-- Composable -->
<script>
  import { useSarmalSVG } from "@sarmal/svelte";

  let svg = $state<SVGSVGElement | null>(null);
  const { instance } = useSarmalSVG(
    () => svg,
    () => curves.astroid,
  );
</script>

<svg bind:this={svg} style="width: 200px; height: 200px;" />

The SVG renderer sets viewBox="0 0 100 100" automatically. Size the <svg> element with CSS. No width/height props needed as SVG already scales naturally.

SSR and SvelteKit

No special configuration is needed. The $effect rune never runs during server-side rendering, and createSarmal is only called inside $effect blocks. No "use client" directives or typeof window guards are required.

CDN

Drop a sarmal into any HTML page without a build step.

Two approaches:

  • auto-init: zero JavaScript
  • ESM import: programmatic use

Auto-Init

Auto-init is Sarmal’s plug-n-play option. Include the auto-init script and add data-sarmal attribute to any <canvas> or <svg> with your preferred curve:

<script src="https://unpkg.com/@sarmal/core/dist/auto-init.js"></script>

<canvas data-sarmal="rose3" width="200" height="200"></canvas>

<!-- svg is sized with CSS -->
<svg data-sarmal="rose3" style="width:200px; height:200px;"></svg>

The script waits for DOMContentLoaded, scans for all canvas[data-sarmal] and svg[data-sarmal] elements, and starts an animation for each one using the built-in curve name as the attribute value. Canvas renderers use pixel-space dimensions; SVG renderers set viewBox="0 0 100 100" automatically and are sized with CSS. That’s all you need to get it working. No additional JavaScript required.

You can also use jsDelivr:

<script src="https://cdn.jsdelivr.net/npm/@sarmal/core/dist/auto-init.js"></script>

Data Attributes

Configure appearance and behavior through data-* attributes on the canvas element:

AttributeTypeExampleDescription
data-sarmal*string"rose3"Required. The built-in curve name.
data-trail-colorstring | string[]"#ff6b6b" or '["#ff6b6b","#ffd93d","#6bcb77"]'Hex color or JSON array for gradient trails
data-skeleton-colorstring"#ffffff66"Hex color for the skeleton ghost path
data-head-colorstring"#ff0000"Hex color for the head dot
data-head-radiusnumber"6"Radius of the head dot. Canvas: CSS pixels (default 4). SVG: viewBox units, 0-100 (default 1.5).
data-trail-lengthnumber"60"Number of trail points
data-trail-stylestring"gradient-animated""default" | "gradient-static" | "gradient-animated"
data-speednumber"0.5"Animation speed multiplier. 0.5 = half speed, 2 = double speed
<canvas
  data-sarmal="rose5"
  width="300"
  height="300"
  data-trail-color='["#ff6b6b","#ffd93d","#6bcb77"]'
  data-trail-style="gradient-animated"
  data-trail-length="80"
  data-speed="0.75"
></canvas>
Info

The curve name must match one of the built-in curve names exactly. Custom curves are not supported through auto-init. Instead, you can use the ESM import approach for custom curves.

SVG Output

To use svg with auto-init, add data-sarmal to an <svg> element and size it with CSS:

<svg
  data-sarmal="rose5"
  style="width:300px; height:300px;"
  data-trail-color="#ff6b6b"
></svg>

The renderer sets viewBox="0 0 100 100" automatically, so coordinates are normalized. All data-* attributes work identically for both canvas and SVG. The script detects the element type and picks the right renderer.

ESM Import

For full programmatic control, import from jsDelivr as an ES module:

<script type="module">
  import { createSarmal } from "https://cdn.jsdelivr.net/npm/@sarmal/core/+esm";
  import { rose3 } from "https://cdn.jsdelivr.net/npm/@sarmal/core/+esm";

  const canvas = document.getElementById("curve-canvas");
  const sarmal = createSarmal(canvas, rose3, {
    trailColor: "#a78bfa",
    trailLength: 120,
  });
</script>

The +esm path resolves to the ESM build and supports named imports. This gives you access to the full API: createSarmal, createSarmalSVG, all curves, palettes, and the createEngine + createRenderer lower-level APIs.

You can also use unpkg (the file paths are the same):

<script type="module">
  import { createSarmal } from "https://unpkg.com/@sarmal/core/dist/index.js";
  import { rose3 } from "https://unpkg.com/@sarmal/core/dist/index.js";
</script>

SVG with CDN

Combine the ESM import approach with createSarmalSVG:

<svg id="sarmal-svg" style="width: 200px; height: 200px;"></svg>

<script type="module">
  import { createSarmalSVG } from "https://cdn.jsdelivr.net/npm/@sarmal/core/+esm";
  import { astroid } from "https://cdn.jsdelivr.net/npm/@sarmal/core/+esm";

  const svg = document.getElementById("sarmal-svg");
  createSarmalSVG(svg, astroid, {
    trailColor: "#a78bfa",
  });
</script>

The SVG renderer sets viewBox="0 0 100 100" automatically. Size the <svg> element with CSS. You don’t need width or height attributes on the svg element itself.