Before / After Slider
Drag the divider across the picture to compare the edited version with the original underneath.
Build an accessible before/after image comparison in React. Stack two images of the same size; the "after" image is on top and clipped with clip-path: inset(0 calc(100% - var(--pos) * 1%) 0 0). Put a native <input type="range"> (0–100, aria-label "Compare before and after") over the whole picture with opacity 0 so it handles dragging, touch and arrow keys, and write its value into --pos. Draw a 2px divider line and a round 40px handle at left: calc(var(--pos) * 1%), with two small chevrons inside. Show "Before" and "After" tags in the corners that fade out when the divider covers them. When the range is focused with the keyboard, show a focus ring on the handle. Start at 50%. No transitions on the clip itself, so it tracks the pointer exactly.
Source
'use client'
import { useState } from 'react'
import s from './demo.module.css'
export default function BeforeAfter({ labels }: { labels: Record<string, string> }) {
const [pos, setPos] = useState(50)
return (
<div className={s.frame} style={{ '--pos': pos } as React.CSSProperties}>
<div className={`${s.scene} ${s.before}`} aria-hidden="true" />
<div className={`${s.scene} ${s.after}`} aria-hidden="true" />
<span className={`${s.tag} ${s.tagBefore}`} data-hidden={pos < 22 || undefined}>
{labels.before}
</span>
<span className={`${s.tag} ${s.tagAfter}`} data-hidden={pos > 78 || undefined}>
{labels.after}
</span>
<span className={s.divider} aria-hidden="true">
<span className={s.handle}>‹ ›</span>
</span>
<input
type="range"
min={0}
max={100}
value={pos}
aria-label={labels.label}
className={s.input}
onChange={(e) => setPos(Number(e.target.value))}
/>
</div>
)
}
.frame {
/* scene palette: a CSS-drawn landscape stands in for a photo */
--sky-top: oklch(78% 0.08 70);
--sky-low: oklch(70% 0.13 50);
--sun: oklch(92% 0.09 85);
--hill-far: oklch(48% 0.07 40);
--hill-near: oklch(30% 0.04 45);
position: relative;
width: min(300px, 100%);
aspect-ratio: 3 / 2;
max-width: 100%;
overflow: hidden;
border-radius: 14px;
user-select: none;
}
.scene {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 70% 40% at 25% 105%, var(--hill-near) 98%, transparent 100%),
radial-gradient(ellipse 80% 45% at 85% 100%, var(--hill-far) 98%, transparent 100%),
radial-gradient(circle at 68% 42%, var(--sun) 0 11%, transparent 11.5%),
linear-gradient(180deg, var(--sky-top), var(--sky-low));
}
.before { filter: grayscale(1) contrast(0.85) brightness(0.9); }
.after { clip-path: inset(0 calc(100% - var(--pos) * 1%) 0 0); }
.tag {
position: absolute;
top: 10px;
padding: 4px 8px;
border-radius: 6px;
background: var(--stage-bg);
color: var(--stage-ink);
font: 500 var(--text-xs) / 1 var(--font-mono);
transition: opacity 150ms linear;
}
.tagAfter { left: 10px; }
.tagBefore { right: 10px; }
.tag[data-hidden] { opacity: 0; }
.divider {
position: absolute;
top: 0;
bottom: 0;
left: calc(var(--pos) * 1%);
width: 2px;
margin-left: -1px;
background: var(--stage-ink);
pointer-events: none;
}
.handle {
position: absolute;
top: 50%;
left: 50%;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--stage-ink);
color: var(--stage-bg);
font: 700 var(--text-base) / 1 var(--font-body);
transform: translate(-50%, -50%);
}
.input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: ew-resize;
}
.input:focus-visible ~ .divider .handle,
.frame:has(.input:focus-visible) .handle { outline: 2px solid var(--color-focus); outline-offset: 3px; }