Dual-Direction Scrub Entrance
Two elements arrive on the same scrubbed trigger from different directions — the copy rises 60px, the panel slides in from the side by 55% of its own width. Because both tweens sit at position 0 of one timeline, their progress is identical at every scroll position, so the pair reads as one composed arrival. The 0.6s scrub lets them keep moving briefly after the wheel stops, which is the difference between weighted and welded.
Dual-Direction Scrub Entrance
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/dual-direction-scrub-entrance.jsonPrompt
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
3 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| primary | ReactNode | — | Rises from below. The reading anchor — usually the copy. |
| secondary | ReactNode | — | Slides in horizontally. Usually the card, figure or panel. |
| from | "left" | "right" | = 'right' | Which edge the secondary enters from. Also flips the grid order. |
| rise | number | = 60 | Pixels the primary rises through. |
| slide | number | = 55 | Secondary travel as a percentage of its own width. |
| scrub | number | = 0.6 | Seconds of catch-up behind the scroll position. |
| start | string | = 'top 82%' | ScrollTrigger start. |
| end | string | = 'top 38%' | ScrollTrigger end. |
| scrollerRef | RefObject<HTMLElement | null> | — | The scroll container that owns the scroll position. Omit for the document. |
| className | string | — | Merged onto the grid container via cn. |
How it works
The technique in order, not the code in order.
A single
gsap.timelineis created withscrollTrigger: { trigger: root, start: "top 82%", end: "top 38%", scrub: 0.6 }. One trigger drives both elements, so there is exactly one progress value in the system.The primary element gets
fromTo({ autoAlpha: 0, y: 60 }, { autoAlpha: 1, y: 0 })at position 0.autoAlpharather thanopacity: it also flipsvisibility, so the invisible element is not a click target on the way in.The secondary gets
fromTo({ autoAlpha: 0, xPercent: ±55 }, { autoAlpha: 1, xPercent: 0 }), also at position 0.xPercentis a share of its own width, so the entrance distance scales with the layout instead of being tuned per breakpoint.Both tweens use
ease: "none". Under a scrub the ease is applied to timeline time, so easing here means the two elements travel at different rates through the same scroll range and stop looking like one gesture.The secondary is wrapped in an
overflow-hiddencell. AtxPercent: 55it begins more than half its width outside the grid column, which without the clip is a horizontal scrollbar on the whole document.scrub: 0.6gives ScrollTrigger 0.6s to catch the timeline up to the scroll position, so the elements decelerate into place after the wheel stops rather than freezing with it.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| scrub | = 0.6 | Seconds of catch-up. true (zero lag) welds the animation to the wheel and reads as dragging a slider. Past about 1.5s the section is still arriving long after you have stopped, which reads as lag rather than weight. 0.6 is the point where it feels like mass. |
| start / end | = 'top 82%' / 'top 38%' | The section begins arriving just as it clears the bottom edge and is fully composed while still in the lower half of the screen. Ending at top 38% means the entrance completes before the content reaches the reading position — an animation that is still resolving under the eye is unreadable. |
| rise | = 60 | Pixels of vertical travel for the copy. Enough to register as arriving; small enough that the text is legible for most of the transit, which matters because the user is reading during it. |
| slide | = 55 | Percent of the secondary's own width. Above ~60% it starts entirely outside its column and the first half of the scrub shows an empty cell; below ~30% it reads as a nudge rather than an entrance. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
No timeline and no trigger are created, so both elements render in their final layout position at full opacity on first paint. Nothing is hidden waiting for a scroll that the user may never make.
useGsap builds the timeline inside a gsap.context and reverts it on unmount or any prop change. The revert kills the ScrollTrigger and restores the inline transform, opacity and visibility the tweens wrote — without it an unmount mid-scrub leaves the panel permanently half-slid and invisible if React reuses the node.
Scroll the section up from the bottom: the copy rises and the panel comes in from the right at exactly the same rate, both finishing together before the section reaches the middle of the screen. Stop the wheel mid-entrance and they keep moving for about half a second before settling. Scroll back down and the entrance reverses. No horizontal scrollbar appears at any point. With reduced motion on, the section is composed and static from the start.
Pitfalls
gsap.from()on a scrubbed trigger. It renders the start state immediately at build time and treats whatever the element's values happen to be as the destination — so any later style change silently rewrites the target and the element ends up somewhere other than where the layout says.fromTostates both ends and is idempotent under a scrub.- Two separate ScrollTriggers, one per element. Their start positions are computed from their own bounding boxes, so on a tall screen the copy is already finished before the panel has begun, and the composition falls apart at exactly the viewport heights you did not test.
- Easing a scrubbed tween. The ease is applied to timeline time, so the two elements move at different rates through the same scroll window and the pair stops reading as one gesture. Use
ease: "none"and let the scrub supply the smoothing. - No
overflow-hiddenaround the sliding element. AtxPercent: 55it sits outside its column at the start of the scrub, which extends the document width and adds a horizontal scrollbar that appears and disappears as you scroll. - Using
opacityinstead ofautoAlpha. The invisible element still receives pointer events, so a link inside a panel that has not arrived yet is clickable and focusable. - Omitting
invalidateOnRefresh.xPercentand the trigger boundaries are both derived from measurements taken at build time; after a resize the panel travels the wrong distance and starts at the wrong scroll position.
Notes
from="left"also reverses the grid order, so the panel enters from the side it visually occupies rather than crossing the copy on its way in.- The
will-change-transformhints sit on the two moving wrappers rather than on their contents, so the promoted layer is created once and the children are not each given their own.