Display

Swipe Actions

A list row whose horizontal drag uncovers real action buttons — resistance, velocity snapping, a full-swipe commit, a second press for destructive actions, and the same actions on the keyboard.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { EllipsisVertical } from "lucide-react"
import { cn } from "@/lib/utils"

/** Which edge the actions live on. `leading` is revealed by dragging the row right. */
export type SwipeSide = "leading" | "trailing"
export type SwipeActionTone = "default" | "primary" | "destructive"

export interface SwipeActionItem {
  id: string
  /** Visible text and the accessible name. Kept short — the pane is narrow. */
  label: string

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/swipe-actions.json

Prompt

Build a React + TypeScript + Tailwind "SwipeActions" list row (React + lucide-react
only — no gesture or animation library; the settle is a CSS transition).

Contract
- Export a forwardRef <div> extending React.HTMLAttributes<HTMLDivElement>:
  children (the row content), label: string (what the row is — it names the action
  groups, the disclosure button and every announcement), leadingActions?: Action[],
  trailingActions?: Action[], fullSwipe = true, confirmTimeout = 4000,
  disabled = false, onOpenChange?: (side: "leading" | "trailing" | null) => void,
  contentClassName?: string.
- Action = { id, label, icon?, tone?: "default" | "primary" | "destructive",
  confirm?: boolean, confirmLabel?: string, disabled?: boolean, disabledReason?:
  string, onSelect: () => void }. onSelect is required: an action that runs nothing
  is a dead button.
- Layout rule, stated once and obeyed everywhere: within a side, index 0 is the
  OUTERMOST action (nearest the row's edge) and is the action a full swipe commits.
  A trailing pane therefore paints its array backwards — reverse the produced
  elements, not the data, and keep both panes flex-row, so DOM order equals visual
  order and the tab order matches what the eye sees.
- The component owns "which side is open"; there is no controlled `open` prop. It
  reports transitions through onOpenChange and closes itself when another row opens.

Behavior
- Geometry. Each pane is absolutely positioned against its edge with
  width: max-content, max-width: 85% of the row, and an inline min-width equal to
  the currently revealed pixels. min-width beats max-width in CSS, which is what
  makes an over-drag stretch the pane; only index 0 has flex-grow, so the extra
  width is absorbed by the very button a release would commit and never by a strip
  of bare track. Measure a pane's natural width by zeroing that inline min-width,
  reading getBoundingClientRect().width and restoring it in the same frame —
  measuring the shown width would let a pane grow with a longer label and never
  shrink back.
- Drag. Pointer Events only. Ignore non-primary mouse buttons, presses that start
  on an action or on the disclosure, and a second finger while a drag is running.
  Nothing moves until the pointer travels 8px; at that moment, if |dy| >= |dx| drop
  the gesture entirely so the page keeps scrolling, otherwise lock horizontally,
  setPointerCapture(pointerId) and close whatever other row was open. Offset
  follows the finger 1:1 up to the action width, then at 0.55x (rubber band); a
  side with no actions does not move at all. Dragging an already-open row continues
  from its current offset.
- Release. Sample velocity per move (v = 0.3*v + 0.7*sample, px/ms) and treat it as
  0 if the last sample is older than 100ms — otherwise "drag, hold still, let go"
  reads as a flick. A flick faster than 0.35 px/ms decides the direction on its own;
  otherwise the row settles open past half the action width and shut below it.
  pointercancel is not a decision: put the row back where it was.
- Full swipe. Past max(paneWidth * 1.25, rowWidth * 0.5) the outermost action arms:
  it stretches, flips to its full-strength tone and relabels itself "Release to
  <action>", and the change is announced. Releasing there runs it without ever
  settling open. Arming is a state transition, not a per-frame effect — announce it
  once per crossing.
- Refusals, the two cases worth getting right. (1) A confirm action never fires on
  the first press or on a full swipe: it settles the row open with itself armed and
  relabelled ("Confirm delete"), starts a confirmTimeout, and only a second press
  runs onSelect. Hold the armed id in a ref written synchronously inside the click
  handler, so a fast double press cannot slip past a pending setState. (2) A
  disabled action gets aria-disabled + a guarded handler (never the native disabled
  attribute, which blurs a focused control onto <body>) and announces
  disabledReason instead of doing nothing; it is also excluded from arming, so a
  full swipe onto it just rubber-bands back.
- Keyboard and screen reader — the actual product, not a courtesy. The action
  buttons are ordinary buttons that sit in the tab order at all times. Focusing one
  opens the row, because focus on a button hidden behind the row is a focus
  visibility failure. A permanently visible disclosure button (⋮) inside the row
  carries aria-expanded plus aria-controls pointing at the pane's id, so the same
  actions are discoverable without a gesture; keep focus on it when it opens (Tab
  moves into the pane naturally — the trailing pane follows the content in the DOM).
  Key map: ArrowLeft reveals the trailing side, ArrowRight the leading side, either
  first closing the other; Escape closes; Tab out closes. Run the whole focus policy
  from one focusin handler on the root: focus inside a pane opens that side, focus
  landing anywhere in the displaced row content closes the row (the only exception
  is the disclosure while its own pane is open, since it travels with the row and
  stays on screen), and any close that happens while focus sits in a pane hands
  focus back to the disclosure. Read the open side from a ref inside the
  disclosure's click handler — a focus-driven close can land between the press and
  the render that produced the handler. Skip the arrow keys when the event target is
  an input, textarea, select or contenteditable, and chain the consumer's onKeyDown
  first, bailing out if it called preventDefault.
- Each pane is role="group" with aria-label "<label>: leading|trailing actions". A
  polite sr-only role="status" region announces arming, refusals, confirmation
  requests and the action that ran; clear it after ~4s so an identical next message
  is announced again.
- One row at a time, page-wide: keep a module-level `let openRow: { close(): void }
  | null` and a stable per-row handle whose .close is re-pointed every render. Two
  rows each holding their own boolean leaves the first hanging open behind the
  second.
- A drag that moved swallows exactly one following click (a timestamp window of
  ~300ms checked in an onClickCapture handler), so the link under the finger does
  not fire; a tap on an open row closes it and is swallowed the same way. Presses
  anywhere else on the page close the open row via a document pointerdown listener
  registered ONLY while open.
- Cleanup: confirm timer, live-region timer, the document listener, the
  ResizeObserver that re-measures on width changes and the page-wide open slot are
  all released on unmount; pointer capture the browser releases itself, on pointerup
  and again when the element leaves the document.
  Re-measure in a layout effect keyed on the action ids/labels and the armed
  confirm id, because an armed label is wider than a resting one.

Rendering & styling
- Semantic tokens only. Track: relative, overflow-hidden, bg-card, touch-pan-y,
  select-none. Moving surface: relative z-10 bg-card (the opaque background is
  load-bearing — a transparent row shows the actions through it). Resting action
  tones: bg-muted/text-foreground, bg-primary/15 + text-primary,
  bg-destructive/15 + text-destructive. Committed tones (armed or awaiting a second
  press): bg-foreground/text-background, bg-primary/text-primary-foreground,
  bg-destructive/text-background — there is no --destructive-foreground token, and
  text-background reads on the red in both themes.
- Settle with transition-[transform,min-width] duration-300
  ease-[cubic-bezier(0.22,1,0.36,1)] applied only when no drag is in flight, and
  motion-reduce:transition-none. Deliberately no overshoot: an overshoot past 0
  would flash the opposite side's actions.
- Focus rings must survive overflow-hidden: draw them inside with
  focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring.
- Icons are aria-hidden and sized by the button ([&_svg]:size-4); labels truncate
  with min-w-0 so an overflowing pane degrades instead of pushing the row away.
- Merge every className through cn(); the consumer's className lands on the track
  and contentClassName on the moving surface.

Customization levers
- Feel: RESISTANCE (0.55) is how heavy the row is past the actions, OPEN_RATIO
  (0.5) how eager it is to stay open, FLICK_VELOCITY (0.35 px/ms) how much a flick
  counts, and the two full-swipe ratios (1.25x pane, 0.5x row) how deliberate a
  commit must be. Raise them for destructive-first rows, but keep the pane ratio low
  enough that the required travel — pane + (threshold - pane) / RESISTANCE — still
  fits inside the row, or pass fullSwipe={false} and say so.
- Density: the action buttons are px-4 text-sm; drop to px-3 or hide labels with
  sr-only for an icon-only pane (measuring is width-agnostic). The 85% pane cap is
  what keeps a strip of the row grabbable — lower it for fat rows.
- Tone map: three entries in and three out. Add a "warning" tone by adding one
  resting pair and one committed pair; nothing else knows the tone exists.
- Confirmation: confirmTimeout, confirmLabel per action, or drop confirm entirely
  and pair the action with an undo toast instead — the two patterns are
  alternatives, not layers. If a fast double press worries you, record the arm
  timestamp and ignore a second press inside ~250ms.
- Composition: the component renders a plain div, so pass role="listitem" inside a
  role="list" wrapper, or wrap rows in a divide-y container. onSelect is where the
  consumer removes the row, fires the request or opens its own dialog — and it owns
  moving focus after a row it deleted leaves the DOM.

Concepts

  • Reveal, not reorder — the drag moves one row over a fixed pane of buttons and always lands back on open or closed; nothing about the list's order changes, which is what separates this from a drag-to-reorder list.
  • Resistance and the far threshold — the row tracks the finger 1:1 only as far as the actions are wide; past that it is damped, so the extra travel reads as "you are pushing into something". Push far enough and the outermost action arms itself, announcing the commitment with a stretch, a colour flip and a new label — commitment is legible before release, never a surprise after it.
  • Distance or velocity — a release settles by whichever happened: past half the action width, or a flick faster than 0.35 px/ms. Distance alone makes a quick flick feel broken; velocity alone makes a slow deliberate drag fail. A flick that is older than 100ms at release is not a flick at all.
  • Arm, then confirm — a destructive action refuses both the first press and the full swipe: it settles the row open wearing "Confirm delete" and waits for a second press. The armed id lives in a ref written inside the handler, so a double press confirms deliberately instead of racing a pending render.
  • Focus opens the row — the actions are real buttons in the tab order even while hidden, so focusing one has to reveal it; a focused control behind an opaque row is a WCAG failure, not an animation. Closing while focus is inside a pane hands focus back to the disclosure button.
  • One row owns the page — a module-level slot holds the single open row and closes the previous one, and an outside press or a Tab away closes it too; without that, half-open rows pile up behind each other in a long list.

On This Page