klieg
v0.9.0
Published
Shiny extruded 3D type, slammed over the web app you already have, on a transparent WebGL overlay
Maintainers
Readme
klieg
Shiny extruded 3D type, slammed over the web app you already have — the JACKPOT! that lands
on screen when something worth celebrating happens. It draws into its own fixed, click-through
canvas above the page, plays one effect, and gives the WebGL context back when it goes idle.
The host page is not touched.
An effect is three motion slots — enter, active, exit — plus a material look.
Every effect is playable at the lab, which is
deployed from main.
Install
npm install klieg threethree is a peer dependency, so the app owns the copy: two copies of three in one bundle break
instanceof and double the download. Any version from 0.185 up will do. The package is
ESM-only, and ships as dist/ with type declarations.
TypeScript users also want npm install -D @types/three — three ships no declarations of its
own, and klieg's types reference it.
Usage
import { createKlieg } from 'klieg';
const bk = createKlieg({ fontUrl: '/fonts/display.ttf' });
await bk.fire('JACKPOT!', { enter: 'slam', active: 'float', exit: 'shatter', look: 'gold' });
bk.destroy();fire() resolves once the effect has left the screen, whether it played out or was cancelled.
It rejects if the font cannot be fetched or parsed — the next fire() retries the load rather
than failing forever. destroy() cancels everything in flight and releases the GL context once
the running effect has settled.
Motion
An effect plays enter, then loops active for hold milliseconds, then plays exit,
crossfading blendMs across each boundary. Enter and exit run at a fixed length per piece
(500–1200ms), so total screen time is about enter + hold + exit.
enter
| name | |
|---|---|
| slam | the whole word punches forward out of depth and overshoots as it lands |
| spin | letters whirl in around their vertical axis, one after the next, fading up |
| flip | letters tip forward over their horizontal axis, one after the next |
| assemble | letters converge on the word from scattered positions and tumbling angles |
| rise | letters lift into place from below, one after the next, with a small overshoot |
| none | the word is simply there |
active
| name | |
|---|---|
| float | a slow bob and yaw, as if the word were hanging |
| pulse | a gentle scale breath, a few percent |
| shimmer | a small yaw ripple travelling letter to letter |
| none | dead still |
exit
| name | |
|---|---|
| shatter | letters fly apart tumbling, fading as they go |
| drop | letters fall out of frame under gravity, tipping alternately |
| recede | the word shrinks back into depth and fades |
| fade | fades out with a slight swell |
| none | cuts |
look
| name | |
|---|---|
| gold | warm polished metal |
| chrome | near-white mirror metal |
| oil | near-black metal under an iridescent thin film |
| gem | clear stone, lit through, dispersing to rainbow at the edges |
| velvet | deep matte nap, bright at grazing angles |
| neon | glowing tube-lit sign; turns bloom on by itself |
| flake | dark body shot through with catching flecks |
| glitter | fine metallic sparkle, close to car paint |
| leather | upholstery panels, creased at the seams |
| tubing | glowing tube piped around a near-invisible volume; turns bloom on by itself |
| piping | corded seam running the edge of a hide |
| sequin | discs sewn flat in staggered rows, catching light as they tilt |
lighting
The environment is what makes metal read as metal, and it is independent of all three motion slots.
| name | |
|---|---|
| sweep | rakes the highlight across the letters, on its own period |
| static | holds the environment still |
| pointer | aims the highlight wherever the cursor or finger is; static until one arrives |
The slot takes a piece instead of a name, or an array mixing both — sweep({ periodMs }),
still() and track({ yawRange, pitchRange, followMs }) build one. Layering here is not
active's: each piece keeps its own duration rather than sharing the slot's, so the pieces run
on unrelated clocks with nothing holding a phase between them.
Layered pieces add per axis, so two that write the same axis give you one motion rather than two
you can pick apart: ['sweep', sweep({ periodMs: 1000 })] is a single uniform turn at the summed
rate, once every 773ms. Layer pieces that write different axes — ['sweep', track({ yawRange: 0 })]
rakes on the clock while the pointer tips the pitch.
All of these turn the one shared environment. For light on the letters near a position instead —
a pool the cursor carries across the word — put a lamp in effects.
Each list is also exported as a runtime array — ENTER_NAMES, ACTIVE_NAMES, EXIT_NAMES,
LOOK_NAMES, LIGHTING_NAMES, POLICY_NAMES — for building a picker.
tint
tint recolors any look to your own color, keeping everything else about the material:
await bk.fire('YOU WIN', { look: 'gold', tint: 0xff2d6f }); // pink metal
await bk.fire('YOU WIN', { look: 'gem', tint: 0x2dff8f }); // green stoneIt goes to whichever property actually carries that look's hue. For the metals that is the base
color; gem is clear stone whose red comes from what light picks up passing through it, and
neon is a near-black body whose color is entirely its glow, so tinting either one's base color
would change nothing you could see.
A function is consulted per letter instead, and may return undefined for "not mine", leaving
that letter the look's own color:
tint: (l) => (l.column === 0 ? 0x2df0ff : undefined)look also takes a plain object instead of a name, for a material of your own:
await bk.fire('YOU WIN', { look: { metalness: 1, roughness: 0.3, color: 0x00e5ff } });Every field is a number, so nothing about three appears in your types. Out-of-range values clamp
rather than throw. tintTarget overrides which channel tint writes to when the default
routing guesses wrong.
gradient
tubing and piping draw a letter as tube followed around the glyph and cut into runs — some lit,
one flat color each from the look's palette, the rest dark glass. gradient sweeps a color ramp
across the lit ones instead of leaving each flat. The tubing is a TubeSpec, the decoration on
either look's spec:
import { specOf, type TubeSpec } from 'klieg';
const tubing = specOf('tubing');
const tube = tubing.decoration as TubeSpec;
await bk.fire('OPEN', {
look: {
...tubing,
decoration: {
...tube,
gradient: {
domain: { of: 'run' },
stops: [0x8a1250, 0xff5cb0, 0x8a1250],
mode: 'replace',
},
},
},
});That is dim at each tube's ends and hot in the middle, which is what a real tube does.
domain is what the ramp is measured along:
| domain | |
|---|---|
| { of: 'run' } | 0 to 1 along each run, restarting at every one |
| { of: 'letter' } | 0 to 1 across each glyph's lit tube, run to run |
| { of: 'runIndex' } | one value per lit run, in run order; flat within a run |
| { of: 'surface' } | one value per entry in the spec's surfaces, so it is flat on the front-only built-ins |
| { of: 'axis', angle } | position across the whole word; angle in degrees, 0 is +x and 90 is +y |
| { of: 'radial', at } | distance from at, a fraction of the word's bounds, [0.5, 0.5] by default |
stops are sRGB hex and interpolate in linear space, so a pink-to-cyan fade does not pass through
gray. Two is a fade, more is a ramp. mode: 'replace' paints the ramp; 'modulate' multiplies each
run's own color by it, keeping the look's palette and shading it — under modulate the stops are
multipliers, and one below about 0x555555 reads as a dead tube rather than a shaded one.
Omit gradient and every run is flat, which is what the built-in looks do. tubing also blooms by
itself, and the glow fills a dim tube end, so a ramp that darkens its ends reads flatter than it is;
bloom: false shows it plainly.
effects
Effects drive a sign's appearance over time, below the level of a letter. A look sets what every
letter is made of; an effect changes some part of it — one tube of a neon sign, not the sign. Set
them on a look with LookSpec.effects, or per fire with FireOptions.effects, which replaces a
look's own list rather than adding to it.
import { fire, EFFECTS, roving } from 'klieg';
fire('JACKPOT!', {
look: 'tubing',
effects: [
// One run of the whole sign, picked by seed, stutters like failing glass.
{ piece: 'flicker', target: { kind: 'run', by: 'index', count: 1 } },
// Every run cycles colour together.
{ piece: 'hue', target: { kind: 'run', by: 'index', amount: 1 } },
],
});| field | meaning |
|---|---|
| piece | a name from EFFECT_NAMES, or a piece from a factory so it can be tuned |
| target | { kind: 'run' \| 'body' } plus a selection — by orders the pool ('seed', 'length', 'index'), amount takes a fraction of it and count a literal number of members, and count wins when both are given |
| stagger | per-part phase spread, the same spec enter and exit take |
| seed | fixes the selection, so a pinned frame is reproducible |
The pool is word-wide, so { count: 1 } picks one bad tube in the sign rather than one in every
letter. A body part only reads brightness; colour reaches run parts only.
flicker — a tube on its way out. EFFECTS.flicker({ depth, unrest, spell, calm, duration }):
depth is the floor of its brightness, unrest the share of the pass spent stuttering. spell and
calm add the long scale — the milliseconds of one flickering bout and the milliseconds held steady
between them, so a tube can stutter for four seconds and sit quiet for fifteen. Both need the other,
and both snap to whole stutter steps; the pass then becomes the nearest whole number of cycles, which
may be longer than the duration asked for or shorter.
hue — a colour sweep across the sign. EFFECTS.hue({ from, span, spread, luminance, duration }),
in turns: span of 1 is the whole wheel and the only value that meets itself at the loop seam, and
spread offsets the hue along the word to make a travelling gradient rather than one synchronized
sign. The sweep holds Rec.709 luminance rather than saturation, so blues and violets come out paler
and the sign glows evenly all the way round — at constant saturation it would brighten through yellow
and fall out of the bloom threshold through blue.
roving(inner, { dwell, seed, epochs }) — takes another piece and moves its affliction from one
part to another, so roving(EFFECTS.flicker()) is one bad tube that jumps every few seconds. It is a
factory rather than a name, because a name cannot carry the piece it wraps. Give it { amount: 1 }:
it picks its holder from the whole pool of that kind, so against a subset the fault can land on a
part the effect does not drive and nothing happens at all.
dwell is roughly how long one part keeps the fault, and it picks who flickers, not how much —
that is the inner piece's unrest. epochs is how many handovers fill a pass, and so the ceiling
on how many parts a pass can reach before it loops; the default of 96 covers a pool of 29 entirely
and most of a pool of 55, which is about as wide as a real sign gets. Raise it for a wider one.
lamp({ source, radius, strength, color, duration }) — puts light on the parts near a position
rather than changing what they are made of. radius is its reach in em of layout space, strength
the light at the centre falling to nothing at that edge, and color the lamp's own, multiplied
against the look's hue. source says where the light is on each pass: fromPointer() is the
default and follows the cursor — the canvas's whole extent maps onto the word's ink, so the
cursor's whole travel is compressed onto the letters: the light runs ahead of the cursor at one end
of the sign and behind it at the other, and sits under it only where the ink fills the canvas.
fixed(x, y) pins the light, orbit({ radius, x, y }) circles it, and along([...]) walks a
polyline at constant time per segment rather than constant speed.
duration is one pass for the sources that read the clock, orbit and along, and does nothing
to fixed or fromPointer, which ignore it. A pointer source stays dark until the pointer has
been inside the canvas, so an untouched page gets no lamp rather than one parked at the origin.
A lamp does not follow a stages regroup. It lights by position, and the part pool is fixed at
construction, so after the letters re-lay the light still lands where they used to be — on a centred
sign that has dropped letters, a cursor over the type can light nothing at all. Combine the two and
the lamp is a silent no-op, not a smaller effect.
Effects layer. Brightness multiplies and colour is replaced, so flicker and hue compose without
either knowing about the other — but two pieces both writing colour fight, and the last one wins.
A hue piece writes colour every frame, which overrides tint: tubing tints its decoration, so a
hue sweep and a tint on that look are the same fight, and the sweep wins.
Stages
An effect can exit part of its word and lay the survivors out again as a word of their own — a
poem whose first letters are their own color, then everything else leaves and those letters
gather into a word. stages is the list, played after the enter:
await bk.fire(poem, {
hold: 'click',
tint: (l) => (l.column === 0 ? 0x2df0ff : undefined),
stages: [
{ keep: (l) => l.column === 0, exit: 'fade', as: 'stack', hold: 'click' },
{ as: 'line', hold: 'click' },
],
});Each stage:
| field | default | |
|---|---|---|
| keep | keeps all | the letters that continue; the rest play this stage's exit |
| exit | 'fade' | how the letters that do not continue leave |
| as | 'line' | the survivors' new layout — one line, 'stack' for one letter per line, or 'place' to leave them exactly where they already are |
| active | 'none' | what the new word does while it holds |
| hold | 1200 | milliseconds, or 'click' to wait for the viewer |
| tween | none | timing for the move into the new layout |
Survivors keep their own material, so a letter's color travels with it, and they are renumbered
against the new word: index, count, line, column, x and y all describe it. A letter
playing its exit instead keeps what it read before the regroup and is marked leaving: true, so
its stagger stays coherent with the word it is leaving.
The per-letter form of tint is how the survivors get their own color.
tween:
| field | default | |
|---|---|---|
| duration | 700 | milliseconds for the move into the new layout |
| ease | easeOutCubic | curve the move runs on |
| delayBy | none | holds one channel back, as a fraction of a span (below) |
delayBy.position is a fraction of the move; delayBy.scale — the viewport refit — is a
fraction of the move or this stage's exit, whichever is longer, so it can wait out an exit that
outlasts the move. delayBy: { scale: 0.45 } lands the word before it grows to fill the screen.
Under prefers-reduced-motion: reduce the stages do not play — that path holds a pose and never
travels, so there is nothing to regroup.
acronym
acronym is that effect pre-baked: type a block whose acronym is capitalised, and it renders with
the capitals picked out, holds to be read, drops the lower case where it stands, and gathers the
capitals into a line that stays until dismissed.
import { acronym } from 'klieg';
await bk.fire(...acronym(`Keep
Lighting
Interesting, Every
Glowing letter`));It returns the arguments to fire() rather than firing, so the look, lighting and queue policy stay
yours — spread the options and override whatever you like.
| field | default | |
|---|---|---|
| caps | cyan | how the capitals are styled, in the block and after they gather |
| body | the look's own colour | how everything else is styled while it is still up |
| read | 'click' | the pause after the block renders, before the lower case leaves |
| settle | 600 | the pause after the lower case has gone, before the capitals gather |
| hold | 'click' | how long the gathered acronym stays |
| exit | 'fade' | how the lower-case letters leave |
| active | 'none' | what the gathered acronym does while it holds |
| tween | none | timing for the gather |
caps and body are objects rather than colours — today each carries a tint, and taking an
object means a richer per-letter style can be added without changing the signature.
A capital is a character whose lower case differs from itself, so digits and punctuation are
dropped along with the lower case. isCapital is exported if you want the same test elsewhere.
Writing your own motion
Every slot also takes a piece you built, or several layered together:
import { spring, transition } from 'klieg';
const swoop = transition(800, {
from: { position: [0, -6, 0], opacity: 0 },
ease: spring({ stiffness: 180, damping: 11 }),
stagger: { each: 0.06, from: 'center' },
});
await bk.fire('YOU WIN', { enter: swoop, active: ['float', 'shimmer'] });Names and pieces mix freely in a layered slot — active: ['float', myShimmer].
A MotionPiece is { duration, offset(t, letter) } where offset returns a relative pose —
position and rotation add onto rest, scale and opacity multiply. It must be a pure function:
the compositor samples up to three pieces at three different points in the same frame to
crossfade them, so a piece that remembers anything between calls will tear.
letter says where that letter sits: index and count in reading order, line, column,
lineCount and columnCount in the block, and x/y, its layout position in em relative to
the block center — negate those to travel to the middle. leaving: true marks a letter a
stage has dropped, which is playing its exit and will not be back.
transition(duration, spec) builds an arrival or a departure. from starts displaced and
relaxes to rest; to starts at rest and departs. Either accepts a function of the letter, which
is how per-letter scatter stays deterministic and screenshots stay stable. keyframes takes N
stops instead. ease sets the curve, easeBy overrides it for one channel, and stagger
controls per-letter delay: spread fixes the total ramp, each fixes per-letter cadence, and
from picks the order — start, end, center, edges or random, with grid: true
measuring it radially over a multiline block.
cycle(duration, spec) builds a looping idle from a per-channel amplitude, an optional
harmonic, and a phase function. Motion moves the letters; to rake the environment highlight
instead, declare an env piece in lighting.
spring({ stiffness, damping, mass }) returns a curve, not an animation — it is the closed-form
solution, so it stays a pure (t) => number and can go anywhere an easing goes.
Easing is exactly (t: number) => number, which is also d3-ease's signature, so any curve
library drops straight in:
import { easeElasticOut } from 'd3-ease';
const bounce = transition(700, { from: { scale: 0 }, ease: easeElasticOut });Options
createKlieg(options):
| field | default | |
|---|---|---|
| fontUrl | required | a TTF or OTF opentype.js can parse, fetched once per instance on the first fire |
| target | document.body | element the overlay canvas is appended to; refused alongside an element placement, which is its own parent |
| clock | requestAnimationFrame | time source; pass the exported ManualClock to drive effects by hand in tests |
| policy | 'queue' | what a fire does when one is already running (below) |
| idleTimeoutMs | 8000 | idle milliseconds before the GL context is torn down; the next fire brings it back |
| framing | { width: 0.62, height: 0.3 } | share of the box the type may fill, per axis — the viewport, or the anchor under an element placement; raise it on a page that is nothing but the type. align: 'start' \| 'center' \| 'end' places the word in the box at that size, in reading order: an anchored word meets the page's own text edge by default, an overlay stays centred |
| placement | { kind: 'fullscreen' } | fullscreen overlay, or { kind: 'element', el } to anchor the type inside one element; fixed for the instance's lifetime |
fire(text, options):
| field | default | |
|---|---|---|
| enter | 'slam' | how it arrives — a name, your own piece, or an array of them |
| active | 'none' | what it does while it holds |
| exit | 'fade' | how it leaves |
| look | 'gold' | the material — a name, or a spec of your own |
| lighting | 'sweep' | how the environment lights it — a name, an env piece, or an array of them; a lamp effect lights the letters instead of the scene |
| tint | none | recolors the look, as 0xff2d6f, or a rule consulted per letter |
| hold | 1200 | milliseconds in the active phase, or 'click' to hold until dismissed; 'click' is refused under an element placement |
| stages | none | stages played after the enter, each regrouping what survives it |
| blendMs | 120 | crossfade window straddling each phase boundary |
| bloom | look's choice | adds a glow pass, at the cost of three render targets while the effect runs |
| wrap | false | break long text into the arrangement that renders largest |
| modal | false | while a 'click' hold waits, let the overlay swallow the dismissing press |
| selectable | 'hidden' | how the fired word appears in the DOM — copyable, findable and readable, or selectable (below) |
Multiple lines
A \n in the text always breaks a line, and each line is centered on its own:
await bk.fire('BIG\nMONEY');wrap: true additionally breaks long text for you. It picks whichever arrangement renders
largest rather than fitting to some column count, so it wraps only when wrapping makes the
type bigger — short text is already at the scale cap and stays on one line. Words are never
split, and the viewport budget means a block realistically runs to two or three lines before
height binds; klieg renders banners, not paragraphs.
Holding until dismissed
hold: 'click' keeps the effect on screen until the viewer presses a pointer or Escape, then
plays the exit normally. The promise stays pending until then, and under the default queue
policy a held effect blocks every later fire() — use replace if a later effect should cancel
it instead. A stage's hold: 'click' waits for the same press whatever the top-level hold is:
each press advances one stage, and only the last ends the effect.
The dismissing click passes through to your page by default, so it both dismisses the effect and
presses whatever was underneath. modal: true makes the overlay swallow it instead, which is why
Escape is always bound. That and selectable: 'layer', which takes a click that lands on a letter,
are the only two things that stop a click reaching your page.
Queue policies
queue— effects play one at a time, in the order fired.replace— a new fire aborts the running effect and drops anything still waiting.concurrent— effects play on top of each other. Avoid it withlighting: 'sweep': the live effects fight over the one shared highlight and it sawtooths between their phases.
Selectable text
klieg draws its letters in WebGL, so by default nothing it renders can be copied, found with
Ctrl+F, or read by a screen reader. selectable puts the fired word into the DOM to fix that:
await bk.fire('CONGRATULATIONS', { selectable: 'layer' });'hidden'(default) — one visually-hidden node carrying the word. Copy, find and screen readers work; the glyphs themselves don't highlight.'layer'— a transparent layer over the type, one span per letter in klieg's own typeface, so a drag across it selects the word. A click on a letter is taken by the layer rather than reaching the page beneath; the gaps between letters, and whitespace, still pass a click through. It needs the word to hold still — under atransform, or a motion piece that moves the letters, it falls back to'hidden'and warns once on the console.'none'— no DOM text, for a page whose own markup already carries the string, such as an elementplacementrendered over a real<h1>.
Browser support
WebGL2 is required. createKlieg never throws for want of it, or for want of a DOM:
construction succeeds during server rendering and in a browser without WebGL2, and reports
supported: false. On an unsupported instance fire() resolves immediately, having loaded no
font and rendered nothing, so calls need no guard — read the flag only to do something else
instead:
if (!bk.supported) confetti();Under prefers-reduced-motion: reduce the word holds the pose its enter settles into for
hold and then leaves, with no travel.
Development
npm run dev -w @klieg/lab— the lab page: every motion, look and policy behind pickers, plus canned sequences.npm run dev:tube-lab -w klieg— the tube lab: several letters at several angles at once with the tube pipeline's own numbers beside the render. Dev-only tooling, never published.npm run check— biome, tsc and the unit suite (723 tests).npm run test:visual— Playwright specs asserting the overlay composites over a live page without tinting or blocking it.npm run build:pages -w @klieg/lab && npm run preview:pages -w @klieg/lab— the lab exactly as GitHub Pages serves it, under the/klieg/subpath the workflow builds for. Plainnpm run buildproduces a root-served build instead.
