skills/showtime/references/components.mdMotion components: API, options and examples (runtime/components)
Read this when you are building an HTML/DOM video page (showtime new dom|short|data) and want
titles, captions, lower thirds, stats, charts, code, browser or device frames, a cursor, chat or
notification UI, a checklist, a Ken Burns still, a map or an end card. For scene-to-scene
handoffs read references/transitions.md; for timing and taste read references/motion-craft.md.
Run showtime motion for the live list.
1. Setup (two lines) and the time model#
<script src="/_st/stage.js"></script> <!-- the time contract -->
<link rel="stylesheet" href="/_st/themes/neutral.css"> <!-- colours, fonts, motion feel -->
<script type="module" src="/_st/components/index.js"></script> <!-- every component, auto-mounted -->
<section class="scene" data-start="0" data-dur="4">
<h1 class="t-display" data-st="kinetic-type" data-style="blur" data-at="0.2">Hello</h1>
</section>
- Mounting. Any element with
data-st="<component>"mounts itself. Options aredata-*attributes in kebab-case (data-exit-at=exitAt); arrays and objects are JSON. Or mount from JS:import { KineticType } from '/_st/components/index.js'; KineticType('#title', { style: 'blur' }). A JSON blob works too:data-options='{"style":"blur","at":0.2}'. - Time.
atis when the component starts, in seconds local to the clip it sits in (the nearest ancestor withdata-start; composition time outside any clip). Every other time option (cues,exitAt,path[].at,highlight[].at,states[].at...) counts from the component'sat: with the defaultat: 0that is clip time, butdata-at="0.6" data-exit-at="5"exits at 5.6 s into the clip. To have a title already on screen at t=0, usedata-style="none"(kinetic-type) rather than a negativeat(which shifts every exit time too). - Seek-safe. Components are pure functions of time: they set inline styles on every seek and never use timers, CSS transitions or accumulated state, so any frame renders alone, in any order. They measure once, after their fonts load and with their clip forced visible.
- Sync points. Each controller exposes
sync(composition seconds of named beats, e.g.count-up.sync.land,cursor.sync.click1) to line up SFX or narration:const c = CountUp('#n', {...}); await c.ready; c.sync.land. - Sizing. Everything is in container units (
cqw,cqh,cqmin)..stageand.sceneare size containers, so the same page works at 16:9, 9:16 and 1:1. Tall frames can switch layout with@container (max-aspect-ratio: 5/6) { ... }. Container units measure the content box: a.scenewith padding makes90cqh90 % of the padded box, not of the frame (set.scene { padding: 0 }or pad an inner wrapper when you place things by frame percentages). - Frame-exact cuts.
data-start/data-durvalues within 1 ms of a frame boundary land on that frame (7.0667is frame 212 at 30 fps), so beat-synced cuts can be written with 4 decimals. - Styling. Components read theme tokens (
--accent,--font-display,--radius...) with fallbacks; component CSS loads first, so your page CSS wins. Class names arest-<component>-*. - Media. Images and videos inside components are awaited before the first frame. Videos must be
muted
<video data-st>(the stage seeks them).
Helpers exported by /_st/components/index.js (from core.js): ease(name) ('power3.out',
'expo.inOut', 'spring(0.5,0.8)', 'glide', 'steps(6)', 'cubic-bezier(...)', named
premium|standard|emphasized|exit|camera), spring(response, damping), seg(t, start, dur, ease),
envelope(t, {start, in, end, out}), stagger(i, n, each, {cap, from}), hash(i, seed), rng(seed),
noise1(x, seed), splitText(el), define({name, defaults, setup}) to write your own component.
2. Text#
kinetic-type#
Split a headline into words, characters or lines and reveal with a stagger (total stagger capped).
| option | default | meaning |
|---|---|---|
style |
rise |
rise mask (slides up from behind a clip) blur pop (spring) slide drop swing (3D flip up) track (letters converge) scramble (glyphs cycle then lock) fade none (on screen from the start; exits still apply) |
by |
words |
words chars lines (per-letter only for 1-3 word heroes) |
dur, stagger, cap |
theme | per-unit entrance time, gap between units, max total stagger (0.6 s) |
from |
start |
start end center edges random |
ease |
per style | any ease name |
distance, blur |
0.55, 0.18 | travel and blur in em |
accent |
comma list of words to colour with --accent (<em> works too) |
|
cues |
array of start times per unit (lock words to narration word times) | |
exit, exitAt, exitDur, hold |
none |
fade rise drop blur mask; exit at exitAt (counted from at), or hold s after landing, or at the clip end |
<h1 class="t-hero" data-st="kinetic-type" data-style="mask" data-accent="faster">Ship faster</h1>
typewriter#
Typed text with a human or uniform cadence; caret solid while typing, blinking when idle.
Options: text (default: the element's text), script ([{type:'...'},{pause:0.3},{back:4}] for
typos and corrections), cadence human|uniform, cps 18, fit (seconds to finish by),
caret bar|block|underline|none, blink 1.06, hideCaretAfter, linePause 0.35, seed.
<p class="t-mono" data-st="typewriter" data-fit="2.5">npx showtime render launch</p>
caption-karaoke#
Word-timed captions grouped into readable cards, spoken word highlighted, placed in the safe zone.
Input: src (JSON URL) or words: a showtime transcript, the voice module's *.words.json, or
any [{text, start, end}] (type other than word is skipped; emph: true marks emphasis).
| option | default | meaning |
|---|---|---|
style |
clean-pop |
clean-pop (sentence-case heavy sans, thin edge and soft shadow, spoken word turns the accent: the default, and the right one for 9:16 shorts), bold-pop (outlined heavy caps, spoken word pops slightly in the accent: loud, hype pieces), highlight-box (accent box glides behind the word), underline-sweep (bar sweeps under the word as it is said), minimal (subtitle, upcoming words dimmed), boxed-pill (card on a rounded plate), fill (each word fills left to right) |
position |
auto |
auto (9:16: lower; 16:9: 10 % above the bottom), top center lower (the block hangs from 62 % height, so a two-line card grows down, never up into the content; lifted only if it would pass the safe bottom) bottom |
maxWords, maxChars |
per style / fit | card limits (clean-pop 5, bold-pop 4 words; cards aim for about one word fewer); maxChars = two lines of what fits the width, at most 32 characters a line on 9:16 (42 otherwise) |
gap |
0.3 | a pause longer than this starts a new card |
emphasis |
comma list of the 3-5 words that carry the message, coloured in the accent (no scale). At most one shows per card (the first); more than 5 shown (or one per 6 s in longer videos) is a check warning, because an accent on every term means nothing. Terms match letters and digits (2.0, --unique) |
|
size |
1 | font scale; upper forces uppercase on/off; holdLast 0.8 s; plate colour |
keep |
comma list of phrases never split across cards or lines ("Command K,Command E"); each word keeps its own highlight time |
|
skipLines |
comma list of voice line ids whose words are not captioned (the title already says it); survives retime --from-voice. A word with "hidden": true in the words file is skipped too |
|
group |
words |
phrase: cards break only at punctuation and pauses (up to 8 words on 2 lines, at least 1 s each), for fast speech (Spanish at ~3.5 words/s gave 0.4 s cards) |
minShow |
0.4 | a card shorter than this joins a neighbour (fast speech would otherwise flash "HIT N" for 0.27 s); a card that still cannot merge is a check warning |
Grouping: a sentence end or a pause longer than gap always ends a card; inside a sentence the |
||
split is chosen as a whole: cards near the target size, on screen at least minShow, breaking at |
||
| commas and breaths, and never ending on a weak word (articles, prepositions, conjunctions, | ||
| auxiliaries: "empty lines before" / "sorting." becomes "clears empty lines" / "before sorting."; | ||
| English plus common Spanish, French, Portuguese and German ones). One-word cards are avoided; cards | ||
| never overlap; each card shows from its first word − 0.05 s to its last word + 0.35 s. Lines use | ||
text-wrap: balance; a weak word stays on the line of the word after it, and a short token ("K") |
||
can still strand away from its modifier: list such pairs in keep. Theme tokens: |
||
--cap-font --cap-weight --cap-ink --cap-accent --cap-outline --cap-plate --cap-active-ink. The |
||
edge, glow and drop shadow are all made from --cap-outline, so on a light ground set a dark ink and a |
||
light outline (as paper does) and the words stay crisp; the accent must pass 4.5:1 on the ground. |
||
For a light 9:16 short, boxed-pill (a plate behind the card) reads best. |
||
Put the layer outside the scenes so it runs across cuts; it is marked data-caption for QA. |
<div data-st="caption-karaoke" data-src="voice/vo.words.json" data-style="highlight-box" data-at="0.6"></div>
3. Identification and numbers#
lower-third#
variant bar|card|kicker|pill, name, role, kicker (label for kicker), avatar (image for
pill), side left|right, hold (seconds on screen after landing; default: until near the clip
end), exit true, in (entrance seconds; default 0.85, kicker 1.0), position auto|top|none
(top: under the safe top, for 9:16 videos whose captions sit in the lower half; none = place it
yourself). In 9:16 the type is larger (name 6.4cqmin, role 4cqmin) so it reads on a phone.
<div data-st="lower-third" data-variant="card" data-name="Ada Park" data-role="Staff engineer" data-at="1"></div>
count-up#
value, from 0, decimals, prefix, suffix, label, dur 1.6, ease power3.out,
compact (12.8k), group, variant plain|ring|bar, of (ring/bar total; default 100 for %),
pulse, align, locale (number format; default the page's <html lang>, so a Spanish page shows
13,7 and 60.000; chart takes it too), suffixAlign (auto: a suffix starting with ° sits at the top of the figure, so
"°C" never reads as "◦C"; top, baseline). The number's box reserves the widest value the count
shows in the real font, so proportional display figures never run into the suffix. Size with
--cu-size (figure) and --cu-ring. Sync: land. Use real numbers only.
Prefix and suffix sit tight against the number ("+160%", not "+ 160 %").
<div data-st="count-up" data-value="71" data-suffix="%" data-variant="ring" data-label="of Earth's surface is ocean"></div>
steps#
steps (array or comma list), variant dots|bar|list, cues (times each step activates; list
items tick on their own cue), or first 0.4 + every 1.2.
4. Data#
chart#
Bar, horizontal-bar and line charts with a story: grow/draw on, highlight one datum, callout.
| option | meaning |
|---|---|
type |
bar hbar (ranked rows that re-order smoothly) line |
src |
JSON file with any of the options below (recommended: keep numbers out of HTML) |
data |
bars: [{label, value}]; lines: {labels: [...], series: [{name, values, color?}]} |
states |
[{at, data, title?}]: morph to new data (axis rescales first, then marks move); a title that changes only after " · " (a race's year) swaps without fading |
title, subtitle |
write the takeaway as the title, computed from the data, never invented (illustration with made-up sample values 42 → 11 min: (42 − 11) / 42 = 74%, so "Median build time fell 74%") |
highlight |
label (bar) or series name (line) in the accent; the rest muted |
annotate |
`{label |
prefix, suffix, decimals, compact, yMax, yMin, ticks 4, locale, grow 0.9, draw 1.6, curve `monotone |
linear` |
valueLabels |
true (auto): bar labels that would come closer than 0.3em shrink a little (never below 0.8x or the chart's minimum font), then the least important hide (the highlighted, annotated, max, min, last and first stay; the rest by size of value; a label that would sit on a neighbouring bar hides too), planned per state so they fade with a morph instead of flickering; "all" shows every label however crowded (showtime check then reports labels_crowded); false hides them (hbar races included) |
count |
true; false shows each value label at its real value, fading in as the bar lands (no "+0.69" mid-count on a paused frame) |
highlightAt |
start; settled colours the highlighted bar only once the bars have landed |
ref |
{value, label, sub?, at?}: a dashed reference line (a target, "next warmest: 2014, +0.75") drawn on after the marks settle |
dots |
auto (line points up to 40), true, false |
| Negative values work in every type: the axis reaches below zero, a zero line appears and bars grow | |
| down (anomalies, deltas, profit/loss); the axis leaves room under the lowest bar for its value label, | |
so it never sits on the category labels (unless yMin is set). A prefix of + signs values (+1.29, −0.49). Options in |
|
the src file apply unless the element sets them (data-* or data-options), so decimals from |
|
data import is honoured; tick labels carry the decimals their step needs (0, 0.5, 1.0). hbar rows |
|
| rank by value (ties keep the data order) and glide only while a state change runs. A single line | |
series' end label shows the value only (the title names it). --chart-muted can be a theme token |
|
on :root. |
|
Needs a sized box (e.g. position:absolute; inset:...). Sync: settled, callout, ref, state2... |
|
| Axis, value and subtitle labels never go below 2.7vmin (29 px at 1080p, readable on a phone); set | |
--chart-min-font lower only for a small inset chart. An annotate callout sits above its bar's |
|
| value label and any neighbour label under it, and the y axis leaves headroom for it. An hbar value | |
label that a ref line would strike through slides past the line as it draws on. |
|
| Many bars: category labels wider than their slot show every n-th (plus the last, highlighted and | |
| annotated ones). Past ~12 labelled bars (or ~6 in 9:16) a value on every bar is noise: keep the | |
| default auto-thinning, label only the highlight and the extremes, aggregate (decades instead of | |
years), or use a line chart whose end label carries the latest value. |
|
From a CSV or JSON table: showtime data import <table> <project> --x <col> --y <col> --scene <id> |
|
writes the chart file and points that scene's chart at it (workflows/data-story.md). |
world-map#
World map or globe (Natural Earth 110m via world-atlas, d3-geo). projection
naturalEarth|equalEarth|mercator|orthographic, spin (deg/s, globe), camera
[{at, center:[lon,lat], zoom, dur}], highlight [{at, names:[...]|ids:[...], color?}],
markers [{at, lon, lat, label}], routes [{at, from:[lon,lat], to:[lon,lat], dur}],
graticule, labels, src/object for other TopoJSON/GeoJSON. Country names are the Natural
Earth names ("United States of America", "France").
One country (a regional map): projection mercator (conformal: the right shape at high latitude),
src the 50m file, and the zoom that fills a fraction f of the frame width with the country's
longitude span L: zoom = f * W / ((L / 360) * min(W, H)) (Mercator's fit makes the world
min(W, H) wide). Routes are single great-circle legs: split a multi-stop route into legs with their
own at. A marker's "current/visited" state is a class you toggle on .st-map-marker from
ST.onSeek; to put a label left of its dot, set text-anchor: end in page CSS.
5. Product and UI#
browser-frame / device-frame#
Brand-free chrome around a screenshot, a video or live HTML children. The URL bar uses the theme's
--font-body; on a page without a theme (a canvas film with DOM layers) set --font-body and link
a font file (/_st/themes/fonts/inter.css), or the bar falls back to the OS UI font.
Shared: src (image or video), scroll [{at, to, dur}] (to 0..1 fraction, pixels, or #id),
zoom [{at, scale, x, y, dur}] (camera push to x/y %), camera page|frame (page: the push
happens inside fixed chrome; frame: the whole window scales, chrome included, and its edges leave
the picture, which reads better than a page sliding under a pinned URL bar), tilt [rx, ry]
degrees, enter rise|none, float. Children given together with src stay as an overlay above
the screenshot (a highlight ring, a cursor target) and move with its scroll and zoom. Scroll
targets are measured once the image has decoded; a step that cannot move (the page is not taller
than the view) is a check warning. A full-page capture scrolls a flat image, so a sticky nav slides
away with it: cut the nav strip from the capture and pin it as a child if the real page keeps it.
browser-frame: url, typeUrl (types the URL), title, theme auto|light|dark.
device-frame: model phone|tablet|laptop, color graphite|silver, notch.
drift auto|none|<scale per second>: a still screenshot (src image) with no scroll or zoom
steps gets a slow push-in by default (+1.2 %/s, capped at +8 %), so the shot is never a frozen frame;
none turns it off (check then flags the hold). A cursor path or a scroll still reads better.
Capture pages at the device's real viewport (a desktop page squeezed into a phone looks wrong).
<div data-st="browser-frame" data-url="acme.dev/pricing" data-src="shots/pricing.png"
data-scroll='[{"at":1.5,"to":0.6,"dur":2}]' style="position:absolute;inset:10% 12%"></div>
cursor and keystrokes#
cursor: path [{at, x, y} | {at, target:"#sel", dx, dy, click, hover}] (x/y in % of the
cursor layer; targets are tracked live, so it follows tilted or moving UI), style
arrow|hand|dot, size 3.4 (cqmin), arc 0.12, hideAfter. It arrives exactly at each at;
clicks press the target (CSS scale) and ripple; hover sets [data-st-hover] on the target.
keystrokes: items [{at, keys:"⌘ K"} | {at, text:"deploy"}], hold 1.5. ⌘ ⇧ ⌥ ⌃ ↵ ⌫ ⇥ ← → ↑ ↓
are drawn as SVG so they look the same on every OS.
<div data-st="cursor" data-path='[{"at":0,"x":85,"y":90},{"at":1.2,"target":"#buy","click":true}]'></div>
code-block#
Editor panel from showtime code file.ts -o code.json tokens (offline shiki; 65 themes).
src/tokens/code (plain text fallback), title, chrome window|none, lineNumbers,
reveal lines|type|none, cps 45 / fit (typing), highlight [{lines:"4-6", at, color?}],
diffAt + diffDur 0.7 (with showtime code old.ts --to new.ts: removed lines flash red and
collapse, added lines open green), focus [{line, at, dur}] (scroll), size (cqmin), dim 0.35.
Line numbers are 1-based and, for diffs, count the new file. Line numbers and diff gutters clear
4.5:1 on the default panel (--code-ln-opacity 0.62, --code-add, --code-del); pick a shiki theme
whose comments and punctuation also clear it (houston does; vitesse-dark draws comments at
#666666), since showtime check judges every token.
chat-thread / notifications#
chat-thread: messages [{from:'me'|'them'|name, text, at?, stream?}], typing 0.9 s indicator
before replies, gap, speed, names. Auto-timed by reading time; scrolls as it fills.
notifications: items [{app, title, text, icon?, time?, at?}], every 0.9, max 4 visible,
top (a CSS length or % of the frame; a top in the page CSS also wins over the safe-area default).
Generic UI only: do not imitate a real app's branding.
feature-grid#
items [{icon, title, text}] or existing children, columns (auto by aspect), focus
[{at, index}] spotlight, stagger, cap 0.55 (the whole stagger's ceiling in seconds; raise it
to land each card on its own beat), dim. Icons: .svg path (inlined, takes the accent; e.g.
/_lib/lucide-static/icons/zap.svg), raw <svg>, image, or text. Avoid emoji (they render
with each OS's own font).
6. Camera, stills, closers, texture#
- camera: a scene camera over the content it wraps (put it at
position:absolute; inset:0around the scene's content).path[{at, dur, zoom, focus, to, ease}]:atseconds from the scene start,durthe move (0 = a cut to that framing),zoom(1 = as laid out),focusa selector inside the camera or[x%, y%](the point looked at),to[x%, y%]where it lands on screen (default the centre),ease(defaultcamera). Zoom is interpolated in log space and the focus with the same curve, so a push reads even.contain(default true): at zoom >= 1 the content always covers the frame.drift(zoom per second after the last move, capped at +6 %): holds keep breathing. Children withdata-depth="k"that fill the camera move k times as much (0.3 = a far layer: parallax).keepText(default true): while the camera moves, the union of the visible text stays inside the frame (the feed-safe box at 9:16), so a push never crops a headline; between moves (withdrift0, the default) the transform sits on whole pixels so type does not shimmer.to: "stay"zooms about the focus where it is laid out. A 5-6 % push onto a result is the premium move;through/match/pan(transitions.md) carry the camera from one scene to the next. - fit:
data-st="fit"on a terminal, a code line or a command pill: the type shrinks until the longest line fits the box (and the lines fit its height) at the frame size of the run, down tomin(0.55); below that, lines wrap with a hanging indent. Measured once, after the components inside it.<div class="cam" data-st="camera" data-drift="0.004" data-path='[{"at":0,"zoom":1},{"at":3,"dur":1.6,"focus":".out","zoom":1.08}]'>...</div> - portal (for the
throughtransition):data-portalon any element makes its box the opening;data-portal="counter"on a single letter (<span data-portal="counter">o</span>) makes the glyph's enclosed hole the opening (measured from the real font). - ken-burns:
srcor child media,from/to{scale, x, y}(x/y %), orfocus[x%, y%]+zoom1.1,dur(default clip length),easesine.inOut,fit(cover;containshows the whole image; page CSS on.st-kb-mediaworks too),fade(default: none when the shot starts with its scene, so beat cuts stay hard cuts; 0.4 s when it appears mid-scene withat> 0),mask(a CSS mask image applied to the moving picture, so it scales with the zoom; a mask on the wrapper stays fixed while the picture grows under it). - logo-reveal:
text(wordmark) or a child<img>/<svg>,styleassemble|mask|blur|draw(draw needs inline SVG paths),dot(accent full stop),bloom(false: no accent glow as it lands; end-card takes it too). - end-card: logo reveal +
tagline+ctapill +url; hold >= 2.5 s.logo(image URL) or a child img/svg plustextshows the mark next to the name (stacked in tall frames): the mark resolves first, the name follows. A mark alone resolves withblur(stylemask|blur|draw). Sizes:--logo-mark-size(default 16cqmin alone, 1.1x--logo-sizenext to the name). - grain: seeded film grain over the frame;
opacity(default--grain),fps24,size,blend. Keeps dark gradients from banding after compression.
7. Themes (runtime/themes)#
neutral (light product), bold (loud launch), editorial (magazine, calm), neon
(night tech), paper (hand-made explainer), terminal (developer console). One <link> each.
Token contract: palette --bg --fg --muted --surface --surface-2 --border --accent --accent-2 --accent-ink --good --bad --shadow; type --font-display --font-body --font-mono --font-hand --weight-display --weight-body --tracking-display --leading-display --case-display; shape/space
--radius --radius-lg --stroke --space-1..4 --safe-x --safe-y; motion --motion-energy --dur-in --dur-out --dur-beat --stagger --ease-in --ease-out --ease-move --ease-emph; captions
--cap-*; texture --grain --glow. Override any token on :root or a scene for a brand.
Layout classes from base.css: .stage .scene .layer .safe .center .stack .row, type
.t-hero .t-display .t-title .t-sub .t-body .t-label .t-mono .t-num, .accent .muted .surface .glow .vignette. Fonts are local files (Fontsource packages); each theme loads only its own
families, themes/fonts.css loads them all, themes/fonts/noto-sans-jp.css adds Japanese.
8. Writing your own component#
import { define, seg, ease } from '/_st/components/index.js';
export const Badge = define({
name: 'badge', defaults: { at: 0, text: 'NEW' },
setup(el, o) { // runs once, fonts loaded, clip visible
el.textContent = o.text;
const E = ease('spring(0.4,0.6)');
return { duration: 0.5, update(lt) { el.style.transform = `scale(${E(seg(lt, 0, 0.5))})`; } };
},
});
Rules: compute everything from lt (local seconds); no setTimeout, CSS transitions or
accumulators; animate transform, opacity, filter, clip-path; measure in setup only.
Mounting: <div data-st="badge" data-text="v2"> elements are mounted as soon as define() runs, even
when the page module defines the component after index.js mounted the rest; or call
Badge('#el', {...}) yourself. A component's DOM is built in setup, which runs after CSS and fonts
load, not when the factory returns: const c = LowerThird(el, {...}); await c.ready; before touching
its inner elements (el.querySelector('.st-lt-role') is null until then).