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

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,
  ...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

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

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
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.

PropTypeDescription
side"left" | "right"Which edge the strip sits on. Required
AttributeValue
data-sideleft, right

SwipeAction

One action inside a strip. Renders a button with type="button".

PropTypeDefaultDescription
closeOnClickbooleantrueClose 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.

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