GodUIGodUI
129Follow on X

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 drag, and what the slider role gives you.

01/03Two layers, one boundary

after only

before only

together

After
base layer, always full
Before
clip-path: inset(...) on top
Handle
role=slider, drag or arrow keys
tsx
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>
01

Two layers, one boundary

ImageCompare looks like it needs two synced images and a lot of pointer math. It's 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 in the DOM, clipped away.

Both <img>s decode and paint up front. Dragging never swaps sources or repaints the image itself; only the clip boundary moves.

02/03The drag has no transition, on purpose
After
static base
Before
opacity stands in for clip
Handle
translateX, no clip involved
tsx
const transition = dragging ? "" : "[transition:clip-path_120ms_ease-out]";
02

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.

Most drag UI smooths the pointer. Here, smoothing would make the boundary lag 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 so it doesn't look 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 transitions clip-path only for 120ms per keyboard press, never continuously, so the substitution changes only how the mechanism is illustrated.

The handle is a slider. The grabber is a role="slider" with aria-valuemin/max/now, so a screen reader announces it as a position control instead of 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. Both input paths share one clamp(), so keyboard and pointer can never disagree about the valid range:

tsx
 
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 shorter timeline than the drag itself, so the grab affordance feels immediate even while the boundary it's attached to is still catching up.

03/03Result
Black and white
B&WColor
03

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, short enough that GodUI treats it as a discrete state change rather than motion to gate.

Motion Score

Image CompareCC: Paint-triggering
Cclip-pathBefore/after reveal geometry (120ms one-shot)
StranslateHandle position
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 →