CSS Variable Stagger Reveal
A reveal system where JavaScript writes one number per element and then gets out of the way. The stagger delay is `calc(var(--kx-reveal-index) * var(--kx-reveal-step))`, so duration, travel, easing and the delay ramp all live in CSS where they can be retuned — or overridden from an unrelated stylesheet — without touching the component. The observer measures against the nearest scrollable ancestor, so the same component works inside a card, a modal, or the document.
CSS Variable Stagger Reveal
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/css-var-stagger-reveal.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
One file, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Mark groups with data-reveal-group and targets with data-reveal. A scope with no groups is treated as one group. |
| scroller | RefObject<HTMLElement | null> | — | Scroll container for the observer. Omit and the nearest scrollable ancestor is found by walking computed overflow. |
| stepMs | number | = 90 | Milliseconds of delay per index step. Written as --kx-reveal-step. |
| durationMs | number | = 700 | Transition duration. Written as --kx-reveal-duration. |
| distance | string | = '28px' | Starting vertical offset, any CSS length. Written as --kx-reveal-distance. |
| ease | string | = 'cubic-bezier(0.22, 1, 0.36, 1)' | Timing function. Written as --kx-reveal-ease. |
| threshold | number | = 0.15 | Fraction of a group that must be visible before it reveals. |
| rootMargin | string | = '0px 0px -8% 0px' | Observation box inset. The negative bottom means "genuinely in view". |
| repeat | boolean | = false | Re-hide groups that leave the container. Off by default, which also lets settled groups be unobserved. |
| className | string | — | Merged onto the scope element. |
How it works
The technique in order, not the code in order.
On mount the component collects
[data-reveal-group]descendants, falling back to itself as a single group, and writes--kx-reveal-indexas an inline custom property on each[data-reveal]inside each group. Index restarts at zero per group, so a second group does not inherit a delay whose beginning the reader never saw.One shipped rule computes the per-element delay:
--kx-reveal-delay: calc(var(--kx-reveal-index, 0) * var(--kx-reveal-step, 90ms)), and both the opacity and transform transitions use it as theirtransition-delay. This is the entire stagger — there is no timeline object and no rAF loop anywhere in the component.The start state (
opacity: 0,translate3d(0, var(--kx-reveal-distance), 0)) is a CSS rule rather than an inline style, so it is already in effect before hydration and there is no frame of un-hidden content.An IntersectionObserver with
threshold: 0.15androotMargin: "0px 0px -8% 0px"setsdata-kx-revealedon the group. The negative bottom margin shrinks the observation box up from the bottom edge, so an element must be genuinely inside the container rather than merely touching its edge — without it, a reveal fires while the element is still one pixel of its own height into view.The observer
rootis resolved in three steps: an explicitscrollerref if given, otherwise a walk up the ancestor chain for the first element whose computedoverflow-xoroverflow-yisauto,scrolloroverlay, otherwisenull, which is the value that means the viewport. Nothing in the component referenceswindow, so it behaves identically in a 320 px preview card and on a full page.Revealed groups are unobserved immediately unless
repeatis set, so a long page is not still servicing a hundred settled groups.repeatinstead deletes the attribute on exit, and CSS runs the same transition backwards.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| stepMs | = 90 | Delay per index. Below ~50 ms the elements read as arriving together and the stagger is wasted; above ~140 ms a group of six takes over three quarters of a second to finish and the last line is still moving after the eye has reached it. |
| durationMs | = 700 | Per-element transition duration, roughly 8x the step. Keeping duration well above step is what makes the group overlap into one motion rather than resolve as six separate events. |
| distance | = 28px | Travel. Small enough that the text never leaves its own line box — a reveal that starts 100 px down reads as an element flying in from elsewhere, which fights the impression that the content was always there. |
| ease | = cubic-bezier(0.22, 1, 0.36, 1) | An ease-out-quint. Nearly all of the travel happens in the first third, so the element appears to arrive and settle rather than to decelerate the whole way. A symmetric ease-in-out on a reveal always reads as slow. |
| threshold | = 0.15 | Fraction of the group that must be visible. At 0 the reveal fires on a single pixel of intersection, which for a tall group means the stagger completes before the content is on screen. Past ~0.4 a group taller than the container can never satisfy it and never reveals at all. |
| rootMargin | = 0px 0px -8% 0px | Shrinks the observation box 8% up from the bottom, so the element is genuinely inside the container before it fires. Positive values here are the usual mistake: they fire the reveal below the fold and the user scrolls onto content that has already finished animating. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
A @media (prefers-reduced-motion: reduce) block in the shipped rule set resolves every target to its END state — opacity: 1, transform: none, transition: none — so the content is simply present, with no travel and no delay ramp to sit through. The observer still runs and still sets data-kx-revealed, so anything else keyed off that attribute keeps working; it just has nothing to transition.
observer.disconnect() in the effect teardown, which releases the observer's references to every group. The inline --kx-reveal-index properties and the data-kx-revealed attributes live on nodes React owns and are discarded with them. There is no rAF, no listener and no timer to cancel — the hoisted <style> is reference-counted and removed by React when the last instance unmounts.
Scroll the container: each group's children rise and fade in one after another, about 90 ms apart, and the group does not start until it is genuinely inside the box rather than touching its bottom edge. Inspect a revealed child and its computed transition-delay equals index x 90 ms. Set --kx-reveal-step: 200ms on any ancestor in dev tools and the stagger widens immediately with no re-render. With reduced motion on, everything is already visible and in place. Unmount the scroller and no observer callbacks fire.
Pitfalls
- Writing the delay as an inline
transition-delayper element. It works, and it moves the choreography into JavaScript where a designer cannot reach it and a stylesheet cannot override it. The point of the index-as-custom-property indirection is that JS contributes a number and CSS owns every unit. - Numbering targets across the whole scope instead of per group. The second group starts at index 6 and sits at a 540 ms delay, so the reader watches nothing happen for half a second after it enters view.
- Assuming the scroll container is the window. Inside any
overflow-y: autopanel, an observer withroot: nullcompares against the viewport: everything in the panel is "in view" from the start, so the entire page reveals at once on mount and never staggers again. - Testing
scrollHeight > clientHeightwhen hunting for the scroll parent. A panel that has not overflowed yet is still the container the reveal will live inside once content arrives; rejecting it picks the document and fires everything at once. - Putting the hidden start state in an inline style set from an effect. The server-rendered markup then paints visible, hides on hydration, and animates — a flash of correct content followed by a reveal, which is worse than no reveal.
- Shipping a hidden-by-default rule with no escape hatch for a document that never runs scripts.
@media (scripting: none)restores opacity and transform; without it the page is simply blank for anyone who has JS off. - Leaving the observer connected. It keeps a strong reference to every observed group, so an unmounted section is still measured on every scroll of the container that outlived it.
- Adding only
[data-kx-revealed] [data-reveal]as the revealed selector. When the scope declares no groups, the marker lands on the scope element itself, which is not a descendant of itself — so the reveal silently never applies.
Notes
- Every knob is a custom property, which means a consumer can retune the whole system from any ancestor:
--kx-reveal-step,--kx-reveal-duration,--kx-reveal-distance,--kx-reveal-ease. The props are conveniences that write those same properties onto the scope element. - This is the CSS-owned sibling of the GSAP reveal systems in the registry. Choose this one when the choreography is a fade-and-rise and the value is that a designer can retune it; choose a GSAP builder when the reveal needs scrubbing, a per-element timeline, or to be measured against a horizontal container animation.
- The observer watches groups, not individual elements. A group that is taller than its container reveals when 15% of it is visible — if a section is very tall, wrap the parts you want revealed separately in their own
data-reveal-group.