MynduiMyndui
0

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.

01/03Two labels, one visible through a mask

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

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.

02/03The flipbook
nature
urban
forest
steps(22)
steps(29)
steps(70)
mask-size 2300%
mask-size 3000%
mask-size 7100%
tsx
hover: "group-hover:animate-mask-nature-in group-focus-visible:animate-mask-nature-in",
02

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:

css
 
--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;
css
 
@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:

tsx
 
const handleKeyDown = (event) => {
  if (event.key === "Enter" || event.key === " ") setPressed(true);
  onKeyDown?.(event);
};
css
 
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.

03/03Result
03

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:

css
 
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

Mask ButtonSSCompositor-only
SscaleScale spring / press
SopacityFade / cross-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 →