Before/after comparison where a captured drag handle drives a clip-path inset over the top layer, with full keyboard slider semantics.
npx shadcn@latest add @paragon/comparison-slider"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ComparisonSliderProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Content shown left of the handle (the top, clipped layer). */
before: React.ReactNode;
/** Content shown right of the handle (the base layer). */
after: React.ReactNode;
/** Initial handle position, 0–100. */
defaultPosition?: number;
/** Fires with the position (0–100) as the handle moves. */
onPositionChange?: (position: number) => void;
/** Optional corner chips labelling each side. */
beforeLabel?: React.ReactNode;
afterLabel?: React.ReactNode;
/** Accessible name for the slider handle. */
label?: string;
/** Disables the eased keyboard/tap glide; position still updates. */
static?: boolean;
}
/**
* Before/after comparison. Both layers stay in the DOM; the top layer is
* clipped with a `clip-path` inset driven by one registered custom property,
* so dragging animates compositor-only work with zero extra DOM. Pointer
* drags are captured and single-touch guarded; the handle is a real slider —
* arrow keys move 5%, Home/End jump to the ends. Keyboard and tap moves
* glide on a short eased transition (removed under prefers-reduced-motion);
* drags track the pointer 1:1.
*/
export function ComparisonSlider({
before,
after,
defaultPosition = 50,
onPositionChange,
beforeLabel,
afterLabel,
label = "Comparison",
static: isStatic = false,
className,
style,
...props
}: ComparisonSliderProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const rootRef = React.useRef<HTMLDivElement>(null);
const handleRef = React.useRef<HTMLDivElement>(null);
const position = React.useRef(Math.min(100, Math.max(0, defaultPosition)));
const activePointer = React.useRef<number | null>(null);
const setPosition = React.useCallback(
(next: number) => {
const clamped = Math.min(100, Math.max(0, next));
position.current = clamped;
rootRef.current?.style.setProperty(`--cmp-pos-${id}`, `${clamped}%`);
handleRef.current?.setAttribute("aria-valuenow", String(Math.round(clamped)));
onPositionChange?.(clamped);
},
[id, onPositionChange],
);
const positionFromEvent = (event: React.PointerEvent) => {
const rect = rootRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return position.current;
return ((event.clientX - rect.left) / rect.width) * 100;
};
return (
<>
<style href={`paragon-comparison-slider-${id}`} precedence="paragon">{`
@property --cmp-pos-${id} {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
[data-cmp="${id}"] {
transition: --cmp-pos-${id} 150ms var(--ease-out);
}
[data-cmp="${id}"][data-dragging] {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
[data-cmp="${id}"] { transition: none; }
}
`}</style>
<div
ref={rootRef}
data-cmp={id}
data-slot="comparison-slider"
data-static={isStatic || undefined}
className={cn(
"relative touch-none overflow-hidden rounded-xl select-none",
isStatic && "transition-none",
className,
)}
style={
{
[`--cmp-pos-${id}`]: `${position.current}%`,
...style,
} as React.CSSProperties
}
onPointerDown={(event) => {
if (activePointer.current !== null || event.button > 0) return;
activePointer.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.setAttribute("data-dragging", "");
setPosition(positionFromEvent(event));
}}
onPointerMove={(event) => {
if (activePointer.current !== event.pointerId) return;
setPosition(positionFromEvent(event));
}}
onPointerUp={(event) => {
if (activePointer.current !== event.pointerId) return;
activePointer.current = null;
event.currentTarget.removeAttribute("data-dragging");
}}
onPointerCancel={(event) => {
if (activePointer.current !== event.pointerId) return;
activePointer.current = null;
event.currentTarget.removeAttribute("data-dragging");
}}
{...props}
>
{/* Base layer — the "after" side. */}
<div className="pointer-events-none">
{after}
{afterLabel != null && (
<span className="absolute top-3 right-3 rounded-full bg-background/80 px-2 py-0.5 text-[11px] font-medium text-foreground shadow-border backdrop-blur-sm">
{afterLabel}
</span>
)}
</div>
{/* Top layer — clipped to the left of the handle. */}
<div
className="pointer-events-none absolute inset-0"
style={{
clipPath: `inset(0 calc(100% - var(--cmp-pos-${id})) 0 0)`,
}}
>
{before}
{beforeLabel != null && (
<span className="absolute top-3 left-3 rounded-full bg-background/80 px-2 py-0.5 text-[11px] font-medium text-foreground shadow-border backdrop-blur-sm">
{beforeLabel}
</span>
)}
</div>
{/* Handle — a full-width layer translated so its right edge sits at
the position; percentage translate is compositor-only. */}
<div
className="pointer-events-none absolute inset-0"
style={{
transform: `translate3d(calc(var(--cmp-pos-${id}) - 100%), 0, 0)`,
}}
>
<div
aria-hidden
className="absolute inset-y-0 right-0 w-px translate-x-1/2 bg-background/90 shadow-[0_0_0_0.5px_rgba(0,0,0,0.2)]"
/>
<div
ref={handleRef}
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(position.current)}
aria-orientation="horizontal"
className="pointer-events-auto absolute top-1/2 right-0 size-3.5 -translate-y-1/2 translate-x-1/2 cursor-ew-resize rounded-full border border-black/15 bg-white shadow-sm after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2"
onKeyDown={(event) => {
const step =
event.key === "ArrowLeft" || event.key === "ArrowDown"
? -5
: event.key === "ArrowRight" || event.key === "ArrowUp"
? 5
: null;
if (step !== null) {
event.preventDefault();
setPosition(position.current + step);
} else if (event.key === "Home") {
event.preventDefault();
setPosition(0);
} else if (event.key === "End") {
event.preventDefault();
setPosition(100);
}
}}
/>
</div>
</div>
</>
);
}