Media
Compare
Two layers with a draggable divider between them, for before and after views and design reviews.
- Built on
clip-pathPointer capture- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.0 kB
- Dependencies
- React
INVOICE 1042Amount due: 1240.00 USDClick here to pay
Invoice 1042$1,240.00Pay now
Code
import { Compare } from "@/components/equip/compare";
const frame = { display: "grid", alignContent: "center", gap: 6, height: "100%", padding: "0 20px", boxSizing: "border-box" } as const;
export default function Demo() {
return (
<Compare
label="Invoice card redesign"
style={{ width: 220, height: 118 }}
before={
<div style={{ ...frame, background: "var(--eq-tint-strong)", fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>
<span>INVOICE 1042</span>
<span style={{ color: "var(--eq-ink)" }}>Amount due: 1240.00 USD</span>
<span style={{ textDecoration: "underline" }}>Click here to pay</span>
</div>
}
after={
<div style={{ ...frame, background: "var(--eq-raised)" }}>
<span style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-3)" }}>Invoice 1042</span>
<strong style={{ fontSize: "var(--eq-text-l)" }}>$1,240.00</strong>
<span
style={{
justifySelf: "start",
padding: "3px 10px",
borderRadius: "var(--eq-radius-s)",
background: "var(--eq-accent)",
color: "var(--eq-accent-ink)",
fontSize: "var(--eq-text-xs)",
fontWeight: "var(--eq-weight-strong)",
}}
>
Pay now
</span>
</div>
}
/>
);
}Examples
Vertical
Up and Down move the divider the way they point, and the labels sit top and bottom.
eu-west-141 ms
us-east-1118 ms
ap-south-1203 ms
sa-east-1247 ms
eu-west-141 ms
us-east-1118 ms
ap-south-1203 ms
sa-east-1247 ms
Code
import { Compare } from "@/components/equip/compare";
const row = { display: "flex", justifyContent: "space-between", padding: "8px 16px", fontSize: "var(--eq-text-s)" } as const;
const rows = [
["eu-west-1", "41 ms"],
["us-east-1", "118 ms"],
["ap-south-1", "203 ms"],
["sa-east-1", "247 ms"],
];
export default function Example() {
return (
<Compare
label="Latency table contrast fix"
orientation="vertical"
defaultPosition={40}
beforeLabel="Old contrast"
afterLabel="New contrast"
style={{ maxWidth: 360 }}
before={
<div style={{ paddingBlock: 28, color: "var(--eq-ink-3)" }}>
{rows.map(([region, ms]) => (
<div key={region} style={row}>
<span>{region}</span>
<span>{ms}</span>
</div>
))}
</div>
}
after={
<div style={{ paddingBlock: 28, background: "var(--eq-raised)", color: "var(--eq-ink)" }}>
{rows.map(([region, ms]) => (
<div key={region} style={{ ...row, borderBottom: "1px solid var(--eq-line)" }}>
<span style={{ fontWeight: "var(--eq-weight-strong)" }}>{region}</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>{ms}</span>
</div>
))}
</div>
}
/>
);
}Controlled, with presets
The position is yours to set: preset buttons glide the divider on the same spring a track click uses.
No deploys yet
No deploys yet
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Compare } from "@/components/equip/compare";
const swatch = (bg: string, ink: string, text: string) => (
<div style={{ display: "grid", placeItems: "center", height: 160, background: bg, color: ink, fontSize: "var(--eq-text-l)", fontWeight: "var(--eq-weight-strong)" }}>
{text}
</div>
);
export default function Example() {
const [position, setPosition] = useState(50);
return (
<div style={{ display: "grid", gap: 12, maxWidth: 420 }}>
<Compare
label="Empty state illustration"
position={position}
onPositionChange={setPosition}
beforeLabel="Light"
afterLabel="Solid"
before={swatch("var(--eq-surface)", "var(--eq-ink)", "No deploys yet")}
after={swatch("var(--eq-solid)", "var(--eq-solid-ink)", "No deploys yet")}
/>
<div style={{ display: "flex", gap: 8 }}>
<Button size="s" variant="outline" onClick={() => setPosition(0)}>
Show solid
</Button>
<Button size="s" variant="outline" onClick={() => setPosition(50)}>
Split
</Button>
<Button size="s" variant="outline" onClick={() => setPosition(100)}>
Show light
</Button>
</div>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the divider for assistive tech, such as the thing being compared. |
position | number | none | Divider position in percent from the start edge, when you control it. |
defaultPosition | number | 50 | Divider position on first render, when you do not. |
onPositionChange | (position: number) => void | none | Called as the divider moves. |
orientation | "horizontal" | "vertical" | "horizontal" | Vertical stacks before above after. |
before | ReactNode | none | Shown on the start side of the divider. |
after | ReactNode | none | Shown on the end side of the divider. |
beforeLabel | string | "Before" | Corner label for the before layer. |
afterLabel | string | "After" | Corner label for the after layer. |
...rest | ComponentProps<"div"> | none | Every native div attribute. Size the frame here. |
Anatomy
| root | .eq-compare | The frame; a press anywhere on it moves the divider. |
|---|---|---|
| layer | .eq-compare-layer | data-layer is before or after; the after layer is clipped at the divider. |
| divider | .eq-compare-divider | |
| handle | .eq-compare-handle | The slider; grows while dragged. |
| label | .eq-compare-label | Fades when the divider comes near it. |
Keyboard
| Left arrowRight arrow | Moves the divider 1% that way. |
|---|---|
| Up arrowDown arrow | Moves the divider 1%: the way they point when vertical, up for more when horizontal. |
| Shift+Arrow keys | Moves the divider 10%. |
| Page UpPage Down | Moves the divider 10%. |
| HomeEnd | Moves the divider to the start or end, showing all of one layer. |
Notes
- The handle is a role slider with a value text that says how much of each layer shows.
- Both layers stay in the DOM: the clipped one is still read by screen readers and its controls can still take focus, so keep layers to static content.
- A drag follows the pointer exactly; a click on the frame, a key press or a new position from outside glides there on the spring.
- Horizontal frames leave vertical touch scrolling alone, and vertical frames leave horizontal scrolling alone.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/compare.tsx171 lines
"use client";
import {
useRef,
useState,
type ComponentProps,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import "./compare.css";
export type CompareProps = Omit<ComponentProps<"div">, "children"> & {
/** Names the divider for assistive tech, e.g. "Homepage redesign". */
label: string;
/** Divider position in percent from the start edge, when you control it. */
position?: number;
/** Divider position on first render, when you do not. */
defaultPosition?: number;
onPositionChange?: (position: number) => void;
orientation?: "horizontal" | "vertical";
/** Shown on the start side of the divider. */
before: ReactNode;
/** Shown on the end side of the divider. */
after: ReactNode;
beforeLabel?: string;
afterLabel?: string;
};
/* A corner label fades once the divider comes within this many percent of it. */
const LABEL_ROOM = 22;
const clamp = (n: number) => Math.min(100, Math.max(0, n));
export function Compare({
label,
position: controlled,
defaultPosition = 50,
onPositionChange,
orientation = "horizontal",
before,
after,
beforeLabel = "Before",
afterLabel = "After",
className,
style,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
...rest
}: CompareProps) {
const [uncontrolled, setUncontrolled] = useState(clamp(defaultPosition));
const position = clamp(controlled ?? uncontrolled);
const [dragging, setDragging] = useState(false);
/* Track clicks, keys and outside changes glide on the spring; a drag follows the pointer exactly. */
const [glide, setGlide] = useState(false);
const vertical = orientation === "vertical";
const pressAt = useRef({ x: 0, y: 0 });
function set(next: number, animate: boolean) {
const value = Math.round(clamp(next) * 10) / 10;
setGlide(animate);
if (value === position) return;
if (controlled === undefined) setUncontrolled(value);
onPositionChange?.(value);
}
function fromPointer(event: PointerEvent<HTMLDivElement>) {
const box = event.currentTarget.getBoundingClientRect();
return vertical
? ((event.clientY - box.top) / box.height) * 100
: ((event.clientX - box.left) / box.width) * 100;
}
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
onPointerDown?.(event);
if (event.defaultPrevented || event.button !== 0) return;
pressAt.current = { x: event.clientX, y: event.clientY };
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
const onHandle = event.target instanceof Element && event.target.closest(".eq-compare-handle");
setDragging(true);
if (onHandle) setGlide(false);
else set(fromPointer(event), true);
event.currentTarget.querySelector<HTMLElement>(".eq-compare-handle")?.focus({ preventScroll: true });
}
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
onPointerMove?.(event);
if (!dragging) return;
/* Let a track click finish its glide unless the pointer really moves. */
if (glide && Math.hypot(event.clientX - pressAt.current.x, event.clientY - pressAt.current.y) < 4) return;
set(fromPointer(event), false);
}
function stop() {
setDragging(false);
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const step = event.shiftKey ? 10 : 1;
const keys: Record<string, number> = vertical
? { ArrowUp: -step, ArrowDown: step, ArrowLeft: -step, ArrowRight: step, PageUp: -10, PageDown: 10 }
: { ArrowLeft: -step, ArrowRight: step, ArrowDown: -step, ArrowUp: step, PageDown: -10, PageUp: 10 };
let next: number | undefined;
if (event.key in keys) next = position + keys[event.key];
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = 100;
if (next === undefined) return;
event.preventDefault();
event.stopPropagation();
set(next, true);
}
const rounded = Math.round(position);
return (
<div
{...rest}
className={["eq-compare", className].filter(Boolean).join(" ")}
style={{ ...style, "--_pos": `${position}%` } as CSSProperties}
data-orientation={orientation}
data-dragging={dragging ? "" : undefined}
data-glide={glide || !dragging ? "" : undefined}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={(event) => {
onPointerUp?.(event);
stop();
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
stop();
}}
onLostPointerCapture={stop}
>
<div className="eq-compare-layer" data-layer="before">
{before}
</div>
<div className="eq-compare-layer" data-layer="after">
{after}
</div>
<span className="eq-compare-label" data-side="before" data-hidden={position < LABEL_ROOM ? "" : undefined} aria-hidden="true">
{beforeLabel}
</span>
<span className="eq-compare-label" data-side="after" data-hidden={position > 100 - LABEL_ROOM ? "" : undefined} aria-hidden="true">
{afterLabel}
</span>
<div className="eq-compare-divider">
<div
role="slider"
tabIndex={0}
aria-label={label}
aria-orientation={orientation}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={rounded}
aria-valuetext={`${rounded}% ${beforeLabel.toLowerCase()}, ${100 - rounded}% ${afterLabel.toLowerCase()}`}
className="eq-compare-handle"
onKeyDown={handleKeyDown}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 4.5L2.5 8 6 11.5M10 4.5l3.5 3.5-3.5 3.5" />
</svg>
</div>
</div>
</div>
);
}components/equip/compare.css153 lines
@layer equip {
.eq-compare {
--_handle: 32px;
position: relative;
display: grid;
overflow: hidden;
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background: var(--eq-surface);
color: var(--eq-ink);
cursor: ew-resize;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
}
.eq-compare[data-orientation="vertical"] {
cursor: ns-resize;
touch-action: pan-x;
}
.eq-compare-layer {
grid-area: 1 / 1;
min-width: 0;
min-height: 0;
}
.eq-compare-layer[data-layer="after"] {
clip-path: inset(0 0 0 var(--_pos));
}
.eq-compare[data-orientation="vertical"] .eq-compare-layer[data-layer="after"] {
clip-path: inset(var(--_pos) 0 0 0);
}
.eq-compare-divider {
position: absolute;
top: 0;
bottom: 0;
left: var(--_pos);
width: 2px;
margin-left: -1px;
background: var(--eq-raised);
box-shadow: 0 0 0 1px var(--eq-line-strong);
pointer-events: none;
}
.eq-compare[data-orientation="vertical"] .eq-compare-divider {
top: var(--_pos);
bottom: auto;
left: 0;
right: 0;
width: auto;
height: 2px;
margin: -1px 0 0;
}
.eq-compare[data-glide] .eq-compare-layer[data-layer="after"] {
transition: clip-path var(--eq-dur-move) var(--eq-spring);
}
.eq-compare[data-glide] .eq-compare-divider {
transition:
left var(--eq-dur-move) var(--eq-spring),
top var(--eq-dur-move) var(--eq-spring);
}
.eq-compare-handle {
box-sizing: border-box;
position: absolute;
top: 50%;
left: 50%;
display: grid;
place-items: center;
width: var(--_handle);
height: var(--_handle);
margin: calc(var(--_handle) / -2) 0 0 calc(var(--_handle) / -2);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
color: var(--eq-ink-2);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
pointer-events: auto;
cursor: grab;
transition:
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring);
}
.eq-compare-handle > svg {
width: 16px;
height: 16px;
}
.eq-compare[data-orientation="vertical"] .eq-compare-handle > svg {
rotate: 90deg;
}
@media (hover: hover) {
.eq-compare-handle:hover {
color: var(--eq-ink);
}
}
/* Grows under the hand while dragged, so the grip reads as held. */
.eq-compare[data-dragging] .eq-compare-handle {
scale: 1.15;
color: var(--eq-ink);
cursor: grabbing;
}
.eq-compare-handle:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-compare-label {
position: absolute;
top: var(--eq-space-2);
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 var(--eq-space-2);
border-radius: var(--eq-radius-s);
background: color-mix(in srgb, var(--eq-raised) 88%, transparent);
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
pointer-events: none;
transition: opacity var(--eq-dur) var(--eq-ease);
}
.eq-compare-label[data-side="before"] {
left: var(--eq-space-2);
}
.eq-compare-label[data-side="after"] {
right: var(--eq-space-2);
}
.eq-compare[data-orientation="vertical"] .eq-compare-label[data-side="after"] {
top: auto;
right: auto;
bottom: var(--eq-space-2);
left: var(--eq-space-2);
}
.eq-compare-label[data-hidden] {
opacity: 0;
}
}