Compare slider
Two states of one screen in a single frame, split by a half-pixel line you can grab anywhere; a click or key glides it, and each label fades as the line nears it.
Loading preview…
Show code
"use client";
import {
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
const STEP = 5;
/** How close the handle's centre can ride to the frame's top and bottom. */
const HANDLE_INSET = 24;
/** The glide for clicks and keys: --compare-value is a registered number, so it transitions. */
const GLIDE = "480ms cubic-bezier(0.16, 1, 0.3, 1)";
function clamp(value: number) {
return Math.min(100, Math.max(0, value));
}
export type CompareSliderProps = {
/** The state left of the line. */
before: ReactNode;
/** The state right of the line, revealed as the line moves left. */
after: ReactNode;
/** Short state name shown on its half, e.g. "At rest". */
beforeLabel?: string;
afterLabel?: string;
/** Where the line starts, as a percentage from the left. */
initial?: number;
className?: string;
};
/**
* Two states of one screen in a single frame: a vertical line splits them,
* and dragging it left or right trades one for the other. The line can be
* grabbed anywhere in the frame; on touch a vertical swipe still scrolls the
* page. The handle is a slider for keyboards and screen readers.
*/
export function CompareSlider({
before,
after,
beforeLabel,
afterLabel,
initial = 50,
className,
}: CompareSliderProps) {
const frameRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState(clamp(initial));
// Pressed holds the pointer; dragging starts on the first move, so a click
// still glides the line to the spot instead of jumping.
const [pressed, setPressed] = useState(false);
const [dragging, setDragging] = useState(false);
// The handle rides its line at the pointer's height; null rests it centred.
const [handleY, setHandleY] = useState<number | null>(null);
const moveTo = (clientX: number) => {
const frame = frameRef.current;
if (!frame) return;
const box = frame.getBoundingClientRect();
setPosition(clamp(((clientX - box.left) / box.width) * 100));
};
const trackY = (clientY: number) => {
const frame = frameRef.current;
if (!frame) return;
const box = frame.getBoundingClientRect();
const y = clientY - box.top;
setHandleY(Math.min(box.height - HANDLE_INSET, Math.max(HANDLE_INSET, y)));
};
const startDrag = (event: PointerEvent<HTMLElement>) => {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setPressed(true);
moveTo(event.clientX);
trackY(event.clientY);
};
const drag = (event: PointerEvent<HTMLElement>) => {
if (event.pointerType === "mouse" || pressed) trackY(event.clientY);
if (!pressed) return;
setDragging(true);
moveTo(event.clientX);
};
const endDrag = (event: PointerEvent<HTMLElement>) => {
setPressed(false);
setDragging(false);
// A finger lifts away, so the handle settles back to centre.
if (event.pointerType !== "mouse") setHandleY(null);
};
const leave = () => {
if (!pressed) setHandleY(null);
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const next: ((current: number) => number) | null =
event.key === "ArrowLeft" || event.key === "ArrowDown"
? (current) => current - STEP
: event.key === "ArrowRight" || event.key === "ArrowUp"
? (current) => current + STEP
: event.key === "Home"
? () => 0
: event.key === "End"
? () => 100
: null;
if (!next) return;
event.preventDefault();
setPosition((current) => clamp(next(current)));
};
const label =
"pointer-events-none absolute top-3 z-[2] inline-flex h-6 items-center whitespace-nowrap rounded-full bg-muted/80 px-2.5 text-xs font-medium text-foreground shadow-[inset_0_0_0_0.5px_var(--border)] backdrop-blur-sm";
return (
<div
ref={frameRef}
data-compare=""
data-pressed={pressed ? "" : undefined}
data-dragging={dragging ? "" : undefined}
data-tracking={handleY === null ? undefined : ""}
className={cn(
"group/compare relative isolate touch-pan-y select-none overflow-hidden",
"[@media(hover:hover)_and_(pointer:fine)]:cursor-ew-resize motion-reduce:transition-none!",
className,
)}
style={
{
"--compare-value": position,
"--compare-position": "calc(var(--compare-value) * 1%)",
"--compare-handle-y": handleY === null ? "50%" : `${handleY}px`,
// Clicks and keys glide; a drag follows the pointer exactly.
transition: dragging ? "none" : `--compare-value ${GLIDE}`,
} as CSSProperties
}
onPointerDown={startDrag}
onPointerMove={drag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onPointerLeave={leave}
>
{before}
{/* The after state is clipped to right of the line, so dragging left reveals more of it. */}
<div className="absolute inset-0 [clip-path:inset(0_0_0_var(--compare-position))]">{after}</div>
{beforeLabel ? (
<span className={cn(label, "left-3")} style={{ opacity: "clamp(0, (var(--compare-value) - 14) / 10, 1)" }}>
{beforeLabel}
</span>
) : null}
{afterLabel ? (
<span className={cn(label, "right-3")} style={{ opacity: "clamp(0, (86 - var(--compare-value)) / 10, 1)" }}>
{afterLabel}
</span>
) : null}
{/* A short frosted band either side of the seam, strongest at the line and gone 20px out. */}
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-(--compare-position) z-[1] w-10 -translate-x-1/2 backdrop-blur-[6px] [mask-image:linear-gradient(to_right,transparent,#000_50%,transparent)]"
/>
{/* A half-pixel line with a soft glow, so it reads on light and dark screens. */}
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-(--compare-position) z-[2] w-px -translate-x-1/2 scale-x-50 bg-white/90 shadow-[0_0_6px_rgb(255_255_255/0.35)]"
/>
<div
role="slider"
tabIndex={0}
aria-label={`Compare ${beforeLabel ?? "before"} and ${afterLabel ?? "after"}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(position)}
aria-valuetext={`${Math.round(100 - position)}% ${afterLabel ?? "after"}`}
onKeyDown={onKeyDown}
className={cn(
"absolute top-(--compare-handle-y) left-(--compare-position) z-[3] grid h-6 w-9 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full",
"bg-background/70 text-foreground backdrop-blur-md backdrop-saturate-[1.4]",
"shadow-[inset_0_0_0_0.5px_var(--border),0_1px_2px_rgb(0_0_0/0.2),0_4px_12px_rgb(0_0_0/0.18)]",
"outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
// Off the pointer the handle glides back to centre; on it, it rides the pointer with no lag.
"[transition:top_480ms_cubic-bezier(0.16,1,0.3,1),scale_160ms_ease-out]",
"group-data-[tracking]/compare:[transition:scale_160ms_ease-out] group-data-[pressed]/compare:scale-[0.94]",
"motion-reduce:transition-none",
)}
>
<svg
viewBox="0 0 20 12"
aria-hidden
className="h-3 w-5 overflow-visible fill-none stroke-current [stroke-linecap:round] [stroke-linejoin:round] [stroke-width:1.5]"
>
{/* On hover the arrows ease apart to say which way it goes. */}
<path
d="M7 3L4 6l3 3"
className="transition-[translate] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover/compare:-translate-x-[1.5px] group-data-[dragging]/compare:-translate-x-[1.5px] motion-reduce:transition-none"
/>
<path
d="M13 3l3 3-3 3"
className="transition-[translate] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover/compare:translate-x-[1.5px] group-data-[dragging]/compare:translate-x-[1.5px] motion-reduce:transition-none"
/>
</svg>
</div>
</div>
);
}