Before / After Compare
Before and after image slider in React: drag to compare AI product-photo retouching, with chips that choose the edits.
- Feature Sections
- E-commerce
- React only · 0 deps
- 1 file
Browser support · All evergreen browsers (clip-path inset, CSS filters, pointer capture).
Install. One command, or copy the file.
$
npx shadcn@latest add https://freedesignmd.com/r/before-after-compare.json- Byte-identical to the preview
- No Tailwind or config needed
- SSR-safe · keyboard accessible
- Respects reduced motion & transparency
components/features/before-after-compare.tsx
1/**2 * BeforeAfterCompare, a draggable before / after comparison for an AI3 * product-photo retouching tool. The "before" is the same photo with a colour4 * cast, uneven light and a cluttered set (stand, cable, tape, dust) built in5 * CSS and SVG; three chips choose which edits the "after" side receives.6 *7 * Props: src, alt, eyebrow, title, lede, edits ({ id, label, detail, stat }),8 * initial (0..100, divider position).9 * Default image /img/prism/parfum.webp (copy it next to your page or pass `src`).10 * CSS vars: --fd-bac-bg, --fd-bac-ink, --fd-bac-acid.11 *12 * Browser support: all evergreen browsers (clip-path inset, CSS filters,13 * pointer events with capture).14 * Interaction: drag anywhere on the image, or focus the handle and use the15 * arrow keys (Shift for 10%), Home and End.16 * Accessibility: the handle is role="slider" with aria-valuenow / valuetext;17 * edit chips are toggle buttons (aria-pressed); a live region says which edits18 * are applied.19 * Reduced motion: no intro sweep, no scan line; divider still moves directly.20 */21import { useEffect, useId, useRef, useState } from "react";22import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";23 24export interface BacEdit {25 id: "clutter" | "color" | "light";26 label: string;27 detail: string;28 stat: string;29}30 31export interface BeforeAfterCompareProps {32 src?: string;33 alt?: string;34 eyebrow?: ReactNode;35 title?: ReactNode;36 lede?: ReactNode;37 edits?: BacEdit[];38 initial?: number;39 className?: string;40}41 42const DEFAULT_EDITS: BacEdit[] = [43 { id: "clutter", label: "Remove clutter", detail: "C-stand, cable, tape marks and 31 dust spots", stat: "14 objects" },44 { id: "color", label: "Fix colour", detail: "Tungsten cast neutralised to your brand white", stat: "ΔE 11.2 → 1.4" },45 { id: "light", label: "Relight", detail: "Soft key from the left, contact shadow rebuilt", stat: "5600 K" },46];47 48/** Deterministic dust so server and client render the same specks. */49const DUST = Array.from({ length: 31 }, (_, i) => {50 const r = (n: number) => { const x = Math.sin(i * 12.9898 + n * 78.233) * 43758.5453; return x - Math.floor(x); };51 const q = (v: number) => Math.round(v * 100) / 100;52 return { x: q(6 + r(1) * 88), y: q(4 + r(2) * 88), s: q(0.5 + r(3) * 1.6), d: r(4) > 0.6 };53});54 55function Clutter() {56 return (57 <svg className="fd-bac-clutter" viewBox="0 0 400 500" preserveAspectRatio="none" aria-hidden="true">58 {/* C-stand on the left */}59 <rect x="22" y="0" width="9" height="430" fill="#2a2a2a" />60 <rect x="18" y="140" width="17" height="22" rx="3" fill="#1b1b1b" />61 <rect x="31" y="146" width="70" height="7" fill="#303030" />62 <circle cx="104" cy="149.5" r="9" fill="#1b1b1b" />63 <path d="M26 430 L-10 500 M26 430 L60 500 M26 430 L26 500" stroke="#262626" strokeWidth="7" />64 {/* cable across the floor */}65 <path d="M-10 452 C 70 430, 120 492, 200 470 S 330 430, 410 476" fill="none" stroke="#111" strokeWidth="6" strokeLinecap="round" />66 <path d="M-10 452 C 70 430, 120 492, 200 470 S 330 430, 410 476" fill="none" stroke="#3a3a3a" strokeWidth="1.5" strokeLinecap="round" transform="translate(0 -1.5)" />67 {/* gaffer tape marks */}68 <rect x="120" y="408" width="46" height="9" fill="#C9B66B" opacity=".9" transform="rotate(-9 143 412)" />69 <rect x="250" y="415" width="38" height="9" fill="#C9B66B" opacity=".9" transform="rotate(14 269 419)" />70 <rect x="252" y="402" width="9" height="34" fill="#C9B66B" opacity=".85" transform="rotate(14 256 419)" />71 {/* clamp shadow on the right */}72 <ellipse cx="392" cy="120" rx="40" ry="110" fill="#000" opacity=".35" />73 {/* price sticker */}74 <rect x="272" y="300" width="34" height="18" rx="2" fill="#F4EFE2" transform="rotate(-6 289 309)" />75 <text x="276" y="313" fontSize="10" fontFamily="ui-monospace,Menlo,monospace" fill="#C03" transform="rotate(-6 289 309)">€49</text>76 </svg>77 );78}79 80export function BeforeAfterCompare({81 src = "/img/prism/parfum.webp",82 alt = "A bottle of PRISM eau de parfum on a grey studio backdrop",83 eyebrow = "Cleanplate for e-commerce",84 title = <>Shot on a kitchen table.<br /><span>Sold on a white page.</span></>,85 lede = "Upload a phone photo, get back a catalogue image that matches the other 400 in your store. Drag the line to compare.",86 edits = DEFAULT_EDITS,87 initial = 50,88 className,89}: BeforeAfterCompareProps) {90 const [pos, setPos] = useState(initial);91 const [on, setOn] = useState<Record<string, boolean>>({ clutter: true, color: true, light: true });92 const [scan, setScan] = useState(0);93 const [drag, setDrag] = useState(false);94 const stage = useRef<HTMLDivElement>(null);95 const uid = useId();96 97 useEffect(() => {98 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;99 const el = stage.current;100 if (!el) return;101 let raf = 0;102 const io = new IntersectionObserver(([e]) => {103 if (!e.isIntersecting) return;104 io.disconnect();105 const t0 = performance.now();106 const step = (t: number) => {107 const k = Math.min(1, (t - t0) / 1400);108 const ease = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;109 setPos(initial + Math.sin(ease * Math.PI) * 26);110 if (k < 1) raf = requestAnimationFrame(step);111 };112 raf = requestAnimationFrame(step);113 }, { threshold: 0.5 });114 io.observe(el);115 return () => { io.disconnect(); cancelAnimationFrame(raf); };116 }, [initial]);117 118 const fromPointer = (clientX: number) => {119 const r = stage.current!.getBoundingClientRect();120 setPos(Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)));121 };122 const onKey = (e: ReactKeyboardEvent) => {123 const d = e.shiftKey ? 10 : 2;124 const k: Record<string, number> = { ArrowLeft: pos - d, ArrowDown: pos - d, ArrowRight: pos + d, ArrowUp: pos + d, Home: 0, End: 100, PageDown: pos - 10, PageUp: pos + 10 };125 if (e.key in k) { e.preventDefault(); setPos(Math.max(0, Math.min(100, k[e.key]))); }126 };127 const toggle = (id: string) => { setOn((o) => ({ ...o, [id]: !o[id] })); setScan((s) => s + 1); };128 const applied = edits.filter((e) => on[e.id]);129 130 const layer = (fix: Record<string, boolean>) => (131 <div className="fd-bac-layer" data-color={fix.color ? "" : undefined} data-light={fix.light ? "" : undefined} data-clean={fix.clutter ? "" : undefined}>132 <img src={src} alt="" width={570} height={818} draggable={false} />133 <div className="fd-bac-cast" />134 <div className="fd-bac-light" />135 <div className="fd-bac-relight" />136 <Clutter />137 <svg className="fd-bac-dust" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">138 {DUST.map((d, i) => <circle key={i} cx={d.x} cy={d.y} r={Math.round(d.s * 35) / 100} fill={d.d ? "rgba(30,25,20,.55)" : "rgba(255,255,255,.7)"} />)}139 </svg>140 </div>141 );142 143 return (144 <section className={`fd-bac${className ? " " + className : ""}`} aria-labelledby={`${uid}-h`}>145 <style>{css}</style>146 <div className="fd-bac-wrap">147 <div className="fd-bac-copy">148 <p className="fd-bac-eyebrow">{eyebrow}</p>149 <h2 id={`${uid}-h`} className="fd-bac-title">{title}</h2>150 <p className="fd-bac-lede">{lede}</p>151 <div className="fd-bac-chips" role="group" aria-label="Edits to apply">152 {edits.map((e, i) => (153 <button key={e.id} type="button" className="fd-bac-chip" aria-pressed={!!on[e.id]} onClick={() => toggle(e.id)}>154 <span className="fd-bac-chip-k">{String(i + 1).padStart(2, "0")}</span>155 <span className="fd-bac-chip-b"><b>{e.label}</b><small>{e.detail}</small></span>156 <span className="fd-bac-chip-s">{e.stat}</span>157 <span className="fd-bac-box" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 5.2 4.2 7.4 8 2.8" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg></span>158 </button>159 ))}160 </div>161 <p className="fd-bac-foot"><span>3.4 s per image</span><span>Exports to Shopify, Shopware, Amazon</span></p>162 <p className="fd-bac-sr" aria-live="polite">{applied.length ? `After shows: ${applied.map((a) => a.label.toLowerCase()).join(", ")}.` : "No edits applied."}</p>163 </div>164 <figure className="fd-bac-fig">165 <div166 ref={stage}167 className="fd-bac-stage"168 data-drag={drag ? "" : undefined}169 onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); setDrag(true); fromPointer(e.clientX); }}170 onPointerMove={(e) => { if (drag) fromPointer(e.clientX); }}171 onPointerUp={() => setDrag(false)}172 onPointerCancel={() => setDrag(false)}173 role="img"174 aria-label={`${alt}. Before and after retouching comparison.`}175 >176 {layer({})}177 <div className="fd-bac-after" style={{ clipPath: `inset(0 0 0 ${pos}%)` }}>178 {layer(on)}179 <span key={scan} className="fd-bac-scan" data-run={scan ? "" : undefined} />180 </div>181 <span className="fd-bac-lab fd-bac-lab-b" style={{ opacity: pos < 14 ? 0 : 1 }}>Before</span>182 <span className="fd-bac-lab fd-bac-lab-a" style={{ opacity: pos > 86 ? 0 : 1 }}>After</span>183 <div className="fd-bac-div" style={{ left: `${pos}%` }}>184 <button185 type="button"186 className="fd-bac-handle"187 role="slider"188 aria-label="Comparison divider"189 aria-valuemin={0}190 aria-valuemax={100}191 aria-valuenow={Math.round(pos)}192 aria-valuetext={`${Math.round(100 - pos)}% after`}193 onKeyDown={onKey}194 >195 <svg width="18" height="10" viewBox="0 0 18 10" aria-hidden="true"><path d="M5 1 1 5l4 4M13 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>196 </button>197 </div>198 </div>199 <figcaption className="fd-bac-cap"><span>IMG_4471.HEIC</span><span>3024 × 4032 → 2000 × 2500, sRGB</span></figcaption>200 </figure>201 </div>202 </section>203 );204}205 206const css = `207:where(.fd-bac,.fd-bac *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none;border:0;background:none}208:where(.fd-bac,.fd-bac *)::before,:where(.fd-bac,.fd-bac *)::after{box-sizing:border-box}209:where(.fd-bac svg,.fd-bac img){display:block}210:where(.fd-bac button){appearance:none;-webkit-appearance:none;outline:0;border-radius:0;cursor:pointer;text-align:left}211.fd-bac{212 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);213 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;214 --fd-bac-bg:#171716;--fd-bac-ink:#F2F1EC;--fd-bac-mut:#8F8E88;--fd-bac-line:rgba(242,241,236,.12);--fd-bac-acid:#E2FF3D;--fd-bac-panel:#1F1F1D;215 --fd-bac-sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;216 --fd-bac-mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;217 display:block;width:100%;background:var(--fd-bac-bg);color:var(--fd-bac-ink);padding:clamp(56px,8vw,112px) clamp(16px,4vw,56px);218 font:400 15px/1.5 var(--fd-bac-sans);-webkit-font-smoothing:antialiased}219.fd-bac-wrap{max-width:1240px;margin:0 auto;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:clamp(32px,6vw,88px);align-items:center}220.fd-bac-eyebrow{display:inline-flex;align-items:center;gap:8px;font:500 12px/16px var(--fd-bac-mono);letter-spacing:.06em;text-transform:uppercase;color:var(--fd-bac-mut)}221.fd-bac-eyebrow::before{content:"";width:14px;height:14px;border-radius:50%;background:conic-gradient(var(--fd-bac-acid) 0 50%,transparent 0);box-shadow:inset 0 0 0 1.5px var(--fd-bac-acid)}222.fd-bac-title{margin-top:18px;font:600 clamp(2.25rem,3.9vw,3.4rem)/1 var(--fd-bac-sans);letter-spacing:-.045em}223.fd-bac-title span{color:var(--fd-bac-mut)}224.fd-bac-lede{margin-top:20px;max-width:42ch;font:400 16px/1.55 var(--fd-bac-sans);color:var(--fd-bac-mut)}225.fd-bac-chips{margin-top:32px;display:flex;flex-direction:column;gap:8px}226.fd-bac-chip{display:grid;grid-template-columns:28px minmax(0,1fr) auto 22px;gap:12px;align-items:center;padding:14px 16px;border-radius:14px;background:var(--fd-bac-panel);box-shadow:inset 0 0 0 1px var(--fd-bac-line);color:var(--fd-bac-ink);transition:box-shadow var(--fd-dur-2) var(--fd-ease-in-out),background-color var(--fd-dur-2),transform var(--fd-dur-1)}227.fd-bac-chip:hover{background:#252523}228.fd-bac-chip:active{transform:scale(.98)}229.fd-bac-chip:focus-visible,.fd-bac-handle:focus-visible{outline:2px solid rgba(226,255,61,.75);outline-offset:2px}230.fd-bac-chip[aria-pressed="true"]{box-shadow:inset 0 0 0 1px rgba(226,255,61,.55)}231.fd-bac-chip-k{font:500 12px/1 var(--fd-bac-mono);color:var(--fd-bac-mut)}232.fd-bac-chip-b{display:flex;flex-direction:column;min-width:0}233.fd-bac-chip-b b{font:600 15px/20px var(--fd-bac-sans);letter-spacing:-.01em}234.fd-bac-chip-b small{font:400 13px/18px var(--fd-bac-sans);color:var(--fd-bac-mut)}235.fd-bac-chip-s{font:500 12px/1 var(--fd-bac-mono);color:var(--fd-bac-mut);white-space:nowrap}236.fd-bac-chip[aria-pressed="true"] .fd-bac-chip-s{color:var(--fd-bac-acid)}237.fd-bac-box{display:grid;place-items:center;width:22px;height:22px;border-radius:6px;box-shadow:inset 0 0 0 1.5px var(--fd-bac-line);color:transparent;transition:background-color var(--fd-dur-2) var(--fd-ease-in-out),color var(--fd-dur-1),transform var(--fd-dur-2) var(--fd-spring)}238.fd-bac-chip[aria-pressed="true"] .fd-bac-box{background:var(--fd-bac-acid);color:#171716;box-shadow:none}239.fd-bac-chip:active .fd-bac-box{transform:scale(.85)}240.fd-bac-foot{display:flex;flex-wrap:wrap;gap:8px 24px;margin-top:24px;font:400 12px/16px var(--fd-bac-mono);color:var(--fd-bac-mut)}241.fd-bac-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}242.fd-bac-fig{margin:0;width:100%;max-width:540px;justify-self:end}243.fd-bac-stage{position:relative;width:100%;aspect-ratio:4/5;overflow:hidden;border-radius:6px;background:#888;cursor:ew-resize;touch-action:pan-y;user-select:none;-webkit-user-select:none}244.fd-bac-layer{position:absolute;inset:0;overflow:hidden;background:#9a9a96}245.fd-bac-layer img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:50% 70%;transform:rotate(1.6deg) scale(1.06);filter:sepia(.42) saturate(.78) brightness(.86) contrast(.84) hue-rotate(-6deg);transition:filter var(--fd-dur-4) var(--fd-ease-in-out),transform var(--fd-dur-4) var(--fd-ease-out)}246.fd-bac-layer[data-color] img{filter:none}247.fd-bac-layer[data-clean] img{transform:none}248.fd-bac-cast{position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,170,60,.18),rgba(120,70,20,.16));mix-blend-mode:multiply;transition:opacity var(--fd-dur-4) var(--fd-ease-in-out)}249.fd-bac-layer[data-color] .fd-bac-cast{opacity:0}250.fd-bac-light{position:absolute;inset:0;background:radial-gradient(35% 28% at 78% 18%,rgba(255,250,230,.55),transparent 70%),linear-gradient(90deg,rgba(0,0,0,.42),transparent 45%,rgba(0,0,0,.2));transition:opacity var(--fd-dur-4) var(--fd-ease-in-out)}251.fd-bac-relight{position:absolute;inset:0;opacity:0;background:radial-gradient(70% 55% at 30% 30%,rgba(255,255,255,.22),transparent 70%),radial-gradient(30% 6% at 50% 86%,rgba(0,0,0,.18),transparent 80%);transition:opacity var(--fd-dur-4) var(--fd-ease-in-out)}252.fd-bac-layer[data-light] .fd-bac-light{opacity:0}253.fd-bac-layer[data-light] .fd-bac-relight{opacity:1}254.fd-bac-clutter,.fd-bac-dust{position:absolute;inset:0;width:100%;height:100%;transition:opacity var(--fd-dur-3) var(--fd-ease-in-out)}255.fd-bac-layer[data-clean] .fd-bac-clutter,.fd-bac-layer[data-clean] .fd-bac-dust{opacity:0}256.fd-bac-after{position:absolute;inset:0}257.fd-bac-scan{position:absolute;top:0;bottom:0;left:-30%;width:30%;background:linear-gradient(90deg,transparent,rgba(226,255,61,.18) 70%,rgba(226,255,61,.85) 98%,transparent);opacity:0;pointer-events:none}258.fd-bac-scan[data-run]{animation:fd-bac-scan var(--fd-dur-4) var(--fd-ease-in-out) both}259@keyframes fd-bac-scan{0%{opacity:1;transform:translateX(0)}100%{opacity:1;transform:translateX(433%)}}260.fd-bac-lab{position:absolute;top:14px;padding:5px 9px;border-radius:999px;font:500 11px/1 var(--fd-bac-mono);letter-spacing:.08em;text-transform:uppercase;transition:opacity var(--fd-dur-2);pointer-events:none}261.fd-bac-lab-b{left:14px;background:rgba(23,23,22,.72);color:var(--fd-bac-ink)}262.fd-bac-lab-a{right:14px;background:var(--fd-bac-acid);color:#171716}263.fd-bac-div{position:absolute;top:0;bottom:0;width:0;z-index:2}264.fd-bac-div::before{content:"";position:absolute;top:0;bottom:0;left:-1px;width:2px;background:var(--fd-bac-acid);box-shadow:0 0 0 1px rgba(0,0,0,.15)}265.fd-bac-handle{position:absolute;left:-22px;top:50%;width:44px;height:44px;margin-top:-22px;border-radius:50%;display:grid;place-items:center;background:var(--fd-bac-acid);color:#171716;box-shadow:0 6px 20px rgba(0,0,0,.35);cursor:ew-resize;transition:transform var(--fd-dur-2) var(--fd-spring)}266.fd-bac-stage[data-drag] .fd-bac-handle{transform:scale(.9)}267.fd-bac-cap{display:flex;justify-content:space-between;gap:12px;margin-top:12px;font:400 12px/16px var(--fd-bac-mono);color:var(--fd-bac-mut)}268@media (max-width:900px){269 .fd-bac-wrap{grid-template-columns:minmax(0,1fr)}270 .fd-bac-copy{display:contents}271 .fd-bac-fig{order:1;margin-top:28px;justify-self:stretch;max-width:none}272 .fd-bac-chips{order:2}273 .fd-bac-foot{order:3}274}275@media (max-width:520px){276 .fd-bac-chip{grid-template-columns:minmax(0,1fr) 22px;padding:12px 14px}277 .fd-bac-chip-k,.fd-bac-chip-s{display:none}278 .fd-bac-cap span:last-child{display:none}279}280@media (prefers-reduced-motion:reduce){281 .fd-bac *{transition-duration:1ms!important}282 .fd-bac-scan{display:none}283}284`;285 286export function Demo() {287 return (288 <div className="fd-bacd">289 <style>{`.fd-bacd{background:#171716;min-height:100vh}.fd-bacd-nav{box-sizing:border-box;max-width:1352px;margin:0 auto;height:60px;padding:0 clamp(16px,4vw,56px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid rgba(242,241,236,.12);font:500 13px/18px ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter",sans-serif;color:#8F8E88}.fd-bacd-nav b{font:700 17px/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter",sans-serif;letter-spacing:-.05em;color:#F2F1EC}.fd-bacd-nav b i{font-style:normal;display:inline-block;width:9px;height:9px;margin-left:3px;border-radius:50%;background:#E2FF3D}`}</style>290 <div className="fd-bacd-nav"><b>cleanplate<i /></b><span>Pricing · API · Sign in</span></div>291 <BeforeAfterCompare />292 </div>293 );294}More Feature Sections. Same file contract.
Questions about Before / After Compare
Run npx shadcn@latest add https://freedesignmd.com/r/before-after-compare.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.
Before and after image slider in React: drag to compare AI product-photo retouching, with chips that choose the edits. It is a free, MIT-licensed React feature sections component from the freedesignmd library.
Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.
All evergreen browsers (clip-path inset, CSS filters, pointer capture).
Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.


