# Tegaki: handwriting animation library and generator for any font > Tegaki is an open-source handwriting animation library and generator for the web: it turns any font into text that writes itself stroke by stroke, in the order a hand would draw it. Source: https://tegaki.ink/ Tegaki (手書き, Japanese for "handwriting") extracts the strokes of every glyph in a font (their order, direction and width) and draws them on a canvas over real, selectable text. Text is written stroke by stroke in the order a hand would write it, in any font: Latin, right-to-left, Indic and CJK scripts alike. ## Features - Package: `tegaki` on npm (https://www.npmjs.com/package/tegaki), MIT licensed. Install with `npm i tegaki`. Source: https://github.com/gkurt/tegaki - Entry points: `tegaki` or `tegaki/react` (React), `tegaki/svelte`, `tegaki/vue`, `tegaki/nuxt` (Nuxt module), `tegaki/solid`, `tegaki/astro`, `tegaki/wc` (`` custom element), `tegaki/core` (`TegakiEngine`, no framework), `tegaki/shaper-harfbuzz` (optional HarfBuzz text shaping). Remotion uses the React component directly. - Bundled fonts (import from `tegaki/fonts/`): `caveat` (Caveat, Latin), `italianno` (Italianno, Latin), `tangerine` (Tangerine, Latin), `parisienne` (Parisienne, Latin), `suez-one` (Suez One, Hebrew), `amiri` (Amiri, Arabic), `tillana` (Tillana, Devanagari), `atma` (Atma, Bengali), `klee-one` (Klee One, Japanese), `nanum-pen-script` (Nanum Pen Script, Korean), `lxgw-wenkai` (LXGW WenKai, Simplified Chinese). - Any other font: generate a bundle in Tegaki Studio (https://tegaki.ink/studio/) and import its `bundle.ts`. - Rendering: a canvas draws the strokes over real DOM text, so the text stays selectable, copyable and readable by screen readers. - Time: uncontrolled (plays on its own, `speed`, `loop`, `catchUp` for streaming text), controlled (seconds or `'50%'`), or `'css'` (reads the `--tegaki-progress` custom property, e.g. from a scroll timeline). - Effects: glow, wobble, pressure width, taper, per-stroke and whole-text gradients; custom `TegakiPlugin`s can reshape and paint strokes, and `createPlugin` gives one typed options and presets. `variationPlugin` makes each glyph a little different, `boilPlugin` makes the lines boil (hand-drawn animation shimmer), `annotatePlugin` underlines, circles, boxes, strikes or highlights the text once written, `textPathPlugin` lays it on an arc, a circle or a (flowing) wave, `captionPlugin` writes each word in time with speech from word timings; a fixed `seed` (default 0) draws the same every time, `seed: "random"` anew on each load. - Writing systems: Latin; Hebrew and Arabic right to left with positional forms; Devanagari and Bengali with headlines drawn across the word; Japanese, Korean and Simplified Chinese in reference stroke order (KanjiVG, Make Me a Hanzi). Text is shaped with HarfBuzz. - Editable text: with `editable`, the renderer takes input and writes each letter as it is typed. - Streaming: update `text` as tokens arrive (for example from an LLM) and the pen continues from where it is. ## Install ```sh npm i tegaki ``` ## Quick start in every framework ### React ```tsx import { TegakiRenderer } from 'tegaki'; import caveat from 'tegaki/fonts/caveat'; export const Note = () => ( Hello, world! ); ``` ### Svelte ```svelte ``` ### Vue ```vue ``` ### Solid ```tsx import { TegakiRenderer } from 'tegaki/solid'; import caveat from 'tegaki/fonts/caveat'; export const Note = () => ( ); ``` ### Astro ```astro --- import TegakiRenderer from 'tegaki/astro'; import caveat from 'tegaki/fonts/caveat'; --- ``` ### Web Component ```html ``` ### Vanilla ```js import { TegakiEngine } from 'tegaki/core'; import caveat from 'tegaki/fonts/caveat'; new TegakiEngine(document.querySelector('#note'), { font: caveat, text: 'Hello, world!', }); ``` ## Generate a handwriting animation from any font Tegaki Studio, a free handwriting animation generator in the browser: pick any Google Font or upload a .ttf/.otf, tune the strokes and timing, then export PNG, GIF, WebM, animated SVG, or a font bundle for your app. Open it at https://tegaki.ink/studio/. ## FAQ ### What is Tegaki? Tegaki is an open-source (MIT) handwriting animation library and generator. It extracts the strokes, stroke order and stroke width of every glyph in a font, then animates text being written by hand on a canvas drawn over real, selectable DOM text. ### How do I add a handwriting animation to my website? Install the package with `npm i tegaki`, import a font bundle such as `tegaki/fonts/caveat`, and render `Hello`. Adapters exist for React, Svelte, Vue, Nuxt, SolidJS, Astro, Web Components and vanilla JavaScript; Remotion renders the same animation to video. ### Can Tegaki animate any font? Yes. Eleven fonts ship ready to import, and the free Tegaki Studio generates a bundle from any Google Font or your own .ttf/.otf file in the browser, with no server. Handwriting and script fonts look most natural, but any outline font works. ### Which languages and writing systems are supported? Latin, Hebrew and Arabic (right to left, with positional forms), Devanagari and Bengali, Japanese, Korean and Simplified Chinese. Chinese, Japanese and Korean characters follow reference stroke order from KanjiVG and Make Me a Hanzi, and text is shaped with HarfBuzz. ### How is Tegaki different from an SVG stroke-dashoffset animation? A stroke-dashoffset trick traces the outline of the letters, so the pen goes around each glyph twice. Tegaki animates the centerline of each stroke with its real width, in natural stroke order, and supports timeline control, streaming text, effects and every writing system above. ### Can I control or scrub the animation? Yes. Pass `time` as seconds, a percentage such as `'50%'`, or `'css'` to read progress from the `--tegaki-progress` custom property, so the animation can follow a slider, a scroll timeline or a video frame. Uncontrolled mode plays on its own and can loop. ### Can I export a handwriting animation as a video, GIF or SVG? Tegaki Studio exports PNG, GIF, WebM and animated SVG. For programmatic video, the Remotion integration renders handwriting frame by frame to MP4. ### Is Tegaki free? Yes. The library, the bundled fonts (each under its own open font license) and the Studio are free; the code is MIT licensed on GitHub. ## Links - Docs: https://tegaki.ink/getting-started/ - Studio: https://tegaki.ink/studio/ - GitHub: https://github.com/gkurt/tegaki - npm: https://www.npmjs.com/package/tegaki --- # Getting Started > Install Tegaki, the handwriting animation library, and render your first animated handwriting in React, Svelte, Vue, SolidJS, Astro, Web Components or vanilla JS. Source: https://tegaki.ink/getting-started/ Tegaki generates stroke data from any font and renders it as handwriting animation. It works with React, Svelte, Vue, SolidJS, Astro, Web Components, or vanilla JavaScript. ## Installation ```sh npm install tegaki ``` ## Quick Start 1. **Pick a font bundle** Use one of the built-in font bundles: ```tsx import bundle from 'tegaki/fonts/caveat'; ``` Built-in fonts: `caveat`, `italianno`, `tangerine`, `parisienne` (Latin), `suez-one` (Hebrew), `amiri` (Arabic), `tillana` (Devanagari), `klee-one` (Japanese), `nanum-pen-script` (Korean), `lxgw-wenkai` (Simplified Chinese). > **Bundle size** > > Each bundle ships the full source TTF as a fallback for characters outside the generated subset. The browser downloads it only when the text contains such a character. The Latin bundles are small (~250–400 KB), but Hebrew/Arabic/Japanese reflect their source fonts' size: `klee-one` is around 7 MB because Klee One's font contains thousands of kanji. `lxgw-wenkai` is the exception: LXGW WenKai's full font is 25 MB, so it ships only the generated subset. Pick a font for the characters outside it with [`fallbackFont`](https://tegaki.ink/api/renderer/#fallbackfont). Bundles are only loaded when you `import` them, but if you need a tighter footprint use the [studio](https://tegaki.ink/studio/) to produce a custom bundle with just the characters you need. Want a different font? Use the [studio](https://tegaki.ink/studio/) to create stroke data for any Google Font, then import the downloaded bundle instead: ```tsx import bundle from './output/my-font/bundle'; ``` 2. **Render the animation** Import the component for your framework and pass the font bundle: **React** ```tsx import { TegakiRenderer } from 'tegaki'; import bundle from 'tegaki/fonts/caveat'; function App() { return ( Hello World ); } ``` **Svelte** ```svelte ``` **Vue** ```vue ``` **SolidJS** ```tsx import { TegakiRenderer } from 'tegaki/solid'; import bundle from 'tegaki/fonts/caveat'; function App() { return ( ); } ``` **Astro** ```astro --- import TegakiRenderer from 'tegaki/astro'; import bundle from 'tegaki/fonts/caveat'; --- ``` Also add `tegaki()` from `tegaki/astro/integration` to your config's `integrations`, so the font loads on server-rendered pages. See the [Astro guide](https://tegaki.ink/frameworks/astro/). **Web Components** ```html ``` **Vanilla JS** ```html
``` > **Using Vite 7 or earlier?** > > Vite's dev pre-bundler breaks the font bundle's `.ttf` URL, so the text is laid out but never draws. Add `optimizeDeps: { exclude: ['tegaki'] }` to `vite.config.ts` and restart the dev server. See [Bundler Setup](https://tegaki.ink/guides/bundlers/#vite). ## What's next? - [Framework guides](https://tegaki.ink/frameworks/react/): React, Svelte, Vue, SolidJS, Astro, Web Components, and vanilla JS - [Generating Font Data](https://tegaki.ink/guides/generating/): the pipelines and their options - [Rendering Animations](https://tegaki.ink/guides/rendering/): timing, effects, and time modes - [Studio](https://tegaki.ink/studio/): generate a bundle for any font in the browser --- # React > Add a handwriting animation to a React or Next.js app with the TegakiRenderer component: controlled time, effects, streaming text and the imperative handle. Source: https://tegaki.ink/frameworks/react/ Tegaki's default export is a React component that supports SSR, streaming text, and imperative playback control. ## Installation ```sh npm install tegaki react react-dom ``` ## Basic example ```tsx import { TegakiRenderer } from 'tegaki'; import bundle from 'tegaki/fonts/caveat'; function App() { return ( Hello World ); } ``` > **Using Vite 7 or earlier?** > > If the text doesn't draw in `vite dev` (you can select it, or see it with `showOverlay`, but nothing animates), add `optimizeDeps: { exclude: ['tegaki'] }` to `vite.config.ts` and restart the dev server. See [Bundler Setup](https://tegaki.ink/guides/bundlers/#vite). ## Controlled time Drive the animation from external state: ```tsx import { useState } from 'react'; import { TegakiRenderer } from 'tegaki'; import bundle from 'tegaki/fonts/caveat'; function App() { const [time, setTime] = useState(0); return ( <> setTime(Number(e.target.value))} /> Scrub me! ); } ``` ## Imperative handle Access the engine instance via `ref`: ```tsx import { useRef } from 'react'; import { TegakiRenderer, type TegakiRendererHandle } from 'tegaki'; import bundle from 'tegaki/fonts/caveat'; function App() { const ref = useRef(null); return ( Hello World ); } ``` The handle exposes: - `ref.current.engine`: the `TegakiEngine` instance (or `null` before mount) - `ref.current.element`: the container `HTMLDivElement` ## Effects ```tsx Fancy effects ``` ## Streaming text For AI chat interfaces, where text arrives a chunk at a time: ```tsx function StreamingMessage({ text }) { return ( {text} ); } ``` See [Streaming Text](https://tegaki.ink/guides/streaming/) for more details. --- # Svelte > Add a handwriting animation to a Svelte 5 app with the TegakiRenderer component: controlled time, effects and streaming text. Source: https://tegaki.ink/frameworks/svelte/ Tegaki provides a Svelte 5 component with SSR support and reactive prop updates. ## Installation ```sh npm install tegaki svelte ``` ## Basic example ```svelte ``` ## Controlled time Bind the time to a reactive variable: ```svelte ``` ## Effects ```svelte ``` ## Streaming text ```svelte ``` ## Props The Svelte component accepts all [`TegakiEngineOptions`](https://tegaki.ink/api/renderer/) plus standard HTML div attributes (`class`, `style`, etc.). --- # Vue > Add a handwriting animation to a Vue 3 app with the TegakiRenderer component: v-model time, effects, streaming text and the exposed instance. Source: https://tegaki.ink/frameworks/vue/ Tegaki provides a Vue 3 component (Composition API) with SSR support and deep reactive prop watching. ## Installation ```sh npm install tegaki vue ``` ## Basic example ```vue ``` ## Controlled time Use `v-model` or a reactive ref to drive the animation: ```vue ``` ## Effects ```vue ``` ## Streaming text ```vue ``` ## Exposed instance The component exposes `engine` and `element` via template refs: ```vue ``` ## Props The Vue component accepts all [`TegakiEngineOptions`](https://tegaki.ink/api/renderer/) as props. --- # Nuxt > Add a handwriting animation to a Nuxt 3 or Nuxt 4 app with the tegaki/nuxt module: SSR and client rendering, and an auto-imported TegakiRenderer. Source: https://tegaki.ink/frameworks/nuxt/ Tegaki ships a Nuxt module (`tegaki/nuxt`) that wires up the Vue adapter for both SSR and client rendering, and auto-imports `` as a global component. ## Installation ```sh npm install tegaki ``` ## Register the module ```ts title="nuxt.config.ts" export default defineNuxtConfig({ modules: ['tegaki/nuxt'], }); ``` The module adds `tegaki` to `build.transpile` so Nuxt's Vite and Nitro bundlers can process the Vue single-file component that ships in source form, and registers `` as a global component. ## Basic example ```vue title="pages/index.vue" ``` The page server-renders the full handwriting markup (canvas, overlay, sentinel) on the first request; the engine hydrates and starts animating on mount. ## Controlled time ```vue title="pages/scrub.vue" ``` ## Module options Pass options via the `tegaki` key in `nuxt.config.ts`: ```ts title="nuxt.config.ts" export default defineNuxtConfig({ modules: ['tegaki/nuxt'], tegaki: { autoImport: true, // register globally (default: true) prefix: '', // component name prefix (default: '') }, }); ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `autoImport` | `boolean` | `true` | Register `` as a global component | | `prefix` | `string` | `""` | Prefix prepended to the component name (e.g. `"Tegaki"` → ``) | If you disable `autoImport`, you can still import the component manually: ```vue ``` ## Example app A runnable example lives at [`examples/nuxt/`](https://github.com/gkurt/tegaki/tree/main/examples/nuxt) in the repo. Clone and run: ```sh bun install bun --filter @tegaki/example-nuxt dev ``` ## Props `` accepts all [`TegakiEngineOptions`](https://tegaki.ink/api/renderer/) as props. See the [Vue guide](https://tegaki.ink/frameworks/vue/) for the full prop surface and effects examples. --- # SolidJS > Add a handwriting animation to a SolidJS app with the TegakiRenderer component: controlled time, effects, streaming text and the ref handle. Source: https://tegaki.ink/frameworks/solid/ Tegaki provides a SolidJS component with SSR support and fine-grained reactive updates. ## Installation ```sh npm install tegaki solid-js ``` ## Basic example ```tsx import { TegakiRenderer } from 'tegaki/solid'; import bundle from 'tegaki/fonts/caveat'; function App() { return ( ); } ``` ## Controlled time Use a signal to drive the animation: ```tsx import { createSignal } from 'solid-js'; import { TegakiRenderer } from 'tegaki/solid'; import bundle from 'tegaki/fonts/caveat'; function App() { const [time, setTime] = createSignal(0); return ( <> setTime(Number(e.currentTarget.value))} /> ); } ``` ## Effects ```tsx ``` ## Streaming text ```tsx import { TegakiRenderer } from 'tegaki/solid'; import bundle from 'tegaki/fonts/caveat'; function StreamingMessage(props: { text: string }) { return ( ); } ``` ## Ref handle Access the engine via a callback ref: ```tsx import { TegakiRenderer, type TegakiRendererHandle } from 'tegaki/solid'; import bundle from 'tegaki/fonts/caveat'; function App() { let handle: TegakiRendererHandle; return ( (handle = h)} font={bundle} text="Hello World" time={{ mode: 'uncontrolled', speed: 1 }} style={{ 'font-size': '48px' }} /> ); } ``` The handle exposes: - `handle.engine`: the `TegakiEngine` instance - `handle.element`: the container `HTMLDivElement` ## Props The SolidJS component accepts all [`TegakiEngineOptions`](https://tegaki.ink/api/renderer/) plus standard HTML div attributes (`class`, `style`, etc.). --- # Astro > Add a handwriting animation to an Astro site with the server-rendered TegakiRenderer component, font registration and effects. Source: https://tegaki.ink/frameworks/astro/ Tegaki has an Astro component that renders full HTML at build time and hydrates on the client. It needs no JavaScript framework, and the text is visible before hydration. ## Installation ```sh npm install tegaki ``` Then add the Tegaki integration to your Astro config: ```js // astro.config.mjs import { defineConfig } from 'astro/config'; import tegaki from 'tegaki/astro/integration'; export default defineConfig({ integrations: [tegaki()], }); ``` The component renders on the server and hands the font bundle to the browser, so the bundle's font URLs have to work there. Vite turns them into public asset URLs for client code but leaves server code as it stands, where they point at files on the build machine. The integration makes Vite resolve them on the server too. Without it, the build warns and the page loads the handwriting in a fallback font's widths. ## Basic example ```astro --- import TegakiRenderer from 'tegaki/astro'; import bundle from 'tegaki/fonts/caveat'; --- ``` ## Font registration When using the same font across multiple components on a page, you can register the bundle once and reference it by name. This avoids duplicating the font data in the HTML output. ### Register the bundle Use the `bundle` prop on a dedicated instance. It serializes the font data into a ` ``` To pin a specific version, add it to the URL: ```js import { registerTegakiElement, TegakiEngine } from 'https://esm.sh/tegaki@0.8.0/wc'; import caveat from 'https://esm.sh/tegaki@0.8.0/fonts/caveat'; ``` ### jsDelivr [jsDelivr](https://www.jsdelivr.com/) can serve package files directly via its ESM endpoint: ```html ``` > **Note** > > `esm.run` is jsDelivr's ESM-specific domain. Like esm.sh, it resolves package exports and bundles dependencies. ### Import maps For cleaner imports, use an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap) to map bare specifiers to CDN URLs: ```html ``` This lets you write the same import paths you'd use with a bundler, while loading from the CDN at runtime. ### Loading font data manually with `createBundle` If the pre-built font bundles don't work in your environment, or you want to load font data from a custom URL, use `createBundle` to assemble a bundle from its parts: ```html ``` This is also useful for loading custom font bundles generated with the [studio](https://tegaki.ink/studio/). ### Full standalone example A complete HTML file you can save and open in a browser: ```html Tegaki CDN Example ``` ## Basic example ```html ``` The element uses Shadow DOM internally. Style the host element with `font-size` and `color` as usual; the renderer inherits them. ## Text content You can set the text via the `text` attribute or as the element's text content: ```html Hello World ``` ## Controlled time Set the `time` attribute to a number to control the animation manually: ```html ``` ## Playback attributes Control uncontrolled playback via attributes: ```html ``` ## Effects Effects are objects and can't be expressed as attributes. Use the `effects` property instead: ```js const el = document.querySelector('tegaki-renderer'); el.effects = { glow: { radius: 8, color: '#00ccff' }, pressureWidth: true, strokeGradient: { colors: 'rainbow' }, }; ``` ## Playback controls The element exposes playback methods and read-only properties: ```js const el = document.querySelector('tegaki-renderer'); el.play(); el.pause(); el.seek(1.5); // jump to 1.5 seconds el.restart(); el.currentTime; // current playback time (seconds) el.duration; // total animation duration (seconds) el.isPlaying; // whether animation is running el.isComplete; // whether animation has finished ``` ## Accessing the engine For advanced use cases, the underlying `TegakiEngine` instance is available: ```js const el = document.querySelector('tegaki-renderer'); const engine = el.engine; engine.update({ timing: { charDuration: 0.5 } }); ``` ## CSS time mode Use `time="css"` to drive the animation from CSS custom properties: ```html ``` See the [Rendering Animations](https://tegaki.ink/guides/rendering/#css-time-mode) guide for details. ## Custom tag name Custom element names [must contain a hyphen](https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name) per the HTML spec. The default is `tegaki-renderer`, but you can choose any valid name: ```js registerTegakiElement('my-handwriting'); ``` ```html ``` ## Attributes reference | Attribute | Type | Description | |---|---|---| | `text` | `string` | Text to render. Falls back to `textContent` if not set. | | `font` | `string` | Registered bundle name (see `TegakiEngine.registerBundle`). | | `time` | `number \| "css"` | Controlled time in seconds, or `"css"` for CSS mode. Omit for uncontrolled. | | `speed` | `number` | Playback speed multiplier (default `1`). | | `playing` | `"true" \| "false"` | Whether animation is playing (default `true`). | | `loop` | _boolean attribute_ | Loop animation when it finishes. | | `reduced-motion` | `"never" \| "user" \| "always"` | Whether to honour reduced motion by showing the text finished (default `"never"`; see [`reducedMotion`](https://tegaki.ink/api/renderer/#reducedmotion)). | | `pixel-ratio` | `number` | Supersampling factor on top of devicePixelRatio (shortcut for `quality.pixelRatio`). | | `segment-size` | `number` | Max drawn segment length in CSS pixels (shortcut for `quality.segmentSize`). | | `seed` | `number \| "random"` | What random choices come from: wobble, gradients, plugins (default `0`; see [`seed`](https://tegaki.ink/api/renderer/#seed)). The element's `engine.seed` is the number it drew with. | | `show-overlay` | _boolean attribute_ | Show debug text overlay. | | `fallback-font` | `string` | CSS font-family list for characters the bundle has no stroke data for (see [`fallbackFont`](https://tegaki.ink/api/renderer/#fallbackfont)). | ## Properties reference These properties are set via JavaScript and support object values that can't be expressed as attributes: | Property | Type | Description | |---|---|---| | `font` | `TegakiBundle \| string` | Font bundle object or registered name. | | `effects` | `TegakiEffects` | Visual effects configuration. | | `timing` | `TimelineConfig` | Timeline timing configuration. | | `quality` | `TegakiQuality` | Render-quality knobs (`pixelRatio`, `segmentSize`). | | `onComplete` | `() => void` | Callback when animation completes. | | `engine` | `TegakiEngine` _(read-only)_ | The underlying engine instance. | For the full options reference, see the [TegakiRenderer API docs](https://tegaki.ink/api/renderer/). --- # Vanilla JS > Animate handwriting in plain JavaScript with TegakiEngine: options, controlled time, effects, cleanup and pre-rendering on the server. Source: https://tegaki.ink/frameworks/vanilla/ You can use `TegakiEngine` directly with plain JavaScript or TypeScript, without a framework. ## Installation ```sh npm install tegaki ``` ## Basic example ```html
``` The engine creates its DOM elements inside the container. ## Updating options Call `update()` to change text, font, time, or effects at any time: ```js engine.update({ text: 'New text' }); engine.update({ effects: { glow: { radius: 8, color: '#00ccff' }, }, }); ``` ## Controlled time Pass a number to `time` and update it on each frame: ```js const engine = new TegakiEngine(container, { text: 'Controlled', font: bundle, time: 0, }); let t = 0; function animate() { t += 0.016; engine.update({ time: t }); requestAnimationFrame(animate); } animate(); ``` ## Effects ```js const engine = new TegakiEngine(container, { text: 'Fancy effects', font: bundle, time: { mode: 'uncontrolled', speed: 1, loop: true }, effects: { glow: { radius: 8, color: '#00ccff' }, pressureWidth: true, strokeGradient: { colors: 'rainbow' }, }, }); ``` ## Cleanup Call `destroy()` when you're done to remove event listeners and stop the animation loop: ```js engine.destroy(); ``` ## Font registration Register bundles globally so you can reference them by name: ```js import { TegakiEngine } from 'tegaki/core'; import caveat from 'tegaki/fonts/caveat'; TegakiEngine.registerBundle(caveat); // Later, anywhere in your app: const engine = new TegakiEngine(container, { text: 'Hello', font: 'Caveat', // resolved from the registry }); ``` ## Pre-rendering (SSR) Use `TegakiEngine.renderElements()` to produce the element tree on the server, then adopt it on the client: ```js import { TegakiEngine } from 'tegaki/core'; // Server: render to HTML string const html = TegakiEngine.renderElements( { text: 'Hello', font: bundle }, (tag, props, ...children) => { // your HTML string builder }, ); // Client: adopt pre-rendered DOM const engine = new TegakiEngine(container, { adopt: true, text: 'Hello', font: bundle }); ``` This is what all framework adapters use internally. --- # Remotion > Render handwriting animations to video with Tegaki and Remotion: frame-accurate timing, font loading and your own fonts. Source: https://tegaki.ink/frameworks/remotion/ Tegaki integrates with [Remotion](https://remotion.dev) for programmatic video rendering. Because Remotion drives time via `useCurrentFrame()`, the trick is to put Tegaki in **controlled progress mode** and map `frame / durationInFrames` to a `0–1` progress value. ## Installation ```sh npm install tegaki remotion @remotion/cli react react-dom ``` ## Basic example ```tsx import { Composition, useCurrentFrame, useVideoConfig } from 'remotion'; import { TegakiRenderer } from 'tegaki'; import caveat from 'tegaki/fonts/caveat'; const Handwriting: React.FC = () => { const frame = useCurrentFrame(); const { durationInFrames } = useVideoConfig(); const progress = frame / durationInFrames; return (
); }; export const RemotionRoot: React.FC = () => { return ( ); }; ``` The `unit: 'progress'` option interprets `value` as a `0–1` ratio of the total animation duration, so you don't need to know Tegaki's computed timeline length: Remotion's composition length drives the mapping directly. ## Loading fonts Tegaki's font bundles (`tegaki/fonts/caveat`, `tegaki/fonts/italianno`, etc.) load their own font: the engine creates a `FontFace` and registers it with `document.fonts`. Remotion waits on `document.fonts.ready` before capturing each frame, so **no `loadFont` call is required**. Pass the bundle to `TegakiRenderer`. ## Webpack override for Remotion Studio Rendering with `remotion render` needs no setup, but **Remotion Studio's dev server serves the TTF URL incorrectly** because the bundled path escapes the studio's root (webpack emits `../../node_modules/tegaki/fonts/caveat/caveat.ttf`, which the SPA catch-all intercepts and returns HTML). The fix is to tell webpack to inline `.ttf` files as data URLs. Add a `remotion.config.ts` at the root of your Remotion project: ```ts // remotion.config.ts import { Config } from '@remotion/cli/config'; Config.overrideWebpackConfig((config) => { return { ...config, module: { ...config.module, rules: [ ...(config.module?.rules ?? []).filter((rule) => { if (typeof rule !== 'object' || !rule || !rule.test) return true; const test = rule.test; return !(test instanceof RegExp && test.test('a.ttf')); }), { test: /\.ttf$/, type: 'asset/inline', }, ], }, }; }); ``` This works in both the studio and in render/build mode. The bundled font is ~60–250 kB depending on the family, which is negligible for a video bundle. ## Controlling timing By default, `progress = frame / durationInFrames` spreads the handwriting animation across the entire composition. Two common variations: **Finish drawing before the end of the composition**, e.g. to hold the finished text for the last second: ```tsx const { fps, durationInFrames } = useVideoConfig(); const drawFrames = durationInFrames - fps; // leave 1s of hold const progress = Math.min(1, frame / drawFrames); ``` **Start drawing after a delay**: ```tsx const { fps } = useVideoConfig(); const delayFrames = fps; // 1s delay const drawFrames = 120; // 2s to draw const progress = Math.max(0, Math.min(1, (frame - delayFrames) / drawFrames)); ``` ## Effects, plugins and the seed Wobble, gradients and plugins such as `variationPlugin` draw their randomness from the renderer's [`seed`](https://tegaki.ink/api/renderer/#seed). It defaults to `0`, so every frame matches, including frames rendered in parallel tabs. To give a video a different look, pass another fixed number (`seed={7}`). Never pass `seed="random"` in a composition, because each rendering tab would pick its own seed and the handwriting would jump between frames. `boilPlugin` picks its drawing from the time you pass, so it renders the same every time too. It follows the timeline's seconds, so pass the time in seconds rather than as progress if the boil should run at video speed and go on after the text is written: ```tsx const { fps } = useVideoConfig(); ; ``` Time past the end keeps the text finished, and the boil keeps cycling. ## Natural pacing `unit: 'progress'` stretches or squeezes the writing to fit the composition. To keep Tegaki's own pace (its per-stroke timing, as on a web page), pass the time in **seconds** and size the composition from the timeline with `calculateMetadata`: ```tsx import { Composition, type CalculateMetadataFunction, useCurrentFrame, useVideoConfig } from 'remotion'; import { computeTimeline, TegakiRenderer } from 'tegaki'; import caveat from 'tegaki/fonts/caveat'; const TEXT = 'What a wonderful world'; const HOLD_SECONDS = 1; const Handwriting: React.FC = () => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); // Past the end, the time clamps: the finished text holds. return ; }; const calculateMetadata: CalculateMetadataFunction> = ({ props }) => { const { totalDuration } = computeTimeline(TEXT, caveat); return { durationInFrames: Math.ceil((totalDuration + HOLD_SECONDS) * 30), props }; }; export const RemotionRoot: React.FC = () => ( ); ``` Pass `computeTimeline` the same `timing` you give the renderer (as its third argument), or the durations won't match. The timeline's `entries` also give each character's start and duration, and [`strokeInstances`](https://tegaki.ink/api/renderer/#strokeinstancestimeline-font) each stroke's, for syncing sound effects or cuts to the pen. ## Arabic, Devanagari and other complex scripts Shaping is opt-in. Fonts that need it (joining, ligatures, reordering: `amiri`, `tillana`, `atma`, `suez-one`) need the harfbuzz shaper registered once, and a composition sized from the **shaped** timeline, which can differ from the unshaped one by a third: ```tsx import { computeTimeline, TegakiEngine } from 'tegaki'; import amiri from 'tegaki/fonts/amiri'; import harfbuzzShaper, { createHarfbuzzShaper } from 'tegaki/shaper-harfbuzz'; TegakiEngine.registerShaper(harfbuzzShaper); const calculateMetadata = async () => { const shaper = await createHarfbuzzShaper(amiri); const { totalDuration } = computeTimeline('مرحبا بالعالم', amiri, undefined, shaper); return { durationInFrames: Math.ceil((totalDuration + 1) * 30) }; }; ``` The shaper loads asynchronously. To be sure no frame is captured before it (and the font) are ready, hold the render on `TegakiEngine.preload(bundle)` with Remotion's `delayRender` / `continueRender`. ## Using your own font The pre-built bundles (`tegaki/fonts/*`) are convenient but you can use any font generated by the Tegaki generator. See [Generating Font Data](https://tegaki.ink/guides/generating/). --- # Generating Font Data > Turn any font into handwriting animation data: the stroke extraction pipelines, their options, and the bundle format Tegaki renders. Source: https://tegaki.ink/guides/generating/ The Tegaki generator processes fonts through a multi-stage pipeline to produce stroke data suitable for handwriting animation. Use the [studio](https://tegaki.ink/studio/) on the Tegaki website to generate font bundles directly in the browser. ## Pipelines The generator has two ways to find a glyph's strokes. Both start the same way: opentype.js extracts the path commands and metrics, and adaptive de Casteljau subdivision flattens the bezier curves into polylines. ### Geometry (default) Works on the outline itself, with no bitmap: 1. **Mesh**: a conforming Delaunay triangulation fills the glyph's ink 2. **Ink graph**: the triangles' chordal axis gives each stroke's centerline, with its width read from the chords 3. **Spurs and nibs**: short offshoots at pointed ends and corners are pruned, and the ink they held is painted by an elliptic nib stamp as the pen passes 4. **Junctions**: where strokes meet, the arms are paired so a pen runs straight through a crossing instead of turning 5. **Simplify**: width-aware Ramer-Douglas-Peucker keeps the points the pen needs to cover the ink; leftover holes get stamps of their own 6. **Stroke order**: ordered by a matching [KanjiVG](https://kanjivg.tagaini.net/) or Hershey reference glyph when one fits the ink, otherwise top-to-bottom/left-to-right ### Raster Rasterizes the glyph and thins it to a skeleton: 1. **Rasterize**: scanline fill with nonzero winding rule produces a binary bitmap 2. **Skeletonize**: Zhang-Suen thinning reduces the bitmap to 1px-wide skeleton 3. **Trace**: walks skeleton pixels into polylines, prunes short spurs, simplifies with Ramer-Douglas-Peucker 4. **Width**: a distance transform computes stroke width at each skeleton point 5. **Stroke order**: groups polylines into connected components, sorts top-to-bottom/left-to-right ## Options | Option | Default | Description | |--------|---------|-------------| | Font | `Caveat` | Google Font family name | | Characters | `A-Za-z0-9` + punctuation | Characters to process | | Pipeline | `geometry` | Stroke extraction: `geometry` or `raster` | | Resolution | `400` | Bitmap resolution for rasterization (raster) | | Skeleton method | `zhang-suen` | Skeletonization algorithm (raster) | | Line cap | `round` | Stroke line cap style | | Bezier tolerance | `0.5` | Flatness tolerance for bezier subdivision | | RDP tolerance | `1.0` | Ramer-Douglas-Peucker simplification tolerance (raster) | ## Output format The generator outputs a compressed format with short keys to minimize bundle size. Per-glyph data includes `w` (advance width), `t` (total animation duration), and `s` (strokes). Each stroke has `p` (points as `[x, y, width]` tuples), `d` (delay), `a` (animation duration), and, for geometry strokes that carry them, `n` (nib stamps as `[pointIndex, dx, dy, major, minor, angle]`). See the `TegakiGlyphData` type for full details. ## Interactive generator Use the [studio](https://tegaki.ink/studio/) to visualize each pipeline stage and fine-tune options. --- # Rendering Animations > Display handwriting animations with Tegaki: styling, the controlled, uncontrolled and CSS time modes, effects and streaming text. Source: https://tegaki.ink/guides/rendering/ Tegaki handles text layout, line wrapping, and animation playback. See the [framework guides](https://tegaki.ink/frameworks/react/) for framework-specific setup. ## Basic usage **React** ```tsx import { TegakiRenderer } from 'tegaki'; import bundle from 'tegaki/fonts/caveat'; Hello World ``` **Svelte** ```svelte ``` **Vue** ```vue ``` **Vanilla JS** ```js import { TegakiEngine } from 'tegaki/core'; import bundle from 'tegaki/fonts/caveat'; new TegakiEngine(document.getElementById('tegaki'), { text: 'Hello World', font: bundle, time: { mode: 'uncontrolled', speed: 1, loop: true }, }); ``` ## Styling Tegaki reads its typography from the container's own CSS. Set `font-size`, `line-height`, `color`, and `letter-spacing` however you normally would (inline style, a class, a CSS variable) and the renderer picks them up, re-measuring when they change. `letter-spacing` is applied to the animated strokes just as the browser would space static text, so spacing, line wrapping, and the drawn glyphs stay in sync. `line-height: normal` gives the font's own line spacing: it differs from font to font (Tangerine sets lines 1em apart, Amiri 1.76em) and is measured from the browser, so the strokes land where the text would. Most pages inherit a fixed `line-height` (Tailwind's base styles set 1.5), which is loose for handwriting fonts with small letters; set `normal` or a smaller ratio on the renderer to tighten it. ```tsx Hello World ``` ## Time control modes ### Uncontrolled The component manages its own animation clock: ```ts time: { mode: 'uncontrolled', speed: 2, loop: true } ``` Options: - **`speed`**: playback speed multiplier (default `1`) - **`loop`**: restart when animation completes (default `false`) - **`playing`**: pause/resume (default `true`) - **`catchUp`**: speed up when buffered text is ahead (for streaming) ### Controlled You provide the current time value directly (useful for syncing with external state): ```ts time: 2.5 // seconds // or time: { mode: 'controlled', value: 2.5 } ``` ### CSS Animation is driven purely by CSS (best performance, no JavaScript animation loop): ```ts time: 'css' // or time: { mode: 'css' } ``` ## Effects All frameworks accept the same effects configuration: ```ts effects: { glow: { radius: 8, color: '#00ccff' }, wobble: { amplitude: 1.5, frequency: 8 }, pressureWidth: { strength: 1 }, taper: { startLength: 0.15, endLength: 0.15 }, strokeGradient: { colors: 'rainbow' }, } ``` Effects can also be set to `true` for defaults: `{ pressureWidth: true }`. ## Streaming text For chat-like interfaces where text arrives incrementally, see [Streaming Text](https://tegaki.ink/guides/streaming/). --- # Streaming Text > Animate handwriting as text streams in from an API or LLM, with the pen catching up to the stream as tokens arrive. Source: https://tegaki.ink/guides/streaming/ Tegaki can animate text as it arrives from a streaming API, as in an AI chat interface. ## Approach Use uncontrolled mode: it follows the growing text and writes new characters as they arrive. The `catchUp` option speeds up playback when there's buffered text, so the animation stays close to the stream without jumping. **React** ```tsx import { TegakiRenderer } from 'tegaki'; function StreamingMessage({ text, font }) { return ( {text} ); } ``` **Svelte** ```svelte ``` **Vue** ```vue ``` **Vanilla JS** ```js import { TegakiEngine } from 'tegaki/core'; const engine = new TegakiEngine(container, { font: bundle, time: { mode: 'uncontrolled', speed: 4, catchUp: 0.5 }, }); // As chunks arrive: engine.update({ text: accumulatedText }); ``` Update `text` as chunks arrive from your API. The renderer writes whatever is new. ### Tuning - **`speed`**: base playback speed multiplier (default `1`). Higher values write faster. - **`catchUp`**: how hard the animation speeds up to close the gap when text arrives faster than it can be drawn. `0` disables catch-up (default). Typical range: `0.2` to `2`. ## Live demo See the streaming chat on the [homepage](https://tegaki.ink/#stream) for a live example. --- # Text Shaping > Enable ligatures, contextual alternates, and complex scripts (Arabic, Indic) with the harfbuzz shaper. Source: https://tegaki.ink/guides/shaping/ By default Tegaki renders text by walking graphemes one-by-one and looking each character up in the bundle's char-keyed glyph map. This is fast and SSR-safe, but it doesn't know how to form ligatures (`fi`, `ff`), apply contextual alternates (`calt`), or shape complex scripts (Arabic positional forms, Indic conjuncts). The optional **harfbuzz shaper** plugs in a wasm-based text shaper so the renderer can resolve those glyphs. ## When you need it Enable the shaper when: - The font has ligatures or contextual alternates you want to see (most cursive fonts). - You're rendering Arabic, Hebrew, or Indic scripts. These need positional forms (`init`/`medi`/`fina`/`isol`) and reordering that a 1:1 char-to-glyph mapping cannot produce. - You generated the bundle with extra subset fonts via `extraFontUrls`. Cross-script text needs the shaper to route each cluster to the subset that contains its glyphs. You can skip it for plain Latin text without ligatures. ## Bundle requirements The shaper resolves glyph ids that the bundle was generated with. To get variant glyphs (ligatures, contextual forms) into the bundle, regenerate it with the relevant OpenType features enabled. The [studio](https://tegaki.ink/studio/) lets you toggle features per font. The bundle's `features` array records which tags it ships, and the shaper enables those during shaping. If the bundle has no `glyphDataById` map, the shaper factory declines and the renderer falls back to the char-keyed path. ## Setup Install `harfbuzzjs` alongside `tegaki`: ```sh npm install tegaki harfbuzzjs ``` Register the shaper once at app startup: ```ts import { TegakiEngine } from 'tegaki/core'; import harfbuzzShaper from 'tegaki/shaper-harfbuzz'; TegakiEngine.registerShaper(harfbuzzShaper); ``` Every renderer instance created after registration picks up the shaper for bundles that support it. The shaper factory is called once per bundle and the result is cached. ### Per-instance opt-out Set `shaper={false}` to render a single instance without shaping (useful for side-by-side comparisons or lightweight previews): ```tsx Without shaping ``` The global registration stays intact for every other instance. ### Unregistering Pass `null` to remove the registered factory and clear the shaper cache: ```ts TegakiEngine.registerShaper(null); ``` ## SSR The shaper factory declines when `fetch` is undefined (Node SSR), so server renders fall back to the char-keyed path with no errors. Hydrate on the client and the shaper kicks in once wasm finishes loading. ## How it works 1. The renderer measures text layout against the DOM (still the source of truth for line breaks). 2. With a shaper registered, it then re-shapes each line through harfbuzz to get the correct glyph ids and accumulated advances. 3. Each shaped glyph id is looked up in `bundle.glyphDataById`. Misses fall back to `bundle.glyphData[char]`. 4. Stroke positions are placed using shaper advances so they align with the glyphs the shaper actually chose. The wasm binary and each font face are loaded once per process and reused across every engine instance. --- # Bundler Setup > Bundler-specific configuration for getting Tegaki running, especially Vite. Source: https://tegaki.ink/guides/bundlers/ Tegaki's font bundles use the ESM import-attributes syntax to load their assets: ```ts // inside tegaki/fonts//bundle.ts (and any bundle the generator produces) import fontUrl from './.ttf' with { type: 'url' }; import glyphData from './glyphData.json' with { type: 'json' }; ``` Most bundlers handle this transparently. The note below covers Vite, where two `optimizeDeps` settings are needed for development to behave like production. ## Vite In **dev mode on Vite 7 and earlier**, the esbuild pre-bundler copies Tegaki's font bundle modules into `/node_modules/.vite/deps/` but leaves the `.ttf` files behind, so the bundle's relative font URL now points to a file that isn't there. The dev server answers that request with your `index.html`, and the browser rejects it as a font. Excluding `tegaki` from pre-bundling sends the imports through Vite's normal asset pipeline, where the URLs resolve correctly. Vite 8's Rolldown-based pre-bundler rewrites these URLs itself, so it doesn't need this setting (though adding it does no harm). Add to `vite.config.ts`: ```ts import { defineConfig } from 'vite'; export default defineConfig({ // ... optimizeDeps: { exclude: ['tegaki'], }, }); ``` After changing this, fully restart the dev server (and consider deleting `node_modules/.vite/deps/` once) so Vite re-runs its pre-bundling pass against the new config. Production builds (Rollup) need no setting; this only affects `vite dev` and `vite preview`. ### Symptoms this fixes | Symptom in the browser | What's happening | Fix | | --- | --- | --- | | Text is invisible but can be selected, or only appears with `showOverlay`. Console shows `Failed to decode downloaded font: …/node_modules/.vite/deps/.ttf` / `OTS parsing error: invalid sfntVersion`, a `NetworkError: A network error occurred` or a `[tegaki] Failed to load font` warning. | The `.ttf` URL broke during pre-bundling, so the dev server returns HTML instead of the font. Older `tegaki` versions stop drawing at this point; newer ones keep drawing with the fallback font's spacing. | `optimizeDeps.exclude: ['tegaki']` | ## Astro Vite turns a bundle's font URLs into public asset URLs only in client code. On the server it leaves `new URL('./font.ttf', import.meta.url)` as it stands, and that evaluates to a `file://` path on the build machine. The [`tegaki/astro`](https://tegaki.ink/frameworks/astro/) component renders on the server and passes that bundle to the browser, which refuses to load a local file: the handwriting still draws, but in a fallback font's widths. The Tegaki integration makes Vite emit the fonts and resolve their URLs on the server as well: ```js // astro.config.mjs import { defineConfig } from 'astro/config'; import tegaki from 'tegaki/astro/integration'; export default defineConfig({ integrations: [tegaki()], }); ``` | Symptom | What's happening | Fix | | --- | --- | --- | | The build logs `[tegaki] The font of "…" resolved to a local file`, and the browser console shows `Not allowed to load local resource: file:///…/.prerender/chunks/.ttf`. The text draws with the wrong letter spacing. | The server evaluated the bundle's font URL as a path on disk. | Add `tegaki()` to `integrations` | ## Other bundlers Webpack, Rollup, Parcel, and esbuild's standalone build mode all handle `with { type: 'url' }` natively in the configurations Tegaki has been tested with. If one breaks, open an issue and its notes can go here. --- # 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 Hello ``` 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 Signed, Ada; ``` ### `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 你好,世界 ``` 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(); 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 `` 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 `` 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(``); 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); }, }), }); Hello; ``` 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 Hello Hello; ``` 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)`. --- # Generator > Reference for the Tegaki generator: the options and output format for turning a font into handwriting animation data. Source: https://tegaki.ink/api/generator/ The Tegaki generator processes fonts and outputs stroke data for handwriting animation. Use the [studio](https://tegaki.ink/studio/) on the Tegaki website to generate font bundles. ## Options | Option | Type | Default | Description | |--------|------|---------|-------------| | Font | `string` | `"Caveat"` | Google Font family name | | Characters | `string` | Default charset | Characters to process | | Resolution | `number` | `400` | Bitmap resolution | | Skeleton method | `string` | `"zhang-suen"` | `zhang-suen` or `voronoi` | | Line cap | `string` | `"round"` | `round` or `butt` | | Bezier tolerance | `number` | `0.5` | Bezier subdivision flatness | | RDP tolerance | `number` | `1.0` | RDP simplification tolerance | | Spur length ratio | `number` | `0.08` | Spur pruning threshold (fraction of resolution) | | Merge threshold ratio | `number` | | Merge threshold for nearby strokes | | Drawing speed | `number` | | Animation drawing speed | | Stroke pause | `number` | | Pause between strokes | --- # Videos > Handwriting animation videos rendered with Tegaki and Remotion, in Latin, Hebrew, Arabic and Japanese. Source: https://tegaki.ink/demos/videos/ A collection of showcase videos rendered with Tegaki and [Remotion](https://tegaki.ink/frameworks/remotion/). ## Tegaki [Watch the video (tegaki.mp4)](https://tegaki.ink/videos/tegaki.mp4) ## Writing systems A promo for the supported writing systems: Latin, Hebrew, Arabic, and Japanese (kana + Kyōiku grade 1–2 kanji). [Watch the video (scripts-promo.mp4)](https://tegaki.ink/videos/scripts-promo.mp4) --- # About Tegaki > What Tegaki is, who makes it, how it is licensed and where its code lives. Source: https://tegaki.ink/about/ Tegaki (手書き, Japanese for "handwriting") is an open-source library and generator that turns any font into text that writes itself stroke by stroke, in the order a hand would draw it. It extracts the strokes, stroke order and stroke width of every glyph in a font, then animates them on a canvas drawn over real, selectable DOM text, so the text stays accessible, searchable and copyable. ## What it includes - The `tegaki` npm package: a framework-agnostic renderer with adapters for React, Svelte, Vue, Nuxt, SolidJS, Astro, Web Components, vanilla JavaScript and Remotion. - Pre-generated font bundles for Latin, Hebrew, Arabic, Devanagari, Bengali, Japanese, Korean and Simplified Chinese, each under its own open font license. - Tegaki Studio, a free in-browser generator and preview app that exports PNG, GIF, WebM, animated SVG and font bundles. - These docs, also served as Markdown for coding agents at `/llms.txt`, `/llms-full.txt` and `/skill.md`. ## Who makes it Tegaki is created and maintained by [Gokhan Kurt](https://gkurt.com) as an independent open-source project. It is not a company product and has no paid tier: the code is MIT licensed, the site is a static site hosted on GitHub Pages, and there are no accounts, no tracking and no backend. ## Source and releases The source, issue tracker and examples are on [GitHub](https://github.com/gkurt/tegaki). Releases are published to [npm](https://www.npmjs.com/package/tegaki) and listed in the changelog in the repository. To ask a question, report a bug or suggest a feature, see the [contact page](https://tegaki.ink/contact/). For how the site treats data, see the [privacy policy](https://tegaki.ink/privacy/). --- # Contact > How to report a bug, ask a question, request a feature or reach the maintainer of Tegaki. Source: https://tegaki.ink/contact/ Tegaki is maintained by one person in the open, so the best way to reach the project is a public GitHub issue: the answer then helps everyone who hits the same thing. ## Where to go - **Bug reports and feature requests:** open an issue at [github.com/gkurt/tegaki/issues](https://github.com/gkurt/tegaki/issues). Include the framework, the font, the text and, if you can, a [Tegaki Studio](https://tegaki.ink/studio/) link, since the studio's URL carries the whole state. - **Questions about using the library:** start a discussion or an issue in the same repository. The [Getting Started guide](https://tegaki.ink/getting-started/) and the [API reference](https://tegaki.ink/api/renderer/) cover most setups. - **Security problems:** please do not post details publicly. Use GitHub's private vulnerability reporting on the repository's Security tab. - **The maintainer:** [Gokhan Kurt](https://gkurt.com) is also on [GitHub](https://github.com/gkurt) and [X](https://twitter.com/gkurttech) for anything that does not fit an issue. ## What to expect Issues are read by the maintainer, usually within a few days. Fixes ship in the next release of the `tegaki` package on npm. There is no paid support plan. --- # Privacy Policy > What data tegaki.ink and the Tegaki library collect. Short answer, none beyond standard web server logs. Source: https://tegaki.ink/privacy/ Tegaki is a static website and an open-source library. It has no accounts, no backend and no database, and it does not sell or share personal data. ## This website - **No accounts or forms.** The site does not ask for your name, email address or any other personal information. - **Studio runs in your browser.** When you pick a Google Font, upload a `.ttf` or `.otf` file or type text in [Tegaki Studio](https://tegaki.ink/studio/), the stroke extraction runs locally. Uploaded fonts and text are not sent to any server of ours. The studio's settings are kept in the page URL and in your browser's local storage, which you can clear at any time. - **Fonts.** Choosing a Google Font in the studio downloads that font from Google Fonts, which is then subject to Google's own privacy policy. - **Hosting and logs.** The site is served by GitHub Pages behind Cloudflare. Those providers process technical data such as IP addresses and request headers to deliver pages and protect them from abuse, under their own policies. We do not receive or use that data ourselves. - **Analytics and cookies.** The site sets no tracking cookies and uses no advertising. Your theme choice (light or dark) is stored in local storage on your device. ## The library The `tegaki` npm package runs entirely in your users' browsers. It makes no network requests except to load the font bundle you import, and it collects no telemetry. ## Changes and questions If this policy changes, the change is recorded in the repository history of this page. Questions about it go through the [contact page](https://tegaki.ink/contact/).