Anatomy of the Shimmer Button
How a single rotating conic gradient becomes a border-only light sweep, and why it speeds up under your cursor without restarting.
↓Scroll to step through it
The three-layer stack
No border is drawn. The shimmer is a spinning gradient blob, mostly hidden behind a solid cutout, so only a sliver of it reaches the edge. Every button renders three things in the same box: a spark (a blurred conic gradient that spins and slides), a cut (a solid inset the exact color of the background), and the label. The spark fills the entire button. The cut sits on top of it and covers everything except a thin ring at the edge. That ring is the only part of the spark you see.
The stacking context does the work: spark at -z-30, cut at -z-20, label
at z-10. There is no border, only paint that is partially covered.
The spark layer is two nested elements, each running its own keyframe. The
outer one, shimmer-slide, translates the whole gradient sideways across
100cqw (container width) with ease-in-out infinite alternate, so it slides
right, then back left, forever. The inner one, spin-around, rotates the
conic gradient itself through a 0° → 90° → 270° → 360° timeline, linear infinite, at calc(var(--speed) * 2), twice as slow as the slide. Together
they make the light orbit instead of bouncing.
--animate-shimmer-slide: shimmer-slide var(--speed) ease-in-out infinite alternate;
--animate-spin-around: spin-around calc(var(--speed) * 2) infinite linear;--speed defaults to 3s and is a plain CSS variable. shimmerDuration
sets it directly, with no re-render.
Speeding up without restarting
Hovering doesn't swap in a faster animation. The same Web Animations API timeline keeps running; the button asks the browser to play it back three times as fast.
Because playbackRate scrubs the existing timeline rather than restarting
it, the gradient never jumps or resets position when you hover. It picks up
speed from wherever it already was. Keyboard focus gets the same treatment,
but only for :focus-visible. Programmatic or pointer-driven focus doesn't
trigger the speed-up, so it stays keyboard-only:
const handleFocus = (e: React.FocusEvent<HTMLButtonElement>) => {
if (e.target.matches(":focus-visible")) setSparkRate(3);
};Enter and Space fire a native click but never toggle CSS :active, so the
component tracks it manually with data-pressed, matching the same
translate-y-px the mouse gets from active:translate-y-px:
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") setPressed(true);
};className="... active:translate-y-px data-[pressed=true]:translate-y-px ..."One rule, two triggers: pointer :active and the synthetic data-pressed
land on the same transform.
The result
A spinning gradient, a solid cutout, a label on top, and one line that scrubs a Web Animations timeline instead of swapping animations.
spark only
spark + cut
+ label
- Spark
- conic gradient, blurred, spinning
- Cut
- solid inset that hides the middle
- Label
- text, always on top
<div ref={sparkRef} className="absolute inset-0 -z-30 overflow-visible blur-[2px] [container-type:size]"><div className="animate-shimmer-slide absolute inset-0 aspect-square h-[100cqh] [mask:none]"> <div className="animate-spin-around absolute -inset-full w-auto [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]" /></div></div><span className="relative z-10">{children}</span><div className="absolute -z-20 [inset:var(--cut)] [border-radius:var(--radius)] [background:var(--bg)]" />Anatomy of the Shimmer Button
How a single rotating conic gradient becomes a border-only light sweep, and why it speeds up under your cursor without restarting.
spark only
spark + cut
+ label
- Spark
- conic gradient, blurred, spinning
- Cut
- solid inset that hides the middle
- Label
- text, always on top
<div ref={sparkRef} className="absolute inset-0 -z-30 overflow-visible blur-[2px] [container-type:size]"><div className="animate-shimmer-slide absolute inset-0 aspect-square h-[100cqh] [mask:none]"> <div className="animate-spin-around absolute -inset-full w-auto [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]" /></div></div><span className="relative z-10">{children}</span><div className="absolute -z-20 [inset:var(--cut)] [border-radius:var(--radius)] [background:var(--bg)]" />The three-layer stack
No border is drawn. The shimmer is a spinning gradient blob, mostly hidden behind a solid cutout, so only a sliver of it reaches the edge. Every button renders three things in the same box: a spark (a blurred conic gradient that spins and slides), a cut (a solid inset the exact color of the background), and the label. The spark fills the entire button. The cut sits on top of it and covers everything except a thin ring at the edge. That ring is the only part of the spark you see.
The stacking context does the work: spark at -z-30, cut at -z-20, label
at z-10. There is no border, only paint that is partially covered.
The spark layer is two nested elements, each running its own keyframe. The
outer one, shimmer-slide, translates the whole gradient sideways across
100cqw (container width) with ease-in-out infinite alternate, so it slides
right, then back left, forever. The inner one, spin-around, rotates the
conic gradient itself through a 0° → 90° → 270° → 360° timeline, linear infinite, at calc(var(--speed) * 2), twice as slow as the slide. Together
they make the light orbit instead of bouncing.
--animate-shimmer-slide: shimmer-slide var(--speed) ease-in-out infinite alternate;
--animate-spin-around: spin-around calc(var(--speed) * 2) infinite linear;--speed defaults to 3s and is a plain CSS variable. shimmerDuration
sets it directly, with no re-render.
leave / blur: rate 1×
hover / focus-visible: rate 3×
- Rate 1×
- resting state, ambient shimmer
- Rate 3×
- hover / keyboard focus, same timeline
const setSparkRate = (rate: number) => {sparkRef.current?.getAnimations({ subtree: true }).forEach((a) => { a.playbackRate = rate;});};const handleMouseEnter = () => setSparkRate(3);const handleMouseLeave = () => setSparkRate(1);Speeding up without restarting
Hovering doesn't swap in a faster animation. The same Web Animations API timeline keeps running; the button asks the browser to play it back three times as fast.
Because playbackRate scrubs the existing timeline rather than restarting
it, the gradient never jumps or resets position when you hover. It picks up
speed from wherever it already was. Keyboard focus gets the same treatment,
but only for :focus-visible. Programmatic or pointer-driven focus doesn't
trigger the speed-up, so it stays keyboard-only:
const handleFocus = (e: React.FocusEvent<HTMLButtonElement>) => {
if (e.target.matches(":focus-visible")) setSparkRate(3);
};Enter and Space fire a native click but never toggle CSS :active, so the
component tracks it manually with data-pressed, matching the same
translate-y-px the mouse gets from active:translate-y-px:
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") setPressed(true);
};className="... active:translate-y-px data-[pressed=true]:translate-y-px ..."One rule, two triggers: pointer :active and the synthetic data-pressed
land on the same transform.
The result
A spinning gradient, a solid cutout, a label on top, and one line that scrubs a Web Animations timeline instead of swapping animations.
Accessibility
Every decorative layer (spark, its inner slide, and the empty overlay)
carries motion-reduce:animate-none, so prefers-reduced-motion users get
a flat, static button with no gradient at all. The
cut and spark also disappear entirely when shimmer={false} or the button
is disabled, via a shared Tailwind group selector rather than duplicated
conditionals:
const HIDDEN_WHEN_OFF =
"group-[&:not([data-shimmer=true])]:hidden group-disabled:hidden";Focus still gets a real ring (focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2), independent of the shimmer speed-up, so
turning off the shimmer never removes the focus indicator.
Motion Score
translateShimmer sweep via translated highlightopacityHighlight fade