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.
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.
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.
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.
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
-
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 --embedThis 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.
-
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.jsonName the document. Without one,
capture.shexports 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, withAnton-OFL.txtbeside 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. -
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 pagedriftype-embed.js165 KBdriftype.wasm1.45 MBhero.json~7 KB
The element
<drift-type> takes three attributes. Its children are
the fallback.
| Attribute | Default | Meaning |
|---|---|---|
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 at | Device ratio | Backing store |
|---|---|---|
| 1000 × 400 | 1× | 1000 × 400 |
| 1000 × 400 | 2× | 2000 × 800 |
| 600 × 240 | 2× | 1200 × 480 |
Pixel ratio is capped at 2×. 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
| Event | Fires | detail |
|---|---|---|
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 kind | also takes | Effect |
|---|---|---|
play | — | Start, or carry on |
pause | — | Stop where it is |
toggle | — | Whichever of those applies |
restart | — | Back to the first state and play |
advance | — | Play 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. |
goto | index | Jump to a text item by index. Transitions are edges, so nothing is ever in one — an index naming a transition is refused. |
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.
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.
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.
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.
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.
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.
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.
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.
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 says | What 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. |
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,AddbecomingAdded— 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.