OVERSCANUI KIT
no button at all a worn bezel with weight channels do not converge zero depth instrumented, full of smoke a field of real characters a camera looking out additive, so no shadow hairlines, no new shader no raster, so no scanlines

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.

selectorkind
.ov-arriveblock

Used on chrome.html, standalone.html.

.ov-boot

No still. A boot animation: a still of it is its last frame.

selectorkind
.ov-bootblock

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.

selectorkind
.ov-codeblock

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

awaiting input

Perpetual first, and completely.

selectorkind
.ov-cursorblock

Used on chrome.html.

.ov-dot

selectorkind
.ov-dotblock
.ov-dot--livemodifier

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.

selectorkind
.ov-hatchblock

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.

selectorkind
.ov-outblock

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.

selectorkind
.ov-revealblock
.ov-reveal--pendingmodifier
--ov-reveal-scalecustom 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.

selectorkind
.ov-runnerblock

Used on motion.html.

.ov-scan

No still. A looping effect; a still of a loop is one arbitrary frame of it.

selectorkind
.ov-scanblock

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.

selectorkind
.ov-sliceblock

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.

selectorkind
.ov-sweepblock

Used on motion.html.

.ov-trace

No still. A looping effect; a still of a loop is one arbitrary frame of it.

selectorkind
.ov-traceblock
--ov-trace-anglecustom property

Used on motion.html.

.ov-type

No still. A typing animation; see demo/motion.html.

Finite ones resolve to their settled state.

selectorkind
.ov-typeblock

No demo page uses it, and no script writes it.