Staggered Panel Wipe
The counter, the word list and the exit wipe are three parts of a single `gsap.timeline`, not three independent animations racing each other. The number is tweened on a plain object with `power2.inOut`, so it accelerates and settles instead of ticking linearly — the difference between a loading count and a progress bar wearing a costume.
Staggered Panel Wipe
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/panel-wipe-preloader.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 |
|---|---|---|---|
| words | string[] | = ['Assets', 'Type', 'Shaders', 'Layout', 'Ready'] | Words shown under the counter, spread evenly across the count. |
| panels | number | = 5 | Number of collapsing columns. |
| countDuration | number | = 1.6 | Seconds the counter takes to reach 100. |
| onDone | () => void | — | Fires from the timeline's onComplete, after the last column has collapsed. |
| position | "fixed" | "absolute" | = "fixed" | Use absolute inside a bounded container; fixed for a real full-page preloader. |
| className | string | — | Merged onto the root via cn. |
How it works
The technique in order, not the code in order.
A plain object
{ v: 0 }is tweened to 100 overcountDurationseconds.onUpdatewritesString(Math.round(count.v)).padStart(3, "0")into a ref'd span. Tweening the object rather than the DOM keeps the eased value in one place and means the number is interpolated by the same engine, on the same clock, as everything else on the timeline.The words are placed with
tl.set(wordEl, { textContent: w }, i * step)wherestep = countDuration / words.length— for five words over 1.6 s that is one word every 320 ms. Because they are positioned on the timeline rather than scheduled withsetTimeout, they stay locked to the counter if the timeline is retimed, paused, or seeked.When the count lands, the content block leaves on
yPercent: -120, opacity: 0over 0.5 s withpower3.inOut. -120 rather than -100 clears the block past its own height plus a margin, so a descender never lingers at the top edge.The five columns then retract with
scaleY: 0andtransformOrigin: "top", 0.9 s ofpower4.inOut, staggered 0.08 s apart, inserted at-=0.2so the wipe begins while the content is still leaving.scaleYis a compositor-friendly transform; animatingheighthere would relayout five elements every frame.The whole build runs inside
useGsap, which wraps it in agsap.contextscoped to the root and callsctx.revert()on unmount, so a preloader torn down mid-count leaves no inline transforms behind.Only after the timeline's
onCompletefires does React setgoneand returnnull. Unmounting on a timer instead would pull the nodes out from under running tweens.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| countDuration | = 1.6 | Seconds for 0 to 100. Under ~1.2 s the digits change too fast to read as a count and the word list flickers; over ~2.5 s people have already decided the site is slow. |
| CONTENT_DURATION | = 0.5 | Content exit. Long enough to read as a departure rather than a cut, short enough that it is finished before the panels have opened far enough to reveal what is behind it. |
| PANEL_DURATION | = 0.9 | Per-column retract. power4.inOut spends most of its time near the ends, so a shorter duration collapses the slow start and the whole wipe reads as a single instant cut. |
| PANEL_STAGGER | = 0.08 | Gap between columns. At 0 the five columns are one panel; past ~0.15 the last column is still standing after the first has finished, which reads as a stuck element rather than a sequence. |
| PANEL_OVERLAP | = 0.2 | Seconds the wipe is pulled back into the content exit. Without the overlap there is a dead beat between the number disappearing and the panels moving, and that pause is where the eye decides something has hung. |
| padStart(3, "0") | = 3 | Fixed three-digit width. Without it the counter changes character count at 10 and 100 and the number visibly jumps sideways twice mid-tween. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
No timeline is built at all. The counter is written straight to 100, the last word is set, the component unmounts itself and onDone fires on the same tick — so a reduced-motion visitor sees the real page immediately rather than a silent panel that still has to be waited out.
useGsap runs the whole build inside a gsap.context scoped to the root and calls ctx.revert() on unmount, which kills the counter tween, the positioned set calls and the panel stagger, and restores every property they touched — including the span's original textContent.
The number counts 000 to 100 with a visible ease, never changing width. Five words swap under it at even intervals and the last word lands as the number does. The content lifts away and five columns retract upward in sequence, the wipe starting before the content has finished leaving. onDone fires once, after the last column. With reduced motion the preloader is gone on the first frame. Unmount mid-count and no transforms or altered text remain.
Pitfalls
- Driving the counter with
setIntervaland the panels with GSAP. Two clocks, and on a loaded main thread the number finishes after the wipe has already covered it. - Tweening a linear counter. It is the ease that sells the count —
power2.inOutslows near 100, which is the shape of real work finishing. - Animating panel
heightinstead ofscaleY. Five elements relayouting every frame on a page that is simultaneously mounting its real content is the one place the jank is guaranteed to be visible. - Unmounting the preloader on a
setTimeoutsized to match the timeline. The two drift under load; unmount fromonCompleteinstead. - Leaving the preloader in the tree with
opacity: 0. It still swallows every click on the page underneath. This returnsnull. - Forgetting
transformOrigin: "top". The default centre origin makes the columns shrink to a band in the middle of the screen instead of retracting like a blind.
Notes
- In a Next.js App Router project this mounts once in
app/layout.tsx, above{children}, and callsonDoneto flip a context flag the hero listens to. It is deliberately not keyed to the pathname — a preloader that replays on every navigation is a bug, not a feature. - If the real site has an actual load signal (fonts ready, hero image decoded), gate
onDoneon both the timeline and that promise. Seesafe-preloaderfor the ceiling-and-floor version of that problem. - The counter is decorative. The root carries
aria-hidden, so a screen reader is never read a number climbing to 100.