Lists

SwipeActions

A list row with actions beside it that a horizontal swipe reveals: swipe toward the start for the trailing actions (Delete, Archive), toward the end for the leading ones (Pin). Wrap the row, usually an Item, and name it with label. The swipe is for touch and pen, and it is never the only way: a long press on the row opens the same actions as a Menu at the finger, for anyone who cannot swipe, and the row's actions button opens that menu too. A mouse sees the button at the end of the row, and its right-click keeps the browser's own menu ("Open in new tab" for a row that is a link); on a touch screen the button is hidden from sight, and a keyboard or screen reader reaches it on any device. While a finger is down on the row, text selection and the iOS callout are off, so the long press opens the menu.

Recipes
  • Shakshuka
    Swipe on a touch screen, or use the actions button
  • Braised short ribs
    Swipe on a touch screen, or use the actions button
  • Lemon tart
    Swipe on a touch screen, or use the actions button

In Spoonjoy

Spoonjoy, a recipe app, is built with Ouro HX (how).

In its source: app/routes/shopping-list.tsx

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/swipe-actions#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/swipe-actions

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/swipe-actions#v1.2.13 ourostack/ouro-ui/swipe-actions-tests#v1.2.13
import { SwipeActions } from "@/components/ouro/swipe-actions/swipe-actions";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<ItemGroup label="Recipes">
  {recipes.map((recipe) => (
    <SwipeActions
      key={recipe.id}
      label={recipe.title}
      leading={[{ label: "Pin", tone: "primary", onClick: () => pin(recipe) }]}
      trailing={[
        {
          label: "Delete",
          tone: "destructive",
          onClick: () => {
            remove(recipe);
            toast("Recipe deleted", { action: { label: "Undo", onClick: () => restore(recipe) } });
          },
        },
        { label: "Archive", onClick: () => archive(recipe) },
      ]}
    >
      <Item href={`/recipes/${recipe.id}`} title={recipe.title} />
    </SwipeActions>
  ))}
</ItemGroup>

Usage

The first action of each side is its primary one: it sits at the outer edge, and a swipe past 60% of the row runs it on release, with no confirmation. So make a destructive primary action undoable: delete, then offer Undo in a toast, as in the example.

The row follows the finger. A quick flick opens or closes it however short, by the direction it was moving at release; a slow release opens it past half the actions' width. A flick alone never runs an action: that takes the distance. The row springs to rest from where it was let go, and a new swipe can catch it on the way.

A vertical swipe that starts on a row scrolls the page. A row that is a link follows it on a tap, never on a swipe. One row is open at a time, and an open row closes on a tap outside it, on a tap on the row, when the page scrolls and on Escape. leading and trailing follow the reading direction. With reduced motion the row still follows the finger, so a swipe shows what it is about to do, and on release it jumps to rest instead of springing.

Use instead when

  • When it cannot be undone, open a ConfirmDialog from onClick instead.
  • Use Item with actions instead when the buttons should always show, and Menu with contextTrigger for actions on something that is not a list row.

Props

Type: SwipeActionsProps.

actionsLabel
Type: string
Default: "Actions for {label}"
The accessible name of the row's actions button; {label} is the row's label. Translate it with the rest of your interface.
children (required)
Type: ReactNode
The row itself, usually an Item. It may be a link (<Item href>): a tap follows it and a swipe does not.
className
Type: string
Layout only: margins (mt-2). Appearance classes warn in development; give an action a tone.
label (required)
Type: string
Names the row for its actions button: "Braised short ribs" gives "Actions for Braised short ribs". It is always required.
leading
Type: readonly SwipeAction[]
The actions a swipe toward the end reveals at the start edge, the first one outermost. A full swipe runs the first.
trailing
Type: readonly SwipeAction[]
The actions a swipe toward the start reveals at the end edge, the first one outermost. A full swipe runs the first.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • actions: Not a prop. Ouro UI names the side: trailing={[{ label: "Delete", onClick: remove }]}.
  • left: Not a prop. Ouro UI uses leading, which follows the reading direction: leading={[{ label: "Pin", onClick: pin }]}.
  • leftActions: Not a prop. Ouro UI uses leading: leading={[{ label: "Pin", onClick: pin }]}.
  • onDelete: Not a prop. Deleting is an action: trailing={[{ label: "Delete", tone: "destructive", onClick: remove }]}.
  • right: Not a prop. Ouro UI uses trailing, which follows the reading direction: trailing={[{ label: "Delete", onClick: remove }]}.
  • rightActions: Not a prop. Ouro UI uses trailing: trailing={[{ label: "Delete", onClick: remove }]}.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

SwipeAction

One action of a SwipeActions row: a button beside the row, and an item in the row's actions menu.

interface SwipeAction {
  /** The button's text: a short verb, such as "Delete" or "Pin". */
  label: string;
  /** Runs when the action is chosen: by a tap on its button, from the actions menu, or by a full swipe when it is the first action of its side. The row then closes. */
  onClick: () => void;
  /**
   * The button's color, a theme token name: `destructive` for an action that deletes.
   * @default "secondary"
   */
  tone?: ToneName | (string & { 'danger is "destructive"; accent is "primary"; neutral is "secondary"': never }) | undefined;

  /** Not a field. Ouro UI uses `onClick`: `{ label: "Delete", onClick: remove }`. */
  onPress?: { 'Use onClick: { label: "Delete", onClick: remove }': never };
  /** Not a field. Ouro UI uses `tone`: `{ label: "Delete", tone: "destructive", onClick: remove }`. */
  variant?: { 'Use tone: { label: "Delete", tone: "destructive", onClick: remove }': never };
}