Actions and inputs
Swipe actions
A list row that slides sideways to show its actions under the edge, runs the first action on a long swipe, and folds away when that action removes the row.
Live preview
Loading preview
An inbox card with four message rows (sender, subject, one-line preview, time). Trailing actions: Archive (primary, removes the row) and Delete (destructive, removes the row); leading action: Mark as read or Mark as unread. A removed row comes back after 2 seconds with enter, so the demo never runs out of rows. Replay closes all rows and scripts one swipe: open the trailing actions on row 2, then close.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add swipe-actionsUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { type SwipeActionItem, SwipeActions, type SwipeActionsHandle } from './swipe-actions';
// Own line icons on a 20px grid; the component sets the stroke width for each size.
const PATHS = {
archive: 'M3 4.5h14v3H3zM4.5 7.5v7.5a1.5 1.5 0 0 0 1.5 1.5h8a1.5 1.5 0 0 0 1.5-1.5V7.5M8 11h4',
trash:
'M3.5 5.5h13M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5M5 5.5l.7 10a1.5 1.5 0 0 0 1.5 1.4h5.6a1.5 1.5 0 0 0 1.5-1.4l.7-10M8.5 9v4.5M11.5 9v4.5',
read: 'M3 5.5A1.5 1.5 0 0 1 4.5 4h11A1.5 1.5 0 0 1 17 5.5v9a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5zM3.5 5l6.5 5 6.5-5',
unread: 'M3 8.5 10 4l7 4.5v6a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5zM3.5 9l6.5 4 6.5-4',
};
function Icon({ name }: { name: keyof typeof PATHS }) {
return (
<svg aria-hidden="true" viewBox="0 0 20 20" fill="none">
<path
d={PATHS[name]}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
interface Message {
id: string;
key: number;
sender: string;
subject: string;
time: string;
unread: boolean;
enter: boolean;
}
const MESSAGES: Message[] = [
{ id: 'm1', sender: 'Maya Chen', subject: 'Invoice for March is ready', time: '9:41' },
{ id: 'm2', sender: 'Leo Park', subject: 'Design review moved to Friday', time: '9:12' },
{ id: 'm3', sender: 'Billing team', subject: 'Your plan renews on May 1', time: 'Tue' },
{ id: 'm4', sender: 'Ana Ruiz', subject: 'Notes from the team call', time: 'Mon' },
].map((m) => ({ ...m, key: 0, unread: false, enter: false }));
// How long the pretend server takes, how long a folded row stays, and when it comes back (ms).
const WORK = 600;
const ANNOUNCE = 800;
const COME_BACK = 2000;
export default function SwipeActionsDemo({ replay = 0 }: { replay?: number }) {
const [messages, setMessages] = React.useState(MESSAGES);
const rows = React.useRef(new Map<string, SwipeActionsHandle>());
const timers = React.useRef<number[]>([]);
const later = React.useCallback((ms: number, run: () => void) => {
timers.current.push(window.setTimeout(run, ms));
}, []);
const remove = (message: Message) =>
new Promise<void>((resolve) => {
later(WORK, () => {
resolve();
// The folded row stays a moment, so its live region can announce the result.
later(ANNOUNCE, () => {
let at = 0;
setMessages((list) => {
at = list.findIndex((m) => m.id === message.id);
return list.filter((m) => m.id !== message.id);
});
// Bring it back, so the demo never runs out of rows.
later(COME_BACK, () =>
setMessages((list) => {
const next = [...list];
next.splice(Math.min(at, next.length), 0, { ...message, key: message.key + 1, enter: true });
return next;
}),
);
});
});
});
const toggleRead = (message: Message) =>
setMessages((list) => list.map((m) => (m.id === message.id ? { ...m, unread: !m.unread } : m)));
const actionsFor = (message: Message) => ({
leading: [
{
id: 'read',
label: message.unread ? 'Mark read' : 'Mark unread',
icon: <Icon name={message.unread ? 'read' : 'unread'} />,
onAction: () => toggleRead(message),
},
] satisfies SwipeActionItem[],
trailing: [
{
id: 'archive',
label: 'Archive',
tone: 'primary',
removes: true,
icon: <Icon name="archive" />,
onAction: () => remove(message),
},
{
id: 'delete',
label: 'Delete',
tone: 'destructive',
removes: true,
icon: <Icon name="trash" />,
onAction: () => remove(message),
},
] satisfies SwipeActionItem[],
});
React.useEffect(() => {
if (replay === 0) return;
for (const timer of timers.current) window.clearTimeout(timer);
timers.current = [];
setMessages(MESSAGES);
for (const row of rows.current.values()) row.close();
later(500, () => rows.current.get('m2')?.open('trailing'));
later(1900, () => rows.current.get('m2')?.close());
}, [replay, later]);
React.useEffect(
() => () => {
for (const timer of timers.current) window.clearTimeout(timer);
},
[],
);
return (
<div className="w-xxs max-w-full overflow-hidden rounded-xl border border-secondary bg-primary shadow-xs">
<div className="flex items-center justify-between border-b border-secondary px-4 py-3">
<h3 className="text-sm font-semibold text-primary">Inbox</h3>
<span className="text-xs text-quaternary">
{messages.length} {messages.length === 1 ? 'message' : 'messages'}
</span>
</div>
{messages.map((message) => {
const { leading, trailing } = actionsFor(message);
return (
<SwipeActions
key={`${message.id}-${message.key}`}
ref={(handle) => {
if (handle) rows.current.set(message.id, handle);
else rows.current.delete(message.id);
}}
data-testid={`row-${message.id}`}
label={`${message.sender}: ${message.subject}`}
leading={leading}
trailing={trailing}
meta={message.time}
enter={message.enter}
>
<div className="flex min-w-0 items-center gap-1.5">
{message.unread && (
<span
aria-label="Unread"
role="img"
className="size-2 shrink-0 rounded-full bg-brand-solid"
/>
)}
<p className="truncate text-sm font-semibold text-primary">{message.sender}</p>
</div>
<p className="mt-0.5 truncate text-sm text-tertiary">{message.subject}</p>
</SwipeActions>
);
})}
</div>
);
}
API
Parts: root (height fold), row (group), leading action cells, trailing action cells, cell face (icon + label), row face (content + meta), meta (time or "Failed"), hover action buttons, divider, status (live region, screen reader only).
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | - | Accessible name of the row, for example the message subject. Required. |
| leading | SwipeAction[] | [] | Actions under the left edge, shown by swiping right. The first one runs on a full swipe. |
| trailing | SwipeAction[] | [] | Actions under the right edge, shown by swiping left, listed from the edge inward. The first one runs on a full swipe. |
| enter | boolean | false | Unfold the row on mount, for a row that comes back after an undo. |
| divider | boolean | true | Show a 1px line under the row. |
| disabled | boolean | false | Turns off swiping and the action buttons. |
| Event | Description |
|---|---|
| action | Each SwipeAction has its own onAction handler ({ id, label, icon, tone?: "neutral" | "primary" | "destructive", removes?: boolean, onAction }). Return a promise; if it rejects, the row comes back and shows "Failed". |
| Slot | Description |
|---|---|
| default | Row content, for example sender, subject and preview. |
| meta | Short text at the end of the row, for example the time. With a mouse, it gives way to the action buttons on hover. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| SA1 | hover or focus within (fine pointer) | row face | background-color | bg-primary → bg-primary_hover | duration-sm (200)ease-out | Swaps at once |
| SA2 | hover or focus within (fine pointer) | meta | opacity | 1 → 0 | duration-sm (200)ease-out | Hides at once |
| SA3 | hover or focus within (fine pointer) | hover action buttons | opacity + transform translateX | 0, 4px → 1, 0 | duration-sm (200)ease-out | Shows at once |
| SA4 | hover a hover action button | hover action button | background-color, color, box-shadow ring | none → bg-primary, 1px border ring, text-primary (destructive: text-error-primary) | duration-xs (160)ease-out | Swaps at once |
| SA5 | drag or two-finger trackpad swipe | row face | transform translateX | 0 → pointer offset (rubber band past 94% of the row; 18% pull, max 22px, on a side with no actions) | 0ease-linear | Moves at once |
| SA6 | drag or two-finger trackpad swipe | action cells | transform translateX (each cell takes an equal share of the uncovered space) | off the row → share of the reveal | 0ease-linear | Moves at once |
| SA7 | drag or two-finger trackpad swipe | cell face | opacity + transform scale (starts after 18px of cell, full at 62% of a 76px cell) | 0, 0.72 → 1, 1 | 0ease-linear | Shows at once |
| SA8 | drag past 62% of the row (Armed) | first action cell | transform translateX + face position (takes the whole space, face rides the row edge) | its share → whole reveal | duration-xs (160)ease-out | Moves at once |
| SA9 | drag back 24px under the arm point | first action cell | reverse of SA8 | whole reveal → its share | duration-xs (160)ease-out | Moves at once |
| SA10 | release past half the rail or flick (Open) | row face + cells | transform translateX (spring, small overshoot) | release point → rail width (76px x actions) | duration-lg (320)ease-throw | Moves at once |
| SA11 | release short, tap the row, tap outside, Escape, or another row opens (close) | row face + cells | transform translateX (spring, no overshoot) | current → 0 | duration-xl (380)ease-settle | Moves at once |
| SA12 | release while Armed, or tap a cell (commit) | row face | transform translateX (slides off the row) | current → row width + 2px | duration-3xl (460)ease-settle | Moves at once |
| SA13 | commit of an action that does not remove the row | row face | transform translateX (back to rest, as SA11) | off the row → 0 | duration-xl (380)ease-settledelay 140ms | Moves at once |
| SA14 | commit of a removing action (Removed) | root | grid-template-rows | 1fr → 0fr | duration-xl (380)ease-out | Resizes at once |
| SA15 | commit of a removing action (Removed) | root | opacity | 1 → 0 | duration-md (260)ease-out | Hides at once |
| SA16 | removing action fails, or mount with enter | root | grid-template-rows + opacity | 0fr, 0 → 1fr, 1 | duration-xl (380)ease-out | Resizes at once |
| SA17 | removing action fails (after unfold) | row face | transform translateX (back to rest, as SA11) | off the row → 0 | duration-xl (380)ease-settledelay 360ms | Moves at once |
| SA18 | action fails (Failed) | row content | transform translateX shake | 0 → -5px, 5px, -3px, 0 | duration-xl (380)ease-exitdelay 420ms | Stays still |
| SA19 | action fails (Failed) | meta -> "Failed" | opacity + filter blur | meta 1, blur 0 → "Failed" 1, meta 0 with blur-swap | duration-lg (320)ease-out | Swaps at once |
| SA20 | 2600ms after Failed | "Failed" -> meta | reverse of SA19 | "Failed" → meta | duration-lg (320)ease-out | Swaps at once |
Accessibility
- The row is a group named by label; swipe cells are aria-hidden and out of the tab order, because every action is also an icon button.
- Tab reaches the hover action buttons (each has aria-label and title); focus within the row shows them the same way hover does.
- Escape closes an open row.
- A polite live region (role status) reads the result, for example "Invoice for March: Archive done" or "Invoice for March: Archive failed. Try again."
- Hover action buttons are 28px, above the 24px minimum target; swipe cells are 76px wide and full row height.
- touch-action pan-y on the row, so vertical scrolling still works; a mostly vertical drag is treated as a scroll.
- A short vibration (6ms) on phones when the full swipe arms, where the browser allows it.
- Reduced motion: the row and cells jump to their end positions, folds and fades happen at once, and there is no shake.