motion.css
Overscan motion.
Three rules, and each exists because of something measured rather than because it looks good.
1. ARRIVAL REVEALS BY MASK, EXCEPT ON SCROLL, WHERE IT GROWS AND FADES. ⚠️ THE EXCEPTION IS NEW, 2026-09-10, asked for after seeing the mask version: ov-reveal.js now scales from 0.965 and fades from zero. The honest cost is stated rather than buried, because the rest of this rule is the argument against it: an element part-way through a fade IS below its palette contrast for those frames. Two things bound it. The opacity ramp is deliberately faster than the scale, so the low-contrast window is a fraction of the arrival rather than all of it, and the resting state remains fully opaque, so nothing is ever left faded. ⭐ It is also the smoother of the two. Opacity and transform are compositor properties; mask-position repaints the layer every frame, which over a live WebGL field is where the stutter came from. The mask rule still holds for .ov-arrive, below, which is the load-time reveal and is unchanged.
ORIGINALLY, AND STILL TRUE OF .ov-arrive: Contrast is time-dependent: an element fading in fails contrast while it fades, and an audit that samples at a fixed delay reports colours that are in no palette anywhere. A hard-edged mask wipe never paints a partial colour, so every pixel is either fully there or absent and the delivered contrast is correct at every frame of the animation. It also composes with the panel's corner clip-path, which a second clip-path could not.
APPLY IT TO A WRAPPER, NEVER TO .ov-panel ITSELF. A mask clips to the border box exactly like a clip-path does, and the panel's label deliberately sits outside that box, riding the top border. Put the class on the panel and the reveal cuts the label in half. Same trap as the corner clip, found the same way: by looking at the render.
2. STAGGER ENCODES SOMETHING, AND THE KIT WILL NOT INVENT WHAT. Two things qualify, and nothing else does. DATA FLOW, within a group: set --ov-stage to a panel's position in the flow and it arrives when its input is ready, so the arrival order tells you what feeds what. The kit cannot know that, so it asks. NO --ov-stage MEANS NO STAGGER and everything in the group arrives together. READING ORDER, down the page: ov-reveal.js arrives a block when you scroll to it. That correlates with the reader's own position, which is the test this rule actually applies. ⚠️ THIS CLAUSE IS NEW, added 2026-09-10 by request, and it replaces a flat statement that a decorative cascade was deliberately impossible. The objection was never stagger as such, it was "variation that correlates with nothing reads as noise". Arrival on scroll correlates with where the reader is. There is still no way to stagger by nothing at all.
3. PERPETUAL MOTION CANCELS FIRST. A naive reduced-motion pass leaves exactly the wrong half running: the status dot, the blinking cursor, the indeterminate bar. Those are the first things off here, and the finite arrivals simply resolve.
src/motion.css · 14 blocks · blocks that belong to a custom element are on that element's page
.ov-arrive
No still. An arrival animation: its still is the settled state, which is the element without it.
Fail safe: the resting state is REVEALED. If a browser will not animate the mask position, the content is simply visible rather than permanently hidden. Safari needed this: a reveal that hides by default and never runs is a blank panel, which is a much worse failure than no animation.
| selector | kind |
|---|---|
.ov-arrive | block |
Used on chrome.html, standalone.html.
.ov-boot
No still. A boot animation: a still of it is its last frame.
| selector | kind |
|---|---|
.ov-boot | block |
Used on chrome.html.
.ov-code
No still yet.
⭐ THE PRE-PAINT ARM. Measured on the home page: first contentful paint at 113ms, the reveal module at 120ms. So the page painted every element fully visible and then, twenty milliseconds later, hid them all to animate them in. That blink is what read as the reveal being broken, and no easing anywhere can fix it, because the content was already on screen.
data-ov-arm is set by an inline classic script in the head, which runs during parsing and therefore BEFORE the first paint. ov-reveal.js takes the attribute off the moment it has the same elements under its own per-element control, and takes it off immediately in every path where it decides not to animate at all.
🔴 STILL SCRIPT-APPLIED, so the safety argument is unchanged: no script means no attribute means a visible page. The inline script also clears the attribute on a timer, so even a module that never loads at all costs a short delay rather than a blank page.
⚠️ This list must match TARGETS in ov-reveal.js exactly; api.py gates it.
| selector | kind |
|---|---|
.ov-code | block |
Also styled in chrome.css, code.css. Written into markup by src/ov-reveal.js, so an element sets it for you. Used on about.html, allocator.html, annunciator.html, ascii.html, aux.html, avatar.html, balance.html, baseline.html and 65 more.
.ov-cursor
Perpetual first, and completely.
| selector | kind |
|---|---|
.ov-cursor | block |
Used on chrome.html.
.ov-dot
| selector | kind |
|---|---|
.ov-dot | block |
.ov-dot--live | modifier |
Also styled in chrome.css. Used on chrome.html, data.html, index.html, screen.html, standalone.html, index.html, themes.html.
.ov-hatch
No still. A looping effect; a still of a loop is one arbitrary frame of it.
| selector | kind |
|---|---|
.ov-hatch | block |
Used on index.html, motion.html, radar.html.
.ov-out
No still. A text-arrival animation chosen by the theme; see demo/motion.html.
One class, and the THEME picks which animation it runs. animation-name takes a custom property, so a theme naming its keyframes is all the wiring this needs, and any element can override the token inline to mix.
Two animations, not one: an ARRIVAL that runs once and an IDLE that loops, either of which a theme can leave empty.
⚠ The mark is why terminal and esper are different animations rather than two easings of one. A reveal is a reveal: what distinguishes typing from a scan is the thing riding its leading edge. Terminal puts a hard block cursor there and steps; esper puts a soft bright edge there and moves smoothly. Both read off --ov-reveal, so the mark cannot drift from the reveal.
| selector | kind |
|---|---|
.ov-out | block |
Written into markup by src/ov-cli.js, src/ov-log.js, so an element sets it for you. Used on motion.html, screen.html, index.html.
.ov-reveal
No still. An arrival animation: its still is the settled state, which is the element without it.
Belt and braces: ov-reveal.js returns before it hides anything under reduced motion, so this class should never appear here. If it somehow does, the mask comes off rather than freezing a page half-wiped.
| selector | kind |
|---|---|
.ov-reveal | block |
.ov-reveal--pending | modifier |
--ov-reveal-scale | custom property |
Written into markup by src/ov-core.js, src/ov-gl.js, src/ov-radar.js, src/ov-reveal.js, so an element sets it for you.
.ov-runner
No still. A looping effect; a still of a loop is one arbitrary frame of it.
| selector | kind |
|---|---|
.ov-runner | block |
Used on motion.html.
.ov-scan
No still. A looping effect; a still of a loop is one arbitrary frame of it.
| selector | kind |
|---|---|
.ov-scan | block |
Used on motion.html, radar.html.
.ov-slice
No still. A looping effect; a still of a loop is one arbitrary frame of it.
Every looping effect in the vocabulary. Add one above, add it here.
| selector | kind |
|---|---|
.ov-slice | block |
No demo page uses it, and no script writes it.
.ov-sweep
No still. A looping effect; a still of a loop is one arbitrary frame of it.
| selector | kind |
|---|---|
.ov-sweep | block |
Used on motion.html.
.ov-trace
No still. A looping effect; a still of a loop is one arbitrary frame of it.
| selector | kind |
|---|---|
.ov-trace | block |
--ov-trace-angle | custom property |
Used on motion.html.
.ov-type
No still. A typing animation; see demo/motion.html.
Finite ones resolve to their settled state.
| selector | kind |
|---|---|
.ov-type | block |
No demo page uses it, and no script writes it.