swipe actions.

An iOS-style swipeable row with two actions, tuned in pixels and locked to one axis so it never fights the list's scroll.

react / gesture / touch / mobile

Loading component...

live component, try it

what to expect.

Drag a row to the left
Let go past the halfway mark to keep it open
Archive or delete to watch the row collapse

how it works.

The numbers are the whole component: the first action appears at 44px of travel, the second at 88, and the gesture commits past 58 and snaps back under it. Pixels rather than fractions of the row, because a fraction makes the gesture change length with the screen and the thresholds stop being tunable by feel. The part that is actually hard is the axis lock. The first few pixels decide whether this is a horizontal swipe or a vertical scroll, and once decided it must not flip, or a diagonal drag hands the gesture back and forth between the row and the list and neither of them wins. Momentum is off on purpose: with it on, a fast flick sails past the open position and gets pulled back, which reads as a bounce nobody asked for.

the component.

import { useRef, useState, type PointerEvent, type TransitionEvent } from "react";
import { haptic } from "@/lib/feedback";
import { useReducedMotion } from "../_shared/media";
import { lockAxis, velocityFrom, type Axis, type Sample } from "../_shared/drag";
import { restingX, revealProgress } from "./thresholds";

export interface SwipeRowProps {
  label: string;
  commit: number;
  firstReveal: number;
  axisLock: boolean;
  momentum: boolean;
  onArchive: () => void;
  onDelete: () => void;
}

const OPEN = 116;

type Leaving = "archive" | "delete";

export default function SwipeRow({
  label,
  commit,
  firstReveal,
  axisLock,
  momentum,
  onArchive,
  onDelete,
}: SwipeRowProps) {
  const reduced = useReducedMotion();
  const [x, setX] = useState(0);
  const [dragging, setDragging] = useState(false);
  const [leaving, setLeaving] = useState<Leaving | null>(null);
  const samplesRef = useRef<Sample[]>([]);
  const startRef = useRef({ x: 0, y: 0, from: 0 });
  const axisRef = useRef<Axis>(null);
  const draggingRef = useRef(false);

  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
    if (reduced || leaving) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    samplesRef.current = [{ t: e.timeStamp, x: e.clientX }];
    startRef.current = { x: e.clientX, y: e.clientY, from: x };
    axisRef.current = null;
    draggingRef.current = true;
    setDragging(true);
  };

  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
    if (!draggingRef.current) return;
    const { x: sx, y: sy, from } = startRef.current;
    const dx = e.clientX - sx;
    const dy = e.clientY - sy;

    // Decide the axis once per gesture, or a diagonal drag fights the list scroll.
    if (!axisLock || axisRef.current === null) axisRef.current = lockAxis(dx, dy);
    if (axisRef.current !== "x") return;

    samplesRef.current.push({ t: e.timeStamp, x: e.clientX });
    const next = Math.min(0, Math.max(-OPEN - 24, from + dx));
    if (-x >= commit !== -next >= commit) haptic("tick");
    setX(next);
  };

  const onPointerUp = () => {
    if (!draggingRef.current) return;
    draggingRef.current = false;
    setDragging(false);
    // Snap open past the commit threshold, otherwise back to closed.
    setX(restingX(x, velocityFrom(samplesRef.current), { commit, open: OPEN, momentum }));
  };

  const dismiss = (kind: Leaving) => {
    if (reduced) {
      (kind === "delete" ? onDelete : onArchive)();
      return;
    }
    if (leaving) return;
    setLeaving(kind);
  };

  const onTransitionEnd = (e: TransitionEvent<HTMLDivElement>) => {
    // Remove the row only after its height has collapsed.
    if (e.target !== e.currentTarget || e.propertyName !== "grid-template-rows") return;
    if (!leaving) return;
    (leaving === "delete" ? onDelete : onArchive)();
  };

  const progress = revealProgress(x, firstReveal, firstReveal * 2);
  const shown = progress.filter((p) => p === 1).length;

  const revealStyle = (p: number) =>
    leaving ? undefined : { opacity: p, transform: `translateX(${(1 - p) * 8}px)` };

  return (
    <div
      className="swipe-row"
      data-shown={shown}
      data-dragging={String(dragging)}
      {...(leaving ? { "data-leaving": leaving } : {})}
      onTransitionEnd={onTransitionEnd}
    >
      <div className="swipe-row-clip">
        <div className="swipe-actions">
          <button
            type="button"
            className="swipe-action"
            style={revealStyle(progress[0])}
            onClick={() => dismiss("archive")}
          >
            archive
          </button>
          <button
            type="button"
            className="swipe-action swipe-action--bad"
            style={revealStyle(progress[1])}
            onClick={() => dismiss("delete")}
          >
            delete
          </button>
        </div>

        <div
          className="swipe-face"
          style={{ transform: leaving ? undefined : `translateX(${x}px)` }}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onPointerCancel={onPointerUp}
        >
          <span>{label}</span>
        </div>
      </div>
    </div>
  );
}