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.
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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then 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.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/swipe-actionsIts 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.13import { 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
ConfirmDialogfromonClickinstead. - Use
Itemwithactionsinstead when the buttons should always show, andMenuwithcontextTriggerfor 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'slabel. 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 atone. 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 usesleading, which follows the reading direction:leading={[{ label: "Pin", onClick: pin }]}.leftActions: Not a prop. Ouro UI usesleading: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 usestrailing, which follows the reading direction:trailing={[{ label: "Delete", onClick: remove }]}.rightActions: Not a prop. Ouro UI usestrailing: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 };
}