Anatomy of the Mask Button
How a button's face wipes away on hover — two stacked labels, a sprite-sheet mask, and a steps() flipbook instead of an eased transition.
↓Scroll to step through it
Two labels, one visible through a mask
The wipe isn't a gradient or a clip-path animating smoothly — it's two copies of the same label and a sprite sheet scrubbed frame by frame. Here's every piece, pulled apart.
Every MaskButton renders its children twice. The first span is plain and
always there. The second is an aria-hidden twin, absolutely positioned on
top, filled with the variant color, and only visible where a mask image lets
it through:
FILL_BASE sets mask-image: var(--mask-img) on that second span. At rest
the mask hides it almost entirely, so you see the plain base label
underneath; the hover animation slides the mask across, and wherever it's
opaque, the colored twin shows through instead.
Same label, twice — a plain span underneath, a colored one clipped by the mask on top. The third panel loops the boundary sweeping across, which is exactly what a hover-then-leave cycle looks like on the real button.
The flipbook
The mask asset is a horizontal sprite sheet, not a single image, and the
reveal is a mask-position animation with a steps() timing function — not
an eased slide. Each mask gets its own step count, because the sprites have
different frame counts:
--animate-mask-nature-in: mask-button-in 0.7s steps(22) forwards;
--animate-mask-nature-out: mask-button-out 0.7s steps(22) forwards;
--animate-mask-urban-in: mask-button-in 0.7s steps(29) forwards;
--animate-mask-forest-in: mask-button-in 0.7s steps(70) forwards;@keyframes mask-button-in {
from { mask-position: 0 0; }
to { mask-position: 100% 0; }
}mask-size stretches the whole sprite strip to a multiple of the button's
own width — 2300% for nature's 22-ish frames, 3000% for urban, 7100%
for forest — so each steps() tick lands exactly on the next frame instead
of somewhere between two.
The playhead jumps across illustrative frames in discrete steps, and the
reveal underneath jumps with it — steps(n) on a mask-position animation
never interpolates between frames, it holds each one and cuts to the next.
Keyboard focus gets the identical *-in class as a mouse hover — same
sprite, same duration, same steps. A keyboard user sees precisely the
transition a mouse user would, instead of a cheaper fallback. Mouse-leave (or
losing focus) plays *-out, the same keyframes run backward, so the wipe
always resolves in the direction it started.
Press feedback works whether the button was clicked or activated from the
keyboard. A CSS class handles the pointer case; data-pressed covers
Enter / Space, since native :active doesn't fire for those:
const handleKeyDown = (event) => {
if (event.key === "Enter" || event.key === " ") setPressed(true);
onKeyDown?.(event);
};enabled:active:scale-[0.96] enabled:data-[pressed=true]:scale-[0.96]Both paths land on the same scale-[0.96] — one state, two triggers.
The result
Every piece, assembled — three real buttons, three sprites. Hover, tab to, or press each one.
That's the whole illusion: a plain label always in the DOM, a colored twin
clipped by a sprite mask, and a steps() timing function doing the one thing
an eased transition can't — holding each frame instead of blending it.
base only
overlay only
together (masked)
- Base label
- z-0, the real children, always in the DOM
- Masked overlay
- z-1, aria-hidden twin, clipped by --mask-img
<span className="relative z-0">{children}</span><spanclassName={`${FILL_BASE} ${fillVariant[variant]} ${cfg.sizeClass} ${cfg.rest} ${cfg.hover}`}style={{ "--mask-img": `url("${MASK_ASSETS[mask]}")` } as React.CSSProperties}aria-hidden="true">{children}</span>Anatomy of the Mask Button
How a button's face wipes away on hover — two stacked labels, a sprite-sheet mask, and a steps() flipbook instead of an eased transition.
base only
overlay only
together (masked)
- Base label
- z-0, the real children, always in the DOM
- Masked overlay
- z-1, aria-hidden twin, clipped by --mask-img
<span className="relative z-0">{children}</span><spanclassName={`${FILL_BASE} ${fillVariant[variant]} ${cfg.sizeClass} ${cfg.rest} ${cfg.hover}`}style={{ "--mask-img": `url("${MASK_ASSETS[mask]}")` } as React.CSSProperties}aria-hidden="true">{children}</span>Two labels, one visible through a mask
The wipe isn't a gradient or a clip-path animating smoothly — it's two copies of the same label and a sprite sheet scrubbed frame by frame. Here's every piece, pulled apart.
Every MaskButton renders its children twice. The first span is plain and
always there. The second is an aria-hidden twin, absolutely positioned on
top, filled with the variant color, and only visible where a mask image lets
it through:
FILL_BASE sets mask-image: var(--mask-img) on that second span. At rest
the mask hides it almost entirely, so you see the plain base label
underneath; the hover animation slides the mask across, and wherever it's
opaque, the colored twin shows through instead.
Same label, twice — a plain span underneath, a colored one clipped by the mask on top. The third panel loops the boundary sweeping across, which is exactly what a hover-then-leave cycle looks like on the real button.
- nature
- urban
- forest
- steps(22)
- steps(29)
- steps(70)
- mask-size 2300%
- mask-size 3000%
- mask-size 7100%
hover: "group-hover:animate-mask-nature-in group-focus-visible:animate-mask-nature-in",The flipbook
The mask asset is a horizontal sprite sheet, not a single image, and the
reveal is a mask-position animation with a steps() timing function — not
an eased slide. Each mask gets its own step count, because the sprites have
different frame counts:
--animate-mask-nature-in: mask-button-in 0.7s steps(22) forwards;
--animate-mask-nature-out: mask-button-out 0.7s steps(22) forwards;
--animate-mask-urban-in: mask-button-in 0.7s steps(29) forwards;
--animate-mask-forest-in: mask-button-in 0.7s steps(70) forwards;@keyframes mask-button-in {
from { mask-position: 0 0; }
to { mask-position: 100% 0; }
}mask-size stretches the whole sprite strip to a multiple of the button's
own width — 2300% for nature's 22-ish frames, 3000% for urban, 7100%
for forest — so each steps() tick lands exactly on the next frame instead
of somewhere between two.
The playhead jumps across illustrative frames in discrete steps, and the
reveal underneath jumps with it — steps(n) on a mask-position animation
never interpolates between frames, it holds each one and cuts to the next.
Keyboard focus gets the identical *-in class as a mouse hover — same
sprite, same duration, same steps. A keyboard user sees precisely the
transition a mouse user would, instead of a cheaper fallback. Mouse-leave (or
losing focus) plays *-out, the same keyframes run backward, so the wipe
always resolves in the direction it started.
Press feedback works whether the button was clicked or activated from the
keyboard. A CSS class handles the pointer case; data-pressed covers
Enter / Space, since native :active doesn't fire for those:
const handleKeyDown = (event) => {
if (event.key === "Enter" || event.key === " ") setPressed(true);
onKeyDown?.(event);
};enabled:active:scale-[0.96] enabled:data-[pressed=true]:scale-[0.96]Both paths land on the same scale-[0.96] — one state, two triggers.
The result
Every piece, assembled — three real buttons, three sprites. Hover, tab to, or press each one.
That's the whole illusion: a plain label always in the DOM, a colored twin
clipped by a sprite mask, and a steps() timing function doing the one thing
an eased transition can't — holding each frame instead of blending it.
Accessibility
motion-reduce drops the flipbook entirely and snaps between the two
mask-position endpoints instead of animating through them:
motion-reduce:animate-none
motion-reduce:[mask-position:0_0]
motion-reduce:group-hover:[mask-position:100%_0]
motion-reduce:group-focus-visible:[mask-position:100%_0]The button still keeps a real focus-visible outline
([outline:2px_solid_var(--ring)], 4px offset) and forwards every native
<button> attribute — the masked span is decorative and aria-hidden, so
assistive tech only ever sees the one real label.
Motion Score
scaleScale spring / pressopacityFade / cross-fade