freeNew275 lines · framer-motion
Before / After Slider
Drag the line. Nothing underneath is a photo. A full-width Features section with a large before/after comparison panel: a flat, grayscale abstract composition on one side and a vivid, glowing version of the same shapes on the other, revealed by a draggable divider handle.
- Use it for
- Showing a transformation, upgrade or 'raw vs polished' result — redesigns, enhancement tools, before/after case studies — without using any real photography.
- Themes
- Light + dark
- License
- Commercial use
Live
Features
See the difference
Drag the line. Every pixel on both sides is drawn live — nothing is a photo.
AfterBefore
Drag the handle, click the panel, or use ← → / Home / End
tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
/**
* Full-width "Features" section: a big before/after comparison panel revealing a
* vivid, refined composition on top of a dull, flat one. Both sides are generated
* entirely from SVG shapes/gradients (no external images) so the contrast between
* "before" and "after" is purely about colour, light and finish.
*
* The divider handle is a real `role="slider"` element: drag it with a pointer,
* click anywhere on the panel to jump, or focus it and use ArrowLeft/ArrowRight/
* Home/End.
*/
export interface BeforeAfterSliderProps {
eyebrow?: string;
title?: string;
subcopy?: string;
beforeLabel?: string;
afterLabel?: string;
/** Initial handle position, 0-100 (% of the panel revealed as "after" from the left). */
defaultValue?: number;
className?: string;
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
type Shape = { cx: number; cy: number; r: number; accent: "signal" | "info" | "lime" | "ok" };
const SHAPES: Shape[] = [
{ cx: 92, cy: 150, r: 78, accent: "signal" },
{ cx: 268, cy: 56, r: 56, accent: "info" },
{ cx: 322, cy: 158, r: 66, accent: "lime" },
{ cx: 168, cy: 182, r: 42, accent: "ok" },
{ cx: 188, cy: 70, r: 34, accent: "info" },
];
const ACCENT_VAR: Record<Shape["accent"], string> = {
signal: "var(--signal)",
info: "var(--info)",
lime: "var(--lime)",
ok: "var(--ok)",
};
function Composition({ vivid, uid }: { vivid: boolean; uid: string }) {
return (
<svg
viewBox="0 0 400 225"
preserveAspectRatio="xMidYMid slice"
className="absolute inset-0 h-full w-full"
aria-hidden
>
<defs>
{vivid &&
SHAPES.map((s, i) => (
<radialGradient key={i} id={`${uid}-g${i}`} cx="35%" cy="30%" r="75%">
<stop offset="0%" stopColor={ACCENT_VAR[s.accent]} stopOpacity={0.95} />
<stop offset="100%" stopColor={ACCENT_VAR[s.accent]} stopOpacity={0.15} />
</radialGradient>
))}
<filter id={`${uid}-glow`} x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="9" />
</filter>
<linearGradient id={`${uid}-wash`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={vivid ? "var(--info)" : "var(--muted-foreground)"} stopOpacity={vivid ? 0.16 : 0.08} />
<stop offset="100%" stopColor={vivid ? "var(--lime)" : "var(--border)"} stopOpacity={vivid ? 0.12 : 0.06} />
</linearGradient>
</defs>
<rect width="400" height="225" fill={vivid ? "var(--card)" : "var(--muted)"} />
<rect width="400" height="225" fill={`url(#${uid}-wash)`} />
{!vivid && (
<g stroke="var(--border)" strokeWidth={1} opacity={0.6}>
{Array.from({ length: 9 }).map((_, i) => (
<line key={`v${i}`} x1={i * 50} y1={0} x2={i * 50} y2={225} />
))}
{Array.from({ length: 6 }).map((_, i) => (
<line key={`h${i}`} x1={0} y1={i * 45} x2={400} y2={i * 45} />
))}
</g>
)}
{vivid && (
<g opacity={0.5} filter={`url(#${uid}-glow)`}>
{SHAPES.map((s, i) => (
<circle key={`glow${i}`} cx={s.cx} cy={s.cy} r={s.r * 0.9} fill={ACCENT_VAR[s.accent]} />
))}
</g>
)}
{SHAPES.map((s, i) =>
vivid ? (
<circle key={i} cx={s.cx} cy={s.cy} r={s.r} fill={`url(#${uid}-g${i})`} />
) : (
<circle
key={i}
cx={s.cx}
cy={s.cy}
r={s.r}
fill="var(--muted-foreground)"
opacity={0.14 + (i % 3) * 0.05}
/>
),
)}
{vivid ? (
<g stroke="var(--foreground)" strokeOpacity={0.18} strokeWidth={1.5} fill="none">
<path d="M40,200 C140,130 220,170 360,90" />
<path d="M20,40 C120,90 260,20 390,70" />
</g>
) : (
<g stroke="var(--muted-foreground)" strokeOpacity={0.3} strokeWidth={1} fill="none">
<path d="M40,200 L220,170 L360,90" />
</g>
)}
{vivid &&
[
[60, 40],
[340, 190],
[200, 20],
[360, 130],
].map(([x, y], i) => (
<circle key={`spark${i}`} cx={x} cy={y} r={2.4} fill="var(--foreground)" opacity={0.5} />
))}
</svg>
);
}
export function BeforeAfterSlider({
eyebrow = "Features",
title = "See the difference",
subcopy = "Drag the line. Every pixel on both sides is drawn live — nothing is a photo.",
beforeLabel = "Before",
afterLabel = "After",
defaultValue = 50,
className,
}: BeforeAfterSliderProps) {
const reduce = useReducedMotionSafe();
const uid = React.useId().replace(/[:]/g, "");
const trackRef = React.useRef<HTMLDivElement>(null);
const draggingRef = React.useRef(false);
const [pct, setPct] = React.useState(() => clamp(defaultValue, 0, 100));
const updateFromClientX = React.useCallback((clientX: number) => {
const el = trackRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.width <= 0) return;
const raw = ((clientX - rect.left) / rect.width) * 100;
setPct(clamp(raw, 0, 100));
}, []);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
draggingRef.current = true;
e.currentTarget.setPointerCapture?.(e.pointerId);
updateFromClientX(e.clientX);
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current) return;
updateFromClientX(e.clientX);
};
const stopDrag = (e: React.PointerEvent<HTMLDivElement>) => {
draggingRef.current = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
let next: number | null = null;
const big = e.shiftKey ? 20 : 5;
if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = pct - big;
else if (e.key === "ArrowRight" || e.key === "ArrowUp") next = pct + big;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = 100;
if (next === null) return;
e.preventDefault();
setPct(clamp(next, 0, 100));
};
const beforeOpacity = clamp((100 - pct) / 40, 0, 1);
const afterOpacity = clamp(pct / 40, 0, 1);
return (
<section className={cn("relative w-full bg-background px-4 py-20 sm:px-6 sm:py-24 lg:px-10", className)}>
<div className="mx-auto max-w-2xl text-center">
<p className="font-mono text-[11px] uppercase tracking-[0.2em] text-signal">{eyebrow}</p>
<h2 className="mt-3 font-display text-[clamp(2rem,5vw,3.25rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-foreground">
{title}
</h2>
<p className="mt-4 text-base leading-relaxed text-muted-foreground sm:text-lg">{subcopy}</p>
</div>
<div className="mx-auto mt-12 max-w-5xl sm:mt-16">
<div
ref={trackRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={stopDrag}
onPointerCancel={stopDrag}
className="relative aspect-[16/9] w-full touch-none select-none overflow-hidden rounded-[1.75rem] border border-border bg-muted shadow-[0_30px_70px_-40px_rgb(0_0_0/0.45)]"
>
<div className="absolute inset-0" aria-hidden>
<Composition vivid={false} uid={`${uid}-before`} />
</div>
<div className="absolute inset-0" style={{ clipPath: `inset(0 ${100 - pct}% 0 0)` }} aria-hidden>
<Composition vivid uid={`${uid}-after`} />
</div>
<motion.span
className="pointer-events-none absolute left-4 top-4 rounded-full border border-border/60 bg-background/80 px-3 py-1 font-mono text-[11px] uppercase tracking-wider text-foreground backdrop-blur"
style={{ opacity: afterOpacity }}
animate={reduce ? undefined : { opacity: afterOpacity }}
transition={{ duration: 0.15 }}
>
{afterLabel}
</motion.span>
<motion.span
className="pointer-events-none absolute right-4 top-4 rounded-full border border-border/60 bg-background/80 px-3 py-1 font-mono text-[11px] uppercase tracking-wider text-foreground backdrop-blur"
style={{ opacity: beforeOpacity }}
animate={reduce ? undefined : { opacity: beforeOpacity }}
transition={{ duration: 0.15 }}
>
{beforeLabel}
</motion.span>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-foreground/70"
style={{ left: `${pct}%` }}
aria-hidden
/>
<div
role="slider"
tabIndex={0}
aria-label="Reveal position"
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(pct)}
aria-valuetext={`${Math.round(pct)}% after`}
onKeyDown={onKeyDown}
className="absolute top-1/2 flex size-10 -translate-x-1/2 -translate-y-1/2 cursor-grab items-center justify-center rounded-full border-2 border-foreground bg-card text-foreground shadow-[0_8px_20px_-8px_rgb(0_0_0/0.5)] outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring"
style={{ left: `${pct}%` }}
>
<span className="flex gap-[3px]" aria-hidden>
<span className="h-4 w-[2px] rounded-full bg-foreground/70" />
<span className="h-4 w-[2px] rounded-full bg-foreground/70" />
</span>
</div>
</div>
<p className="mt-4 text-center font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
Drag the handle, click the panel, or use ← → / Home / End
</p>
</div>
</section>
);
}
export default BeforeAfterSlider;
How it behaves
- →Both panels are generated SVG: identical circle/path layout, rendered flat and desaturated for 'before' and with radial gradients + a Gaussian-blur glow filter in the brand accent colours for 'after'.
- →The 'after' layer sits on top of 'before' and is revealed via clip-path: inset(0 X% 0 0), where X is 100 - handlePosition, so only the left handlePosition% shows the vivid version.
- →The whole panel is the drag track: pointerdown captures the pointer and jumps the handle to that X position, pointermove continues the drag using the captured pointer, pointerup/pointercancel release it — no document-level listeners to clean up.
- →The divider handle is a real role="slider" div with aria-valuemin/max/now; focusing it and pressing ArrowLeft/ArrowRight/ArrowUp/ArrowDown moves it 5% (20% with Shift), Home/End jump to the ends.
- →'Before'/'After' pill labels fade in and out based on how much of their respective side is currently visible.
- →Colours come entirely from the design-system tokens (--signal, --info, --lime, --ok, --muted, --border), so both layers automatically repaint correctly when .dark is toggled.
Accessibility
- ✓The divider is a focusable `role="slider"` element with `aria-valuemin`, `aria-valuemax`, `aria-valuenow` and a descriptive `aria-valuetext`, fully operable by keyboard (arrows, Home, End) with no pointer required.
- ✓Both SVG compositions are `aria-hidden`; only the slider control and the two text labels are exposed to assistive tech.
- ✓Focus-visible ring on the handle uses the shared `--ring` token so it is visible in both themes.
- ✓Honours prefers-reduced-motion.