Batched Blur Reveal
A page-wide entrance that reveals elements in groups rather than one at a time. Everything that crosses the start line inside the same 100 ms window is handed to one tween, so a real stagger runs across a row of cards that entered simultaneously. Opacity, rise, scale and blur move together, which reads as the content focusing rather than sliding.
Batched Blur 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/batched-blur-reveal.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 | — | Section content. Anything matching selector inside it is revealed. |
| selector | string | = '[data-reveal]' | Which descendants reveal. Queried against the container, not the document. |
| start | string | = 'top 88%' | ScrollTrigger start position for every batched element. |
| distance | number | = 90 | Pixels the element rises through. |
| blur | number | = 12 | Starting blur radius. Set 0 to drop the focus-pull and keep the rise. |
| scale | number | = 0.94 | Starting scale. |
| duration | number | = 1.1 | Seconds per element. |
| stagger | number | = 0.14 | Seconds between elements within one batch. |
| once | boolean | = true | Reveal once and never replay. |
| batchMax | number | = 6 | Largest group a single callback may receive. |
| scrollerRef | RefObject<HTMLElement | null> | — | The scroll container that owns the scroll position. Omit for the document. |
| className | string | — | Merged onto the container via cn. |
How it works
The technique in order, not the code in order.
On mount, every
[data-reveal]descendant of the container — queried from the container element, never fromdocument— is set to{ opacity: 0, y: 90, scale: 0.94, filter: "blur(12px)" }plus awill-changehint for transform, filter and opacity.ScrollTrigger.batch(targets, { start: "top 88%", interval: 0.1, batchMax: 6 })replaces the usual one-trigger-per-element loop. Elements that cross the start line withinintervalseconds of each other are collected and delivered toonEnteras a single array.That array is animated by one
gsap.towithstagger: 0.14, so the offset is computed across the whole group. Individual triggers cannot do this: each one starts at its own t=0 and knows nothing about its neighbours.The tween carries
overwrite: trueso a fast flick that re-enters an element mid-reveal restarts it cleanly instead of interpolating from a half-finished state.onCompletecallsgsap.set(batch, { clearProps: "filter,willChange" }). Afilterthat survives the animation keeps the node on its own composited layer for the life of the page, which is exactly the cost the reveal was supposed to pay once.Under
prefers-reduced-motion, no trigger is created at all: the targets aregsap.setto their final values and the content is already correct on first paint.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| start | = 'top 88%' | The element begins revealing when its top reaches 88% down the scroller — a sliver above the fold. At top bottom the reveal happens off-screen and the user sees nothing; below about 80% the element is already well into view before it starts, which reads as a late load. |
| BATCH_INTERVAL | = 0.1 | Seconds of grace before a batch is flushed. Too small and a row of three cards splits into three batches with no stagger between them — the exact failure this component exists to avoid. Too large and the first element visibly waits before anything moves. |
| batchMax | = 6 | Cap on group size. With stagger: 0.14, a batch of 20 puts 2.7 s between the first and last element, so the tail is still blurred long after the user has read the head. Six keeps the whole group inside roughly a second. |
| stagger | = 0.14 | Offset within a batch. Below ~0.08 the group reads as simultaneous and the batching is invisible; above ~0.2 it reads as a queue rather than a wave. |
| blur | = 12 | Starting blur in pixels. This is the expensive property — it forces a full repaint of the layer every frame — and it is what turns a slide into a focus-pull. Past ~20px the text is unreadable for long enough that the reveal feels broken. |
| distance | = 90 | Pixels of rise. Paired with scale: 0.94 it reads as depth. Much more and the element travels further than its own height, which looks like a layout jump rather than an entrance. |
| duration | = 1.1 | Long for an entrance, and deliberately so: the blur needs time to resolve or the focus-pull reads as a flicker. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
No ScrollTrigger is created. The targets are set to their final state — full opacity, no offset, no blur — during the first effect, so the section renders as finished content and never moves.
useGsap runs the whole setup inside a gsap.context and calls ctx.revert() on unmount and on any prop change. That kills every batched ScrollTrigger and every in-flight tween, and restores the inline styles the initial gsap.set wrote, so an unmount mid-reveal leaves no opacity or blur behind on a node React may reuse.
Scroll a grid of cards into view: the cards in the same row lift and focus one after another with a visible offset, not all at once. Scroll back up and they stay revealed. Inspect a settled card and it has no filter or will-change left in its inline style. With reduced motion enabled the section is fully visible before any scrolling happens.
Pitfalls
- Creating one ScrollTrigger per element. Sixty targets means sixty sets of start/end calculations on every refresh, and — the visible part — two cards entering in the same frame both begin at t=0, so the row lands flat.
staggercannot cross independent triggers. - Querying targets with
document.querySelectorAll. Two instances of the component on one page each claim the other's elements, so both apply the initial state and both tween it, and elements reveal twice at different speeds. - Leaving
filteron the node after the reveal. A non-nonefilter keeps the element on its own composited layer permanently; on a page with sixty of them the compositor memory cost outlives the animation by the whole session. Clear it inonComplete. - Omitting
overwrite: true. Flick-scrolling re-enters an element while its reveal is running and the second tween starts from the partial state, so opacity and blur end up somewhere between the two. - Animating
filter: blur()without awill-changehint. The first frames go to the browser deciding whether to promote the layer, so every reveal starts with a visible hitch. - Registering ScrollTrigger inside the component body. It runs on every render and touches
document, which throws during server rendering. Register once at module scope behind atypeof windowguard.
Notes
- Pass
scrollerRefwhen the content scrolls inside a container rather than the document — ScrollTrigger measures against the window by default and every start position will be wrong. once: trueis the default because these are entrances, not a narrative. Set it false when the section is something the user is expected to scroll back through.