Blog//6 min read
Before and after slider in React, accessible and draggable
Build a React before after slider with pointer events, a clip-path reveal, arrow key support and the ARIA slider role. Works with touch and a keyboard.
MSMarco Schulz, founder of Facade UI
Roofers, painters, landscapers and remodelers all have the same proof: a photo from before the job and a photo from after. Put the two side by side and the eye wanders. Put them in one frame with a handle the visitor drags and the eye follows the handle. That is why the comparison slider earns a spot on every home services site I build, and why I spent more time on the Before / after slider in Facade than its size suggests. Most of that time went into things you cannot see in a demo: touch, keyboard and screen readers.
Pointer events, not mouse events
The first version of every comparison slider on the internet listens to mousedown, mousemove and mouseup. Then someone opens it on a phone and nothing happens, so touchstart and touchmove get bolted on, and now there are two code paths that drift apart.
Pointer events replace both. onPointerDown, onPointerMove and onPointerUp fire for mouse, touch and pen with the same event shape, and clientX means the same thing in all three. The one extra call that makes dragging reliable is setPointerCapture: once the visitor presses down, the element keeps receiving move events even when the finger or cursor leaves its box. Without it, a fast drag that exits the frame freezes the handle halfway.
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
fromClientX(e.clientX);
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
fromClientX(e.clientX);
};
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
setDragging(false);
};fromClientX reads the container's bounding rect and converts the pointer position into a percentage between 0 and 100. That percentage is the only piece of state. Everything else, the clip, the handle position, the ARIA attributes, derives from it.
Listen on the whole frame, not just the handle. Visitors tap anywhere on the photo and expect the handle to jump there. A 24 pixel handle as the only hit target is a usability bug on touch screens.
The clip-path reveal
The after photo fills the frame. The before photo sits on top of it in an absolutely positioned layer, and clip-path: inset(0 X% 0 0) cuts that layer off on the right at the handle position. Dragging changes X and the before photo reveals or hides the after photo underneath.
Clip-path is the right tool because the two images never move or resize. A common alternative is to change the width of the top layer, which stretches the image unless you also counter-scale it, and that path leads to subpixel jitter during a drag. With clip-path both images stay pixel aligned and the browser only repaints the clipped region.
In the Facade component the value runs through a useSpring from motion/react and useTransform turns the spring into the clip string. The trick is in one effect: while dragging, the spring jumps straight to the value so the handle follows the finger one to one; when the visitor releases or taps somewhere, the spring eases to the new position. Reduced motion users get the jump in both cases.
const spring = useSpring(value, { stiffness: 400, damping: 38, mass: 0.6 });
const clip = useTransform(spring, (v) => `inset(0 ${100 - v}% 0 0)`);
React.useEffect(() => {
if (dragging || reduce) spring.jump(value);
else spring.set(value);
}, [value, dragging, reduce, spring]);Keyboard support with arrow keys
A slider that only works with a pointer is a slider half the audience cannot use. Keyboard support is a single handler on the focusable handle, and it follows the conventions people already know from range inputs:
- Left and Down move the handle toward "before", Right and Up toward "after", by
steppercent. The default step is 2. - Shift multiplies the step by 5, so a held shift arrow sweeps the frame in a few presses.
- Page Up and Page Down move by 10.
- Home jumps to 0 and End to 100, the two states a visitor most wants to compare.
Call preventDefault on the keys you handle so the page does not scroll when someone presses Down with the handle focused. Do not handle keys you do not use; Tab must keep working.
The ARIA slider role
Screen reader support is where I see the most comparison sliders fail, and it is not much work. The handle is the one element in the tab order, and it carries:
role="slider"witharia-valuemin={0},aria-valuemax={100}andaria-valuenowset to the rounded value.aria-valuetextthat reads as a sentence, like "60% before, 40% after", so the announcement means something. A bare "60" does not.aria-labelfrom thelabelprop, defaulting to "Before and after comparison", so a page with three sliders announces which one has focus.
The two images are not decorative. When you pass beforeSrc and afterSrc, each layer renders an <img> with the beforeAlt and afterAlt text, because "Roof before replacement, missing shingles on the south side" is information a sighted visitor gets from the photo. The illustrated fallback that renders without URLs uses role="img" with the same labels.
Touch handling on mobile
Three things break comparison sliders on phones, and all three are fixable with CSS and one attribute.
First, scrolling. A horizontal drag on a touch screen is also a scroll gesture, and the browser will happily scroll the page while you try to move the handle. touch-action: none on the frame tells the browser that this element owns its touches. Only set it on the slider, never on the page.
Second, selection. A long press selects the image or the labels. user-select: none on the frame prevents it.
Third, the handle size. The visual handle can be slim, but the hit area is the entire frame thanks to the pointer handlers above, so a thumb never has to land on a two pixel line. The handle itself still gets a 44 pixel circle so it reads as something to grab.
Install and use
The slider is a Pro item and installs from the private registry once your license key is in components.json (one time setup in the installation docs):
npx shadcn@latest add @facade/before-after-sliderimport { BeforeAfterSlider } from "@/components/facade/before-after-slider";
export function RoofProject() {
return (
<BeforeAfterSlider
beforeSrc="/projects/elm-street/before.jpg"
afterSrc="/projects/elm-street/after.jpg"
beforeAlt="Roof before replacement, curling shingles and moss on the north slope"
afterAlt="Roof after replacement, new architectural shingles and copper flashing"
beforeLabel="Before"
afterLabel="After"
defaultValue={50}
step={2}
label="Elm Street roof replacement"
onValueChange={(v) => console.log(v)}
className="max-w-3xl"
/>
);
}Shoot the two photos from the same spot with the same lens, or the reveal shows a misaligned house instead of a transformed roof. A tripod mark on the driveway is enough. On the roofer page the slider sits directly under the hero, before the stats, because a roofer's work is the proof and the proof should come before the claims.
FAQ
Can I use it for staging photos in real estate?
Yes. Empty room before, staged room after, same camera position. It works for renovations, landscaping, dental smile makeovers and anything else with a credible before state. It does not work for stock photos, and visitors can tell.
Does it work with next/image?
The beforeSrc and afterSrc props take plain URLs and render standard images. If you need next/image for sizing and formats, the source is yours after installation; swap the two <img> elements and keep the alt text in place.
Why a 16 by 10 frame?
The frame uses aspect-ratio so both photos share one box regardless of their native size, and 16 by 10 fits most exterior and interior shots without cropping the roofline. Override it with a Tailwind aspect-* class through className.