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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/swipe-actions.jsonPrompt
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.
Infinite Scroll
A sentinel-driven feed with four first-class data states — a failed page keeps every row already loaded, the in-flight lock is a ref so no page is ever fetched twice, and the manual trigger plus a role=feed keyboard walk make it reachable without a mouse.
Activity Feed
A grouped activity stream — consecutive entries that share a verb and a target fold into one line, with day separators, an unread boundary and four data states.