Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 02:51:15Get Lifetime →
loopkit
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.