Skip to content

TegakiRenderer

The renderer component is available for every supported framework. See the framework guides for import paths.

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

Type: TegakiBundle · Required

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

Type: string

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

Type: number | TimeControlProp

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

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

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

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.
<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.

Type: TegakiEffectConfigs

Effects configuration:

{
  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 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.

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. See 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 hook draws into it instead.

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, 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.

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

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

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

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.

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:

<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.

Type: () => void

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

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.

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

TegakiEngine.registerShaper(harfbuzzShaper);

See Text Shaping for the full setup, bundle requirements, and SSR behavior.

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).

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

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 gives them. The list is recomputed whenever the timeline is (see onChangeTimeline).

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 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:

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)`;
}

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)
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)
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)
steps: { count, fps, idle?, paintOnly? } not a hook: a setting redrawing the ink a few ways in turn, over time (see Drawings over time)

The built-in 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 (0 otherwise). random(key) returns a random number generator seeded by the renderer’s 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.

geometry gets a stroke’s 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.

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

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.

// 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.

paint gets each stroke (stroke.stroke, a stroke of the frame 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.

// 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 }) };
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);
  },
};

A pen at the tip:

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:

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:

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();
    }
  },
};

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.

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), 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.

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 }),
};

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
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 is drawn.

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.

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)

Section titled “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:

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.

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.

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 plus the glyph’s place in the text. So the two Hellos 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.

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.

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.

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).

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, and toSVG draws the marks too (drawn on in an animated file).

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.

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 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.

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.

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, so the writing stays in step with the audio.

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

Section titled “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.

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.

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.

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

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

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).

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.

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.

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.

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

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 returns the box covering all of boxes, skipping nulls (and null when none are left). expandBox grows a box by by px on every side. Both are handy in bounds.

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.

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).