Documentation menu

compare

Before/after clip-path wipe with pointer, keyboard, and slider-role support.

figureinteractiveslider

Preview

draftfinal
before
after

Installation

Ask Claude (MCP)

With the MCP server configured, just ask:

Claude
add the moco compare component

shadcn CLI

Terminal
npx shadcn@latest add https://mocoui.site/r/compare.json

Manual

Copy each file from the Source section into its target path:

registry/compare/compare.tsx  →  components/moco/compare.tsx
registry/compare/compare.css  →  components/moco/compare.css

Needs moco items: tokens

Usage

usage.tsx
import { Compare } from "./compare";

export default function CompareDemo() {
  return (
    <Compare
      height={220}
      beforeLabel="draft"
      afterLabel="final"
      before={
        <div
          style={{
            position: "absolute",
            inset: 0,
            display: "grid",
            placeItems: "center",
            background: "var(--moco-panel)",
            color: "var(--moco-muted)",
            fontFamily: "var(--moco-mono)",
          }}
        >
          before
        </div>
      }
      after={
        <div
          style={{
            position: "absolute",
            inset: 0,
            display: "grid",
            placeItems: "center",
            background: "var(--moco-accent-wash)",
            color: "var(--moco-ink)",
            fontFamily: "var(--moco-mono)",
          }}
        >
          after
        </div>
      }
    />
  );
}

Source

components/moco/compare.tsx
"use client";

import * as React from "react";
import "./compare.css";

export interface CompareProps {
  before: React.ReactNode;
  after: React.ReactNode;
  height?: number;
  beforeLabel?: string;
  afterLabel?: string;
}

/**
 * Before/after wipe. Works for images and for live DOM. Drag the handle,
 * click anywhere to position it, or focus it and use the arrow keys.
 */
export function Compare({
  before,
  after,
  height = 300,
  beforeLabel = "before",
  afterLabel = "after",
}: CompareProps) {
  const [pct, setPct] = React.useState(50);
  const box = React.useRef<HTMLDivElement>(null);
  const dragging = React.useRef(false);

  const setFromClientX = (x: number) => {
    const el = box.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    setPct(Math.min(100, Math.max(0, ((x - r.left) / r.width) * 100)));
  };

  return (
    <div
      ref={box}
      className="moco-cmp"
      style={{ height }}
      onPointerDown={(e) => {
        dragging.current = true;
        (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
        setFromClientX(e.clientX);
      }}
      onPointerMove={(e) => dragging.current && setFromClientX(e.clientX)}
      onPointerUp={() => (dragging.current = false)}
      onPointerCancel={() => (dragging.current = false)}
    >
      <span className="moco-cmp-label" style={{ left: 12 }}>
        {beforeLabel}
      </span>
      <span className="moco-cmp-label" style={{ right: 12 }}>
        {afterLabel}
      </span>

      <div style={{ position: "absolute", inset: 0 }}>{before}</div>
      <div className="moco-cmp-after" style={{ clipPath: `inset(0 0 0 ${pct}%)` }}>
        {after}
      </div>

      <div className="moco-cmp-handle" style={{ left: `${pct}%` }}>
        <button
          type="button"
          className="moco-cmp-grab"
          role="slider"
          aria-label={`Reveal ${afterLabel} over ${beforeLabel}`}
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={Math.round(pct)}
          aria-valuetext={`${Math.round(pct)} percent`}
          onKeyDown={(e) => {
            if (e.key === "ArrowLeft") setPct((v) => Math.max(0, v - 4));
            if (e.key === "ArrowRight") setPct((v) => Math.min(100, v + 4));
            if (e.key === "Home") setPct(0);
            if (e.key === "End") setPct(100);
          }}
        >
          ⇄
        </button>
      </div>
    </div>
  );
}
components/moco/compare.css
.moco-cmp {
  position: relative;
  overflow: hidden;
  user-select: none;
  touch-action: pan-y;
  border: 1px solid var(--moco-line);
  border-radius: 2px;
  background: var(--moco-bg);
}

.moco-cmp-after {
  position: absolute;
  inset: 0;
  overflow: hidden;
  /* Opaque ground: without it, translucent `after` content lets the before
     layer (and its text) bleed through the revealed side. */
  background: var(--moco-bg);
}

.moco-cmp-handle {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--moco-accent-dk);
  z-index: 4;
}

.moco-cmp-grab {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 22px;
  height: 34px;
  border: 1px solid var(--moco-accent-dk);
  border-radius: 2px;
  background: var(--moco-bg);
  cursor: ew-resize;
  display: grid;
  place-items: center;
  color: var(--moco-accent-dk);
  font-size: 10px;
}

.moco-cmp-label {
  position: absolute;
  top: 8px;
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--moco-faint);
  z-index: 3;
}