Anatomy of the Magic Button
How a pushable 3D button is built from three flat layers, a springy bezier, and a rainbow that costs nothing when idle.
↓Scroll to step through it
The three-layer stack
There is no 3D geometry here. The depth is three stacked <span>s: a blurred
shadow on the surface, a colored edge that fakes the side wall, and the
front face you actually read. Lift the face a few pixels above the edge and
your eye fills in a solid object.
Each layer is absolutely positioned in the same box, so they overlap perfectly at rest. Only their vertical offsets differ.
The push physics
Depth is static; push is motion. The face sits at -4px, lifts to -6px on
hover, and dips to -2px on press — while the shadow deepens underneath. What
sells it is the timing: a slow, springy settle at rest, a snappy overshoot on
hover, and a near-instant 34ms dip on press.
The overshoot lives in the bezier: cubic-bezier(0.3, 0.7, 0.4, 1.5) — that
final 1.5 control point pushes past the target and springs back.
Every offset animates translate, never top/margin or an animated
box-shadow. Transforms are composited — the browser moves an already-painted
layer on the GPU without re-running layout or paint. Animating top would
re-flow the page every frame; animating box-shadow would re-paint it. This is
the house rule across Myndui's motion, enforced in CI.
"[will-change:translate] [transition:translate_600ms_cubic-bezier(0.3,0.7,0.4,1)]"The rainbow edge
The optional rainbow isn't a video or a canvas — it's one gradient twice as wide
as the button, slid sideways forever. background-size: 200% 100% gives the
slack; a keyframe animates background-position across it.
motion-reduce:animate-none freezes the sweep for anyone who's asked the OS to
cut motion.
An infinite background-position keyframe is main-thread paint — cheap, but not
free, and pointless when the button is off screen. An IntersectionObserver
pauses the animation's animationPlayState whenever the button scrolls out of
view, and resumes it seamlessly on the way back in.
const io = new IntersectionObserver(
([entry]) => {
for (const layer of root.querySelectorAll(".animate-magic-rainbow"))
layer.style.animationPlayState = entry.isIntersecting ? "" : "paused";
},
{ rootMargin: "128px" },
);The 128px margin resumes it just before it re-enters, so you never catch it
mid-restart.
The result
Every piece, assembled — the real component. Hover it, tab to it, press and hold. Each motion is one of the mechanisms above doing its job.
That's the whole illusion: three flat layers, one springy curve, a gradient on a timer, and the discipline to animate only what the GPU can move for free.
- Front face
- what you press
- Edge
- fakes the thickness
- Shadow
- grounds it
<button className="group relative ..."><span className="absolute inset-0 rounded-xl ..." aria-hidden /> {/* shadow */}<span className="absolute inset-0 rounded-xl ..." aria-hidden /> {/* edge */}<span className="relative block rounded-xl ...">{children}</span> {/* face */}</button>Anatomy of the Magic Button
How a pushable 3D button is built from three flat layers, a springy bezier, and a rainbow that costs nothing when idle.
- Front face
- what you press
- Edge
- fakes the thickness
- Shadow
- grounds it
<button className="group relative ..."><span className="absolute inset-0 rounded-xl ..." aria-hidden /> {/* shadow */}<span className="absolute inset-0 rounded-xl ..." aria-hidden /> {/* edge */}<span className="relative block rounded-xl ...">{children}</span> {/* face */}</button>The three-layer stack
There is no 3D geometry here. The depth is three stacked <span>s: a blurred
shadow on the surface, a colored edge that fakes the side wall, and the
front face you actually read. Lift the face a few pixels above the edge and
your eye fills in a solid object.
Each layer is absolutely positioned in the same box, so they overlap perfectly at rest. Only their vertical offsets differ.
- rest
- hover
- press
- 600ms
- 250ms
- 34ms
- face -4px
- face -6px
- face -2px
// front face"-translate-y-[4px] [transition:translate_600ms_cubic-bezier(0.3,0.7,0.4,1)]group-hover:-translate-y-[6px]group-hover:[transition:translate_250ms_cubic-bezier(0.3,0.7,0.4,1.5)]group-active:-translate-y-[2px] group-active:[transition:translate_34ms]"The push physics
Depth is static; push is motion. The face sits at -4px, lifts to -6px on
hover, and dips to -2px on press — while the shadow deepens underneath. What
sells it is the timing: a slow, springy settle at rest, a snappy overshoot on
hover, and a near-instant 34ms dip on press.
The overshoot lives in the bezier: cubic-bezier(0.3, 0.7, 0.4, 1.5) — that
final 1.5 control point pushes past the target and springs back.
Every offset animates translate, never top/margin or an animated
box-shadow. Transforms are composited — the browser moves an already-painted
layer on the GPU without re-running layout or paint. Animating top would
re-flow the page every frame; animating box-shadow would re-paint it. This is
the house rule across Myndui's motion, enforced in CI.
"[will-change:translate] [transition:translate_600ms_cubic-bezier(0.3,0.7,0.4,1)]"background-position slides across a 200%-wide gradient
"[background-image:linear-gradient(90deg,var(--rainbow-1),var(--rainbow-5),...)][background-size:200%_100%] animate-magic-rainbow motion-reduce:animate-none"The rainbow edge
The optional rainbow isn't a video or a canvas — it's one gradient twice as wide
as the button, slid sideways forever. background-size: 200% 100% gives the
slack; a keyframe animates background-position across it.
motion-reduce:animate-none freezes the sweep for anyone who's asked the OS to
cut motion.
An infinite background-position keyframe is main-thread paint — cheap, but not
free, and pointless when the button is off screen. An IntersectionObserver
pauses the animation's animationPlayState whenever the button scrolls out of
view, and resumes it seamlessly on the way back in.
const io = new IntersectionObserver(
([entry]) => {
for (const layer of root.querySelectorAll(".animate-magic-rainbow"))
layer.style.animationPlayState = entry.isIntersecting ? "" : "paused";
},
{ rootMargin: "128px" },
);The 128px margin resumes it just before it re-enters, so you never catch it
mid-restart.
The result
Every piece, assembled — the real component. Hover it, tab to it, press and hold. Each motion is one of the mechanisms above doing its job.
That's the whole illusion: three flat layers, one springy curve, a gradient on a timer, and the discipline to animate only what the GPU can move for free.
Accessibility
Real motion needs real semantics. The button tracks a pressed state from
Enter/Space so keyboard users get the same dip as a mouse press;
focus-visible mirrors the hover lift so the focused state reads as raised; and
motion-reduce disables both the rainbow and — via the OS setting — the springy
transitions.
const handleKeyDown = (e) => {
if (e.key === "Enter" || e.key === " ") setPressed(true);
};
// focus-visible gets the same lift as hover:
"group-focus-visible:-translate-y-[6px]";Motion Score
translatePush physics — shadow + front face lift and dipfilterBrightness shift on hover / focus-visiblebackground-positionRainbow gradient keyframe loop