Anatomy of the Image Compare
How a before/after slider stays a single clip-path on the top layer, why the drag transition disappears while you're actually dragging, and what the slider role buys you for free.
↓Scroll to step through it
Two layers, one boundary
ImageCompare looks like it needs two synced images and a lot of pointer
math. It's actually one number — pos, 0 to 100 — read by three things: a
clip-path on the top layer, the divider's left, and the handle's left.
Nothing else in the component cares where the handle is.
The "after" image sits underneath, full-bleed, always fully painted. The
"before" image sits on top of it, clipped to inset(0 ${100 - pos}% 0 0)
so only the region left of the handle is visible — everything to the right
is still there in the DOM, just clipped away.
Both <img>s decode and paint up front — dragging never swaps sources or
triggers a repaint of the image itself, only the clip boundary moves.
The drag has no transition — on purpose
While dragging is true, pos updates with zero transition: the clip
boundary tracks the pointer 1:1, every pixel of pointer movement becomes a
pixel of clip movement, with no easing lag to fight. The 120ms
ease-out only turns on once the pointer lets go.
That's the opposite of most "smoothed" drag UI: smoothing here would mean
the boundary visibly lags the cursor, which reads as latency. The eased
transition exists for the other way pos changes — keyboard steps — where
a discrete jump benefits from a little easing to feel intentional rather
than teleported.
The loop above swaps the literal clip-path for an opacity crossfade so it
can run forever without a timed layout property — the real component only
ever transitions clip-path for 120ms per keyboard press, never
continuously, so this substitution doesn't change the mechanism, just how
it's illustrated.
The handle is a slider, not a button. The grabber is a role="slider" with
aria-valuemin/max/now, so a screen reader announces it as a position
control, not a generic clickable icon. onKeyDown moves it by 2 normally
or 10 with Shift held, clamped to [0, 100] the same way the
pointer handler is — one clamp() shared by both input paths, so keyboard
and pointer can never disagree about the valid range:
const step = e.shiftKey ? 10 : 2;
if (e.key === dec) setPos((p) => clamp(p - step));
if (e.key === inc) setPos((p) => clamp(p + step));The handle itself scales on hover (110%) and press (95%) over 150ms —
a much shorter, snappier timeline than the drag itself, so the grab
affordance feels immediate even while the boundary it's attached to is
still catching up.
The result
One pos value, one clip-path, one role="slider" — drag the handle
above or tab to it and use the arrow keys.
after only
before only
together
- After
- base layer, always full
- Before
- clip-path: inset(...) on top
- Handle
- role=slider, drag or arrow keys
const clip = horizontal? `inset(0 ${100 - pos}% 0 0)`: `inset(0 0 ${100 - pos}% 0)`;<div className="[&_img]:size-full [&_img]:object-cover">{after}</div><div style={{ clipPath: clip }} className="absolute inset-0">{before}</div>Anatomy of the Image Compare
How a before/after slider stays a single clip-path on the top layer, why the drag transition disappears while you're actually dragging, and what the slider role buys you for free.
after only
before only
together
- After
- base layer, always full
- Before
- clip-path: inset(...) on top
- Handle
- role=slider, drag or arrow keys
const clip = horizontal? `inset(0 ${100 - pos}% 0 0)`: `inset(0 0 ${100 - pos}% 0)`;<div className="[&_img]:size-full [&_img]:object-cover">{after}</div><div style={{ clipPath: clip }} className="absolute inset-0">{before}</div>Two layers, one boundary
ImageCompare looks like it needs two synced images and a lot of pointer
math. It's actually one number — pos, 0 to 100 — read by three things: a
clip-path on the top layer, the divider's left, and the handle's left.
Nothing else in the component cares where the handle is.
The "after" image sits underneath, full-bleed, always fully painted. The
"before" image sits on top of it, clipped to inset(0 ${100 - pos}% 0 0)
so only the region left of the handle is visible — everything to the right
is still there in the DOM, just clipped away.
Both <img>s decode and paint up front — dragging never swaps sources or
triggers a repaint of the image itself, only the clip boundary moves.
- After
- static base
- Before
- opacity stands in for clip
- Handle
- translateX, no clip involved
const transition = dragging ? "" : "[transition:clip-path_120ms_ease-out]";The drag has no transition — on purpose
While dragging is true, pos updates with zero transition: the clip
boundary tracks the pointer 1:1, every pixel of pointer movement becomes a
pixel of clip movement, with no easing lag to fight. The 120ms
ease-out only turns on once the pointer lets go.
That's the opposite of most "smoothed" drag UI: smoothing here would mean
the boundary visibly lags the cursor, which reads as latency. The eased
transition exists for the other way pos changes — keyboard steps — where
a discrete jump benefits from a little easing to feel intentional rather
than teleported.
The loop above swaps the literal clip-path for an opacity crossfade so it
can run forever without a timed layout property — the real component only
ever transitions clip-path for 120ms per keyboard press, never
continuously, so this substitution doesn't change the mechanism, just how
it's illustrated.
The handle is a slider, not a button. The grabber is a role="slider" with
aria-valuemin/max/now, so a screen reader announces it as a position
control, not a generic clickable icon. onKeyDown moves it by 2 normally
or 10 with Shift held, clamped to [0, 100] the same way the
pointer handler is — one clamp() shared by both input paths, so keyboard
and pointer can never disagree about the valid range:
const step = e.shiftKey ? 10 : 2;
if (e.key === dec) setPos((p) => clamp(p - step));
if (e.key === inc) setPos((p) => clamp(p + step));The handle itself scales on hover (110%) and press (95%) over 150ms —
a much shorter, snappier timeline than the drag itself, so the grab
affordance feels immediate even while the boundary it's attached to is
still catching up.
The result
One pos value, one clip-path, one role="slider" — drag the handle
above or tab to it and use the arrow keys.
Accessibility
role="slider" plus the value attributes give the handle a real
accessibility-tree identity; aria-orientation mirrors the horizontal /
vertical prop. focus-visible:ring-2 marks the handle for keyboard users
without adding a ring on pointer interaction. There's no
prefers-reduced-motion check in this component — the only animated
property is the 120ms clip easing, already short enough that Myndui treats
it as a discrete state change rather than motion to gate.
Motion Score
clip-pathBefore/after reveal geometry (120ms one-shot)translateHandle position