compare
Before/after clip-path wipe with pointer, keyboard, and slider-role support.
figureinteractiveslider
Preview
draftfinal
before
after
Installation
shadcn CLI
Terminal
npx shadcn@latest add https://mocoui.site/r/compare.jsonManual
Copy each file from the Source section into its target path:
registry/compare/compare.tsx → components/moco/compare.tsx
registry/compare/compare.css → components/moco/compare.cssNeeds moco items: tokens
Usage
usage.tsx
import { Compare } from "./compare";
export default function CompareDemo() {
return (
<Compare
height={220}
beforeLabel="draft"
afterLabel="final"
before={
<div
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
background: "var(--moco-panel)",
color: "var(--moco-muted)",
fontFamily: "var(--moco-mono)",
}}
>
before
</div>
}
after={
<div
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
background: "var(--moco-accent-wash)",
color: "var(--moco-ink)",
fontFamily: "var(--moco-mono)",
}}
>
after
</div>
}
/>
);
}
Source
components/moco/compare.tsx
"use client";
import * as React from "react";
import "./compare.css";
export interface CompareProps {
before: React.ReactNode;
after: React.ReactNode;
height?: number;
beforeLabel?: string;
afterLabel?: string;
}
/**
* Before/after wipe. Works for images and for live DOM. Drag the handle,
* click anywhere to position it, or focus it and use the arrow keys.
*/
export function Compare({
before,
after,
height = 300,
beforeLabel = "before",
afterLabel = "after",
}: CompareProps) {
const [pct, setPct] = React.useState(50);
const box = React.useRef<HTMLDivElement>(null);
const dragging = React.useRef(false);
const setFromClientX = (x: number) => {
const el = box.current;
if (!el) return;
const r = el.getBoundingClientRect();
setPct(Math.min(100, Math.max(0, ((x - r.left) / r.width) * 100)));
};
return (
<div
ref={box}
className="moco-cmp"
style={{ height }}
onPointerDown={(e) => {
dragging.current = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setFromClientX(e.clientX);
}}
onPointerMove={(e) => dragging.current && setFromClientX(e.clientX)}
onPointerUp={() => (dragging.current = false)}
onPointerCancel={() => (dragging.current = false)}
>
<span className="moco-cmp-label" style={{ left: 12 }}>
{beforeLabel}
</span>
<span className="moco-cmp-label" style={{ right: 12 }}>
{afterLabel}
</span>
<div style={{ position: "absolute", inset: 0 }}>{before}</div>
<div className="moco-cmp-after" style={{ clipPath: `inset(0 0 0 ${pct}%)` }}>
{after}
</div>
<div className="moco-cmp-handle" style={{ left: `${pct}%` }}>
<button
type="button"
className="moco-cmp-grab"
role="slider"
aria-label={`Reveal ${afterLabel} over ${beforeLabel}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(pct)}
aria-valuetext={`${Math.round(pct)} percent`}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") setPct((v) => Math.max(0, v - 4));
if (e.key === "ArrowRight") setPct((v) => Math.min(100, v + 4));
if (e.key === "Home") setPct(0);
if (e.key === "End") setPct(100);
}}
>
⇄
</button>
</div>
</div>
);
}
components/moco/compare.css
.moco-cmp {
position: relative;
overflow: hidden;
user-select: none;
touch-action: pan-y;
border: 1px solid var(--moco-line);
border-radius: 2px;
background: var(--moco-bg);
}
.moco-cmp-after {
position: absolute;
inset: 0;
overflow: hidden;
/* Opaque ground: without it, translucent `after` content lets the before
layer (and its text) bleed through the revealed side. */
background: var(--moco-bg);
}
.moco-cmp-handle {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--moco-accent-dk);
z-index: 4;
}
.moco-cmp-grab {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 22px;
height: 34px;
border: 1px solid var(--moco-accent-dk);
border-radius: 2px;
background: var(--moco-bg);
cursor: ew-resize;
display: grid;
place-items: center;
color: var(--moco-accent-dk);
font-size: 10px;
}
.moco-cmp-label {
position: absolute;
top: 8px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--moco-faint);
z-index: 3;
}