Asymmetric In/Out Staggers
A list whose entrance and exit are deliberately different animations. In: 24px up, 0.5s each, 0.06s apart, after a 0.25s hold that lets the outgoing view clear. Out: 16px up, 0.3s each, 0.03s apart, running from the last item back to the first. The exit is roughly half the total time of the entrance, which is what makes a view swap feel responsive without making the arrival feel rushed.
Asymmetric In/Out Staggers
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/asymmetric-stagger.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 |
|---|---|---|---|
| children | ReactNode | — | List items. Each direct child is wrapped and animated on its own. |
| show | boolean | — | True enters, false exits. The component stays mounted through both. |
| inStagger | number | = 0.06 | Seconds between entering items. |
| outStagger | number | = 0.03 | Seconds between leaving items. |
| inDelay | number | = 0.25 | Hold before the entrance, sized to the outgoing view. |
| inDuration | number | = 0.5 | Per-item entrance length in seconds. |
| outDuration | number | = 0.3 | Per-item exit length in seconds. |
| inDistance | number | = 24 | Pixels travelled on the way in. |
| outDistance | number | = 16 | Pixels travelled on the way out. |
| onExited | () => void | — | Called once the last item has left. Safe place to unmount the data. |
| className | string | — | Merged onto the flex column wrapper. |
| itemClassName | string | — | Merged onto every generated item wrapper. |
How it works
The technique in order, not the code in order.
Every direct child is wrapped in a
[data-stagger-item]div, so the caller writes ordinary list markup and the component owns the animated node — no cloneElement, no required ref forwarding.Entering runs
gsap.fromTo(items, { autoAlpha: 0, y: 24 }, { autoAlpha: 1, y: 0, duration: 0.5, ease: "power2.out", stagger: 0.06, delay: 0.25 }). The delay is not decoration: it is the window in which whatever this list replaces finishes leaving.Leaving runs
gsap.to(items, { autoAlpha: 0, y: -16, duration: 0.3, ease: "power2.in", stagger: { each: 0.03, from: "end" } }). Halving the stagger and reversing its origin is what stops the exit from reading as the entrance rewound.autoAlpharather thanopacity: at zero it also writesvisibility: hidden, so a faded row cannot swallow a click aimed at the view underneath it.Both tweens carry
overwrite: "auto", and the whole thing lives in auseGsapcontext keyed onshow, so toggling twice in quick succession cancels the in-flight direction instead of layering two opposing tweens on the same nodes.A layout effect hides the items before first paint when the component mounts closed, and an internal
hasShownflag makes sureonExitedcan only fire after a real entrance.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| inStagger | = 0.06 | Seconds between arriving items. Below ~0.04 a five-item list reads as one block; above ~0.1 the last row lands well after the eye has moved on and the list feels slow to load. |
| outStagger | = 0.03 | Half the entrance value. Exits are acknowledgement, not presentation — the wave still reads as a direction, but the whole list is gone in under 200ms. |
| inDelay | = 0.25 | Seconds of hold before the first item arrives. Sized to the outgoing view's own fade (~0.3-0.4s) so the two never overlap; at 0 both views are half-opaque at the same time and the frame looks like a rendering bug. |
| inDuration / outDuration | = 0.5 / 0.3 | Per-item. The exit is shorter for the same reason its stagger is: nobody is reading rows that are leaving. |
| inDistance / outDistance | = 24 / 16 | Pixels. The entrance comes up from below (from where the content is arriving); the exit continues upward and travels less, because a long exit path is time spent watching nothing. |
| stagger.from | = 'end' | Reverses the exit wave. Same-direction staggers make the exit look like a backwards entrance; from the end it looks like the list is being pulled off the screen. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
No travel and no stagger. Items are set to their visible or hidden state in one gsap.set, and onExited still fires, so the surrounding state machine behaves identically — only the choreography is dropped.
useGsap reverts the context on unmount and on every show change, killing both tweens and restoring the inline styles GSAP wrote. onExited is held in a ref so a completion callback cannot reach a stale closure after the parent has re-rendered.
Switch to the list: the previous view fades, a quarter-second of nothing, then rows arrive top to bottom. Switch back: the bottom row leaves first and the whole list is gone in about half the time it took to arrive. Toggle rapidly and rows never settle at a partial opacity.
Pitfalls
- Reusing the entrance timeline with
reverse(). That gives you power2.out played backwards (so it decelerates into nothing), the entrance delay stranded at the end, and the same stagger direction — three separate reasons the exit feels heavy. - Unmounting the list when
showflips false. React removes the nodes on the same frame, so the exit never runs; the component stays mounted and reports completion throughonExited. - Animating
opacityalone. A finished row sits at opacity 0 with full hit area and eats clicks intended for the view underneath.autoAlphawritesvisibility: hiddenat zero. - Omitting
overwrite: "auto". Toggling twice inside 300ms leaves the entrance tween running under the exit tween, and the rows settle at whatever opacity the two happen to agree on. - Setting the initial hidden state in a passive effect. Mounting with
show={false}paints the list once, then hides it — a visible flash of content that was never meant to be seen. It is set inuseLayoutEffectinstead. - Firing
onExitedon mount. Withshow={false}at mount there is nothing to exit, and under StrictMode the double-invoked effect would call it twice; ahasShownflag gates it.
Notes
- The same numbers apply outside lists — modals, drawers, route content. Any pair of entrance/exit values that are equal is worth questioning.
- App Router wiring: drive
showfrom a pathname comparison in a layout, and do the actualrouter.pushfromonExitedif you want the route to change only once the list has gone.