driftype · integration

Putting type
on your page.

driftype renders kinetic typography from a signed distance field on the GPU. You embed it with one custom element and a document you exported earlier. Two files, no build step on your side, and it degrades to plain text when WebGPU is missing.

What driftype is

Type rendered as a signed distance field rather than as glyphs. The whole line is one continuous surface, so letters can swell into each other and weld, a word can morph into a different word through the field rather than cross-fading, and effects operate on the shape itself — eroded, hollowed, beaded into liquid pixels.

None of that is available to anything that rasterises glyphs and composites them, which is most of the alternatives. It is also not a video: it runs live on the GPU in about 1.6 MB, at whatever size the page gives it, and it responds to a click or the pointer if the piece says it should.

CALM, THEN FRANTIC
Live, from example.json. This page carries its own copy of the engine, so it plays opened straight from disk.

It is for the type on a page that is the point — a wordmark, a hero, a title card, a piece of art. It is not a way to render text in general, and specifically not a way to render labels, navigation, body copy or the text inside a control. See By design, not yet.

How a piece gets made

You author in the editor — text, a sequence of states, a look assembled from the preset vocabulary — and export. The export is a self-contained document: the glyph geometry travels inside it, so the piece renders on a machine that has never seen the font and renders the same way in a year. That file is what you point this element at.

Honest about the edges

It needs WebGPU, so a browser without it shows your fallback text rather than the piece. One font per document. And the looks come from a published preset set — there is no user-authored shader surface, which is a deliberate limit rather than a missing feature.

What you need

Two files, which must sit in the same directory. The engine resolves its binary as new URL("driftype.wasm", import.meta.url), so moving one without the other breaks it — unless you hand it the bytes yourself, see Without a server.

driftype-embed-auto.js113 B
driftype-embed.js165 KB
driftype.wasm1.45 MB

Two entry points, because registering a custom element is a side effect and a <script> tag cannot call a function. -auto.js is two lines that register <drift-type> for you; importing driftype-embed.js registers nothing and exports define(), so a bundler decides when — and under what tag name.

import { define } from "./driftype-embed.js";
define();                 // <drift-type>
define("hero-type");      // or your own name

Plus your document — a self-contained .json that carries its own glyph geometry. A short wordmark runs about 7 KB. Nothing fetches a font at runtime.

Browser support

WebGPU is required — Chrome and Edge, or Safari 26 and later. Anywhere it is missing, the element shows its fallback content and reports why on the console. Support elsewhere is moving; test rather than assume.

Serving

The bundle is an ES module, so it must be served over http:// or https://. Opening the page from file:// fails at the module load, silently — unless the page carries everything itself, which is Without a server. Serve .wasm as application/wasm if you can — the engine falls back to a slower non-streaming path when the type is wrong, but it still works.

Getting started

  1. Build the embed

    From a driftype checkout:

    # Writes dist/: the bundle, the wasm, example.html with its example.json,
    # and driftype-embed.zip holding all of it plus this guide.
    ./build.sh --embed

    This is a different build from the editor's, not a repackaging of it. It drops the preloaded fonts, which is what takes the payload from 24.5 MB to 1.61 MB. Embedded documents carry their own geometry and never open a font file.

  2. Export a document

    Author in the editor and use its export button, or export a document you already have headlessly, with the server running:

    ./run_server.sh &
    ./capture.sh "name=hero&export=1" my-piece.json
    # -> capture/input/hero.json

    Name the document. Without one, capture.sh exports its own test sequence, set in Arial Unicode — a font whose outlines you may not redistribute. To see the whole path work before you have a piece, the build already ships one: example.json, set in Anton under the SIL Open Font License, with Anton-OFL.txt beside it.

    Export is what makes the document portable: the glyphs are decomposed and embedded, so the file renders in a session that never loads a font. Copy it next to your page as hero.json.

  3. Add the tag

    <script type="module" src="driftype-embed-auto.js"></script>
    
    <drift-type src="hero.json" width="1000" height="400">
      Calm, then urgent
    </drift-type>

    That is the whole integration. Four files in a directory:

    index.htmlyour page
    driftype-embed.js165 KB
    driftype.wasm1.45 MB
    hero.json~7 KB

The element

<drift-type> takes three attributes. Its children are the fallback.

AttributeDefaultMeaning
src URL of an exported document. Required unless the document is inline — see Without a server. Changing it after load tears down and reboots the element.
width 1000 Default width in CSS pixels, and the numerator of the aspect ratio. A default, not a cap — page CSS overrides it. Never the buffer size.
height 400 Default height in CSS pixels, and the denominator of the aspect ratio. Overridden by CSS the same way width is.
sync absent Locks this element's clock to others. With no value it groups by src, so an inline document needs a name; with a value it groups by that name. Absent, the element runs on its own clock — see Several on one page.

The fallback is the point

Whatever you put inside the tag stays visible until a frame is actually going to be drawn, and comes back if anything fails afterwards. A browser without WebGPU, a document that no longer validates, a 404 on src — all of them leave your text on the page and print the reason on the console.

Put the real words there, not a placeholder. It is what a reader sees when the canvas cannot run.

Sizing and sharpness

width and height work exactly as they do on <img>. They are an intrinsic size that also supplies the aspect ratio, and CSS overrides them. An embed in a narrower column shrinks rather than overflowing; a page that asks for more gets more; set only a height and the width follows from the ratio.

/* Smaller: put it in a narrower container, or size the element. */
drift-type { width: 320px; }

/* Larger than the attribute: the same way. */
drift-type { width: 900px; }

/* Full bleed, aspect ratio kept. */
drift-type { width: 100%; }

/* Height only — width follows from the ratio, as on an img. */
drift-type { height: 120px; width: auto; }

/* Both — an explicit box wins and the ratio yields. */
drift-type { width: 500px; height: 300px; }

The element is display: block with max-width: 100% and the aspect ratio by default, installed as a stylesheet rule rather than an inline style so an ordinary selector beats it. The canvas then fills the element exactly, whatever box it ends up with. The backing store is the size it is actually laid out at, times the display's pixel ratio — read after layout, never assumed from the attributes.

Laid out atDevice ratioBacking store
1000 × 4001000 × 400
1000 × 4002000 × 800
600 × 2401200 × 480

Pixel ratio is capped at . Not for fill rate — the field margin scales with device pixels, and the glyph store's capacity falls off with the square of that margin, so a 3× display could run a long document out of glyphs for very little visible return.

Resizing

A resize is a re-warm, not a resize. Type size is derived from canvas height, so every glyph in the store was baked at the old one and has to be thrown away and regenerated. The element handles this — a ResizeObserver, debounced 150 ms, plus a media query that catches a window dragged onto a display with a different pixel ratio, which changes nothing about the CSS size and so fires no resize event.

The clock does not move. A piece three quarters of the way through a transition comes back three quarters of the way through it.

Scripting it

The element forwards the player's own verbs. These are the same four the editor drives, so a piece you script and a piece driven by its own triggers go through one path.

const hero = document.querySelector("drift-type");

hero.play();
hero.pause();
hero.toggle();
hero.restart();

hero.playing;  // boolean, false before it is ready

Events

EventFiresdetail
drift-ready Once, when the first frame is about to draw { cycle, dpr, backing, css } — cycle is the sequence length in ms
drift-resize After a re-warm completes { backing, css }
drift-error On any failure; the fallback is showing by now { message }
hero.addEventListener("drift-ready", (e) => {
  console.log("cycle", e.detail.cycle, "ms");
});

hero.addEventListener("drift-error", (e) => {
  // The fallback is already visible. Log it, or swap in your own.
  console.warn("driftype:", e.detail.message);
});

What drift-error covers

Every failure the element can detect, not only the ones during load: a missing or malformed document, a font that will not restore, a failure while resizing, anything thrown by the engine on a frame, and the GPU device being lost. In all of them the canvas is removed and your fallback content is put back before the event fires, so the element never sits there as a blank or frozen rectangle.

One case is deliberately not an error. A GPU validation warning is reported to the console and the element keeps rendering, because those are usually survivable and tearing the piece down over one would be a worse outcome than whatever artefact it warns about.

driftype has no telemetry and makes no network requests of its own beyond fetching the src you give it and driftype.wasm beside the script — and not even those when a page carries both itself. Nothing is reported anywhere unless you report it.

If you run error monitoring — Sentry, Datadog, or your own handler — wiring drift-error into it is the whole integration, and it is worth doing: a GPU fault on a stranger's machine is otherwise invisible to you as well as to us.

Triggers live in the document

A document can bind its own interactions, and the element wires them to the canvas for you. You do not add listeners for these. The events it can bind are click, pointer_enter and pointer_leave; the actions are these:

action kindalso takesEffect
playStart, or carry on
pauseStop where it is
toggleWhichever of those applies
restartBack to the first state and play
advancePlay forward and stop when the next text state is entered — one click, one step. Wraps the loop seam when the sequence repeats; does nothing at the end of one that does not.
gotoindexJump to a text item by index. Transitions are edges, so nothing is ever in one — an index naming a transition is refused.
Declare the input first

A document cannot bind an event it never said it reads. Pointer triggers need inputs.pointer set, and a document that binds one without it is refused rather than silently inert.

Why a closed set

An open action list would be a scripting surface in a document, and the same argument that rules out user-authored shaders rules that out too. A document describes; it does not execute. Anything beyond these belongs in your own code, driving the methods above.

Sitting over your page

A document can declare surface.mode: "transparent", and the canvas then carries an alpha channel so whatever is behind it shows through. This is a property of the whole piece rather than of one state — the canvas is one surface, so it cannot be transparent for one word and opaque for the next.

The preset's background knob is still declared and still crosses between states; the runtime simply does not paint it. That means a document authored opaque can be exported transparent without touching its presets.

Several on one page

Supported, and cheap. The WebAssembly module — the expensive part — is instantiated once per page and shared; each element gets its own rendering context beside it.

Each element also drives its own requestAnimationFrame rather than the engine's internal loop. That detail matters more than it sounds: a module has exactly one main loop, and an earlier version that used it aborted the whole runtime the moment a second <drift-type> appeared — taking the first one down with it. A page with a header and a footer is not an exotic case.

Layering, and the sync attribute

Stacking elements is how you build what one document cannot express — a chromatic offset, a parallax pair, a blurred copy behind a sharp one. It only works if the layers are in register, and by default they are not: each element starts its clock when its own warm finishes, so two instances of the same document sit permanently out of phase by the difference in their boot times. Measured at 31ms, and different on every load, because it depends on when each document happened to arrive.

sync puts them on one clock. With no value it groups by src, which is the layering case — several elements showing one piece:

<div class="stack">
  <drift-type src="hero.json" sync></drift-type>
  <drift-type src="hero.json" sync></drift-type>
</div>

Give it a value to group layers built from different documents that still have to move together — sync="hero" on each.

The first element in a group fixes the clock and the rest adopt it. One added later — a lazy load, a route change — adopts it too and steps forward to catch up, so it arrives already in phase rather than starting from the beginning. Both clocks are caught up: the document's position, and the engine's own accumulator, which is what a knob like ripple_speed reads.

"In phase" means exactly, not approximately

The test suite samples four elements twenty-five times and compares their canvases as strings. Two synced at load matched in 25 of 25; a latecomer added half a second late matched in 25 of 25 as well. An element without sync differed in about 10 of them — the count moves from run to run with where the samples happen to fall, which is the point: it is never zero.

The catch-up has a ceiling

An element joining more than 60 seconds after its group started is not brought fully into phase. Catching up is a synchronous render per frame-sized slice, so an element added ten minutes into a page's life would run six thousand of them before drawing anything, holding the main thread throughout. Slightly out of phase is the better failure.

And when there is nothing left to draw

A sequence set to play once stops drawing when it reaches the end, rather than re-rendering the same final frame forever. A piece whose light follows the pointer, or which uses a preset that animates off the engine's own clock, keeps running — those can still change after the sequence is over. Calling play() or restart() starts it again.

It stops when it cannot be seen

An element with nothing on screen stops drawing, and starts again when it comes back — you do not need an IntersectionObserver of your own. Resuming re-anchors the clock, so a piece that scrolls back into view carries on rather than jumping forward by however long it was away.

It respects reduced motion

With the reader's system set to reduce motion, the element renders the state its document opens on and does not animate. The type still appears — the request is to stop the movement, not to hide the words. The preference is watched live, so turning it on mid-visit stills whatever is moving.

Without a server

A page opened straight from disk — a file someone was sent, a demo on a stick, this guide inside the zip — cannot fetch anything. The browser refuses a module script with a src, the engine's request for driftype.wasm, and the element's request for its document. WebGPU itself is fine there: file:// counts as a secure context. So the page has to carry everything, and the element takes both halves directly.

The live example at the top of this guide is built exactly this way, which is why it plays when you open the file from the archive. Its source is the working reference.

The editor makes these

You rarely have to assemble one by hand. The editor's export page writes your piece as a single <id>.html — the exported document inline, the engine gzipped beside it — that plays when double-clicked. It is about 780 KB, and it carries the engine the editor was built with. Use it to send a piece to someone, or to keep one offline; use export embedded and the files above to put one on a site.

The document, inline

Put the exported document inside the element as a <script type="application/json"> and leave out src. A script element is never rendered, so it sits beside the fallback without becoming part of it.

<drift-type width="1000" height="400">
  <script type="application/json">{ "schema": 3, ... }</script>
  Calm, then urgent
</drift-type>

One of the two, never both. An element with a src and an inline document is refused rather than guessing which one you meant, and so is a bare sync on an inline document — it groups by src, and there is none. Name the group instead: sync="hero".

The HTML parser ends a script at the first </script, whatever the JSON around it says. Exported glyph data cannot contain one, but text can; if yours does, write it as <\/script, which is the same string to JSON.

The engine, handed over

define() takes the wasm bytes as an option, and the engine then never goes looking for the file. An ArrayBuffer, a typed array, or a promise of either — a promise so the bytes can still be decompressing when you call it.

import { define } from "./driftype-embed.js";

// Gzipped base64 in the page: about 630 KB, against 2 MB unzipped.
const text = document.getElementById("driftype-wasm").textContent.trim();
const gzipped = Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
const wasm = new Response(
  new Blob([gzipped]).stream().pipeThrough(new DecompressionStream("gzip"))
).arrayBuffer();

define("drift-type", { wasm });

The import above is for your bundler, not the browser: from file:// the page cannot load that module either, so the bundle has to end up inline in a <script type="module"> too. How the wasm gets into the page is up to your build — the base64 element above is what the example uses.

Before the first element

The binary is page-wide, because the module is. It has to be handed over before any element starts; once the engine has loaded, supplying a different one throws define: the engine is already loaded rather than being quietly ignored.

What it costs

Every standalone page carries its own copy of the engine — the example page is about 840 KB — and nothing is shared or cached between two of them. It is also frozen: an engine fix reaches a page served from files the next time you replace the wasm, and never reaches a standalone file already sent. If you can serve files, serve them.

When it does not work

Every failure the element can see leaves the fallback visible and prints a reason prefixed [drift-type]; driftype's own warnings say [driftype]. The first row below is the one failure the element cannot see, because it never got to run. These are the ones you are most likely to meet.

Console saysWhat happened
Nothing at all, from file:// The page was opened from disk and the browser refused the module script, so the element never loaded and has no reason to print — the browser's own console line is the only trace. Serve the directory, or see Without a server.
hero.json: 404 The document is not where src points. Paths resolve against the page, not the script.
A 404 on driftype.wasm The binary is not beside the bundle. It is resolved relative to the script's own URL.
document invalid — … The document does not validate against this build. Each error names its path. Most often a preset id that has moved on: ids are frozen versioned contracts, so re-export rather than hand-editing.
glyphs did not finish loading Decomposition never completed. Usually a document whose glyph stream is truncated or was hand-edited.
expected schema 2, got 1 — this is an older document; re-export it … The document predates schema 2, which every export now records what made it in (exported_by). Older documents are refused rather than read: open the piece in a current editor and export it again.
document glyphs are format 1/1, this build reads … The document was exported by a different build. Re-export it; the versions are checked so geometry never renders subtly wrong.
both src and an inline document; use one The element has a src and a <script type="application/json"> child. Remove one.
sync with an inline document needs a group name Bare sync groups by src, and an inline document has none. Write sync="name".
wasm: … The bytes handed to define() are not a working engine — truncated, still compressed, or from another build. The rest of the message is the browser's own.
field_reach is too small for this preset A real rendering fault, described below.
The background painted as type

If the whole canvas comes out in the type colour with the letterform a ghost outline, the document's type.field_reach is too small for its weight and edge_width.

The validator catches the part it can, but edge_width is in pixels and a document carries no canvas size — so the last term depends on the size it renders at and can only be caught here. driftype prints the exact numbers when it happens: far-field clamp 2.244px does not clear dilation 2.2px plus edge 2.2px. Raise field_reach and re-export.

By design, not yet

A few limits are deliberate, and knowing which is which saves you looking for a switch that does not exist.

  • Not for UI text. A label, a menu item, a paragraph or the text inside a <button> should be real text styled with CSS. A control's words have to be readable by a screen reader, selectable, findable with your browser's find and translatable, and canvas text is none of those — the fallback carries the real words, so the accessible copy is the one hidden whenever the rendering works. A document's character set is closed and its outlines are baked in, so a label that changes at runtime — a count, a name, Add becoming Added — cannot be expressed at all. The rendering context is per element, so twenty of these is twenty GPU devices and twenty frame loops to draw a few words. And the element has a declared aspect ratio, so it cannot grow to fit a longer label the way a button does.
  • One font per document. The schema has a single font block. Two typefaces means two documents.
  • No custom shaders. Looks come from the published preset vocabulary. Shipping a shader extension point means shipping a public contract on uniform and buffer layout, which is not on the table at this stage.
  • No pixel canvas in the document. Type size is a fraction of container height, so one document renders correctly at any size instead of letterboxing away from the size it was authored at.
  • Rendering is a pure function of the document. Same document, same elapsed time, same frame — on any machine, at any real speed. Seeds live in the document; nothing is resolved at runtime.