mask-reveal
in progress

Mask Reveal

arena.net had this gorgeous ink-splash scroll effect. I had to know how it worked. Turns out: pure CSS, no JavaScript at all.

#css #animation #react

↳ Published 28 April 2026

Mask Reveal
// the write-up

I spotted this effect on arena.net and immediately wanted to know how it worked. Scroll down and full-colour images explode into view with an ink-splash or paint-burst — the kind of effect that usually screams “canvas” or “WebGL”. It turns out it’s pure CSS.

The secret is mask-image with a sprite strip and steps() timing. Here’s how it works — and three demos at the end wired to different triggers: scroll, hover, and a scrubber.

The two-layer structure

Two images sit on top of each other. The bottom is the same image rendered greyscale via CSS filter. The top is the full-colour version, initially invisible because a mask hides it entirely.

<div class="relative">
  <!-- base layer: sizes the container, shown in greyscale -->
  <img src="photo.jpg" class="grayscale" />
  <!-- colour overlay: hidden until the mask reveals it -->
  <div class="absolute inset-0 color-overlay" />
</div>

The <img> tag doing the sizing is the key trick — it gives the container natural dimensions without hardcoding heights.

The mask as a stencil

mask-image works like a stencil placed over the colour overlay. White pixels in the mask make the overlay visible; transparent pixels hide it.

.color-overlay {
  background-image: url('photo.jpg');
  background-size: cover;
  -webkit-mask-image: url('mask-strip.png');
  mask-image: url('mask-strip.png');
  -webkit-mask-size: auto 100%;
  mask-size: auto 100%;
}

mask-size: auto 100% scales the strip by height so each frame always fills the element regardless of aspect ratio. Using cover instead would scale by the larger dimension — breaking on portrait elements.

The sprite strip

The mask isn’t a single frame — it’s a long horizontal PNG with N+1 frames for steps(N). With frameCount={24}, the strip needs 25 frames: one fully transparent frame at the start (image hidden) and 24 ink-spread frames. Frame 25 is the organic end state. The frames between show progressively more of the paint-splash shape.

By sliding mask-position from 0% 0% to 100% 0% horizontally, we sweep through all 25 frames. The N+1 count is required because CSS percentage-based mask-position from 0%→100% consumes N intervals to traverse N+1 frames — one short of what steps(N) expects if you only have N frames.

The strip’s aspect ratio also matters. mask-size: auto 100% scales the strip by the element’s height, so each frame’s rendered width equals the element width only when their aspect ratios match. Pass aspectRatio to force the container to match the strip’s frame dimensions:

The steps() trick

A normal ease or linear animation would slide the strip like a curtain — you’d see the mask moving, not the paint splash. steps(24) snaps to exactly 24 discrete positions — one per frame. The result is a flipbook.

@keyframes mask-reveal {
  from { mask-position: 0% 0%; }
  to   { mask-position: 100% 0%; }
}

.revealed {
  animation: mask-reveal 700ms steps(24) forwards;
}

forwards keeps the final frame visible after the animation ends. Without it, the colour image vanishes the moment the animation finishes.

steps() works identically on CSS transitions — which matters for bidirectional triggers like hover.

Trigger strategies

The reveal itself is just a display primitive — give it revealed: boolean or progress: number and it does its thing. What drives it is up to you.

Scroll reveal

MaskRevealScroll uses IntersectionObserver — once the element hits the visibility threshold, flip revealed = true and disconnect. Because it’s one-way, the underlying MaskReveal just uses a CSS mask-position transition with steps(24). No keyframe animation needed.

<MaskRevealScroll
  src="/photo.jpg"
  alt="..."
  maskSrc="/experiments/mask-reveal/mask-strip-ink.png"
  frameCount={24}
  duration={700}
  threshold={0.3}
/>

Hover reveal

MaskRevealHover should be simple — forward on enter, reverse on leave. Bidirectional CSS transitions with steps() do work, but they cancel mid-flight unreliably. Rapid hover/unhover snaps the mask to random frames.

The fix is a requestAnimationFrame loop that tracks a progress value (0–1) and increments or decrements it at a constant rate. Reversing always picks up from exactly where the animation is — no snapping, consistent speed both ways. The loop drives MaskReveal via its progress prop, which seeks to that frame using animation-play-state: paused and a negative animation-delay.

<MaskRevealHover
  src="/photo.jpg"
  alt="..."
  maskSrc="/experiments/mask-reveal/mask-strip-ink.png"
  frameCount={24}
  duration={700}
/>

Scrubber

MaskRevealScrubber is the fun one — a range input with direct frame control. Drag right to reveal, drag left to rewind. The slider drives a progress state (0–1), which MaskReveal translates to animation-delay: -(progress × duration)ms on a paused keyframe animation. Exactly like scrubbing a video to a timestamp.

<MaskRevealScrubber
  src="/photo.jpg"
  alt="..."
  maskSrc="/experiments/mask-reveal/mask-strip-ink.png"
  frameCount={24}
  duration={700}
/>

Building your own trigger

Anything works as a trigger. Pass revealed for fire-and-hold, or progress if you want frame-level control:

// Play on click, reset on double-click
const [revealed, setRevealed] = useState(false);
<div onClick={() => setRevealed(true)} onDoubleClick={() => setRevealed(false)}>
  <MaskReveal revealed={revealed} src="..." maskSrc="..." frameCount={24} />
</div>

// Focus reveal for keyboard accessibility
const [revealed, setRevealed] = useState(false);
<div tabIndex={0} onFocus={() => setRevealed(true)}>
  <MaskReveal revealed={revealed} src="..." maskSrc="..." frameCount={24} />
</div>