A/B Layer Ping-Pong
A double buffer for DOM content. Incoming content is written into whichever of two absolutely-positioned layers is currently invisible, then the two swap opacity. Because the outgoing content is still on screen, every change is a real crossfade rather than a swap with a fade bolted on — and running two of these at different durations is how a hover preview gets its lag between thumbnail and full-bleed background.
A/B Layer Ping-Pong
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/ab-layer-crossfade.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
2 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| contentKey | string | — | Identity of the current content. A change starts a crossfade. |
| children | ReactNode | — | Content for the current key. Captured into a layer at the moment the key changes. |
| duration | number | = 1 | Crossfade length in seconds. Use a shorter one for the smaller element in a pair. |
| ease | string | = 'power2.inOut' | GSAP ease applied to both halves. |
| className | string | — | Merged onto the positioned wrapper. Give it a size — the layers are absolute. |
How it works
The technique in order, not the code in order.
Two absolutely positioned layers share a stacking context. A
frontslot ("a"or"b") records which one is currently visible; the other is always at opacity 0.When
contentKeychanges, the new children are written into the BACK slot andfrontflips. The back layer is already transparent, so the new content mounts invisibly — writing into the front layer instead would replace the outgoing content before it had faded.gsap.killTweensOf([inEl, outEl])runs before every transition. Moving across a list faster than the duration otherwise leaves an unfinished fade-out driving the same node a fade-in is now driving up, and the layers flicker.Two symmetric tweens —
opacity: 1on the incoming layer,opacity: 0on the outgoing — withpower2.inOut, which keeps the midpoint of the fade at roughly equal luminance instead of dipping through a visible trough.The first commit is a
gsap.set, not a tween, so the initial content is simply present; only subsequent key changes animate.The last
contentKeyacted on is held in a ref, so a parent re-rendering with the same key (new inline children, a hover on the row that is already active) does not restart the fade.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| duration (background) | = 1 | Seconds. A full-bleed area needs a long fade or the whole frame appears to jump brightness; at 0.5s a large gradient change reads as a cut. |
| duration (thumbnail) | = 0.6 | The small element under the cursor answers first. Both at 1s and the interface feels unresponsive; both at 0.6s and the background flickers on fast passes. The 0.4s difference is the effect. |
| ease | = 'power2.inOut' | Symmetric ease on both halves keeps their crossover near 50/50. With power2.out on the way in and power2.in on the way out, both layers are dim at the midpoint and the panel visibly darkens mid-fade. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The layers still ping-pong, but the swap is a gsap.set rather than a tween: content changes instantly with no fade. The double-buffer structure is unchanged, so nothing else in the component behaves differently.
An unmount effect calls gsap.killTweensOf on both layer elements, so an in-flight fade cannot write opacity to a detached node after React has removed it.
Move down a list at a normal pace and each change is a smooth blend with no dip in brightness at the midpoint. Sweep the cursor across every row as fast as you can and the layers never flicker or settle half-faded. In a paired setup, the small preview visibly finishes before the background does.
Pitfalls
- Crossfading one element by swapping its content at opacity 0. There is nothing to fade out of, so it reads as a blink; the two-layer buffer is the whole point.
- Writing incoming content into the front layer. The old content is replaced before it has faded, which is a hard cut with a pointless fade on top.
- Skipping
killTweensOf. Hovering four rows in a second stacks eight opacity tweens across two nodes, and the layers end up at whatever the last-finishing pair leaves behind — usually both partly transparent. - Giving both layers the same duration in a paired preview. The lag between the small element and the full-bleed one is what makes the interaction feel like it is answering you; without it the whole frame changes at once and reads as a page change.
- Restarting the fade when the parent re-renders. Any inline
childrenarray is a new object every render, so the effect must comparecontentKey, not identity, or a hover crossfades continuously. - Leaving tweens running past unmount. They keep writing opacity onto detached nodes; the unmount cleanup kills both layers.
Notes
- Layers hold whatever you give them — a gradient, a canvas, a
<video>, an image. Nothing here is image-specific. - Content is captured per key, so if the same key needs to show new children you must change the key. That is deliberate: it is the only way to know when a transition is warranted.
- App Router wiring: pass
contentKey={usePathname()}and render the route content as children for a whole-page crossfade with no layout thrash.