Acme Supplies
Workshop tools, delivered next day across the UK.
Shop the rangeCustomize
Usage
import { CompareSlider } from "@/components/motionkit/compare-slider";
<CompareSlider
beforeLabel="Old site"
afterLabel="Redesign"
before={<img src="/old.png" alt="" className="size-full object-cover" />}
after={<img src="/new.png" alt="" className="size-full object-cover" />}
className="aspect-video"
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Divider follow
Dragged or arrow keys
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/compare-slider.tsx.
"use client";
import { useState, type ReactNode } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
type CompareSliderProps = {
/** Shown on the left of the handle. */
before: ReactNode;
/** Shown on the right of the handle. */
after: ReactNode;
beforeLabel?: string;
afterLabel?: string;
/** Starting handle position, 0–100. */
initial?: number;
className?: string;
};
export function CompareSlider({
before,
after,
beforeLabel = "Before",
afterLabel = "After",
initial = 50,
className = "",
}: CompareSliderProps) {
const [value, setValue] = useState(initial);
const reduceMotion = useReducedMotion();
const target = useMotionValue(initial);
const smooth = useSpring(target, { stiffness: 400, damping: 40 });
const position = reduceMotion ? target : smooth;
const clipPath = useTransform(position, (v) => `inset(0 ${100 - v}% 0 0)`);
const left = useTransform(position, (v) => `${v}%`);
return (
<div className={`group relative isolate select-none overflow-hidden rounded-2xl border border-white/10 ${className}`}>
<div className="h-full w-full">{after}</div>
<motion.div aria-hidden className="absolute inset-0" style={{ clipPath }}>
{before}
</motion.div>
<span className="pointer-events-none absolute left-3 top-3 rounded-full bg-black/60 px-2 py-0.5 text-[11px] text-white backdrop-blur">
{beforeLabel}
</span>
<span className="pointer-events-none absolute right-3 top-3 rounded-full bg-black/60 px-2 py-0.5 text-[11px] text-white backdrop-blur">
{afterLabel}
</span>
<motion.div aria-hidden className="pointer-events-none absolute inset-y-0 w-px -translate-x-1/2 bg-white/80" style={{ left }}>
<span className="absolute left-1/2 top-1/2 flex size-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white/30 bg-[#0C1010]/80 text-white shadow-lg backdrop-blur group-has-[input:focus-visible]:outline-2 group-has-[input:focus-visible]:outline-offset-2 group-has-[input:focus-visible]:outline-[#5FAEB4]">
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<path d="m9 7-5 5 5 5M15 7l5 5-5 5" />
</svg>
</span>
</motion.div>
{/*
An invisible native range input covers the whole area: click or drag anywhere,
use the arrow keys, and screen readers announce the position. No custom pointer code.
*/}
<input
type="range"
min={0}
max={100}
value={value}
aria-label={`${beforeLabel} and ${afterLabel} comparison`}
aria-valuetext={`${value}% ${beforeLabel}`}
onChange={(event) => {
const next = Number(event.target.value);
setValue(next);
target.set(next);
}}
className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0"
/>
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| before | ReactNode | — | Shown on the left of the handle. |
| after | ReactNode | — | Shown on the right. |
| beforeLabel / afterLabel | string | "Before" / "After" | Corner labels, also used in the accessible name. |
| initial | number | 50 | Starting handle position, 0 to 100. |
Parallax Stack
Overlapping images that drift at different speeds and tidy into a stack as you scroll, for editorial storytelling.
Marquee
A seamless infinite scroller for logos and testimonials that pauses on hover.
Accordion
FAQ panels that open with a smooth, springy height and a turning chevron.