Data Display

Swipe Actions

Swipeable list row that reveals actions with rubber-band resistance and a flick-velocity snap, falling back to hover reveal on fine pointers.

Install

npx shadcn@latest add @paragon/swipe-actions

swipe-actions.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

/** Flick speed (px/ms) that snaps the row open or shut regardless of distance. */
const FLICK_VELOCITY = 0.11;
/** Movement (px) before a gesture commits to a horizontal swipe. */
const INTENT_SLOP = 6;

/** Rubber-band: asymptotic resistance for distance dragged past the limit. */
function resist(over: number) {
  return over / (1 + over / 48);
}

export interface SwipeActionsProps extends React.ComponentProps<"div"> {
  /** Action buttons revealed behind the row — compose from <SwipeAction>. */
  actions: React.ReactNode;
  /** Styles the sliding row surface (padding, layout). */
  contentClassName?: string;
  /** Disables swiping and reveal transitions; actions stay focus-reachable. */
  static?: boolean;
}

/**
 * Mobile-pattern swipeable list row. Touch drags are pointer-captured and
 * single-touch guarded; movement past the action tray rubber-bands, a flick
 * past ~0.11 px/ms snaps open regardless of distance, and tapping anywhere
 * else closes. Vertical intent hands the gesture back to the scroller
 * (touch-action: pan-y). On fine pointers the tray reveals on hover instead,
 * and keyboard focus reveals it everywhere. Transforms are written straight
 * to the row — no per-frame React renders.
 */
export function SwipeActions({
  actions,
  children,
  className,
  contentClassName,
  static: isStatic = false,
  ...props
}: SwipeActionsProps) {
  const reducedMotion = useReducedMotion();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const contentRef = React.useRef<HTMLDivElement>(null);
  const actionsRef = React.useRef<HTMLDivElement>(null);
  const [open, setOpen] = React.useState(false);

  const drag = React.useRef({
    pointerId: null as number | null,
    startX: 0,
    startY: 0,
    startOffset: 0,
    offset: 0,
    lastX: 0,
    lastTime: 0,
    velocity: 0,
    intent: null as "x" | "y" | null,
  });
  // Timestamp of the last horizontal drag, to swallow the click it synthesizes.
  const lastDragEnd = React.useRef(0);

  // Tray width, published as --swipe-w for the CSS hover/focus reveal.
  React.useEffect(() => {
    const tray = actionsRef.current;
    const root = rootRef.current;
    if (!tray || !root) return;
    const observer = new ResizeObserver(() => {
      root.style.setProperty("--swipe-w", `${tray.offsetWidth}px`);
    });
    observer.observe(tray);
    return () => observer.disconnect();
  }, []);

  const settle = React.useCallback(
    (opening: boolean) => {
      const el = contentRef.current;
      const width = actionsRef.current?.offsetWidth ?? 0;
      if (el) {
        // Include `translate` so the CSS hover/focus reveal (which drives the
        // translate property) keeps its transition after inline overrides.
        el.style.transition = reducedMotion
          ? "none"
          : `transform ${opening ? "250ms" : "200ms"} var(--ease-out), translate 250ms var(--ease-out)`;
        el.style.transform = `translate3d(${opening ? -width : 0}px, 0, 0)`;
      }
      setOpen(opening);
    },
    [reducedMotion],
  );

  // Tapping anywhere outside the row closes an open tray.
  React.useEffect(() => {
    if (!open) return;
    const onPointerDown = (event: PointerEvent) => {
      if (!rootRef.current?.contains(event.target as Node)) settle(false);
    };
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open, settle]);

  const endDrag = (pointerId: number, commit: boolean) => {
    const state = drag.current;
    if (state.pointerId !== pointerId) return;
    state.pointerId = null;
    rootRef.current?.removeAttribute("data-dragging");
    if (state.intent !== "x") {
      // A tap (or abandoned gesture) — resume whatever state was frozen.
      settle(open);
      return;
    }
    lastDragEnd.current = Date.now();
    const width = actionsRef.current?.offsetWidth ?? 0;
    const opening = commit
      ? state.velocity <= -FLICK_VELOCITY
        ? true
        : state.velocity >= FLICK_VELOCITY
          ? false
          : state.offset < -width / 2
      : false;
    settle(opening);
  };

  return (
    <>
      <style href="paragon-swipe-actions" precedence="paragon">{`
        [data-swipe-content] {
          transition: translate 150ms var(--ease-exit);
        }
        [data-swipe-root]:has(:focus-visible) [data-swipe-content] {
          translate: calc(var(--swipe-w, 0px) * -1) 0;
          transition: translate 250ms var(--ease-out);
        }
        @media (hover: hover) and (pointer: fine) {
          [data-swipe-root]:not([data-state="open"]):not([data-dragging]):hover [data-swipe-content] {
            translate: calc(var(--swipe-w, 0px) * -1) 0;
            transition: translate 250ms var(--ease-out);
          }
        }
        [data-swipe-root][data-static] [data-swipe-content] {
          transition: none;
        }
        @media (prefers-reduced-motion: reduce) {
          [data-swipe-content] { transition: none !important; }
        }
      `}</style>
      <div
        ref={rootRef}
        data-swipe-root=""
        data-slot="swipe-actions"
        data-state={open ? "open" : "closed"}
        data-static={isStatic || undefined}
        className={cn("relative overflow-hidden", className)}
        onKeyDown={(event) => {
          if (event.key === "Escape" && open) settle(false);
        }}
        {...props}
      >
        <div
          ref={actionsRef}
          data-slot="swipe-actions-tray"
          className="absolute inset-y-0 right-0 flex items-stretch"
        >
          {actions}
        </div>
        <div
          ref={contentRef}
          data-swipe-content=""
          data-slot="swipe-actions-content"
          className={cn(
            "relative touch-pan-y bg-card select-none",
            contentClassName,
          )}
          style={{ transform: "translate3d(0px, 0px, 0px)" }}
          onPointerDown={(event) => {
            const state = drag.current;
            // Fine pointers get the hover reveal; guard multi-touch.
            if (isStatic || event.pointerType === "mouse") return;
            if (state.pointerId !== null) return;
            const el = contentRef.current;
            if (!el) return;
            const computed = getComputedStyle(el).transform;
            state.pointerId = event.pointerId;
            state.startX = event.clientX;
            state.startY = event.clientY;
            state.startOffset =
              computed && computed !== "none"
                ? new DOMMatrixReadOnly(computed).m41
                : 0;
            state.offset = state.startOffset;
            state.lastX = event.clientX;
            state.lastTime = event.timeStamp;
            state.velocity = 0;
            state.intent = null;
            // Freeze any in-flight snap at its current position so grabbing
            // mid-animation is exact and interruptible.
            el.style.transition = "none";
            el.style.transform = `translate3d(${state.startOffset}px, 0, 0)`;
          }}
          onPointerMove={(event) => {
            const state = drag.current;
            if (state.pointerId !== event.pointerId) return;
            const dx = event.clientX - state.startX;
            const dy = event.clientY - state.startY;
            if (state.intent === null) {
              if (Math.abs(dx) < INTENT_SLOP && Math.abs(dy) < INTENT_SLOP)
                return;
              if (Math.abs(dy) > Math.abs(dx)) {
                // Vertical intent — hand the gesture to the scroller.
                state.pointerId = null;
                return;
              }
              state.intent = "x";
              event.currentTarget.setPointerCapture(event.pointerId);
              rootRef.current?.setAttribute("data-dragging", "");
            }
            const width = actionsRef.current?.offsetWidth ?? 0;
            let offset = state.startOffset + dx;
            if (offset > 0) offset = resist(offset);
            else if (offset < -width) offset = -width - resist(-(offset + width));
            state.offset = offset;
            const dt = event.timeStamp - state.lastTime;
            if (dt > 0) {
              const instant = (event.clientX - state.lastX) / dt;
              state.velocity = state.velocity * 0.2 + instant * 0.8;
            }
            state.lastX = event.clientX;
            state.lastTime = event.timeStamp;
            const el = contentRef.current;
            if (el) {
              el.style.transition = "none";
              el.style.transform = `translate3d(${offset}px, 0, 0)`;
            }
          }}
          onPointerUp={(event) => endDrag(event.pointerId, true)}
          onPointerCancel={(event) => endDrag(event.pointerId, false)}
          onClick={(event) => {
            // Swallow the click a horizontal drag synthesizes on release.
            if (Date.now() - lastDragEnd.current < 400) {
              event.preventDefault();
              event.stopPropagation();
              return;
            }
            // Tap on the row itself while open closes instead of activating.
            if (open) {
              event.preventDefault();
              event.stopPropagation();
              settle(false);
            }
          }}
        >
          {children}
        </div>
      </div>
    </>
  );
}

export interface SwipeActionProps extends React.ComponentProps<"button"> {
  variant?: "default" | "destructive";
}

/** One action in the tray — full-height, icon-over-label. */
export function SwipeAction({
  variant = "default",
  className,
  ...props
}: SwipeActionProps) {
  return (
    <button
      type="button"
      data-slot="swipe-action"
      className={cn(
        "flex w-16 flex-col items-center justify-center gap-1 text-xs font-medium transition-[background-color] duration-150 ease-out [&_svg]:size-4 [&_svg]:shrink-0",
        variant === "destructive"
          ? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
          : "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        className,
      )}
      {...props}
    />
  );
}