Swipe Actions
Swipe a row in a list to reveal actions on the left or right.
Drag with a mouse or a finger, or focus a row and press the arrow keys.
Installation
The package ships behaviour only, with no CSS to import and no runtime dependencies.
npm i @ncdai/react-swipe-actionsreact and motion are peer dependencies.
The styled component
Copy this into components/ui/swipe-actions.tsx and you get the demo above. It
wraps the three parts that need styling and passes the other two straight
through. Needs Tailwind and a cn helper.
import type { ComponentProps } from "react"
import * as SwipeActionsPrimitive from "@ncdai/react-swipe-actions"
import { cn } from "@/lib/utils"
type SwipeSide = SwipeActionsPrimitive.SwipeSide
type SwipeState = SwipeActionsPrimitive.SwipeState
const SwipeRoot = SwipeActionsPrimitive.SwipeRoot
const SwipeItem = SwipeActionsPrimitive.SwipeItem
const SwipeActions = SwipeActionsPrimitive.SwipeActions
function SwipeAction({
className,
...props
}: ComponentProps<typeof SwipeActionsPrimitive.SwipeAction>) {
return (
<SwipeActionsPrimitive.SwipeAction
className={cn(
"flex min-w-20 cursor-pointer flex-col items-center justify-center gap-1.5 px-4",
"text-xs leading-tight whitespace-nowrap select-none",
"bg-secondary text-secondary-foreground",
"transition-[filter] hover:brightness-110 active:brightness-90",
"focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-current",
"[-webkit-tap-highlight-color:transparent] [&_svg]:size-5",
className
)}
{...props}
/>
)
}
function SwipeContent({
className,
...props
}: ComponentProps<typeof SwipeActionsPrimitive.SwipeContent>) {
return (
<SwipeActionsPrimitive.SwipeContent
className={cn("bg-background data-dragging:cursor-grabbing", className)}
{...props}
/>
)
}
export { SwipeAction, SwipeActions, SwipeContent, SwipeItem, SwipeRoot }
export type { SwipeSide, SwipeState }
Usage
The demo above, in full.
"use client"
import { useState } from "react"
import { ArchiveIcon, FlagIcon, Trash2Icon } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import {
SwipeAction,
SwipeActions,
SwipeContent,
SwipeItem,
SwipeRoot,
} from "@/components/ui/swipe-actions"
export function SwipeActionsDemo() {
const [mails, setMails] = useState(INITIAL_MAILS)
const shouldReduceMotion = useReducedMotion()
const removeMail = (id: string) =>
setMails((prev) => prev.filter((mail) => mail.id !== id))
const toggleFlag = (id: string) =>
setMails((prev) =>
prev.map((mail) =>
mail.id === id ? { ...mail, flagged: !mail.flagged } : mail
)
)
return (
<div className="not-prose relative overflow-clip rounded-md">
<div className="pointer-events-none absolute inset-0 z-1 rounded-md inset-ring-1 inset-ring-foreground/10" />
<SwipeRoot render={<ul role="list" />}>
<AnimatePresence initial={false}>
{mails.map((mail) => (
<SwipeItem
key={mail.id}
render={
<motion.li
layout={!shouldReduceMotion}
exit={
shouldReduceMotion
? { opacity: 0 }
: { height: 0, opacity: 0 }
}
transition={
shouldReduceMotion
? { duration: 0.1 }
: { duration: 0.22, ease: [0.32, 0.72, 0, 1] }
}
/>
}
>
<SwipeActions side="left">
<SwipeAction
className="bg-info text-white"
onClick={() => removeMail(mail.id)}
>
<ArchiveIcon />
Archive
</SwipeAction>
</SwipeActions>
<SwipeActions side="right">
<SwipeAction
className="bg-success text-white"
onClick={() => toggleFlag(mail.id)}
>
<FlagIcon />
{mail.flagged ? "Unflag" : "Flag"}
</SwipeAction>
<SwipeAction
className="bg-destructive text-white"
onClick={() => removeMail(mail.id)}
>
<Trash2Icon />
Delete
</SwipeAction>
</SwipeActions>
<SwipeContent>
<button
type="button"
className="flex w-full flex-col items-start gap-1 px-4 pt-3 pb-4 text-left"
>
<span className="flex items-center gap-1.5 font-semibold">
{mail.sender}
{mail.flagged && (
<FlagIcon className="size-3.5 text-success" />
)}
</span>
<span className="text-sm">{mail.subject}</span>
<span className="w-full truncate text-xs text-muted-foreground">
{mail.preview}
</span>
</button>
</SwipeContent>
</SwipeItem>
))}
</AnimatePresence>
</SwipeRoot>
{mails.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
No messages left.
</p>
)}
</div>
)
}
type Mail = {
id: string
sender: string
subject: string
preview: string
flagged: boolean
}
const INITIAL_MAILS: Mail[] = [
{
id: "1",
sender: "shadcn",
subject: "Namespaced registries",
preview: "components.json can point at any registry URL now, take a look.",
flagged: false,
},
{
id: "2",
sender: "Evil Rabbit",
subject: "Spacing scale review",
preview: "Dropped the 6px step so every surface lands on the 4px grid.",
flagged: false,
},
{
id: "3",
sender: "Shu",
subject: "SWR cache inspector",
preview: "Devtools panel is in, the mutation timeline still needs work.",
flagged: true,
},
]
API reference
| Part | Renders | Role |
|---|---|---|
SwipeRoot | div | Holds the list. Optional |
SwipeItem | div | One row. Owns the gesture |
SwipeActions | div | An edge strip |
SwipeAction | button | One action |
SwipeContent | motion.div | The draggable layer |
SwipeActions, SwipeAction and SwipeContent must live inside a SwipeItem.
Every part forwards ref, passes unknown props through and carries a
data-slot. Every part except SwipeContent also takes a render prop, so a
row can be an li, a motion.li, or anything else.
Style against the data attributes below rather than tracking state yourself.
SwipeRoot
Makes rows close one another, so only one stays open at a time. It renders
nothing else, so a lone SwipeItem works without it.
SwipeItem
One row, and the element the gesture is bound to.
| Prop | Type | Default | Description |
|---|---|---|---|
threshold | number | 0.5 | Fraction of the strip the drag must cross to snap open |
velocityFactor | number | 0.2 | Seconds of release velocity added to the position, so a flick opens without a full drag |
disabled | boolean | false | Turns the gesture off |
closeOnScroll | boolean | false | Close the row on any scroll on the page |
onOpenChange | (state: SwipeState) => void | Called with closed, left or right |
| Attribute | Value |
|---|---|
data-state | closed, left, right |
data-disabled | Present when disabled |
data-dragging | Present while dragging |
SwipeActions
An edge strip. Its measured width is how far the row opens, so an action with no width never appears.
| Prop | Type | Description |
|---|---|---|
side | "left" | "right" | Which edge the strip sits on. Required |
| Attribute | Value |
|---|---|
data-side | left, right |
SwipeAction
One action inside a strip. Renders a button with type="button".
| Prop | Type | Default | Description |
|---|---|---|---|
closeOnClick | boolean | true | Close the row once the click handler has run |
SwipeContent
The layer that moves under the finger. Give it an opaque background: it is what
hides the strips while the row is closed. Takes motion.div props, so it has no
render prop.
| Attribute | Value |
|---|---|
data-dragging | Present while dragging |
Accessibility
- Closed strips are
inert, so their buttons stay out of the tab order, the accessibility tree and hit testing. - With focus inside a row,
ArrowLeftandArrowRightmove it one step in that direction andEscapecloses it. - Under
prefers-reduced-motionthe row snaps into place instead of animating.