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-actions motionreact 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,
children,
...props
}: ComponentProps<typeof SwipeActionsPrimitive.SwipeAction>) {
return (
<SwipeActionsPrimitive.SwipeAction
className={cn(
"bg-secondary text-xs font-medium whitespace-nowrap text-secondary-foreground select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-foreground data-armed:brightness-90 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<SwipeActionsPrimitive.SwipeActionContent className="min-w-20 flex-col items-center justify-center gap-1.5 px-4">
{children}
</SwipeActionsPrimitive.SwipeActionContent>
</SwipeActionsPrimitive.SwipeAction>
)
}
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 overflow-clip rounded-xl border">
<SwipeRoot render={<ul role="list" className="divide-y" />}>
<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" fullSwipe>
<SwipeAction
className="rounded-xl bg-sky-500 text-white"
onClick={() => removeMail(mail.id)}
>
<ArchiveIcon />
Archive
</SwipeAction>
</SwipeActions>
<SwipeActions side="right" fullSwipe>
<SwipeAction
className="rounded-xl bg-green-500 text-white"
onClick={() => toggleFlag(mail.id)}
>
<FlagIcon />
{mail.flagged ? "Unflag" : "Flag"}
</SwipeAction>
<SwipeAction
className="rounded-xl bg-red-500 text-white"
onClick={() => removeMail(mail.id)}
>
<Trash2Icon />
Delete
</SwipeAction>
</SwipeActions>
<SwipeContent>
<button
type="button"
className="flex w-full flex-col items-start gap-1 rounded-xl p-4 pt-3 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-foreground"
>
<span className="flex items-center gap-1.5 font-semibold">
{mail.sender}
{mail.flagged && (
<FlagIcon className="size-3.5 text-green-500" />
)}
</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 |
SwipeActionContent | span | The visible slice of an action |
SwipeContent | motion.div | The draggable layer |
SwipeActions and SwipeContent must live inside a SwipeItem, SwipeAction
inside a SwipeActions, and SwipeActionContent inside a SwipeAction.
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 | |
fullSwipeThreshold | number | 0.5 | Fraction of the row width the drag must cross to arm a fullSwipe strip |
| 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 | Default | Description |
|---|---|---|---|
side | "left" | "right" | Which edge the strip sits on. Required | |
fullSwipe | boolean | false | Let a drag across the row run the outermost action |
| Attribute | Value |
|---|---|
data-side | left, right |
data-armed | Present while the full swipe is armed |
SwipeAction
One action inside a strip. Renders a button with type="button" that covers
the whole row, and shows only the slice its SwipeActionContent occupies. Paint
belongs here; layout belongs on the content.
| Prop | Type | Default | Description |
|---|---|---|---|
closeOnClick | boolean | true | Close the row once the click handler has run |
| Attribute | Value |
|---|---|
data-armed | Present while this action is armed |
A full swipe runs the outermost action of the strip, as it does on iOS. The
takeover is that action covering the others, which only the outermost one can
do. It dispatches a real click, so one onClick serves both the tap and the
gesture.
SwipeActionContent
The visible slice of an action. Its width is what decides how far the row opens, so give it one.
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.