Swipe actionsNew

A row that makes room for its next step. Swipe to reveal, then choose what happens.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/swipe-actions.json

Overview

Keep secondary actions close without making every row a toolbar. A horizontal swipe moves the foreground aside, with a little resistance at either end and a sampled spring on release. Actions stay explicit: swiping reveals them, clicking performs them. Each callback owns its real work and reports pending, success or retry in the same button.

States

reveal
After 8px of horizontal intent, the row follows the pointer on one transform. Vertical scrolling remains available.
settle
Reveal more than 45% of the action rail to leave it open. A 420ms sampled spring settles either way.
perform
An explicit action click runs its callback once. The button morphs through pending and success, then the row closes after 1300ms.
retry
A rejected callback leaves the actions open with a Retry button. Another click attempts the operation again.

Usage

usage.tsx
import { SwipeActions } from "@/components/swipe-actions";<SwipeActions label="Recipe notes" actions={[  { id: "pin", label: "Pin", onAction: () => pinNote() },  { id: "archive", label: "Archive", onAction: () => archiveNote() },]}>  <p>Recipe notes</p></SwipeActions>

Works with

Let the application own the result

Resolve after the operation succeeds, or reject to leave a retry available. Use one to three short actions; row content can contain ordinary links and controls.

example.tsx
import { SwipeActions } from "@/components/swipe-actions";<SwipeActions label={note.title} actions={[  { id: "archive", label: "Archive", pendingLabel: "Archiving",    successLabel: "Archived", onAction: async () => {      const result = await fetch("/api/notes/" + note.id + "/archive", { method: "POST" });      if (!result.ok) throw new Error("Could not archive");      refreshNotes();    } },]}>{note.title}</SwipeActions>

Props

PropTypeDescription
actionsSwipeAction[]id, label, optional icon, disabled, pendingLabel and successLabel, plus an async or synchronous onAction callback.
labelstringAccessible name for this row and its actions toggle.
childrenReactNodeThe foreground row. Interactive children keep their normal behaviour.
disabledbooleandefault falseDisables gestures, the reveal button and all actions.

Accessibility and motion

  • The three-dot button reveals actions without a gesture. Tab reaches each button; Escape closes the rail and returns focus.
  • Closed actions are inert and hidden from assistive technology. Leaving the row with keyboard focus closes it.
  • A swipe never performs an action. Vertical touch scrolling wins when the first movement is predominantly vertical.
  • The gesture measures its action rail once, then writes only the foreground transform. There are no React renders per move.
  • Reduced motion removes the release spring. Callback state labels and checks follow Status button’s reduced-motion behaviour.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Status button.

Source

components/swipe-actions.tsx
'use client';import * as React from 'react';import { StatusButton, type ActionStatus } from './status-button';import { INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';import { useSwipeReveal } from './use-swipe-reveal';export type SwipeAction = { id: string; label: string; icon?: React.ReactNode; pendingLabel?: string; successLabel?: string; onAction: () => void | Promise<void>; disabled?: boolean };export interface SwipeActionsProps extends React.HTMLAttributes<HTMLDivElement> { actions: SwipeAction[]; label: string; disabled?: boolean }/** A foreground row parts to reveal explicit actions. Crossing a swipe threshold never performs an action. */export function SwipeActions({ actions, label, disabled = false, children, className = '', ...props }: SwipeActionsProps) {  const reduced = useInteractionReducedMotion();  const [result, setResult] = React.useState<{ id: string; status: ActionStatus } | null>(null);  const busy = React.useRef(false), alive = React.useRef(false);  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);  const swipe = useSwipeReveal(reduced, disabled || result?.status === 'pending');  const id = React.useId();  React.useEffect(() => { alive.current = true; return () => { alive.current = false; if (timer.current) clearTimeout(timer.current); }; }, []);  const execute = async (action: SwipeAction) => {    if (busy.current || result?.status === 'success' || disabled || action.disabled) return;    busy.current = true; if (timer.current) clearTimeout(timer.current); setResult({ id: action.id, status: 'pending' });    try {      await action.onAction();      if (alive.current) {        setResult({ id: action.id, status: 'success' });        timer.current = setTimeout(() => {          const ownsFocus = swipe.content.current?.parentElement?.contains(document.activeElement) ?? false;          setResult(null); swipe.settle(false, ownsFocus);        }, 1300);      }    } catch { if (alive.current) setResult({ id: action.id, status: 'error' }); }    finally { busy.current = false; }  };  return <div {...props} {...swipe.handlers} role="group" aria-label={label} className={`relative isolate overflow-hidden rounded-[18px] border border-border bg-muted/35 text-foreground ${className}`} style={{ touchAction: 'pan-y', ...props.style }}>    <div ref={swipe.actions} id={id} inert={!swipe.open} aria-hidden={!swipe.open} className="absolute inset-y-0 right-0 flex max-w-[80%] items-stretch">      {actions.map((action) => <div key={action.id} className="flex w-[84px] min-w-0 items-center justify-center border-l border-border px-1">        <StatusButton status={result?.id === action.id ? result.status : 'idle'} labels={{ idle: action.label, pending: action.pendingLabel ?? 'Working', success: action.successLabel ?? 'Done', error: 'Retry' }}          icon={action.icon} variant="ghost" size="sm" disabled={disabled || action.disabled} aria-disabled={result?.status === 'pending' || result?.status === 'success' || disabled || action.disabled || undefined} onClick={() => void execute(action)} className="!h-auto flex-col gap-1.5 !px-1 !py-2 !text-[11px]" />      </div>)}    </div>    <div ref={swipe.content} className="relative flex min-h-[88px] items-center gap-3 rounded-[17px] bg-background px-4 py-4 will-change-transform">      <div className="min-w-0 flex-1">{children}</div>      <button ref={swipe.trigger} type="button" aria-label={`${swipe.open ? 'Hide' : 'Show'} actions for ${label}`} aria-expanded={swipe.open} aria-controls={id} disabled={disabled || result?.status === 'pending'}        onClick={() => swipe.settle(!swipe.open)} className={`flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-default disabled:opacity-40 ${INTERACTION_FOCUS}`}>        <svg aria-hidden="true" viewBox="0 0 20 20" className="size-4"><path d="M4 10h.01M10 10h.01M16 10h.01" stroke="currentColor" strokeWidth="3" strokeLinecap="round" /></svg>      </button>    </div>    <span role="status" className="sr-only">{result ? `${actions.find((action) => action.id === result.id)?.label}: ${result.status === 'error' ? 'Failed. Try again.' : result.status}` : ''}</span>  </div>;}
components/use-swipe-reveal.ts
'use client';import * as React from 'react';import { springFrames } from './ui-interaction-motion';type Gesture = { id: number; x: number; y: number; origin: number; last: number; started: boolean; width: number };/** Measure once at pointer-down; every move writes only the foreground transform. */export function useSwipeReveal(reduced: boolean, disabled: boolean) {  const content = React.useRef<HTMLDivElement>(null);  const actions = React.useRef<HTMLDivElement>(null);  const trigger = React.useRef<HTMLButtonElement>(null);  const position = React.useRef(0);  const gesture = React.useRef<Gesture | null>(null);  const animation = React.useRef<Animation | null>(null);  const suppressed = React.useRef(false);  const [open, setOpen] = React.useState(false);  const paint = (x: number) => { position.current = x; if (content.current) content.current.style.transform = `translate3d(${x}px,0,0)`; };  const settle = (show: boolean, restoreFocus = false) => {    const from = animation.current?.playState === 'running' && content.current ? new DOMMatrixReadOnly(getComputedStyle(content.current).transform).m41 : position.current;    const target = show ? -(actions.current?.offsetWidth ?? 0) : 0;    animation.current?.cancel(); paint(target); setOpen(show);    if (!reduced && content.current && Math.abs(from - target) > 0.5) {      animation.current = content.current.animate(springFrames(from, target, 0, 420).map(({ value, offset }) => ({ transform: `translate3d(${value}px,0,0)`, offset })), { duration: 420, easing: 'linear' });    }    if (restoreFocus) trigger.current?.focus({ preventScroll: true });  };  const finish = (cancelled: boolean) => {    const g = gesture.current;    if (!g) return;    gesture.current = null;    if (!g.started) return;    suppressed.current = true;    settle(cancelled ? g.origin < 0 : -g.last > g.width * 0.45);  };  const finishRef = React.useRef(finish); finishRef.current = finish;  React.useEffect(() => {    const cancel = () => finishRef.current(true);    const hidden = () => { if (document.hidden) cancel(); };    window.addEventListener('blur', cancel); document.addEventListener('visibilitychange', hidden);    const observer = new ResizeObserver(() => { if (position.current < 0 && !gesture.current) paint(-(actions.current?.offsetWidth ?? 0)); });    if (actions.current) observer.observe(actions.current);    return () => { animation.current?.cancel(); observer.disconnect(); window.removeEventListener('blur', cancel); document.removeEventListener('visibilitychange', hidden); };  }, []);  React.useEffect(() => { if (disabled) finishRef.current(true); }, [disabled]);  return {    content, actions, trigger, open, settle,    handlers: {      onPointerDown(event: React.PointerEvent<HTMLDivElement>) {        suppressed.current = false;        if (disabled || event.button !== 0 || !event.isPrimary || (event.target as Element).closest('button, a, input, textarea, select, [role="slider"]')) return;        const current = animation.current?.playState === 'running' && content.current ? new DOMMatrixReadOnly(getComputedStyle(content.current).transform).m41 : position.current;        animation.current?.cancel(); paint(current);        gesture.current = { id: event.pointerId, x: event.clientX, y: event.clientY, origin: position.current, last: position.current, started: false, width: actions.current?.offsetWidth ?? 0 };      },      onPointerMove(event: React.PointerEvent<HTMLDivElement>) {        const g = gesture.current;        if (!g || event.pointerId !== g.id) return;        const dx = event.clientX - g.x, dy = event.clientY - g.y;        if (!g.started) {          if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;          if (Math.abs(dy) > Math.abs(dx)) { gesture.current = null; return; }          g.started = true; event.currentTarget.setPointerCapture(event.pointerId);        }        const raw = g.origin + dx;        g.last = raw > 0 ? Math.min(18, raw * 0.15) : raw < -g.width ? -g.width - Math.min(18, (-raw - g.width) * 0.15) : raw;        paint(g.last);      },      onPointerUp(event: React.PointerEvent<HTMLDivElement>) { if (event.pointerId === gesture.current?.id) finish(false); },      onPointerCancel(event: React.PointerEvent<HTMLDivElement>) { if (event.pointerId === gesture.current?.id) finish(true); },      onLostPointerCapture(event: React.PointerEvent<HTMLDivElement>) { if (event.pointerId === gesture.current?.id) finish(true); },      onClickCapture(event: React.MouseEvent) { if (suppressed.current) { event.preventDefault(); event.stopPropagation(); suppressed.current = false; } },      onKeyDown(event: React.KeyboardEvent) { if (event.key === 'Escape') { event.preventDefault(); settle(false, true); } },      onBlur(event: React.FocusEvent) { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { gesture.current = null; settle(false); } },    },  };}
components/ui-interaction-motion.ts
'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';export const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', notify);  return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() {  const ref = React.useRef<T>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const el = ref.current;    if (!el) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(el);    const visibility = () => setHidden(document.hidden);    visibility(); document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) {  samples.push({ x, time });  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) {  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;  const first = samples[0], last = samples[samples.length - 1];  return (last.x - first.x) / Math.max(1, last.time - first.time);}/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */export function springFrames(from: number, to: number, velocity = 0, duration = 520) {  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);  const displacement = from - to;  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;  return Array.from({ length: 33 }, (_, i) => {    const t = (i / 32) * duration / 1000;    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));    return { value: i === 32 ? to : value, offset: i / 32 };  });}

Registry item: /r/swipe-actions.json