MynduiMyndui
0

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.

01/03The three-layer stack

spark only

spark + cut

+ label

Spark
conic gradient, blurred, spinning
Cut
solid inset that hides the middle
Label
text, always on top
tsx
<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)]" />
01

The three-layer stack

The shimmer isn't a border animation — there's no border being drawn. It's a spinning gradient blob, mostly hidden behind a solid cutout, so only a sliver of it ever 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 ever see.

The stacking context does the work: spark at -z-30, cut at -z-20, label at z-10. Nothing here is a border — it's paint you can only partially see.

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) — 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. The combination is what keeps the light looking like it's orbiting instead of just bouncing.

css
 
--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 just a CSS variable — shimmerDuration sets it directly, no re-render required.

02/03Speeding up without restarting

leave / blur — rate 1×

hover / focus-visible — rate 3×

Rate 1×
resting state, ambient shimmer
Rate 3×
hover / keyboard focus, same timeline
tsx
const setSparkRate = (rate: number) => {sparkRef.current?.getAnimations({ subtree: true }).forEach((a) => {  a.playbackRate = rate;});};const handleMouseEnter = () => setSparkRate(3);const handleMouseLeave = () => setSparkRate(1);
02

Speeding up without restarting

Hovering doesn't swap in a faster animation. The same Web Animations API timeline keeps running; the button just 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 just 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 a keyboard-only affordance:

tsx
 
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:

tsx
 
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
  if (e.key === "Enter" || e.key === " ") setPressed(true);
};
tsx
 
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 exact same transform.

03/03Result
03

The result

Spinning gradient, solid cutout, a label riding on top, and one line that scrubs a Web Animations timeline instead of swapping animations. That's the whole light show.

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, not just a slower one. 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:

tsx
 
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 removing the light show never removes the focus indicator.

Motion Score

Shimmer ButtonSSCompositor-only
StranslateShimmer sweep via translated highlight
SopacityHighlight fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →