TegakiRenderer
The renderer component is available for every supported framework. See the framework guides for import paths.
Import
Section titled “Import”Type: TegakiBundle · Required
The font bundle containing glyph components, metrics, and timing data.
A bundle can hold several weights of its font (faces, made by the studio or generate --weights). The renderer then draws the weight the text’s CSS font-weight picks, by the same font matching the browser uses, so the strokes and the laid-out text always come from the same face. The weight can be set on the renderer or on any element above it, and when it changes later through a class or a media query, the renderer switches faces. A face’s font loads only once a weight picks it:
The browser never fakes a bold from a lighter face (font-synthesis-weight: none on the renderer), because the strokes would not follow it. A bundle with one weight draws that weight whatever font-weight asks for.
children / text
Section titled “children / text”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:
reducedMotion
Section titled “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’sprefers-reduced-motionsetting, including changes while the page is open.'always': never animate.
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.
pressure
Section titled “pressure”Type: number · Default: 1
How much the ink’s width follows the pen pressure the bundle records, from 0 to 1. At 1 each point is as wide as the bundle says. At 0 each stroke is drawn at one width, its mean. Values in between blend the two. It runs ahead of every plugin, so a plugin’s geometry gets the widths it made.
plugins
Section titled “plugins”Type: readonly TegakiPluginSpec[]
Code that reshapes, retimes or paints the ink, paints under or over it, or runs on every frame drawn: a glow, a brush, a pen at the tip, stroke-order arrows, a typist’s pace, sound. They run in the order listed. See Plugins.
Each entry is a plugin object (glowPlugin({ radius: 0.15 })) or a plugin named by its registered factory: 'glow', or ['glow', { radius: 0.15 }] with options. Names are plain data, so they work where functions can’t go: an HTML attribute, the Astro component’s props, a CLI flag. See Naming plugins.
In React, keep the array stable (define it outside the component or memoize it), since a new array of new plugin objects redraws the canvas. A list of names can be written inline: the renderer keeps the plugin it made for a name as long as the name and its options stay the same. 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: 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.
Style object or string applied to the container. Use fontSize to control the text size.
shaper
Section titled “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.
fallbackFont
Section titled “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:
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
Section titled “onComplete”Type: () => void
Callback fired when the animation reaches the end (uncontrolled mode only).
Static methods
Section titled “Static methods”TegakiEngine.registerShaper(factory)
Section titled “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.
See Text Shaping for the full setup, bundle requirements, and SSR behavior.
TegakiEngine.registerBundle(bundle)
Section titled “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).
TegakiEngine.registerPlugin(...factories)
Section titled “TegakiEngine.registerPlugin(...factories)”Register plugin factories (made with createPlugin) so plugins can name them. Each is registered under its name. Registering another factory under the same name replaces it. The same function is exported from tegaki/core as registerPlugin, and TegakiEngine.getPlugin(name) returns the factory registered under a name. See Naming plugins.
Strokes and frames
Section titled “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
Section titled “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 gives them. The list is recomputed whenever the timeline is (see onChangeTimeline).
engine.frameAt(time?)
Section titled “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, aStrokePathin CSS px from the top-left of the text box, reshaped by every plugin’sgeometry(thepressureoption’s widths first, then a taper or variation if you list one), 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.rawPathis the stroke as the bundle has it, before any plugin: unmoved, with the bundle’s own widths.nibs: the stroke’s nib stamps (blobs the pen leaves at some points), placed alongpath:t, an offsetdx/dyin px, radiirx/ryas multiples of the ink width there, and anangle.seed: a number fixed per glyph, which plugins vary each glyph by (where a gradient starts, howvariationPluginchanges it).place: where the stroke’s glyph sits:xandyin px (the top of the glyph’s ascender at its left edge),scale(px per font unit) andascender(in font units), so the baseline is aty + ascender * scale. Withglyph.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 hasxandy,angle(the direction of travel in radians, with y pointing down) andwidth(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:
Plugins
Section titled “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) |
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) |
Plugins run in the order they’re listed, each hook seeing what the ones before it made: a geometry listed after a taper gets the tapered path, and a paint listed after a gradient gets the gradient as its style. For paint, the later plugin wins: a strokeGradientPlugin listed after a globalGradientPlugin overrides it. The pressure option runs ahead of them all. The glow, taper and gradients are plugins like any other (see Style plugins).
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.
Reshaping the ink
Section titled “Reshaping the ink”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.
Retiming the strokes: timing
Section titled “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.
- Everything plays the new timeline:
engine.duration,currentTimeas a share of it ('50%', CSS time), the pen head andframe.active,onComplete,onChangeTimeline, and the SVG fromtoSVG. - The timeline runs on past the last stroke as long as it did before. Return
durationtoo 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
timingoption schedules the text in the first place, and plugins retime what it scheduled. - A stroke with a
durationof0is drawn all at once.
Painting strokes
Section titled “Painting strokes”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. Every painting hook (paint, ink, underlay, overlay) also gets clipped, true when clip-to-text is on, for a plugin that works one way or the other: the glow plugin blurs each stroke when it’s off, and the clipped ink as a whole in ink when it’s on.
A plugin whose paint only restyles strokes the pen has drawn (a color, a glow) can say so by wrapping its factory in paintsDrawnOnly(factory), from tegaki/core. The engine then skips the pending strokes for it, and doesn’t set up a layer past clip-to-text on its account. Don’t use it for a plugin that paints ahead of the pen (a tracing guide, a typewriter).
Painting around the ink
Section titled “Painting around the ink”A pen at the tip:
A tick for each stroke that starts:
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:
Drawings over time: steps
Section titled “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.
- The canvas is sized to hold every drawing, so it holds still while they cycle.
frameAt(),underlay,overlayandonFrameall 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: truethe engine also keeps redrawing once the text is written or paused, in uncontrolled and CSS time, while the canvas is on screen. - 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.
In an exported SVG: svg
Section titled “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 (a color, a url(#…) paint, or a function of draw progress (t) => color for a color that changes along the stroke), and a <g> of attributes around it. A later call’s color wins |
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 |
textBox |
the box the text’s lines fill, in the file’s px: what a gradient across the text spans |
region |
attributes that size a filter, mask or clip region to the whole file, so a blur isn’t cut off at the default region’s edge |
If the plugin has bounds, a cropped export takes them in. Only the first drawing of any steps is drawn.
Setting up and releasing: attach
Section titled “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.
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:
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 seven 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 |
'length' |
a size: a bare number in unit, or a string with its own unit, '8px' or '0.1em' |
unit ('em', the default, or 'px'), and min, max, step in it |
'boolean' |
true or false |
|
'select' |
one of options |
options: strings, or { value, label } |
'color' |
a CSS color | |
'colors' |
a list of CSS colors (a gradient’s stops, a palette), the empty list included | |
'text' |
any string, the empty one included (a list of cue times, say) | placeholder |
A length in em is a share of the font size, so it grows with the text; in px it stays put. Turn one into pixels in a hook with lengthToPx(length, ctx.fontSize). The range applies to lengths in the param’s own unit, which resolve writes as a bare number ('0.2em' becomes 0.2). One in the other unit can’t be compared before the font size is known, so it’s kept as written, except that it isn’t let below 0 when min isn’t.
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 frominput, 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 and lengths 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 ininputthat differ from their defaults, asresolvetakes them: the part worth saving.
TegakiPluginFactory with no type argument is the type of any factory, for a list of several.
Naming plugins
Section titled “Naming plugins”A factory made with createPlugin can be registered by its name, the way a font bundle is registered by its family. plugins can then name it instead of holding the plugin:
- An entry is a name (
'taper') or a name and its options (['glow', { radius: 0.15 }]). The options are read through the factory’s params, asresolvereads them: numbers are kept in range and unknown keys dropped. - Names and plugin objects can be mixed in one list, and run in the order listed. The same name twice makes two plugins (two glows).
- A name registered after the renderer is created is picked up when it’s registered, so the script that registers a plugin can run after the one that renders. A name still unregistered once the page has loaded is warned about once in the console.
- Nothing is registered for you, not even the plugins
tegaki/coreships: register each factory you name.
The built-in factories’ names are glow, taper, strokeGradient, globalGradient, variation, boil and annotate. Your own plugins register the same way, under the name you gave createPlugin.
Names are plain data, so they reach places a plugin object can’t:
- The
<tegaki-renderer>web component’spluginsattribute:plugins="taper glow", or JSON with options. See Web Components. - The Astro component’s
pluginsprop, which is sent to the browser as JSON. See Astro. - The command line’s
--pluginsflag, which knows every built-in plugin without registering. See Command Line. textToSvg(text, bundle, { plugins }), which throws on a name no factory is registered as.
Style plugins
Section titled “Style plugins”These change how the ink looks: its glow, its width along the stroke, its colors. Each is a factory from tegaki/core (re-exported from tegaki and every framework entry), registered under the name in its heading.
Glow: glowPlugin (glow)
Section titled “Glow: glowPlugin (glow)”A soft light around the ink: a blurred copy of each stroke under it. With clip-to-text on, the clip would cut those copies away, so the glow lights the clipped ink as a whole instead (characters drawn from the fallback font too). To stack two glows, list the plugin twice.
| Option | Default | What it changes |
|---|---|---|
radius |
0.1 |
How far the light spreads (0–1em). |
color |
'' |
The light’s color. Unset or '', the text’s color. |
offsetX |
0 |
Moves the light right (−1–1em). |
offsetY |
0 |
Moves the light down (−1–1em). |
The three are lengths: a bare number is in em, a share of the font size, so the glow grows with the text: radius: 0.1 at 80px spreads 8px. For a fixed size, give pixels: radius: '8px'.
Taper: taperPlugin (taper)
Section titled “Taper: taperPlugin (taper)”Strokes that thin to a point toward their ends, the way a brush lifts off the page. A dot takes the width of a stroke’s middle.
| Option | Default | What it changes |
|---|---|---|
startLength |
0.15 |
Share of the stroke, from its start, that thins toward it (0–1). |
endLength |
0.15 |
Share of the stroke, from its end, that thins toward it (0–1). |
Stroke gradient: strokeGradientPlugin (strokeGradient)
Section titled “Stroke gradient: strokeGradientPlugin (strokeGradient)”Each stroke colored along its length: a rainbow, or a run through your colors. Each glyph starts the run in its own place.
| Option | Default | What it changes |
|---|---|---|
colors |
[] |
Hex colors to run through. None (the default) for a rainbow. |
saturation |
80 |
The rainbow’s saturation, in percent. |
lightness |
55 |
The rainbow’s lightness, in percent. |
Text gradient: globalGradientPlugin (globalGradient)
Section titled “Text gradient: globalGradientPlugin (globalGradient)”One linear gradient across the whole text, as CSS background-clip: text would paint it: the first color at one edge of the text and the last at the other, whatever strokes it falls on.
| Option | Default | What it changes |
|---|---|---|
colors |
['#ff0000', '#0000ff'] |
The color stops, in order. |
angle |
0 |
Direction in degrees: 0 runs left to right, 90 top to bottom. |
Both gradients choose the stroke’s paint, so if you list both, the one listed later wins. The glow takes the text’s color (or its own), not a gradient’s. All four reach toSVG too: taper through the ink’s shape, the glow as an SVG filter, and the gradients as the strokes’ paint. On the canvas, characters drawn from the fallback font are drawn plain, with no gradient, and no glow unless clip-to-text is on.
Variation: variationPlugin
Section titled “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.
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: a length, em unless it says px ('2px'). |
warp |
0.015 |
A slow bend through the glyph: a length, em unless it says px. |
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
Section titled “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.
| 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: a length, em unless it says px ('1px'). |
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.
Annotate: annotatePlugin
Section titled “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).
| 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: a length, em unless it says px ('3px'). |
padding |
0.12 |
How far the marks sit off the ink: a length, em unless it says px. |
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).
Demo plugins
Section titled “Demo plugins”The Plugins tab of Tegaki Studio has many more plugins, written against this API as a user of tegaki would write them: text on an arc or a wave, captions written in step with a voice, a hand that wobbles, a brush, a typewriter, chalk and more. They aren’t part of tegaki. Each one links to its source on GitHub, which you can copy into your project and change: text on a path, captions and wobble, for example.
Migrating from effects
Section titled “Migrating from effects”The effects option is gone from the engine and every framework component. Each effect is now a style plugin with the same options. The units are the same too, except for the glow’s, which are now em:
| Before | Now |
|---|---|
effects={{ glow: { radius: 8, color: '#0cf' } }} |
plugins={[glowPlugin({ radius: '8px', color: '#0cf' })]}, or in em, radius: 0.1, to grow with the text |
glow: { radius: '0.1em' } |
glowPlugin({ radius: 0.1 }) |
glow: { offsetX: 50 } (font units) |
glowPlugin({ offsetX: 0.05 }) at 1000 units per em, or in px, offsetX: '4px' |
wobble: { amplitude: 2, frequency: 6 } |
no longer built in: variationPlugin() (its warp bends each glyph; boilPlugin for a line that moves), or copy the Studio’s wobble demo into your project |
taper: true |
taperPlugin() |
strokeGradient: { colors: 'rainbow' } |
strokeGradientPlugin() (colors: [], the default, is the rainbow) |
globalGradient: { colors: ['#f06', '#40f'], angle: 90 } |
globalGradientPlugin({ colors: ['#f06', '#40f'], angle: 90 }) |
pressureWidth: { strength: 0.5 } |
the pressure option: pressure={0.5} |
pressureWidth: false |
pressure={0} |
two glows under custom keys ({ effect: 'glow', … }) |
glowPlugin listed twice |
effects on <tegaki-renderer> |
the plugins property, or the plugins attribute with registered names |
--effects '{"glow":{"radius":10}}' (CLI) |
--plugins '[["glow", {"radius": "10px"}]]' |
An effect set to false or { enabled: false } is a plugin left out of the list. The effects used to run ahead of your plugins, sorted by their order. Style plugins run where you list them, so put a taperPlugin ahead of any plugin of yours that should see the tapered path. resolveEffects, findEffect, findEffects, effectPlugins and the TegakiEffects / TegakiEffectConfigs / CSSLength types are no longer exported.
Helper functions
Section titled “Helper functions”textToSvg(text, bundle, options?)
Section titled “textToSvg(text, bundle, options?)”The text as an animated SVG string, with no DOM: what the command line writes. options.plugins takes plugin objects or registered names, as the renderer’s plugins does, and throws on a name no factory is registered as. pressure defaults to 1, or to 0 in the default 'loop' mode.
fontWeight picks a weight of a bundle with several. A shaper passed with it must be built from that weight’s face, bundleForWeight(bundle, fontWeight).
bundleForWeight(bundle, weight) / bundleWeights(bundle) / matchFontWeight(weights, weight)
Section titled “bundleForWeight(bundle, weight) / bundleWeights(bundle) / matchFontWeight(weights, weight)”bundleWeights lists the weights a bundle has a face for, its own first. matchFontWeight is CSS font matching for weights: the one of weights a text of weight is drawn in. bundleForWeight returns the bundle as that weight draws it, with the face’s files and glyph data in place of the bundle’s own. It returns the same object every time for the same face, and the bundle itself when the match is the bundle’s own face.
drawGlyph(ctx, glyph, pos, localTime, options?)
Section titled “drawGlyph(ctx, glyph, pos, localTime, options?)”Draws one glyph’s strokes onto a canvas context, as far as they’re written localTime seconds after the glyph starts, with no engine. The strokes go through the same pressure step and plugins the engine runs (all but the ink hooks, which work on a whole canvas of ink). pos is where the glyph goes: { x, y, fontSize, unitsPerEm, ascender, descender }, with y the top of the em square. options takes color, style (a gradient or pattern to paint with), lineCap, pressure, plugins (plugin objects only), seed, strokeEasing, strokeScale, and strokeDelays / strokeTimeScale to fit the strokes to a different slot.
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.
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.
StrokePath
Section titled “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 progresst, as{ x, y, angle, width }(anddata, when the points carry some).slice(from, to): the part between two progress values, as a path of its own withtrunning from 0 to 1 again.bounds(): the box the ink covers, with each point padded by half its width, ornullfor an empty path.map(fn): a path of the pointsfnmakes from these. Between points it maps the exact pen position here, so the pen stays on the new ink (see Reshaping the ink).
paintStroke(stroke)
Section titled “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)
Section titled “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?)
Section titled “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.
clearance(point, paths)
Section titled “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)
Section titled “unionBoxes(boxes) / expandBox(box, by)”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.
groupStrokes(strokes, by, fontSize)
Section titled “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 and the studio’s cursive joins and captions group strokes with it.
seededRandom(seed, key)
Section titled “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).
tegaki/internal
Section titled “tegaki/internal”The package also ships tegaki/internal: helpers the Tegaki website and generator share with the renderer, such as stroke sampling, text layout and bundle loading. It isn’t covered by semver, so any release may change or remove what’s in it. Don’t depend on it. If you need one of its functions, open an issue to have it made public.
Some of it used to be exported from tegaki/core, such as strokeInstances, sampleStroke and computeTextLayout. A plugin doesn’t need them: its hooks already get the placed strokes and every frame. Outside a plugin, engine.strokes lists each stroke with its start and duration, and engine.frameAt(time) gives every stroke’s state at a time.