Media

Compare

Two layers with a draggable divider between them, for before and after views and design reviews.

Built on
clip-pathPointer capture
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.0 kB
Dependencies
React
INVOICE 1042Amount due: 1240.00 USDClick here to pay
Invoice 1042$1,240.00Pay now
Code
import { Compare } from "@/components/equip/compare";

const frame = { display: "grid", alignContent: "center", gap: 6, height: "100%", padding: "0 20px", boxSizing: "border-box" } as const;

export default function Demo() {
  return (
    <Compare
      label="Invoice card redesign"
      style={{ width: 220, height: 118 }}
      before={
        <div style={{ ...frame, background: "var(--eq-tint-strong)", fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>
          <span>INVOICE 1042</span>
          <span style={{ color: "var(--eq-ink)" }}>Amount due: 1240.00 USD</span>
          <span style={{ textDecoration: "underline" }}>Click here to pay</span>
        </div>
      }
      after={
        <div style={{ ...frame, background: "var(--eq-raised)" }}>
          <span style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-3)" }}>Invoice 1042</span>
          <strong style={{ fontSize: "var(--eq-text-l)" }}>$1,240.00</strong>
          <span
            style={{
              justifySelf: "start",
              padding: "3px 10px",
              borderRadius: "var(--eq-radius-s)",
              background: "var(--eq-accent)",
              color: "var(--eq-accent-ink)",
              fontSize: "var(--eq-text-xs)",
              fontWeight: "var(--eq-weight-strong)",
            }}
          >
            Pay now
          </span>
        </div>
      }
    />
  );
}

Examples

Vertical

Up and Down move the divider the way they point, and the labels sit top and bottom.

eu-west-141 ms
us-east-1118 ms
ap-south-1203 ms
sa-east-1247 ms
eu-west-141 ms
us-east-1118 ms
ap-south-1203 ms
sa-east-1247 ms
Code
import { Compare } from "@/components/equip/compare";

const row = { display: "flex", justifyContent: "space-between", padding: "8px 16px", fontSize: "var(--eq-text-s)" } as const;
const rows = [
  ["eu-west-1", "41 ms"],
  ["us-east-1", "118 ms"],
  ["ap-south-1", "203 ms"],
  ["sa-east-1", "247 ms"],
];

export default function Example() {
  return (
    <Compare
      label="Latency table contrast fix"
      orientation="vertical"
      defaultPosition={40}
      beforeLabel="Old contrast"
      afterLabel="New contrast"
      style={{ maxWidth: 360 }}
      before={
        <div style={{ paddingBlock: 28, color: "var(--eq-ink-3)" }}>
          {rows.map(([region, ms]) => (
            <div key={region} style={row}>
              <span>{region}</span>
              <span>{ms}</span>
            </div>
          ))}
        </div>
      }
      after={
        <div style={{ paddingBlock: 28, background: "var(--eq-raised)", color: "var(--eq-ink)" }}>
          {rows.map(([region, ms]) => (
            <div key={region} style={{ ...row, borderBottom: "1px solid var(--eq-line)" }}>
              <span style={{ fontWeight: "var(--eq-weight-strong)" }}>{region}</span>
              <span style={{ fontVariantNumeric: "tabular-nums" }}>{ms}</span>
            </div>
          ))}
        </div>
      }
    />
  );
}

Controlled, with presets

The position is yours to set: preset buttons glide the divider on the same spring a track click uses.

No deploys yet
No deploys yet
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Compare } from "@/components/equip/compare";

const swatch = (bg: string, ink: string, text: string) => (
  <div style={{ display: "grid", placeItems: "center", height: 160, background: bg, color: ink, fontSize: "var(--eq-text-l)", fontWeight: "var(--eq-weight-strong)" }}>
    {text}
  </div>
);

export default function Example() {
  const [position, setPosition] = useState(50);
  return (
    <div style={{ display: "grid", gap: 12, maxWidth: 420 }}>
      <Compare
        label="Empty state illustration"
        position={position}
        onPositionChange={setPosition}
        beforeLabel="Light"
        afterLabel="Solid"
        before={swatch("var(--eq-surface)", "var(--eq-ink)", "No deploys yet")}
        after={swatch("var(--eq-solid)", "var(--eq-solid-ink)", "No deploys yet")}
      />
      <div style={{ display: "flex", gap: 8 }}>
        <Button size="s" variant="outline" onClick={() => setPosition(0)}>
          Show solid
        </Button>
        <Button size="s" variant="outline" onClick={() => setPosition(50)}>
          Split
        </Button>
        <Button size="s" variant="outline" onClick={() => setPosition(100)}>
          Show light
        </Button>
      </div>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the divider for assistive tech, such as the thing being compared.
positionnumbernoneDivider position in percent from the start edge, when you control it.
defaultPositionnumber50Divider position on first render, when you do not.
onPositionChange(position: number) => voidnoneCalled as the divider moves.
orientation"horizontal" | "vertical""horizontal"Vertical stacks before above after.
beforeReactNodenoneShown on the start side of the divider.
afterReactNodenoneShown on the end side of the divider.
beforeLabelstring"Before"Corner label for the before layer.
afterLabelstring"After"Corner label for the after layer.
...restComponentProps<"div">noneEvery native div attribute. Size the frame here.

Anatomy

root.eq-compareThe frame; a press anywhere on it moves the divider.
layer.eq-compare-layerdata-layer is before or after; the after layer is clipped at the divider.
divider.eq-compare-divider
handle.eq-compare-handleThe slider; grows while dragged.
label.eq-compare-labelFades when the divider comes near it.

Keyboard

Left arrowRight arrowMoves the divider 1% that way.
Up arrowDown arrowMoves the divider 1%: the way they point when vertical, up for more when horizontal.
Shift+Arrow keysMoves the divider 10%.
Page UpPage DownMoves the divider 10%.
HomeEndMoves the divider to the start or end, showing all of one layer.

Notes

  • The handle is a role slider with a value text that says how much of each layer shows.
  • Both layers stay in the DOM: the clipped one is still read by screen readers and its controls can still take focus, so keep layers to static content.
  • A drag follows the pointer exactly; a click on the frame, a key press or a new position from outside glides there on the spring.
  • Horizontal frames leave vertical touch scrolling alone, and vertical frames leave horizontal scrolling alone.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/compare.tsx171 lines
"use client";

import {
  useRef,
  useState,
  type ComponentProps,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from "react";
import "./compare.css";

export type CompareProps = Omit<ComponentProps<"div">, "children"> & {
  /** Names the divider for assistive tech, e.g. "Homepage redesign". */
  label: string;
  /** Divider position in percent from the start edge, when you control it. */
  position?: number;
  /** Divider position on first render, when you do not. */
  defaultPosition?: number;
  onPositionChange?: (position: number) => void;
  orientation?: "horizontal" | "vertical";
  /** Shown on the start side of the divider. */
  before: ReactNode;
  /** Shown on the end side of the divider. */
  after: ReactNode;
  beforeLabel?: string;
  afterLabel?: string;
};

/* A corner label fades once the divider comes within this many percent of it. */
const LABEL_ROOM = 22;

const clamp = (n: number) => Math.min(100, Math.max(0, n));

export function Compare({
  label,
  position: controlled,
  defaultPosition = 50,
  onPositionChange,
  orientation = "horizontal",
  before,
  after,
  beforeLabel = "Before",
  afterLabel = "After",
  className,
  style,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  ...rest
}: CompareProps) {
  const [uncontrolled, setUncontrolled] = useState(clamp(defaultPosition));
  const position = clamp(controlled ?? uncontrolled);
  const [dragging, setDragging] = useState(false);
  /* Track clicks, keys and outside changes glide on the spring; a drag follows the pointer exactly. */
  const [glide, setGlide] = useState(false);
  const vertical = orientation === "vertical";
  const pressAt = useRef({ x: 0, y: 0 });

  function set(next: number, animate: boolean) {
    const value = Math.round(clamp(next) * 10) / 10;
    setGlide(animate);
    if (value === position) return;
    if (controlled === undefined) setUncontrolled(value);
    onPositionChange?.(value);
  }

  function fromPointer(event: PointerEvent<HTMLDivElement>) {
    const box = event.currentTarget.getBoundingClientRect();
    return vertical
      ? ((event.clientY - box.top) / box.height) * 100
      : ((event.clientX - box.left) / box.width) * 100;
  }

  function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
    onPointerDown?.(event);
    if (event.defaultPrevented || event.button !== 0) return;
    pressAt.current = { x: event.clientX, y: event.clientY };
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    const onHandle = event.target instanceof Element && event.target.closest(".eq-compare-handle");
    setDragging(true);
    if (onHandle) setGlide(false);
    else set(fromPointer(event), true);
    event.currentTarget.querySelector<HTMLElement>(".eq-compare-handle")?.focus({ preventScroll: true });
  }

  function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
    onPointerMove?.(event);
    if (!dragging) return;
    /* Let a track click finish its glide unless the pointer really moves. */
    if (glide && Math.hypot(event.clientX - pressAt.current.x, event.clientY - pressAt.current.y) < 4) return;
    set(fromPointer(event), false);
  }

  function stop() {
    setDragging(false);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    const step = event.shiftKey ? 10 : 1;
    const keys: Record<string, number> = vertical
      ? { ArrowUp: -step, ArrowDown: step, ArrowLeft: -step, ArrowRight: step, PageUp: -10, PageDown: 10 }
      : { ArrowLeft: -step, ArrowRight: step, ArrowDown: -step, ArrowUp: step, PageDown: -10, PageUp: 10 };
    let next: number | undefined;
    if (event.key in keys) next = position + keys[event.key];
    else if (event.key === "Home") next = 0;
    else if (event.key === "End") next = 100;
    if (next === undefined) return;
    event.preventDefault();
    event.stopPropagation();
    set(next, true);
  }

  const rounded = Math.round(position);

  return (
    <div
      {...rest}
      className={["eq-compare", className].filter(Boolean).join(" ")}
      style={{ ...style, "--_pos": `${position}%` } as CSSProperties}
      data-orientation={orientation}
      data-dragging={dragging ? "" : undefined}
      data-glide={glide || !dragging ? "" : undefined}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={(event) => {
        onPointerUp?.(event);
        stop();
      }}
      onPointerCancel={(event) => {
        onPointerCancel?.(event);
        stop();
      }}
      onLostPointerCapture={stop}
    >
      <div className="eq-compare-layer" data-layer="before">
        {before}
      </div>
      <div className="eq-compare-layer" data-layer="after">
        {after}
      </div>
      <span className="eq-compare-label" data-side="before" data-hidden={position < LABEL_ROOM ? "" : undefined} aria-hidden="true">
        {beforeLabel}
      </span>
      <span className="eq-compare-label" data-side="after" data-hidden={position > 100 - LABEL_ROOM ? "" : undefined} aria-hidden="true">
        {afterLabel}
      </span>
      <div className="eq-compare-divider">
        <div
          role="slider"
          tabIndex={0}
          aria-label={label}
          aria-orientation={orientation}
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={rounded}
          aria-valuetext={`${rounded}% ${beforeLabel.toLowerCase()}, ${100 - rounded}% ${afterLabel.toLowerCase()}`}
          className="eq-compare-handle"
          onKeyDown={handleKeyDown}
        >
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M6 4.5L2.5 8 6 11.5M10 4.5l3.5 3.5-3.5 3.5" />
          </svg>
        </div>
      </div>
    </div>
  );
}
components/equip/compare.css153 lines
@layer equip {
  .eq-compare {
    --_handle: 32px;

    position: relative;
    display: grid;
    overflow: hidden;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-l);
    background: var(--eq-surface);
    color: var(--eq-ink);
    cursor: ew-resize;
    touch-action: pan-y;
    user-select: none;
    -webkit-user-select: none;
  }

  .eq-compare[data-orientation="vertical"] {
    cursor: ns-resize;
    touch-action: pan-x;
  }

  .eq-compare-layer {
    grid-area: 1 / 1;
    min-width: 0;
    min-height: 0;
  }

  .eq-compare-layer[data-layer="after"] {
    clip-path: inset(0 0 0 var(--_pos));
  }

  .eq-compare[data-orientation="vertical"] .eq-compare-layer[data-layer="after"] {
    clip-path: inset(var(--_pos) 0 0 0);
  }

  .eq-compare-divider {
    position: absolute;
    top: 0;
    bottom: 0;
    left: var(--_pos);
    width: 2px;
    margin-left: -1px;
    background: var(--eq-raised);
    box-shadow: 0 0 0 1px var(--eq-line-strong);
    pointer-events: none;
  }

  .eq-compare[data-orientation="vertical"] .eq-compare-divider {
    top: var(--_pos);
    bottom: auto;
    left: 0;
    right: 0;
    width: auto;
    height: 2px;
    margin: -1px 0 0;
  }

  .eq-compare[data-glide] .eq-compare-layer[data-layer="after"] {
    transition: clip-path var(--eq-dur-move) var(--eq-spring);
  }

  .eq-compare[data-glide] .eq-compare-divider {
    transition:
      left var(--eq-dur-move) var(--eq-spring),
      top var(--eq-dur-move) var(--eq-spring);
  }

  .eq-compare-handle {
    box-sizing: border-box;
    position: absolute;
    top: 50%;
    left: 50%;
    display: grid;
    place-items: center;
    width: var(--_handle);
    height: var(--_handle);
    margin: calc(var(--_handle) / -2) 0 0 calc(var(--_handle) / -2);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-full);
    background: var(--eq-raised);
    color: var(--eq-ink-2);
    box-shadow: var(--eq-shadow);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    pointer-events: auto;
    cursor: grab;
    transition:
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring);
  }

  .eq-compare-handle > svg {
    width: 16px;
    height: 16px;
  }

  .eq-compare[data-orientation="vertical"] .eq-compare-handle > svg {
    rotate: 90deg;
  }

  @media (hover: hover) {
    .eq-compare-handle:hover {
      color: var(--eq-ink);
    }
  }

  /* Grows under the hand while dragged, so the grip reads as held. */
  .eq-compare[data-dragging] .eq-compare-handle {
    scale: 1.15;
    color: var(--eq-ink);
    cursor: grabbing;
  }

  .eq-compare-handle:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-compare-label {
    position: absolute;
    top: var(--eq-space-2);
    display: inline-flex;
    align-items: center;
    height: 20px;
    padding: 0 var(--eq-space-2);
    border-radius: var(--eq-radius-s);
    background: color-mix(in srgb, var(--eq-raised) 88%, transparent);
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
    pointer-events: none;
    transition: opacity var(--eq-dur) var(--eq-ease);
  }

  .eq-compare-label[data-side="before"] {
    left: var(--eq-space-2);
  }

  .eq-compare-label[data-side="after"] {
    right: var(--eq-space-2);
  }

  .eq-compare[data-orientation="vertical"] .eq-compare-label[data-side="after"] {
    top: auto;
    right: auto;
    bottom: var(--eq-space-2);
    left: var(--eq-space-2);
  }

  .eq-compare-label[data-hidden] {
    opacity: 0;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index