Mischief

132 / Controls

Swipe Actions

List rows that swipe aside to reveal what can be done with them, the way a mail inbox does. Every action is also in a menu.

  • Marta Alves, unread9:41

    Lease renewal for flat 3B

    The new terms start in March. I attached the signed copy.

  • Riverside Dental, unread8:15

    Your appointment on Thursday

    Reply C to confirm or call us to move it.

  • Tomás ReyesYesterday

    Photos from Sunday

    Forty-two of them. The one with the dog is the best.

  • City LibraryMon

    Two books due next week

    You can renew them online up to three times.

Swipe a message, or use its More actions button.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/swipe-actions
import { SwipeActions } from "mischief-ui/swipe-actions"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/swipe-actions/swipe-actions.tsx
"use client" import * as React from "react"import { createPortal } from "react-dom"import { MoreHorizontal } from "lucide-react" import { cn } from "@/lib/utils" export type SwipeActionTone = "neutral" | "primary" | "destructive" export type SwipeAction = {  label: string  icon?: React.ReactNode  tone?: SwipeActionTone

Usage

export function Inbox({ messages }: { messages: Message[] }) {
  return (
    <SwipeActions label="Inbox">
      {messages.map((message) => (
        <SwipeActionsItem
          key={message.id}
          label={message.subject}
          leading={[
            { label: "Read", icon: <MailOpen />, tone: "primary", onSelect: () => markRead(message) },
          ]}
          trailing={[
            { label: "Delete", icon: <Trash2 />, tone: "destructive", onSelect: () => remove(message) },
            { label: "Archive", icon: <Archive />, onSelect: () => archive(message) },
          ]}
        >
          <MessageRow message={message} />
        </SwipeActionsItem>
      ))}
    </SwipeActions>
  )
}

How a swipe resolves

  • Swiping right reveals leading. Swiping left reveals trailing. A side with no actions barely moves.
  • Let go past half the actions' width, or flick, and the row stays open. Let go short of that and it springs closed.
  • Past the actions the row resists, so you can feel the end of them.
  • Keep going past about 60 percent of the row and the edge action, the first in its array, takes over the whole revealed area with a quick snap. Let go there and it runs. Phones that support it give a short vibration at that moment.
  • Only one row in a list is open at a time. Pressing anywhere else closes it, and the tap that closes a row never reaches the link under it.

Vertical movement is left to the page, so a list of rows still scrolls normally on a phone. fullSwipe={false} on a row turns the full swipe off where running the edge action by accident would cost too much, and the action then waits for a tap.

Without a swipe

A swipe cannot be found with a keyboard, and screen readers on phones take the gesture for themselves. So each row also has a More actions button that lists every action, leading first, in a menu. It is always in the tab order and appears when the row is hovered or the button has keyboard focus. The menu takes the arrow keys, Home, End, and Escape, and gives focus back to the button when it closes.

API

labelstringThe accessible name of the list.
classNamestringClasses for the list.
label (SwipeActionsItem)stringNames the row in its More actions button and menu.
leading (SwipeActionsItem)readonly SwipeAction[]Revealed by swiping right. The first sits at the edge and runs on a full swipe.
trailing (SwipeActionsItem)readonly SwipeAction[]Revealed by swiping left. The first sits at the edge and runs on a full swipe.
fullSwipe (SwipeActionsItem)booleanLets a long swipe run the edge action. Defaults to true.
moreLabel (SwipeActionsItem)stringThe menu button's name, placed before the row label. Defaults to More actions.
children (SwipeActionsItem)ReactNodeThe row's content. Links and buttons inside keep working on a tap.
tone (SwipeActionTone)"neutral" | "primary" | "destructive"Which theme fill an action uses.
...ulProps (SwipeActionsProps)HTMLAttributesNative list attributes.
...liProps (SwipeActionsItemProps)HTMLAttributesNative list item attributes.

SwipeAction

labelstringThe button text, and the menu item's name.
iconReactNodeDrawn above the label. Hidden from assistive technology.
tone"neutral" | "primary" | "destructive"The fill behind the action. Defaults to neutral.
onSelect() => voidRuns the action. The row closes first.

Accessibility

The list is a named ul and each row a li. Every action is reachable without a swipe through the row's More actions button, a named button with aria-haspopup that opens a menu of menuitems with arrow key, Home, End, and Escape handling and focus returned on close. The revealed swipe buttons are hidden from assistive technology and out of the tab order until their side is open, so a screen reader never meets buttons it cannot see. Escape closes an open row. With reduced motion the row follows the finger but does not rubber band, and it opens, closes, and commits without animating.