# Notification List Swipe — React Notification Component

> Notification rows you can swipe: right to archive, left to delete. The action underneath grows with the drag, and a flick or a long pull sends the row flying before the list closes up.

- Category: [Notifications](https://pixelpapa.com/components/notifications.md)
- Tags: notifications, swipe, drag, list, mobile
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/notifications/notification-list-swipe

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/notification-list-swipe.json"
```

The file is written to `components/pixelpapa/notification-list-swipe.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Archive, Trash2 } from "lucide-react";
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from "motion/react";
import { useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";

type SwipeRowProps = {
  children: ReactNode;
  onArchive: () => void;
  onDelete: () => void;
  /** Drag distance in px that triggers the action. */
  threshold?: number;
  /** Rest slightly open to hint at the gesture, until the pointer arrives. */
  peek?: boolean;
  label: string;
  className?: string;
};

export function SwipeRow({ children, onArchive, onDelete, threshold = 110, peek = false, label, className }: SwipeRowProps) {
  const x = useMotionValue(peek ? -84 : 0);
  const [gone, setGone] = useState<"archive" | "delete" | null>(null);
  // Each action fades and scales in with the pull, and "arms" once past the threshold.
  const archiveOpacity = useTransform(x, [0, 60], [0, 1]);
  const deleteOpacity = useTransform(x, [-60, 0], [1, 0]);
  const archiveScale = useTransform(x, [20, threshold, threshold + 1], [0.6, 1, 1.25], { clamp: true });
  const deleteScale = useTransform(x, [-threshold - 1, -threshold, -20], [1.25, 1, 0.6], { clamp: true });

  useEffect(() => {
    if (!gone) return;
    const a = animate(x, gone === "archive" ? 520 : -520, { duration: 0.22, ease: "easeIn", onComplete: gone === "archive" ? onArchive : onDelete });
    return () => a.stop();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [gone]);

  const settle = () => !gone && animate(x, 0, { type: "spring", stiffness: 500, damping: 36 });

  function onKeyDown(e: KeyboardEvent<HTMLLIElement>) {
    if (e.key === "Delete" || e.key === "Backspace") setGone("delete");
    else if (e.key.toLowerCase() === "e") setGone("archive");
  }

  return (
    <motion.li
      layout
      tabIndex={0}
      aria-label={`${label}. Press E to archive, Delete to remove.`}
      onKeyDown={onKeyDown}
      onPointerEnter={() => peek && settle()}
      exit={{ height: 0, opacity: 0, transition: { duration: 0.2 } }}
      className={cn("relative overflow-hidden outline-offset-[-2px]", className)}
    >
      <motion.span aria-hidden style={{ opacity: archiveOpacity }} className="absolute inset-0 flex items-center bg-emerald-500 pl-5 text-white">
        <motion.span style={{ scale: archiveScale }} className="flex flex-col items-center gap-1 text-[11px] font-medium">
          <Archive className="size-5" /> Archive
        </motion.span>
      </motion.span>
      <motion.span aria-hidden style={{ opacity: deleteOpacity }} className="absolute inset-0 flex items-center justify-end bg-destructive pr-5 text-white">
        <motion.span style={{ scale: deleteScale }} className="flex flex-col items-center gap-1 text-[11px] font-medium">
          <Trash2 className="size-5" /> Delete
        </motion.span>
      </motion.span>
      <motion.div
        drag={gone ? false : "x"}
        dragDirectionLock
        dragConstraints={{ left: 0, right: 0 }}
        dragElastic={0.8}
        style={{ x }}
        onDragEnd={(_, info) => {
          const far = Math.abs(info.offset.x) > threshold;
          const flick = Math.abs(info.velocity.x) > 600 && Math.abs(info.offset.x) > 40;
          if (far || flick) setGone(info.offset.x > 0 ? "archive" : "delete");
        }}
        className="relative cursor-grab touch-pan-y bg-card active:cursor-grabbing"
      >
        {children}
      </motion.div>
    </motion.li>
  );
}

const seed = [
  { id: "1", initials: "MT", tone: "bg-chart-2/30", title: "Mei mentioned you", body: "“Can you check the annual toggle copy?”", time: "4m" },
  { id: "2", initials: "TL", tone: "bg-chart-4/30", title: "Invoice 2291 needs approval", body: "Harbour Coffee · $1,240.00", time: "38m" },
  { id: "3", initials: "IO", tone: "bg-chart-3/30", title: "Deploy succeeded", body: "atlas-web · v2.41.0 is live", time: "2h" },
  { id: "4", initials: "RB", tone: "bg-chart-5/30", title: "Rafael shared a board", body: "“November planning” · 14 cards", time: "1d" },
];

export default function NotificationListSwipeDemo() {
  const [items, setItems] = useState(seed);
  const [log, setLog] = useState("Swipe a row, or focus it and press E or Delete.");

  function remove(id: string, verb: string) {
    const item = items.find((i) => i.id === id);
    setItems((list) => list.filter((i) => i.id !== id));
    if (item) setLog(`${verb} “${item.title}”.`);
  }

  return (
    <div className="w-full max-w-sm">
      <div className="overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
        <div className="flex items-center justify-between border-b border-border px-5 py-3.5">
          <p className="font-semibold tracking-tight text-card-foreground">
            Notifications <span className="ml-1 text-sm font-normal text-muted-foreground tabular-nums">{items.length}</span>
          </p>
          <button onClick={() => setItems(seed)} className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline">
            Reset
          </button>
        </div>
        <ul className="min-h-[72px] divide-y divide-border">
          <AnimatePresence initial={false}>
            {items.map((n, i) => (
              <SwipeRow key={n.id} label={n.title} peek={i === 1 && items.length === seed.length} onArchive={() => remove(n.id, "Archived")} onDelete={() => remove(n.id, "Deleted")}>
                <div className="flex items-center gap-3 px-5 py-3.5 select-none">
                  <span className={cn("flex size-10 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-foreground", n.tone)}>{n.initials}</span>
                  <span className="min-w-0 flex-1">
                    <span className="block truncate text-sm font-medium text-card-foreground">{n.title}</span>
                    <span className="block truncate text-xs text-muted-foreground">{n.body}</span>
                  </span>
                  <span className="text-xs text-muted-foreground tabular-nums">{n.time}</span>
                </div>
              </SwipeRow>
            ))}
          </AnimatePresence>
          {!items.length && <li className="flex h-[72px] items-center justify-center text-sm text-muted-foreground">Nothing left. Enjoy the quiet.</li>}
        </ul>
      </div>
      <p aria-live="polite" className="mt-3 text-center text-xs text-muted-foreground">
        {log}
      </p>
    </div>
  );
}
```

## AI prompt

```text
Add the "Notification List Swipe" component from PixelPapa UI to this project.

What it is: Notification rows you can swipe: right to archive, left to delete. The action underneath grows with the drag, and a flick or a long pull sends the row flying before the list closes up.
Category: Notifications. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/notification-list-swipe.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/notification-list-swipe.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/notification-list-swipe" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More notifications

- [Alert Inline Variants](https://pixelpapa.com/components/notifications/alert-inline-variants.md): Inline alerts in info, success, warning and error tones with an optional action; dismissing one blurs it out and the stack closes the gap on a spring.
- [Notification Dynamic Island](https://pixelpapa.com/components/notifications/notification-dynamic-island.md) (Pro): A pill that springs between sizes to hold live activities (an incoming call, a timer, now playing) with content that blurs across each morph.
- [Notification Inbox Popover](https://pixelpapa.com/components/notifications/notification-inbox-popover.md) (Pro): A bell popover with Unread and All tabs: marking a row read drains its dot and folds it out of the unread list while the badge count rolls down.
- [Toast Stack Sonner Like](https://pixelpapa.com/components/notifications/toast-stack-sonner-like.md) (Pro): A hand-built toaster: toasts pile into a tight stack, fan out into a readable list on hover, pause their timers while you look, and promise toasts morph from loading to done.
- [Toast Undo Progress](https://pixelpapa.com/components/notifications/toast-undo-progress.md): An undo toast with a draining countdown bar that pauses on hover; undo springs the removed row back into the list, and letting it run out commits the action.
