One Reveal Engine, Two Axes
Lines rise out of a clipping box and fade up as their group enters. The interesting part is that the same builder serves a vertical page section and a panel riding a pinned horizontal strip — handed the strip's tween as `containerAnimation`, ScrollTrigger measures against that timeline's progress instead of scroll position. One set of numbers, two directions, nothing to keep in sync by hand.
One Reveal Engine, Two Axes
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/container-animation-reveals.jsonAlso installs pinned-horizontal-track from this registry.
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
2 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Markup containing [data-reveal-group] scopes and [data-reveal] targets. |
| scroller | RefObject<HTMLElement | null> | — | Scroll container the triggers read. Omit for the document. |
| containerAnimation | gsap.core.Animation | null | — | Omit for vertical flow, pass a horizontal tween to measure against it, pass null to hold off until that tween exists. |
| scrub | number | = 0.4 | Seconds of catch-up on the reveal. |
| lift | number | = 110 | Start offset as a percentage of the element height. |
| stagger | number | = 0.12 | Seconds between elements inside a group. |
| className | string | — | Merged onto the wrapper. Use contents when the wrapper must not affect layout. |
How it works
The technique in order, not the code in order.
The subtree is scanned for
[data-reveal-group]elements; each becomes one ScrollTrigger. A subtree with no groups reveals as a single group rooted on the component itself.Inside a group, every
[data-reveal]element is a target of onegsap.fromTorunning{ yPercent: 110, opacity: 0.15 }to{ yPercent: 0, opacity: 1 }onpower3.outwith a 0.12 s stagger — so the group is one tween, not one tween per line, and the stagger is real rather than simulated with delays.When a
containerAnimationis supplied it is spread into the trigger vars and the position keywords flip fromtop 88% / top 45%toleft 85% / left 45%, because a trigger attached to a container animation measures along that container's axis.The
containerAnimationprop is tri-state:undefinedmeans vertical flow, an animation means horizontal, andnullmeans "a sibling owns the timeline and has not built it yet" — in which case nothing is built at all this commit.scrub: 0.4ties the reveal to scroll position with a short catch-up, so reversing direction reverses the reveal instead of replaying it.Everything is created inside a
gsap.contextscoped to the root, soctx.revert()kills the triggers and restores the inlinetransformandopacitythe tween wrote.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| lift | = 110 | Start offset as a percentage of the element's own height. 100 puts the line exactly at the bottom edge of its clipping box; 110 adds a sliver of overshoot so descenders are hidden through the whole travel rather than peeking under the mask on the first frame. |
| START_OPACITY | = 0.15 | Not 0. A line that fades from fully transparent reads as two separate events — a fade and a move. Starting at 0.15 makes the movement the thing you notice and the fade a supporting detail. |
| stagger | = 0.12 | Seconds between lines within a group. Below ~0.06 the lines read as one block; above ~0.2 the last line arrives after the eye has already moved on, which reads as lag rather than rhythm. |
| scrub | = 0.4 | Short catch-up so the reveal follows the scrollbar but survives a wheel step. At 0 the tween inherits every input spike; above ~1 the lines are still arriving after the section has left. |
| start / end (horizontal) | = 'left 85%' / 'left 45%' | With containerAnimation the trigger is measured along the container's axis. Vertical keywords resolve to a position the element never occupies on that axis, so the tween simply never runs. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
No triggers are created and no fromTo runs, so every line renders at its final position and full opacity on first paint. The clipping boxes stay in the markup and do nothing, so the layout is byte-for-byte the finished state.
ctx.revert() on unmount and on every dependency change: it kills each group's tween and ScrollTrigger and restores the inline transform and opacity GSAP wrote, so a re-mount measures clean markup rather than the last run's end state.
Scroll the vertical section: the three lines rise out of their masks in sequence and reverse cleanly when you scroll back up. Keep scrolling into the pinned strip: each panel's lines rise with the same rhythm as it crosses the container, not as it crosses the viewport. With reduced motion on, every line is already in place.
Pitfalls
- Keeping vertical keywords when passing
containerAnimation.start: "top 85%"produces no error, no warning and no animation — the panels simply arrive already revealed, and the bug survives review because nothing is broken, only absent. - Passing
containerAnimation: undefinedexplicitly. ScrollTrigger branches on whether the key exists, so anundefinedvalue is not the same as an absent key; the code spreads the object conditionally for exactly this reason. - Treating a not-yet-built timeline as the vertical case. The strip's tween is created in a sibling effect and arrives one render later, so a naive
containerAnimation ?? undefinedbuilds a full set of vertical triggers on horizontal content and tears them down on the next commit — visible as a flash of pre-revealed panels. - Writing a second reveal system for the horizontal section. Two systems mean two staggers, two easings and two sets of position keywords, and they diverge the first time someone tunes one of them.
- Building one tween per line instead of one tween per group. Stagger stops being a property of the group and becomes a pile of hand-computed delays, which no longer reverse correctly when the user scrolls back up.
Notes
- Combine with
pinned-horizontal-track: pass itsonTrackpayload'sanimationstraight intocontainerAnimation. - ScrollTrigger forbids
pinon a trigger that has acontainerAnimation. Pin the strip's section, never the panels riding it.