# TegakiRenderer

> API reference for TegakiRenderer and TegakiEngine: props, time control, effects, plugins, frames and helper functions for handwriting animation.

Source: https://tegaki.ink/api/renderer/

The renderer component is available for every supported framework. See the [framework guides](https://tegaki.ink/frameworks/react/) for import paths.

## Import

```tsx
import { TegakiRenderer } from 'tegaki';        // React
import { TegakiRenderer } from 'tegaki/svelte';  // Svelte
import { TegakiRenderer } from 'tegaki/vue';     // Vue
import { TegakiRenderer } from 'tegaki/solid';   // SolidJS
import TegakiRenderer from 'tegaki/astro';       // Astro
import { TegakiEngine } from 'tegaki/core';      // Vanilla JS
```

## Props

### `font`

**Type:** `TegakiBundle` · **Required**

The font bundle containing glyph components, metrics, and timing data.

### `children` / `text`

**Type:** `string`

The text to render. Can be passed as children or the `text` prop.

### `time`

**Type:** `number | TimeControlProp`

Controls animation timing. Pass a number for controlled mode, or an object:

```ts
// Uncontrolled: the component manages its own clock
{ mode: 'uncontrolled', speed?: number, loop?: boolean }

// CSS: animation driven by CSS transitions
{ mode: 'css', speed?: number }
```

### `reducedMotion`

**Type:** `'never' | 'user' | 'always'` · **Default:** `'never'`

Whether uncontrolled playback honours reduced motion. Under reduced motion the text is drawn finished instead of written out, and `onComplete` still fires.

- `'never'`: always animate. Ink appearing in place isn't the kind of motion (parallax, zooming, sliding) that the OS setting guards against.
- `'user'`: follow the visitor's `prefers-reduced-motion` setting, including changes while the page is open.
- `'always'`: never animate.

```tsx
<TegakiRenderer font={caveat} reducedMotion="user">
  Hello
</TegakiRenderer>
```

Controlled and `css` time are driven by your own code, so this option doesn't affect them. Check `prefers-reduced-motion` yourself there if you need to.

### `effects`

**Type:** `TegakiEffectConfigs`

Effects configuration:

```ts
{
  glow?: { radius: number, color: string, offsetX?: number, offsetY?: number },
  wobble?: { amplitude: number, frequency: number, mode?: 'sine' | 'noise' },
  pressureWidth?: { strength: number },
  taper?: { startLength: number, endLength: number },
  strokeGradient?: { colors: string | string[], saturation?: number, lightness?: number },
  globalGradient?: { colors: string[], angle?: number },
}
```

The effects are [plugins](#plugins) built into the renderer, run ahead of your own: pressure width, taper and wobble reshape each stroke (`geometry`), the gradients choose its paint (`paint`), and glow draws a blurred copy under each stroke (`paint`) or, with clip-to-text on, lights up the clipped ink as a whole (`ink`), since the clip would cut the copies away.

### `plugins`

**Type:** `readonly TegakiPlugin[]`

Code that reshapes, retimes or paints the ink, paints under or over it, or runs on every frame drawn: a brush, a pen at the tip, stroke-order arrows, a typist's pace, sound. They run after the built-in [effects](#effects). See [Plugins](#plugins). In React, keep the array stable (define it outside the component or memoize it), since a new array redraws the canvas. Plugins are functions, so the Astro component doesn't take them. `toSVG` draws the ink their `geometry` and `outline` shape, at the times their `timing` gives it. Painting hooks don't reach the file; a plugin's [`svg`](#in-an-exported-svg-svg) hook draws into it instead.

### `seed`

**Type:** `number | 'random'` · **Default:** `0`

The number the renderer's random choices come from: the wobble's phase, a gradient's hue, the [variation plugin](#variation-variationplugin), and whatever plugins draw with `random(key)`. Each character adds its place in the text to the seed, so repeated letters still differ from each other.

The same seed draws the same text every time: on every page load, in every tab, and in every process that renders a video. That's why the default is a fixed number. Change it to get another version of the text, and keep a version you like by keeping its seed.

`'random'` picks a new seed each time a renderer is created, so the text looks a little different on every load. Read the number it picked from `engine.seed` (in React, `ref.current.engine.seed`) and pass it back as `seed` to draw that version again. Don't use `'random'` in a video: each frame could get a different seed.

```tsx
const plugins = [variationPlugin()]; // outside the component, so it stays the same array

<TegakiRenderer font={caveat} plugins={plugins} seed="random">Signed, Ada</TegakiRenderer>;
```

### `style`

Style object or string applied to the container. Use `fontSize` to control the text size.

### `shaper`

**Type:** `boolean` · **Default:** `true`

Whether this instance uses the globally-registered harfbuzz shaper. Set to `false` to opt one renderer out of shaping (e.g. side-by-side comparisons) without unregistering the shaper for the whole process. Has no effect when no shaper is registered. See [Text Shaping](https://tegaki.ink/guides/shaping/).

### `fallbackFont`

**Type:** `string` (a CSS font-family list)

The font for characters the bundle has no stroke data for. They appear without a handwriting animation, in the first font of this stack that covers them:

```tsx
<TegakiRenderer font={lxgwWenKai} fallbackFont='"Noto Serif SC", serif'>
  你好，世界
</TegakiRenderer>
```

The list goes after the bundle's own fonts. A bundle that ships its full font (all built-in bundles except `lxgw-wenkai`) tries that first. The full font loads only once the text contains a character outside the generated set, so it costs nothing for text inside the set. A subset-only bundle goes straight to `fallbackFont`, or to the browser's default font without it. Load any web font you name here yourself, for example with an `@font-face` rule.

### `onComplete`

**Type:** `() => void`

Callback fired when the animation reaches the end (uncontrolled mode only).

## Static methods

### `TegakiEngine.registerShaper(factory)`

Register a shaper factory globally so every engine instance can shape text. Pass the `harfbuzzShaper` export from `tegaki/shaper-harfbuzz`, or pass `null` to unregister and clear the shaper cache.

```ts
import { TegakiEngine } from 'tegaki/core';
import harfbuzzShaper from 'tegaki/shaper-harfbuzz';

TegakiEngine.registerShaper(harfbuzzShaper);
```

See [Text Shaping](https://tegaki.ink/guides/shaping/) for the full setup, bundle requirements, and SSR behavior.

### `TegakiEngine.registerBundle(bundle)`

Register a font bundle so it can be referenced by family name (used by the Web Component adapter and any code that wants to pass a bundle name as a string instead of the bundle object).

## Strokes and frames

The engine exposes the timeline one stroke at a time. In React, the engine is on the component's ref (`ref.current.engine`).

### `engine.strokes`

**Type:** `readonly StrokeInstance[]`

Every stroke the text draws, in the order the canvas paints them. Each one has an `id` that stays the same while the timeline does, plus the timeline `entry` it belongs to (character, glyph, position), its `strokeIndex` in the glyph's stroke order, and its `start` and `duration` in seconds, as the plugins' [`timing`](#retiming-the-strokes-timing) gives them. The list is recomputed whenever the timeline is (see `onChangeTimeline`).

### `engine.frameAt(time?)`

**Returns:** `TegakiFrame`

Every stroke at `time` (default: the current time). For each stroke you get:

- `state`: `'pending'`, `'drawing'` or `'done'`.
- `progress`: the eased draw progress, 0–1.
- `path`: the whole stroke, a [`StrokePath`](#strokepath) in CSS px from the top-left of the text box, reshaped by every plugin's `geometry` (the built-in wobble, pressure width and taper among them), its widths the pen's own. With clip-to-text set to a factor (`clipText: 1.2`), the default painter draws it that much wider on the clipped canvas so the ink fills the letters; the path doesn't carry that. `rawPath` is the stroke as the bundle has it, before any plugin: unwobbled, with the bundle's own widths.
- `nibs`: the stroke's nib stamps (blobs the pen leaves at some points), placed along `path`: `t`, an offset `dx`/`dy` in px, radii `rx`/`ry` as multiples of the ink width there, and an `angle`.
- `seed`: a number fixed per glyph, which the built-in effects vary by.
- `place`: where the stroke's glyph sits: `x` and `y` in px (the top of the glyph's ascender at its left edge), `scale` (px per font unit) and `ascender` (in font units), so the baseline is at `y + ascender * scale`. With `glyph.w * scale`, the glyph's advance, it's what a practice grid lays its squares out by.
- `head`: once the stroke has started, the pen at the end of the ink, `path.pointAt(progress)`. It has `x` and `y`, `angle` (the direction of travel in radians, with y pointing down) and `width` (the ink width in px).

`frame.active` lists the strokes being drawn right now. A stroke's `path` is the same object from frame to frame until the layout changes, so anything you compute from it can be cached against it (in a `WeakMap`, for example).

The result depends only on `time`, so it works the same under controlled, uncontrolled and CSS time:

```ts
const frame = engine.frameAt();
for (const stroke of frame.active) {
  pen.style.transform = `translate(${stroke.head.x}px, ${stroke.head.y}px) rotate(${stroke.head.angle}rad)`;
}
```

## Plugins

A plugin is an object with a `name` and any of these hooks:

| Hook | Called | Use it for |
|------|--------|------------|
| `geometry(path, ctx)` | once per stroke, when the layout changes | moving the ink's points, changing its widths |
| `timing({ strokes, duration, fontSize, random })` | once, when the layout changes | when each stroke draws: a hand's pace, a typewriter's keys, a sweep (see [Retiming the strokes](#retiming-the-strokes-timing)) |
| `paint(stroke, next)` | every frame, for every stroke (drawn or not: `next` paints only what's drawn) | a brush, a texture, a paint of your own |
| `ink({ ctx, bounds, frame, … })` | every frame, after the strokes | glows, shadows, filters over the finished ink |
| `underlay({ ctx, frame, fontSize, color, random })` | every frame, before the ink | guides, stroke-order arrows, paper texture |
| `overlay({ ctx, frame, fontSize, color, random })` | every frame, after the ink | a pen at the tip, highlights |
| `onFrame(frame, prev)` | after every frame is painted | sound, events, syncing other UI |
| `bounds({ strokes, fontSize })` | when the layout changes | reserving room for what you paint outside the ink |
| `outline(contour, ctx)` | once per glyph, with clip-to-text on | reshaping the letter shapes the ink is clipped to, in step with `geometry` |
| `svg(svg)` | once per `toSVG` / `exportSVG` | what the plugin paints, in the exported SVG (see [In an exported SVG](#in-an-exported-svg-svg)) |
| `attach({ redraw })` | when a renderer starts running the plugin; what it returns, when it stops | setting up and releasing what the plugin needs: a WebGL context, a worker, a library to load (see [Setting up and releasing](#setting-up-and-releasing-attach)) |
| `steps: { count, fps, idle?, paintOnly? }` | not a hook: a setting | redrawing the ink a few ways in turn, over time (see [Drawings over time](#drawings-over-time-steps)) |

The built-in [effects](#effects) are plugins too, and run first, so each hook of yours sees what they made: `geometry` gets the path after pressure width, taper and wobble, and `paint` gets the gradient as its `style`.

`ctx` is the canvas context, set up so one unit is one CSS px from the top-left of the text box. The engine saves and restores it around every hook. Underlays go on a layer of their own under the finished ink, so clip-to-text doesn't cut them. `color` is the text's color, and `step` is the plugin's current drawing if it has [`steps`](#drawings-over-time-steps) (`0` otherwise). `random(key)` returns a random number generator seeded by the renderer's [`seed`](#seed), so the same key gives the same numbers on every frame and, at the same seed, on every load. `prev` is the frame `onFrame` saw last, or `null` the first time.

`ink` runs once the strokes are painted (and clipped, with clip-to-text): `ctx.canvas` holds the finished ink, in device px, as the `ink` hooks before yours left it, so a shadow cast after a glow includes the glow. Copy the part you read before drawing over it. It also gets the `frame` the ink is of, and `bounds`, the box the ink drawn so far covers in the same px as `ctx` (`ctx.getTransform()` maps it to device px). Work inside `bounds`: outside it the canvas is empty, and a filter or blur costs what it covers.

The canvas only reaches a little past the ink, so anything painted further out is cut off. Return a box (`{ minX, minY, maxX, maxY }`, in the same px) from `bounds` and the canvas grows to hold it. A hook that throws is logged once and skipped, and the rest of the render carries on.

### Reshaping the ink

`geometry` gets a stroke's [`StrokePath`](#strokepath) and returns the one to draw. It runs once per layout, not per frame. Build the new path with `path.map`, which keeps the pen's position exact between points. `ctx` has the stroke (`ctx.stroke`), where its glyph sits (`ctx.place`), its `rawPath` and its `seed`, and `ctx.textBox`, the box the text's lines fill, for plugins that lay out the whole text (on a curve, say). An `outline` hook gets `place`, `seed` and `textBox` too, so it can move a glyph's clip outline the same way. A dot is a path of one point.

```ts
const swell: TegakiPlugin = {
  name: 'swell',
  geometry: (path) => path.map((p) => ({ ...p, width: p.width * (0.4 + 1.4 * Math.sin(Math.PI * p.t)) })),
};
```

### Retiming the strokes: `timing`

`timing` decides when each stroke draws. It gets every placed stroke (`strokes`, in the order the canvas paints them, each with its `start` and `duration` in seconds, and its `path` and `place`) and `duration`, how long the timeline runs. It returns one `{ start, duration }` per stroke, in the same order. It runs once per layout, after `geometry`, so where a stroke is can decide when it draws: a sweep across the text, or a pen that takes longer to travel to a stroke that's further away.

```ts
// Every stroke at the same pace, 4 ems of line a second, one after another.
const steady: TegakiPlugin = {
  name: 'steady',
  timing({ strokes, fontSize }) {
    let clock = 0;
    return {
      strokes: strokes.map((s) => {
        const time = { start: clock, duration: s.path.length / fontSize / 4 };
        clock += time.duration + 0.05;
        return time;
      }),
    };
  },
};
```

- Everything plays the new timeline: `engine.duration`, `currentTime` as a share of it (`'50%'`, CSS time), the pen head and `frame.active`, `onComplete`, `onChangeTimeline`, and the SVG from `toSVG`.
- The timeline runs on past the last stroke as long as it did before. Return `duration` too to change that: to hold at the end, or to leave time for an effect of yours to finish (an eraser rubbing the text out). It never ends before the last stroke does.
- A glyph's easing (`timing.glyphEasing`) runs over the time its strokes now span. Characters drawn from the fallback font keep their distance from the glyph before them.
- Plugins run in order, each on the times the one before returned. The `timing` option schedules the text in the first place, and plugins retime what it scheduled.
- A stroke with a `duration` of `0` is drawn all at once.

### Painting strokes

`paint` gets each stroke (`stroke.stroke`, a stroke of [the frame](#engineframeattime) with its `state`, `path` and `progress`) and `next`, which paints it the way the rest of the chain would: the plugins after yours, then the default painter. Change what you pass to `next` (`style`, the stroke's `path`), call it more than once, draw around it, or skip it. `style` is a CSS color, a canvas gradient or pattern, or a function giving the color at each draw progress. `textBox` is the box the text's lines fill. Each stroke's `place.scale` is px per font unit. `frame` is the whole frame: its `time` in seconds, and every stroke in it (the rest of the glyph, or when the last stroke ends). With the stroke's `start` and `duration`, `frame.time - (start + duration * t)` is about how long ago the pen passed the point at progress `t`, for ink that dries or fades.

`paint` is called for every stroke on every frame, including the ones the pen hasn't reached (their `stroke.state` is `'pending'`), so a plugin can decide for itself what shows when: stamp a whole glyph the moment its first stroke starts, or show the text written out from the start and rub it away. The default painter paints only what's drawn, so a pending stroke passed on unchanged shows nothing; to show it, pass it on with `state: 'done'` and the `progress` you want. Anything you draw directly, rather than through `next`, should check `stroke.state` first.

With clip-to-text on, everything painted on `ctx` is cut to the letters' shapes once the strokes are drawn. To paint past their edges (a wide brush, a pass around the ink, flecks off the line), paint on `unclipped` instead: pass the stroke on as `next({ ...stroke, ctx: stroke.unclipped })`, or draw on it directly. It's a layer of its own, laid under the clipped ink before the `ink` hooks run, so a glow or a shadow still sees it as ink, and strokes painted on it keep the pen's own width (clip-to-text's widening is only for ink it cuts). With clip-to-text off it's `ctx` itself.

```ts
// A broad nib's full width, whether or not the text is clipped.
const unclippedInk: TegakiPlugin = { name: 'unclipped', paint: (s, next) => next({ ...s, ctx: s.unclipped }) };
```

```ts
const shadow: TegakiPlugin = {
  name: 'shadow',
  paint(s, next) {
    const path = s.stroke.path.map((p) => ({ ...p, x: p.x + 4, y: p.y + 4 }));
    next({ ...s, style: 'rgba(40, 80, 200, 0.35)', stroke: { ...s.stroke, path } });
    next(s);
  },
};
```

### Painting around the ink

A pen at the tip:

```ts
import { expandBox, type TegakiPlugin, unionBoxes } from 'tegaki/core';

const pen: TegakiPlugin = {
  name: 'pen',
  bounds: ({ strokes, fontSize }) => expandBox(unionBoxes(strokes.map((s) => s.path.bounds())), fontSize * 0.4),
  overlay: ({ ctx, frame }) => {
    for (const { head } of frame.active) {
      ctx.save();
      ctx.translate(head.x, head.y);
      ctx.rotate(-Math.PI / 4);
      ctx.drawImage(penImage, -4, -penImage.height);
      ctx.restore();
    }
  },
};
```

A tick for each stroke that starts:

```ts
const ticks: TegakiPlugin = {
  name: 'ticks',
  onFrame: (frame, prev) => {
    for (const s of frame.active) {
      const before = prev?.strokes.find((p) => p.id === s.id);
      if (!before || before.state === 'pending') tick.play();
    }
  },
};
```

Stroke-order arrows that run alongside the first part of each stroke, a gap off its ink, on whichever side is clear of the character's other strokes:

```ts
import { clearance, expandBox, inkEdge, offsetPath, type PlacedStroke, type StrokePath, type TegakiPlugin, unionBoxes } from 'tegaki/core';

const arrows = new WeakMap<StrokePath, StrokePath>();
function arrowFor(stroke: PlacedStroke, all: readonly PlacedStroke[]): StrokePath {
  let arrow = arrows.get(stroke.path);
  if (arrow) return arrow;
  const others = all.filter((o) => o.entryIndex === stroke.entryIndex && o !== stroke).map((o) => o.path);
  const lead = stroke.path.slice(0, 0.4);
  const left = offsetPath(lead, inkEdge(5));
  const right = offsetPath(lead, inkEdge(5, -1));
  arrow = clearance(left.pointAt(0.5), others) >= clearance(right.pointAt(0.5), others) ? left : right;
  arrows.set(stroke.path, arrow);
  return arrow;
}

const strokeOrder: TegakiPlugin = {
  name: 'stroke-order',
  bounds: ({ strokes }) => unionBoxes(strokes.map((s) => expandBox(arrowFor(s, strokes).bounds(), 6))),
  underlay: ({ ctx, frame }) => {
    for (const s of frame.strokes) {
      ctx.strokeStyle = s.state === 'pending' ? '#bbb' : '#e5484d';
      ctx.beginPath();
      for (const p of arrowFor(s, frame.strokes).points) ctx.lineTo(p.x, p.y);
      ctx.stroke();
    }
  },
};
```

### Drawings over time: `steps`

`geometry` runs once per layout, so on its own it can't change the ink over time. `steps` lets it: the plugin asks for a cycle of `count` drawings shown `fps` times a second, and its `geometry` and `outline` are called once per drawing per layout, told which one in `ctx.step` (from `0`). The engine keeps every drawing and shows one at a time, so cycling costs nothing per frame. This is how line boil works in hand-drawn animation.

```ts
const shimmer: TegakiPlugin = {
  name: 'shimmer',
  steps: { count: 3, fps: 12 },
  geometry: (path, ctx) => path.map((p) => ({ ...p, y: p.y + (ctx.step - 1) * 0.8 })),
};
```

- The canvas is sized to hold every drawing, so it holds still while they cycle.
- `frameAt()`, `underlay`, `overlay` and `onFrame` all see the drawing on screen.
- In controlled time the drawing comes from the time given, so a video renders the same every time. Otherwise it comes from the clock.
- The ink cycles whenever it's drawn: while the text writes, and on every redraw. With `idle: true` the engine also keeps redrawing once the text is written or paused, in uncontrolled and CSS time.
- When motion is reduced (see [`reducedMotion`](#reducedmotion)), the first drawing is shown and nothing cycles.

The painting hooks (`paint`, `ink`, `underlay`, `overlay`) are told the plugin's drawing too, as `step`. So a plugin with no `geometry` or `outline` can use `steps` purely as a clock, say for a neon sign's flicker. Its steps don't reshape anything, so they cost nothing but a redraw per step: the strokes aren't placed again for each one, and the canvas doesn't grow for them. A plugin that has a `geometry` or `outline` hook but steps only its painting (a camera swaying over ink it shaped once) says so with `paintOnly: true`: its hooks are then called once per layout, for drawing `0`.

```ts
const flicker: TegakiPlugin = {
  name: 'flicker',
  steps: { count: 240, fps: 20, idle: true },
  // Dim every glyph now and then: a different pattern each step, the same every time round.
  paint: (s, next) => next({ ...s, style: seededRandom(s.stroke.seed, s.step)() < 0.1 ? '#888' : s.style }),
};
```

### In an exported SVG: `svg`

`toSVG` and `exportSVG` write markup, not canvas pixels, so the painting hooks (`paint`, `ink`, `underlay`, `overlay`) don't reach the file. The ink does: the file draws every stroke as `geometry` shaped it, clips it to letters as `outline` shaped them, and plays it at the times `timing` gave it. A plugin's `svg` hook draws the rest. It runs once per export and gets the strokes (placed and timed, in the file's px) with these ways to add to the file:

| | |
|---|---|
| `defs(markup)` | a filter, gradient or pattern; name it with `id(name)`, which is unique in the file |
| `underlay(markup)` / `overlay(markup)` | markup under or over the ink. Clip-to-text doesn't cut either. A looping file fades the overlay with the ink but keeps the underlay |
| `ink(attrs)` | a `<g>` around the ink, after clip-to-text: `filter="url(#…)"`, `opacity="…"` |
| `style(stroke, { color?, attrs? })` | one stroke's paint (in place of the text's, and of a stroke gradient), and a `<g>` of attributes around it |
| `appear(t)` | what shows an element from timeline second `t` on, whether the file plays once (SMIL), loops (CSS) or is static: put `attrs` in its tag and `inner` inside it |
| `seconds(t)` / `mode` | the file's own clock (the export's speed) and how it plays, for SMIL of your own |

```ts
const shadow: TegakiPlugin = {
  name: 'shadow',
  ink({ ctx }) { /* … the canvas shadow … */ },
  svg(svg) {
    const id = svg.id('shadow');
    svg.defs(`<filter id="${id}"><feDropShadow dx="2" dy="3" stdDeviation="1" flood-opacity="0.4" /></filter>`);
    svg.ink(`filter="url(#${id})"`);
  },
};
```

If the plugin has `bounds`, a cropped export takes them in. Only the first drawing of any [`steps`](#drawings-over-time-steps) is drawn.

### Setting up and releasing: `attach`

A plugin that holds something the page must give back (a WebGL context, a worker, an event listener) sets it up in `attach` and returns the function that releases it. The engine calls `attach` when it starts running the plugin: when the plugin appears in `plugins`, once per renderer. It calls what `attach` returned when it stops: when `plugins` no longer holds the plugin, or when the renderer unmounts. A plugin shared by two renderers is attached to each, so keep what it holds until the last one lets it go.

The other hooks may run before an asynchronous setup is done. Paint nothing or a fallback until it is, then call `redraw()`: paused or controlled time draws only when something changes, so the frame on screen is drawn again for you. Several calls before the next animation frame draw once, and a detached plugin's `redraw` does nothing.

```ts
import type { TegakiPlugin } from 'tegaki/core';

/** A stamp at every pen, from an image loaded while a renderer runs the plugin. */
function stamped(url: string): TegakiPlugin {
  let stamp: ImageBitmap | null = null;
  // One per renderer the plugin is attached to.
  const redraws = new Set<() => void>();
  return {
    name: 'stamped',
    attach({ redraw }) {
      redraws.add(redraw);
      if (redraws.size === 1) {
        fetch(url)
          .then((r) => r.blob())
          .then(createImageBitmap)
          .then((image) => {
            if (redraws.size === 0) return image.close();
            stamp = image;
            for (const r of redraws) r();
          });
      }
      return () => {
        redraws.delete(redraw);
        if (redraws.size > 0) return;
        stamp?.close();
        stamp = null;
      };
    },
    overlay({ ctx, frame }) {
      if (!stamp) return;
      for (const s of frame.active) ctx.drawImage(stamp, s.head.x - 12, s.head.y - 12, 24, 24);
    },
  };
}
```

`drawGlyph()` draws one glyph without an engine, so it doesn't attach: set up what the plugin needs before calling it.

### Plugins with options: `createPlugin(definition)`

`createPlugin` turns a plugin into a factory that takes options. List what the plugin can be set to in `params`, and write the hooks in `setup`, which gets every option filled in:

```ts
import { createPlugin } from 'tegaki/core';

export const shadow = createPlugin({
  name: 'shadow',
  label: 'Shadow',
  description: 'A tinted copy of each stroke, offset down and to the right.',
  params: {
    offset: { type: 'number', label: 'Offset', default: 4, min: 0, max: 20, step: 1 },
    color: { type: 'color', label: 'Color', default: '#2850c8' },
    under: { type: 'boolean', label: 'Under the ink', default: true },
    cap: { type: 'select', default: 'round', options: ['round', { value: 'butt', label: 'Flat' }] },
  },
  presets: {
    Deep: { offset: 12 },
  },
  setup: ({ offset, color, under, cap }) => ({
    paint(s, next) {
      const path = s.stroke.path.map((p) => ({ ...p, x: p.x + offset, y: p.y + offset }));
      const copy = { ...s, style: color, lineCap: cap, stroke: { ...s.stroke, path } };
      if (under) next(copy);
      next(s);
      if (!under) next(copy);
    },
  }),
});

<TegakiRenderer font={caveat} plugins={[shadow({ offset: 8 })]}>Hello</TegakiRenderer>;
```

Each call makes a new plugin with the options you pass on top of the defaults. `setup` runs once per plugin made, so a cache or other state in its closure belongs to that plugin alone. The options are typed from `params`: `offset` is a `number`, and `cap` is `'round' | 'butt'`.

A param is one of five types, and each has a `default` plus an optional `label` and `description`:

| `type` | Value | Also takes |
|--------|-------|------------|
| `'number'` | a number, kept within `min`–`max` | `min`, `max`, `step` |
| `'boolean'` | `true` or `false` | |
| `'select'` | one of `options` | `options`: strings, or `{ value, label }` |
| `'color'` | a CSS color | |
| `'text'` | any string, the empty one included (a list of cue times, say) | `placeholder` |

`presets` are named settings worth trying. Each one lists only the options it changes, and the rest keep their defaults.

The factory also describes the plugin, so a UI can build its controls. The studio's Plugins tab does this for its demos:

- `name`, `label`, `description`, `params`, `presets`: as defined.
- `defaults`: every option at its default.
- `resolve(input)`: every option set from `input`, which can be anything (JSON from a URL, say). Values that are missing, of the wrong type, or not among a select's options get their defaults, numbers are kept in range, and unknown keys are dropped. The factory resolves the options it's called with the same way.
- `changed(input)`: just the options in `input` that differ from their defaults, as `resolve` takes them: the part worth saving.

`TegakiPluginFactory` with no type argument is the type of any factory, for a list of several.

### Variation: `variationPlugin`

Handwriting is never quite the same twice. `variationPlugin` makes each glyph a little different while keeping its shape: it grows or shrinks, leans, turns, drifts off its place and bends slightly, and each stroke runs a little thinner or thicker.

```tsx
import { variationPlugin } from 'tegaki/core';

const plugins = [variationPlugin()]; // or variationPlugin({ amount: 1.6 }) for looser writing

<TegakiRenderer font={caveat} plugins={plugins}>Hello Hello</TegakiRenderer>;
```

How each glyph strays comes from its seed, the renderer's [`seed`](#seed) plus the glyph's place in the text. So the two `Hello`s above differ from each other, the same seed draws the same text every time, and `seed="random"` draws a new version on each load. The glyph's outline moves with its strokes, so clip-to-text follows the ink.

| Option | Default | What it changes |
|--------|---------|-----------------|
| `amount` | `1` | Scales all of the below: `0` draws the font as it is, `2` twice as loose. |
| `size` | `0.05` | How much a glyph grows or shrinks, as a share of its size. |
| `slant` | `4` | Lean, in degrees either way. |
| `rotation` | `2` | Turn, in degrees either way. |
| `drift` | `0.02` | Shift off the glyph's place, in ems. |
| `warp` | `0.015` | A slow bend through the glyph, in ems. |
| `width` | `0.12` | Ink width, as a share of the stroke's. |

It comes with three presets: `Subtle`, `Loose` and `Signature`. `toSVG` draws the glyphs as it varies them.

### Line boil: `boilPlugin`

The shimmer of hand-drawn animation, stop motion and games drawn that way: the ink is redrawn a few slightly different ways, in turn, several times a second. Each glyph moves through its own smooth field for each drawing, and the clip outline moves with it. It uses [`steps`](#drawings-over-time-steps).

```tsx
import { boilPlugin } from 'tegaki/core';

const plugins = [boilPlugin()]; // boils while the text writes
const always = [boilPlugin({ idle: true, fps: 8 })]; // and after it's written, choppier
```

| Option | Default | What it changes |
|--------|---------|-----------------|
| `drawings` | `3` | How many drawings the cycle has (2–8). |
| `fps` | `12` | Drawings a second. 12 is animation "on twos"; 8 is choppier. |
| `amount` | `0.012` | How far the line wanders, in ems. |
| `detail` | `0.3` | `0` bends the lines slowly; `1` makes them jitter along their length. |
| `width` | `0.08` | How much the ink width wanders, as a share of the stroke's. |
| `idle` | `false` | Keep boiling once the text is written, or while it's paused (uncontrolled and CSS time). |

Presets: `Cartoon` (3 drawings at 8 fps, slow bends, keeps boiling), `Nervous` (fast fine jitter) and `Sketchy` (wide, with width wander, keeps boiling). Each glyph boils its own way, from the renderer's [`seed`](#seed).

### Annotate: `annotatePlugin`

Marks the text the way a pen marks a page: it underlines it (once or twice), strikes it through, crosses it out, draws a circle or a box round it, or runs a highlighter over it. It can mark the whole text, each line, each word, or just one of them. The marks are drawn once the text is written, or each one as soon as what it marks is written (the writing waits for it).

```tsx
import { annotatePlugin } from 'tegaki/core';

const underline = [annotatePlugin()]; // the whole text, once it's written
const circle = [annotatePlugin({ mark: 'circle', target: 'words', pick: 2 })]; // just the second word
const highlight = [annotatePlugin({ mark: 'highlight', target: 'lines', color: '#ffd400' })];
```

| Option | Default | What it changes |
|--------|---------|-----------------|
| `mark` | `'underline'` | `'underline'`, `'double'`, `'highlight'`, `'strike'`, `'circle'`, `'box'` or `'cross'`. |
| `target` | `'text'` | What each mark goes on: `'text'`, `'lines'` or `'words'`. Underlines, strikes and highlights run along a line, so the whole text gets one per line. |
| `pick` | `0` | Which one to mark, counting from 1. `0` marks every one. |
| `when` | `'after'` | `'after'` draws the marks one after another once the text is written. `'each'` draws each mark as soon as what it marks is written, and holds the writing back while it's drawn. |
| `color` | `'#e5484d'` | The pen's color. A highlighter is drawn see-through, under the ink. |
| `width` | `0.045` | The pen's width, in ems. |
| `padding` | `0.12` | How far the marks sit off the ink, in ems. |
| `roughness` | `0.5` | How loosely the hand draws: `0` is ruler-straight, `1` is dashed off. |
| `duration` | `0.5` | Seconds each mark takes to draw. |
| `delay` | `0.25` | Seconds before each mark starts. |

The timeline runs on to make time for the marks, so `duration`, `onComplete` and CSS time include them. Each mark's shape comes from the renderer's [`seed`](#seed), and `toSVG` draws the marks too (drawn on in an animated file).

### Text on a path: `textPathPlugin`

Lays the text on a curve: an arc (a rainbow, a smile, or right round a circle, the way a badge's lettering runs) or a wave. A wave can flow along the text like a flag in the wind. Each glyph is moved whole and turned to lie along the curve, kept upright, or bent with it.

```tsx
import { textPathPlugin } from 'tegaki/core';

const rainbow = [textPathPlugin({ angle: 120 })];
const badge = [textPathPlugin({ angle: 330 })]; // nearly a full circle
const flag = [textPathPlugin({ shape: 'wave', amplitude: 0.2, glyphs: 'bend', flow: 0.6 })];
```

| Option | Default | What it changes |
|--------|---------|-----------------|
| `shape` | `'arc'` | `'arc'` or `'wave'`. |
| `angle` | `60` | Degrees the arc turns across the text: positive bows it up, negative down, `±360` wraps it round a circle. |
| `amplitude` | `0.25` | A wave's height either way, in ems. |
| `wavelength` | `4` | A wave's length, crest to crest, in ems. |
| `phase` | `0` | Where the wave starts, in degrees. |
| `glyphs` | `'rotate'` | `'rotate'` turns each glyph to lie along the curve, `'upright'` only moves it, `'bend'` bends it with the curve. |
| `flow` | `0` | Waves a second the wave travels along the text; `0` holds it still. A flowing wave redraws with [`steps`](#drawings-over-time-steps) and keeps going once the text is written. |

The text's middle stays where it was and the text keeps its length along the curve. Glyph outlines move with the strokes, so clip-to-text follows. Presets: `Rainbow`, `Smile`, `Badge` and `Flag`. The DOM text under the canvas (for selection and screen readers) stays where the layout put it.

### Captions: `captionPlugin`

Writes each word of the text while it's said, for captions, lyrics and narrated videos. Give it when each word starts (and ends), as a transcriber's word timings in JSON or as `start end word` lines. Without cues it writes at a steady pace, word by word, pausing at commas and full stops.

```tsx
import { captionPlugin } from 'tegaki/core';

const words = [{ word: 'Hello', start: 0.1, end: 0.5 }, { word: 'world', start: 0.62, end: 1.3 }]; // from your transcript
const plugins = [captionPlugin({ cues: JSON.stringify(words) })];
// or typed: captionPlugin({ cues: '0.1 0.5 Hello; 0.62 1.3 world' })
```

| Option | Default | What it changes |
|--------|---------|-----------------|
| `cues` | `''` | One cue per word of the text, in order. JSON: an array of `{ start, end? }` (`startTime` / `endTime` and `[start, end]` pairs work too). Text: cues separated by new lines or `;`, each `start [end] [word]`, with times in seconds or `m:ss.s`. A cue with no end lasts until the next one starts. The words aren't matched against the text; the text's words take the cues in order. |
| `fit` | `'stretch'` | `'stretch'` fits each word's writing to its cue. `'pace'` keeps the hand's own pace and only squeezes a word that would run into the next. |
| `lead` | `0` | Seconds the writing runs behind the voice (negative: ahead of it). |
| `wpm` | `70` | Words a minute for words with no cue, and for all of them when there are none. |

A word is the glyphs between spaces on one line, so text without spaces (Chinese, Japanese) takes one cue per line. `parseCues(text)` reads a cue list the way the plugin does. In Remotion, drive the renderer with the time in seconds, as in the [Remotion guide](https://tegaki.ink/frameworks/remotion/), so the writing stays in step with the audio.

## Helper functions

### `computeTimeline(text, font, timing?, shaper?)`

Computes the total animation duration for a given text and font bundle. Useful for controlled time mode, and for sizing a video to the writing.

```ts
import { computeTimeline } from 'tegaki';

const { totalDuration } = computeTimeline('Hello', bundle);
```

Pass the same `timing` the renderer gets and, when a shaper is registered, the shaper (`await createHarfbuzzShaper(bundle)` from `tegaki/shaper-harfbuzz`), so the duration matches what is drawn.

### `strokeInstances(timeline, font)`

The `engine.strokes` list for any timeline, with no engine or DOM needed. Use it to schedule things by stroke, for example a sound at each stroke's `start` in a Remotion composition. `sampleStroke(stroke, time, timing?)` gives a stroke's state and progress at a given time.

```ts
import { computeTimeline, strokeInstances } from 'tegaki/core';

const starts = strokeInstances(computeTimeline('Hello', bundle), bundle).map((s) => s.start);
```

### `StrokePath`

A polyline of points, each with `x`, `y`, `width` (the ink width there) and `t` (the draw progress at that point, 0–1). The paths in a frame are in CSS px. `new StrokePath(points)` makes one.

A point can also carry `data`: numbers by key that a `geometry` hook attaches for the hooks after it, such as a depth for a painter that draws the ink in 3D. A hook that spreads the point (`{ ...p, x }`) keeps them. `pointAt`, `slice` and `offsetPath` carry them too, interpolated between points the way `width` is. Prefix the keys with your plugin's name so two plugins don't collide.

- `length`: the arc length in px.
- `pointAt(t)`: the point at draw progress `t`, as `{ x, y, angle, width }` (and `data`, when the points carry some).
- `slice(from, to)`: the part between two progress values, as a path of its own with `t` running from 0 to 1 again.
- `bounds()`: the box the ink covers, with each point padded by half its width, or `null` for an empty path.
- `map(fn)`: a path of the points `fn` makes from these. Between points it maps the exact pen position here, so the pen stays on the new ink (see [Reshaping the ink](#reshaping-the-ink)).

### `paintStroke(stroke)`

The default painter, the end of every `paint` chain: paints `stroke.stroke.path` up to its `progress` with `stroke.style` and `stroke.lineCap`, each stretch as wide as the path is there, then its nib stamps. Use it to paint a stroke outside the engine.

### `offsetPath(path, distance)`

A path running parallel to `path`, `distance` px to its left (negative for the right, with y pointing down). `distance` can also be a function of each point, to follow a stroke that swells and thins. Corners are mitered, or beveled when they are too sharp. Where an inside offset folds back on itself around a tight curve, the loop is cut out. Points keep the `t` of the point they came from.

### `inkEdge(gap, side?)`

An `offsetPath` distance that keeps `gap` px clear of the ink's edge at every point, whatever the ink's width there. `side` is `1` for the left (the default) and `-1` for the right.

```ts
const guide = offsetPath(stroke.path, inkEdge(4, -1));
```

### `clearance(point, paths)`

How far `point` is from the nearest ink edge of any of `paths`, in px. It is negative inside the ink, and `Infinity` when there are no paths. Use it to pick the clear side of a stroke, or to check that a label has room.

### `unionBoxes(boxes)` / `expandBox(box, by)`

`unionBoxes` returns the box covering all of `boxes`, skipping `null`s (and `null` when none are left). `expandBox` grows a box by `by` px on every side. Both are handy in `bounds`.

### `groupStrokes(strokes, by, fontSize)`

Gathers a list of strokes into the whole text (`'text'`), lines (`'lines'`) or words (`'words'`), in text order. Each group has its strokes' indices (`members`), its glyphs, its first line and baseline, the box its ink covers, and whether it's written right to left. Annotate, Captions and the studio's cursive joins group strokes with it.

### `seededRandom(seed, key)`

A random number generator for `[0, 1)` that gives the same numbers for the same `seed` and `key`. Plugins get one already seeded, as `random(key)`.
