# Media Image Compare Slider — React Media Component

> A before/after comparison you drag anywhere on the image; the divider follows on a tight spring, works from the keyboard and nudges itself once as a hint.

- Category: [Audio & Media](https://pixelpapa.com/components/media.md)
- Tags: media, compare, before-after, slider, drag
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/media/media-image-compare-slider

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/media-image-compare-slider.json"
```

The file is written to `components/pixelpapa/media-image-compare-slider.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ChevronsLeftRight } from "lucide-react";
import { animate, motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
import { useEffect, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";

type CompareSliderProps = {
  before: ReactNode;
  after: ReactNode;
  beforeLabel?: string;
  afterLabel?: string;
  /** Divider position, 0–100. */
  defaultValue?: number;
  className?: string;
};

export function CompareSlider({ before, after, beforeLabel = "Before", afterLabel = "After", defaultValue = 50, className }: CompareSliderProps) {
  const [value, setValue] = useState(defaultValue);
  const raw = useMotionValue(defaultValue);
  const pos = useSpring(raw, { stiffness: 700, damping: 50 });
  const left = useTransform(pos, (v) => `${v}%`);
  const clip = useMotionTemplate`inset(0 0 0 ${left})`;
  const reduce = useReducedMotion();

  useEffect(() => {
    if (reduce) return;
    const hint = animate(raw, [defaultValue, defaultValue + 14, defaultValue], { duration: 1.3, delay: 0.7, ease: "easeInOut" });
    return () => hint.stop();
  }, [raw, defaultValue, reduce]);

  function set(v: number) {
    const next = Math.min(100, Math.max(0, v));
    raw.set(next);
    setValue(Math.round(next));
  }

  function fromPointer(e: PointerEvent<HTMLDivElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    set(((e.clientX - r.left) / r.width) * 100);
  }

  function onKeyDown(e: KeyboardEvent) {
    const stepSize = e.shiftKey ? 10 : 2;
    if (e.key === "ArrowLeft") set(value - stepSize);
    else if (e.key === "ArrowRight") set(value + stepSize);
    else if (e.key === "Home") set(0);
    else if (e.key === "End") set(100);
    else return;
    e.preventDefault();
  }

  const tag = "pointer-events-none absolute top-4 rounded-full bg-black/45 px-3 py-1 text-xs font-medium text-white backdrop-blur-md";
  return (
    <div
      onPointerDown={(e) => {
        e.currentTarget.setPointerCapture(e.pointerId);
        fromPointer(e);
      }}
      onPointerMove={(e) => e.buttons === 1 && fromPointer(e)}
      className={cn("relative cursor-ew-resize touch-none overflow-hidden rounded-3xl shadow-2xl shadow-black/20 select-none", className)}
    >
      <div className="absolute inset-0">
        {before}
        <span className={cn(tag, "left-4")}>{beforeLabel}</span>
      </div>
      <motion.div className="absolute inset-0" style={{ clipPath: clip }}>
        {after}
        <span className={cn(tag, "right-4")}>{afterLabel}</span>
      </motion.div>
      <motion.div className="absolute inset-y-0 w-0" style={{ left }}>
        <div className="absolute inset-y-0 -left-px w-0.5 bg-white shadow-[0_0_12px_rgb(0_0_0/0.5)]" />
        <div
          role="slider"
          tabIndex={0}
          aria-label={`Reveal ${afterLabel}`}
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={value}
          onKeyDown={onKeyDown}
          className="absolute top-1/2 left-0 flex size-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white text-neutral-900 shadow-xl outline-none transition-transform hover:scale-110 focus-visible:ring-4 focus-visible:ring-ring"
        >
          <ChevronsLeftRight className="size-5" />
        </div>
      </motion.div>
    </div>
  );
}

function Valley({ night = false }: { night?: boolean }) {
  const c = night
    ? { sky: ["oklch(0.16 0.06 275)", "oklch(0.36 0.11 250)"], orb: "oklch(0.95 0.02 250)", far: "oklch(0.3 0.08 265)", mid: "oklch(0.23 0.07 265)", near: "oklch(0.15 0.04 265)", lake: "oklch(0.3 0.09 245)", win: "oklch(0.88 0.15 85)" }
    : { sky: ["oklch(0.66 0.14 235)", "oklch(0.92 0.05 200)"], orb: "oklch(0.98 0.07 95)", far: "oklch(0.68 0.07 225)", mid: "oklch(0.56 0.12 150)", near: "oklch(0.4 0.1 150)", lake: "oklch(0.74 0.1 215)", win: "oklch(0.35 0.04 240)" };
  const id = night ? "pp-media-image-compare-slider-n" : "pp-media-image-compare-slider-d";
  return (
    <svg viewBox="0 0 640 400" preserveAspectRatio="xMidYMid slice" className="size-full" aria-hidden>
      <defs>
        <linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor={c.sky[0]} />
          <stop offset="1" stopColor={c.sky[1]} />
        </linearGradient>
      </defs>
      <rect width="640" height="400" fill={`url(#${id})`} />
      {night && [[60, 50], [150, 90], [240, 36], [330, 78], [420, 44], [560, 96], [600, 30], [100, 140], [380, 130]].map(([x, y]) => <circle key={x} cx={x} cy={y} r="1.6" fill="white" fillOpacity="0.85" />)}
      <circle cx="486" cy="92" r="52" fill={c.orb} fillOpacity="0.18" />
      <circle cx="486" cy="92" r="30" fill={c.orb} />
      {night && <circle cx="498" cy="84" r="26" fill={c.sky[0]} />}
      <path d="M0 250 90 150l70 62 110-112 120 118 90-60 160 92v150H0Z" fill={c.far} />
      <path d="M0 290c110-64 190 30 320-30s220-34 320 30v110H0Z" fill={c.mid} />
      <path d="M0 322h640v78H0Z" fill={c.lake} />
      <path d="M440 322h92M452 336h68M462 350h48" stroke={c.orb} strokeOpacity="0.5" strokeWidth="3" strokeLinecap="round" />
      <path d="M0 400V330c80-22 150 6 230 2 40-2 60 14 60 68Z" fill={c.near} />
      <path d="M118 318l34-44 34 44v26h-68Z" fill="oklch(0.28 0.05 40)" />
      <rect x="143" y="312" width="18" height="18" rx="2" fill={c.win} />
      {night && <circle cx="152" cy="321" r="26" fill={c.win} fillOpacity="0.2" />}
      {[40, 66, 214, 240].map((x, i) => (
        <path key={x} d={`M${x} ${352 - (i % 2) * 10}l-16 44h32Z M${x} ${330 - (i % 2) * 10}l-12 34h24Z`} fill="oklch(0.2 0.05 165)" />
      ))}
    </svg>
  );
}

export default function MediaImageCompareSliderDemo() {
  return <CompareSlider className="aspect-[16/10] w-full max-w-[620px]" before={<Valley />} after={<Valley night />} beforeLabel="12:00" afterLabel="23:00" defaultValue={46} />;
}
```

## AI prompt

```text
Add the "Media Image Compare Slider" component from PixelPapa UI to this project.

What it is: A before/after comparison you drag anywhere on the image; the divider follows on a tight spring, works from the keyboard and nudges itself once as a hint.
Category: Audio & Media. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/media-image-compare-slider.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/media-image-compare-slider.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/media-image-compare-slider" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More audio & media

- [Media Audio Waveform](https://pixelpapa.com/components/media/media-audio-waveform.md): A voice-note waveform that fills as it plays, previews where you are about to seek on hover and lets you click, drag or arrow-key to any point.
- [Media Music Player Card](https://pixelpapa.com/components/media/media-music-player-card.md) (Pro): A music player whose record slides out of its sleeve and spins while playing, with a seekable scrubber and track changes that slide in.
- [Media Podcast Episode Row](https://pixelpapa.com/components/media/media-podcast-episode-row.md): An episode list where the playing row gets a sliding highlight, a live equaliser on its cover and a progress ring around the play button.
- [Media Video Player Controls](https://pixelpapa.com/components/media/media-video-player-controls.md) (Pro): A video player shell with a full control bar: scrubber with buffer and hover time, expanding volume, captions, speed and keyboard shortcuts over a drawn scene.
