Skip to content

BeforeAfter

MD · 1950B gzip budget

Draggable slider comparing two images side by side.

Category
Media & layout
Budget
md, several states or a live subscription
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale
After
Before

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/before-after.json

Copies the source into your project.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import React, { useCallback, useRef, useState } from "react";

export interface BeforeAfterProps {
  beforeSrc: string;
  afterSrc: string;
  beforeLabel?: string;
  afterLabel?: string;
  label?: string;
}

export function BeforeAfter({
  beforeSrc,
  afterSrc,
  beforeLabel = "Before",
  afterLabel = "After",
  label = "Before and after comparison",
}: BeforeAfterProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState(50);
  const dragging = useRef(false);

  const updatePosition = useCallback((clientX: number) => {
    const el = containerRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
    setPosition((x / rect.width) * 100);
  }, []);

  const handleMouseDown = useCallback(() => {
    dragging.current = true;
  }, []);

  const handleMouseMove = useCallback(
    (e: React.MouseEvent) => {
      if (!dragging.current) return;
      updatePosition(e.clientX);
    },
    [updatePosition]
  );

  const handleMouseUp = useCallback(() => {
    dragging.current = false;
  }, []);

  const handleTouchMove = useCallback(
    (e: React.TouchEvent) => {
      updatePosition(e.touches[0].clientX);
    },
    [updatePosition]
  );

  return (
    <div
      ref={containerRef}
      role="group"
      aria-label={label}
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
      onMouseLeave={handleMouseUp}
      onTouchMove={handleTouchMove}
      style={{
        position: "relative",
        width: "100%",
        overflow: "hidden",
        borderRadius: "0.75rem",
        cursor: "ew-resize",
        userSelect: "none",
      }}
    >
      <img
        src={afterSrc}
        alt={afterLabel}
        style={{ width: "100%", display: "block" }}
        draggable={false}
      />
      <div
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          width: `${position}%`,
          height: "100%",
          overflow: "hidden",
        }}
      >
        <img
          src={beforeSrc}
          alt={beforeLabel}
          style={{
            width: `${(100 / position) * 100}%`,
            maxWidth: "none",
            display: "block",
          }}
          draggable={false}
        />
      </div>
      <div
        aria-hidden="true"
        style={{
          position: "absolute",
          top: 0,
          left: `${position}%`,
          width: 3,
          height: "100%",
          backgroundColor: "#fff",
          transform: "translateX(-50%)",
          boxShadow: "0 0 4px rgba(0,0,0,0.3)",
        }}
      />
      <div
        onMouseDown={handleMouseDown}
        onTouchStart={handleMouseDown}
        role="slider"
        aria-label="Comparison slider"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(position)}
        tabIndex={0}
        onKeyDown={(e) => {
          if (e.key === "ArrowLeft") setPosition((p) => Math.max(0, p - 2));
          if (e.key === "ArrowRight") setPosition((p) => Math.min(100, p + 2));
        }}
        style={{
          position: "absolute",
          top: "50%",
          left: `${position}%`,
          transform: "translate(-50%, -50%)",
          width: 40,
          height: 40,
          borderRadius: "50%",
          backgroundColor: "#fff",
          boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          cursor: "ew-resize",
          outline: "none",
        }}
      >
        <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
          <path d="M6 10H14M6 10L8 7M6 10L8 13M14 10L12 7M14 10L12 13" stroke="#333" strokeWidth="1.5" strokeLinecap="round" />
        </svg>
      </div>
      <span
        aria-hidden="true"
        style={{
          position: "absolute",
          bottom: "0.75rem",
          left: "0.75rem",
          backgroundColor: "rgba(0,0,0,0.6)",
          color: "#fff",
          padding: "0.25rem 0.5rem",
          borderRadius: "0.25rem",
          fontSize: "0.75rem",
        }}
      >
        {beforeLabel}
      </span>
      <span
        aria-hidden="true"
        style={{
          position: "absolute",
          bottom: "0.75rem",
          right: "0.75rem",
          backgroundColor: "rgba(0,0,0,0.6)",
          color: "#fff",
          padding: "0.25rem 0.5rem",
          borderRadius: "0.25rem",
          fontSize: "0.75rem",
        }}
      >
        {afterLabel}
      </span>
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation