How to build a drag-to-reveal comparison slider in React
A drag-to-reveal comparison slider in React clips a "before" panel using a CSS clip-path that updates in real time as the user drags a handle. Framer Motion's drag API handles pointer and touch input; useMotionValue and useTransform convert the handle position into a live clip-path string without triggering re-renders.
- Stack: React 18 + Framer Motion 11, zero icon library, ~360 lines total.
- Core APIs: useMotionValue, useTransform, animate, drag + dragConstraints.
- Auto-presentation animation on mount (left 30%, right 70%, back to center) before first user interaction.
- ResizeObserver keeps container width in sync so the clip-path percentage stays accurate on window resize.
- Works on touch screens via Framer Motion's pointer-event unification; touchAction: none prevents scroll conflicts.
The Before After Slider is a draggable React section that lets visitors physically pull a handle to reveal a transformation, a redesign, a product result, or a service outcome. The clip-path animates live without triggering React re-renders, so the drag stays smooth even on lower-end devices. An auto-play demo animation fires on mount so users understand the interaction without any instruction.
Anatomy
The component has three visual layers stacked absolutely inside a 16:9 container: the "after" panel at the base (full width, always visible), the "before" panel on top clipped to the left of the handle, and a draggable handle group (vertical line + circular button + double-arrow icon). Two floating badge labels are pinned to the top corners and fade in on mount. A separate header block with title and description sits above the slider, animated in with whileInView.
How it works
A single Framer Motion useMotionValue (x) tracks the horizontal offset of the handle relative to the container center. The clip-path of the before panel is derived via useTransform: it maps x to a percentage and builds an `inset(0 ${100 - pct}% 0 0)` string so the clipped area shrinks as the handle moves right. The handle position label is computed the same way with a clamped percentage. Framer Motion's drag="x" with dragConstraints={containerRef} constrains movement to the container bounds and handles pointer capture, touch events, and momentum cancellation (dragMomentum=false) in one prop.
How to build it in React
Set up the container and measure its width
Create a div ref for the slider container and use a ResizeObserver inside useEffect to keep a containerWidth state in sync. This value is needed to convert the pixel drag offset into a 0-100% clip-path percentage. Set touchAction: none on the container to prevent the browser from intercepting touch drags as scrolls.
const containerRef = useRef<HTMLDivElement>(null); const [containerWidth, setContainerWidth] = useState(0); useEffect(() => { const el = containerRef.current; if (!el) return; const obs = new ResizeObserver(() => setContainerWidth(el.offsetWidth)); obs.observe(el); setContainerWidth(el.offsetWidth); return () => obs.disconnect(); }, []);Drive the clip-path from a single motion value
Declare a useMotionValue for the horizontal offset (x, starting at 0 = center). Derive the before panel's clip-path with useTransform: map x to a clamped percentage, then return an inset() string. Do the same for the handle's left position. Both update at 60fps without touching React state.
const x = useMotionValue(0); const clipRight = useTransform(x, (v) => { const pct = Math.max(0, Math.min(100, 50 + (v / containerWidth) * 100)); return `inset(0 ${100 - pct}% 0 0)`; }); const handleLeft = useTransform(x, (v) => { const pct = Math.max(2, Math.min(98, 50 + (v / containerWidth) * 100)); return `${pct}%`; });Attach drag to the handle and wire clip-path to the before panel
Render a motion.div handle with drag="x", dragConstraints={containerRef}, dragElastic={0}, and dragMomentum={false}. Pass the x motion value directly as its x style prop. Apply clipPath={clipRight} to the before panel as a motion.div style. The two are now in sync without any event handlers.
<motion.div drag="x" dragConstraints={containerRef} dragElastic={0} dragMomentum={false} style={{ x }}> {/* handle icon */} </motion.div> <motion.div style={{ clipPath: clipRight, position: "absolute", inset: 0 }}> {/* before content */} </motion.div>Add the auto-play intro animation
Use Framer Motion's animate() imperative function to run a sequence (left, right, center) on the x motion value after a short delay once containerWidth is available. Gate it behind a hasInteracted state so the sequence stops the moment the user grabs the handle.
useEffect(() => { if (!containerWidth || hasInteracted) return; const half = containerWidth / 2; const timer = setTimeout(() => { animate(x, -half * 0.4, { duration: 0.9, ease: [0.4, 0, 0.2, 1], onComplete: () => animate(x, half * 0.4, { duration: 1.0, ease: [0.4, 0, 0.2, 1], onComplete: () => animate(x, 0, { duration: 0.7 }) }) }); }, 600); return () => clearTimeout(timer); }, [containerWidth, hasInteracted, x]);
When to use it
This slider excels anywhere a transformation needs to be felt rather than described: photo retouching tools, design-before-redesign showcases, skincare and beauty results, real estate renovation reveals, or any SaaS product demonstrating a before/after workflow. Avoid it on pages where the comparison content is not compelling enough to justify the interaction cost, and always provide meaningful before and after images rather than placeholder gradients in production.
Used by
- Shopify, Uses before/after comparisons in merchant success stories to show store transformations visually.
- Canva, Deploys image comparison sliders in feature landing pages to showcase AI background remover and photo editing results.
- Figma, Uses reveal-style comparisons in release announcements to demonstrate UI improvements side by side.
- Lightroom (Adobe), The before/after toggle is a core editing pattern throughout its web and mobile apps for comparing raw vs edited photos.
FAQ
Why use useMotionValue instead of React state for the drag position?
useMotionValue updates outside the React render cycle, so each pixel of drag does not trigger a re-render. The clip-path and handle position update at the animation frame rate (up to 120fps) without any setState overhead, which is why the slider stays smooth even on lower-end hardware.
How does the clip-path approach compare to using two absolutely-positioned divs with overflow:hidden?
The clip-path approach is GPU-composited and does not cause layout recalculations, unlike resizing a div's width on every frame. It also handles the dividing line and non-rectangular shapes more cleanly. The trade-off is that clip-path animations can cause subpixel aliasing on some screens at the edge.
Does the drag work on touch screens?
Yes. Framer Motion's drag API unifies mouse and pointer events, so touch drag works out of the box. The touchAction: none style on the container is required to prevent the browser from stealing the touch event for page scrolling before Framer Motion can handle it.
How can I pass real images instead of the placeholder mockups?
Pass the beforeImage and afterImage props as absolute URLs or relative paths. When set, the component renders them as CSS background-image with center/cover, replacing the built-in placeholder mockups entirely. Both props are optional so the component works as a visual demo out of the box.