ncdai/react-primitives

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 motion

react 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

PartRendersRole
SwipeRootdivHolds the list. Optional
SwipeItemdivOne row. Owns the gesture
SwipeActionsdivAn edge strip
SwipeActionbuttonOne action
SwipeActionContentspanThe visible slice of an action
SwipeContentmotion.divThe 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.

PropTypeDefaultDescription
thresholdnumber0.5Fraction of the strip the drag must cross to snap open
velocityFactornumber0.2Seconds of release velocity added to the position, so a flick opens without a full drag
disabledbooleanfalseTurns the gesture off
closeOnScrollbooleanfalseClose the row on any scroll on the page
onOpenChange(state: SwipeState) => voidCalled with closed, left or right
fullSwipeThresholdnumber0.5Fraction of the row width the drag must cross to arm a fullSwipe strip
AttributeValue
data-stateclosed, left, right
data-disabledPresent when disabled
data-draggingPresent while dragging

SwipeActions

An edge strip. Its measured width is how far the row opens, so an action with no width never appears.

PropTypeDefaultDescription
side"left" | "right"Which edge the strip sits on. Required
fullSwipebooleanfalseLet a drag across the row run the outermost action
AttributeValue
data-sideleft, right
data-armedPresent 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.

PropTypeDefaultDescription
closeOnClickbooleantrueClose the row once the click handler has run
AttributeValue
data-armedPresent 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.

AttributeValue
data-draggingPresent 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, ArrowLeft and ArrowRight move it one step in that direction and Escape closes it.
  • Under prefers-reduced-motion the row snaps into place instead of animating.

On this page