Swipe row

Swipe-to-reveal row actions with progressive arming, momentum projection, and velocity handed into the settle spring.

Show code
"use client";

import {
  useEffect,
  useRef,
  useState,
  type ComponentType,
  type FocusEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
  type SVGProps,
} from "react";
import { animate } from "animejs";
import { prefersReducedMotion, spring } from "@/lib/openkit/anime/tokens";
import { nearestSnap, project } from "@/lib/openkit/anime/project";
import { cn } from "@/lib/utils";

// Swipe-to-reveal row actions. A drag gesture is rare on the web but right for
// acting on a single item: drag a row to reveal save / add-to-kit on one edge
// and a constructive action on the other, then tap. Pointer events drive
// translateX, constrained to one axis with a stiff edge so the row lands on a
// snap point we choose, not where a fling throws it, and anime.js springs it
// home on release. Progressive reveal arms each action at a pixel threshold.

type SwipeTone = "default" | "accent" | "destructive";

export interface SwipeAction {
  id: string;
  label: string;
  Icon: ComponentType<SVGProps<SVGSVGElement>>;
  tone?: SwipeTone;
  onAction: () => void;
  /** When true, swap to the active glyph/label (e.g. saved). */
  active?: boolean;
  activeIcon?: ComponentType<SVGProps<SVGSVGElement>>;
  activeLabel?: string;
}

const ACTION_W = 76; // px per action; comfortably past the 44px touch target
const REVEAL_1 = 44; // first action arms here
const REVEAL_2 = 88; // second action arms here
const OPEN_THRESHOLD = 38; // past this on release, snap open instead of closed
const FLING_VELOCITY = 110; // px/s, Apple's fling threshold
const EDGE_RESIST = 0.2; // rubber-band past the bounds

const toneClass: Record<SwipeTone, string> = {
  default: "bg-muted font-medium text-foreground",
  accent: "bg-primary font-medium text-primary-foreground",
  destructive: "bg-destructive font-medium text-white",
};

export function SwipeRow({
  leading,
  trailing,
  initialOpen,
  children,
  className,
}: {
  /** Revealed by swiping right; sits on the left edge. */
  leading?: SwipeAction;
  /** Revealed by swiping left; sits on the right edge (up to two, progressive). */
  trailing?: SwipeAction[];
  /** Rest open on mount without animating (docs states, onboarding hints). */
  initialOpen?: "leading" | "trailing";
  children: ReactNode;
  className?: string;
}) {
  const rootRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const trailingWidth = Math.min(trailing?.length ?? 0, 2) * ACTION_W;
  const leadingWidth = leading ? ACTION_W : 0;
  const restX =
    initialOpen === "trailing" ? -trailingWidth : initialOpen === "leading" ? leadingWidth : 0;

  // Current translate, the running snap animation, and the live drag session.
  const x = useRef(restX);
  const anim = useRef<ReturnType<typeof animate> | null>(null);
  const drag = useRef({ active: false, startX: 0, base: 0, lastX: 0, lastT: 0, v: 0 });

  // Progressive reveal: arm each action once the drag passes its threshold.
  const [tLevel, setTLevel] = useState(() =>
    restX <= -REVEAL_2 ? 2 : restX <= -REVEAL_1 ? 1 : 0,
  );
  const [lArmed, setLArmed] = useState(() => restX >= REVEAL_1);

  const applyArms = (v: number) => {
    if (trailing?.length) {
      const lvl = v <= -REVEAL_2 ? 2 : v <= -REVEAL_1 ? 1 : 0;
      setTLevel((p) => (p === lvl ? p : lvl));
    }
    if (leading) {
      const armed = v >= REVEAL_1;
      setLArmed((p) => (p === armed ? p : armed));
    }
  };

  const render = () => {
    const el = contentRef.current;
    if (el) el.style.transform = `translateX(${x.current}px)`;
    applyArms(x.current);
  };

  const stop = () => {
    anim.current?.pause();
    anim.current = null;
  };

  const snap = (target: number, velocity = 0) => {
    stop();
    if (prefersReducedMotion()) {
      x.current = target;
      render();
      return;
    }
    anim.current = animate(x, {
      current: target,
      ease: spring({ velocity }),
      onUpdate: render,
    });
  };

  const close = (velocity = 0) => snap(0, velocity);
  const openTrailing = (velocity = 0) => snap(-trailingWidth, velocity);
  const openLeading = (velocity = 0) => snap(leadingWidth, velocity);

  useEffect(() => stop, []);

  const onPointerDown = (e: ReactPointerEvent) => {
    if (!e.isPrimary) return;
    if (e.pointerType === "mouse" && e.button !== 0) return;
    stop();
    contentRef.current?.setPointerCapture(e.pointerId);
    drag.current = {
      active: true,
      startX: e.clientX,
      base: x.current,
      lastX: e.clientX,
      lastT: performance.now(),
      v: 0,
    };
  };

  const onPointerMove = (e: ReactPointerEvent) => {
    const d = drag.current;
    if (!d.active) return;
    let next = d.base + (e.clientX - d.startX);
    if (next < -trailingWidth) next = -trailingWidth + (next + trailingWidth) * EDGE_RESIST;
    else if (next > leadingWidth) next = leadingWidth + (next - leadingWidth) * EDGE_RESIST;
    x.current = next;
    render();
    const now = performance.now();
    const dt = now - d.lastT;
    if (dt > 0) d.v = ((e.clientX - d.lastX) / dt) * 1000; // px/s
    d.lastX = e.clientX;
    d.lastT = now;
  };

  const endDrag = (e: ReactPointerEvent) => {
    const d = drag.current;
    if (!d.active) return;
    d.active = false;
    contentRef.current?.releasePointerCapture?.(e.pointerId);
    const pos = x.current;
    const fling = d.v;

    // Fling: project resting point, then snap to nearest open/closed target.
    if (Math.abs(fling) >= FLING_VELOCITY) {
      const points = [0];
      if (leading) points.push(leadingWidth);
      if (trailing?.length) points.push(-trailingWidth);
      snap(nearestSnap(pos + project(fling), points), fling);
      return;
    }

    // Slow release: position threshold.
    if (trailing?.length && pos <= -OPEN_THRESHOLD) openTrailing(fling);
    else if (leading && pos >= OPEN_THRESHOLD) openLeading(fling);
    else close(fling);
  };

  const fire = (action: SwipeAction) => {
    action.onAction();
    close();
  };

  // Keyboard parity: a swipe is pointer-only, so when an action button takes
  // focus we open its side, and close once focus leaves the row entirely.
  const handleBlur = (e: FocusEvent) => {
    if (!rootRef.current?.contains(e.relatedTarget as Node | null)) close();
  };

  return (
    <div ref={rootRef} className={cn("relative overflow-clip", className)} onBlur={handleBlur}>
      {leading ? (
        <div className="absolute inset-y-0 left-0 flex">
          <ActionButton
            action={leading}
            armed={lArmed}
            width={ACTION_W}
            origin="left"
            onFocus={openLeading}
            onClick={() => fire(leading)}
          />
        </div>
      ) : null}

      {trailing?.length ? (
        <div className="absolute inset-y-0 right-0 flex">
          {trailing.slice(0, 2).map((action, i) => (
            <ActionButton
              key={action.id}
              action={action}
              armed={tLevel >= i + 1}
              width={ACTION_W}
              origin="right"
              onFocus={openTrailing}
              onClick={() => fire(action)}
            />
          ))}
        </div>
      ) : null}

      <div
        ref={contentRef}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        style={restX ? { transform: `translateX(${restX}px)` } : undefined}
        className="relative z-10 cursor-grab touch-pan-y bg-background active:cursor-grabbing"
      >
        {children}
      </div>
    </div>
  );
}

function ActionButton({
  action,
  armed,
  width,
  origin,
  onFocus,
  onClick,
}: {
  action: SwipeAction;
  armed: boolean;
  width: number;
  origin: "left" | "right";
  onFocus: () => void;
  onClick: () => void;
}) {
  const tone = action.tone ?? "default";
  const Glyph = action.active && action.activeIcon ? action.activeIcon : action.Icon;
  const label = action.active && action.activeLabel ? action.activeLabel : action.label;
  return (
    <button
      type="button"
      onClick={onClick}
      onFocus={onFocus}
      aria-label={label}
      style={{ width, transformOrigin: origin === "right" ? "right center" : "left center" }}
      // Arm with opacity + scale, growing from the revealing edge. CSS
      // transitions keep it interruptible as the drag arms and disarms.
      className={cn(
        "flex h-full flex-col items-center justify-center gap-1 text-sm outline-none",
        "transition-[opacity,scale] duration-200 ease-out active:scale-[0.96]",
        armed ? "scale-100 opacity-100" : "scale-[0.8] opacity-35",
        "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70",
        toneClass[tone],
      )}
    >
      <Glyph width={18} height={18} className="shrink-0" />
      <span>{label}</span>
    </button>
  );
}