Lunia DS

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.

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-actions

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
labelstring-Accessible name of the row, for example the message subject. Required.
leadingSwipeAction[][]Actions under the left edge, shown by swiping right. The first one runs on a full swipe.
trailingSwipeAction[][]Actions under the right edge, shown by swiping left, listed from the edge inward. The first one runs on a full swipe.
enterbooleanfalseUnfold the row on mount, for a row that comes back after an undo.
dividerbooleantrueShow a 1px line under the row.
disabledbooleanfalseTurns off swiping and the action buttons.
EventDescription
actionEach 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".
SlotDescription
defaultRow content, for example sender, subject and preview.
metaShort 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
SA1hover or focus within (fine pointer)row facebackground-colorbg-primary → bg-primary_hoverduration-sm (200)ease-outSwaps at once
SA2hover or focus within (fine pointer)metaopacity1 → 0duration-sm (200)ease-outHides at once
SA3hover or focus within (fine pointer)hover action buttonsopacity + transform translateX0, 4px → 1, 0duration-sm (200)ease-outShows at once
SA4hover a hover action buttonhover action buttonbackground-color, color, box-shadow ringnone → bg-primary, 1px border ring, text-primary (destructive: text-error-primary)duration-xs (160)ease-outSwaps at once
SA5drag or two-finger trackpad swiperow facetransform translateX0 → pointer offset (rubber band past 94% of the row; 18% pull, max 22px, on a side with no actions)0ease-linearMoves at once
SA6drag or two-finger trackpad swipeaction cellstransform translateX (each cell takes an equal share of the uncovered space)off the row → share of the reveal0ease-linearMoves at once
SA7drag or two-finger trackpad swipecell faceopacity + transform scale (starts after 18px of cell, full at 62% of a 76px cell)0, 0.72 → 1, 10ease-linearShows at once
SA8drag past 62% of the row (Armed)first action celltransform translateX + face position (takes the whole space, face rides the row edge)its share → whole revealduration-xs (160)ease-outMoves at once
SA9drag back 24px under the arm pointfirst action cellreverse of SA8whole reveal → its shareduration-xs (160)ease-outMoves at once
SA10release past half the rail or flick (Open)row face + cellstransform translateX (spring, small overshoot)release point → rail width (76px x actions)duration-lg (320)ease-throwMoves at once
SA11release short, tap the row, tap outside, Escape, or another row opens (close)row face + cellstransform translateX (spring, no overshoot)current → 0duration-xl (380)ease-settleMoves at once
SA12release while Armed, or tap a cell (commit)row facetransform translateX (slides off the row)current → row width + 2pxduration-3xl (460)ease-settleMoves at once
SA13commit of an action that does not remove the rowrow facetransform translateX (back to rest, as SA11)off the row → 0duration-xl (380)ease-settledelay 140msMoves at once
SA14commit of a removing action (Removed)rootgrid-template-rows1fr → 0frduration-xl (380)ease-outResizes at once
SA15commit of a removing action (Removed)rootopacity1 → 0duration-md (260)ease-outHides at once
SA16removing action fails, or mount with enterrootgrid-template-rows + opacity0fr, 0 → 1fr, 1duration-xl (380)ease-outResizes at once
SA17removing action fails (after unfold)row facetransform translateX (back to rest, as SA11)off the row → 0duration-xl (380)ease-settledelay 360msMoves at once
SA18action fails (Failed)row contenttransform translateX shake0 → -5px, 5px, -3px, 0duration-xl (380)ease-exitdelay 420msStays still
SA19action fails (Failed)meta -> "Failed"opacity + filter blurmeta 1, blur 0 → "Failed" 1, meta 0 with blur-swapduration-lg (320)ease-outSwaps at once
SA202600ms after Failed"Failed" -> metareverse of SA19"Failed" → metaduration-lg (320)ease-outSwaps 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.