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.
"use client"
import * as React from "react"
import { Archive, Mail, MailOpen, Trash2 } from "lucide-react"
import {
SwipeActions,
SwipeActionsItem,
} from "mischief-ui/swipe-actions"
type Message = {
id: string
from: string
subject: string
preview: string
time: string
unread: boolean
}
const inbox: Message[] = [
{
id: "lease",
from: "Marta Alves",
subject: "Lease renewal for flat 3B",
preview: "The new terms start in March. I attached the signed copy.",
time: "9:41",
unread: true,
},
{
id: "dentist",
from: "Riverside Dental",
subject: "Your appointment on Thursday",
preview: "Reply C to confirm or call us to move it.",
time: "8:15",
unread: true,
},
{
id: "photos",
from: "Tomás Reyes",
subject: "Photos from Sunday",
preview: "Forty-two of them. The one with the dog is the best.",
time: "Yesterday",
unread: false,
},
{
id: "library",
from: "City Library",
subject: "Two books due next week",
preview: "You can renew them online up to three times.",
time: "Mon",
unread: false,
},
]
export function SwipeActionsDemo() {
const [messages, setMessages] = React.useState(inbox)
const [last, setLast] = React.useState<{
verb: string
message: Message
index: number
} | null>(null)
const remove = (message: Message, verb: string) => {
const index = messages.findIndex((item) => item.id === message.id)
setLast({ verb, message, index })
setMessages(messages.filter((item) => item.id !== message.id))
}
const toggleRead = (message: Message) =>
setMessages((current) =>
current.map((item) =>
item.id === message.id ? { ...item, unread: !item.unread } : item
)
)
return (
<div className="w-full max-w-[26rem]">
<SwipeActions label="Inbox">
{messages.map((message) => (
<SwipeActionsItem
key={message.id}
label={message.subject}
leading={[
{
label: message.unread ? "Read" : "Unread",
icon: message.unread ? <MailOpen /> : <Mail />,
tone: "primary",
onSelect: () => toggleRead(message),
},
]}
trailing={[
{
label: "Delete",
icon: <Trash2 />,
tone: "destructive",
onSelect: () => remove(message, "Deleted"),
},
{
label: "Archive",
icon: <Archive />,
onSelect: () => remove(message, "Archived"),
},
]}
>
<div className="grid gap-0.5 px-4 py-3">
<div className="flex items-baseline gap-2">
<span
aria-hidden="true"
className={
message.unread
? "bg-primary size-2 shrink-0 self-center rounded-full"
: "size-2 shrink-0"
}
/>
<span
className={
message.unread
? "min-w-0 flex-1 truncate text-sm font-semibold"
: "min-w-0 flex-1 truncate text-sm font-medium"
}
>
{message.from}
{message.unread ? (
<span className="sr-only">, unread</span>
) : null}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{message.time}
</span>
</div>
<p className="truncate pl-4 text-sm">{message.subject}</p>
<p className="text-muted-foreground truncate pl-4 text-xs">
{message.preview}
</p>
</div>
</SwipeActionsItem>
))}
</SwipeActions>
{messages.length === 0 ? (
<p className="text-muted-foreground mt-3 text-sm">Inbox is empty.</p>
) : null}
<div
className="mt-3 flex min-h-8 items-center justify-between gap-3 text-sm"
role="status"
>
{last ? (
<>
<span className="text-muted-foreground truncate">
{last.verb} “{last.message.subject}”
</span>
<button
type="button"
className="text-primary focus-visible:ring-ring shrink-0 rounded-sm font-medium underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none"
onClick={() => {
setMessages((current) => {
const next = [...current]
next.splice(last.index, 0, last.message)
return next
})
setLast(null)
}}
>
Undo
</button>
</>
) : (
<span className="text-muted-foreground">
Swipe a message, or use its More actions button.
</span>
)}
</div>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/swipe-actionsimport { SwipeActions } from "mischief-ui/swipe-actions"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"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?: SwipeActionToneUsage
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.