Swipeable Row

Animated

A row whose actions are revealed by a velocity-projected swipe.

View Source

#Installation

npx @dinachi/cli@latest add swipeable-row

#Usage

tsx
import { SwipeableRow, SwipeableRowGroup } from "@/components/ui/swipeable-row"
tsx
<SwipeableRowGroup>
  <SwipeableRow
    actions={[
      { label: "Archive message", icon: <Archive className="h-4 w-4" />, onSelect: archive },
      { label: "Delete message", icon: <Trash2 className="h-4 w-4" />, onSelect: remove, destructive: true },
    ]}
    onDismiss={remove}
  >
    Weekly digest
  </SwipeableRow>
</SwipeableRowGroup>

#Examples

#The release decides where it lands

Snapping to the nearest position on release ignores that the finger was still moving, and the row stops dead under a hand that was clearly throwing it. This one projects instead, asking where the row would end up if it kept decelerating the way a flicked object does. That projected point is what gets compared against the thresholds.

So a short fast flick opens the row, and a long slow drag that stops short of the same point falls back. Both match what the hand meant. The spring that takes over is handed the pointer's exit velocity, so there is no seam between the finger driving the row and the animation driving it.

The projection is measured from where the row is, not from how far the finger travelled. Under drag elasticity those two disagree, and the eye is following the row.

#The threshold is shown, not documented

Past dismissAt the destructive action takes the whole row, before release. A reader cannot be asked to estimate a threshold from how far their own finger has moved. It snaps rather than fading in proportionally, because a half-committed delete is not a thing.

Omit onDismiss and there is no threshold and no destructive layer: the row only ever opens. Arming a commit that leads nowhere is a promise the row cannot keep.

#When to use it

Use it forNot for
A long list on a touch device where the actions would otherwise be permanent clutter: mail, tasks, notifications.A short list. Two rows do not need a hidden gesture; show the buttons.
Actions that are reversible, or gated behind the full-swipe threshold.A destructive action with no undo. The threshold makes a commit deliberate, not confirmed.

#Behaviour

  • SwipeableRowGroup keeps at most one row open. Opening a second closes the first, because the reader swiping a second row has said they are done with the first. A row outside a group still works; it just does not know about its neighbours.
  • The open resting point is derived from actionWidth × actions.length, so adding an action moves the resting point instead of leaving a button off the edge.
  • The row is free to travel its full width. The open position is a resting point the release decides on, not a wall the finger has to fight through. Anything the gesture must overcome to reach the threshold makes the threshold feel like a bug.
  • The row's content is yours. This component owns the gesture, not the markup.

#Accessibility

  • Every action is a real button with an accessible name. A swipe is not an affordance on its own, and it is unavailable to anyone using a keyboard.
  • Focusing an action opens the row, so the reader can see what they are on.
  • Reduced motion switches the drag off and gives the actions room in the layout rather than hiding them under the row. The functionality is not the gesture.
  • The icons are yours to label. They carry no text, so label is the only accessible name each button has.

#API Reference

PropTypeDefaultDescription
actionsSwipeableRowAction[]Revealed under the row, right to left. Each is { label, icon, onSelect, destructive?, className? }
onDismiss() => voidCommitting a full swipe. Omit it and the row only ever opens: no threshold, no destructive layer
dismissAtnumber0.5Fraction of the row's width past which releasing commits onDismiss
actionWidthnumber68Width of each action button in px. The open resting point is derived from it