# Notification Popover

> Bell with unread badge, All/Unread tabs and a mark-all-read action.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/notification-popover
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/notification-popover.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/notification-popover-demo.tsx"
"use client"

import { NotificationPopover, type NotificationItem } from "@/components/notification-popover"

const items: NotificationItem[] = [
  { id: "1", title: "Grace commented on Roadmap", body: "“Can we move the launch to the 14th?”", time: "2m ago", unread: true },
  { id: "2", title: "Deploy succeeded", body: "atelier-ui · production is live.", time: "1h ago", unread: true },
  { id: "3", title: "New teammate joined", body: "Linus accepted your invite.", time: "Yesterday" },
  { id: "4", title: "Invoice paid", body: "Your October invoice has been paid.", time: "Mon" },
]

export default function NotificationPopoverDemo() {
  return (
    <div className="flex min-h-96 w-full max-w-md items-start justify-end pt-4">
      <NotificationPopover items={items} />
    </div>
  )
}
```

## Source

```tsx title="components/notification-popover.tsx"
"use client"

import * as React from "react"
import { Bell, CheckCheck } from "lucide-react"

import { cn } from "@/lib/utils"

export type NotificationItem = {
  id: string
  title: string
  body: string
  time: string
  unread?: boolean
}

export interface NotificationPopoverProps extends Omit<React.ComponentProps<"div">, "children"> {
  items: NotificationItem[]
  align?: "start" | "end"
}

export function NotificationPopover({ items, align = "end", className, ...props }: NotificationPopoverProps) {
  const [list, setList] = React.useState(items)
  const [open, setOpen] = React.useState(false)
  const [tab, setTab] = React.useState<"all" | "unread">("all")
  const root = React.useRef<HTMLDivElement>(null)
  const button = React.useRef<HTMLButtonElement>(null)
  const id = React.useId()
  const unread = list.filter((item) => item.unread).length
  const visible = tab === "unread" ? list.filter((item) => item.unread) : list

  React.useEffect(() => {
    if (!open) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(false)
    }
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return
      setOpen(false)
      button.current?.focus()
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onKeyDown)
    }
  }, [open])

  return (
    <div ref={root} className={cn("relative inline-block", className)} {...props}>
      <button
        ref={button}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-controls={open ? id : undefined}
        aria-label={unread ? `Notifications, ${unread} unread` : "Notifications"}
        onClick={() => setOpen((value) => !value)}
        className="relative flex size-10 items-center justify-center rounded-full border bg-background transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none aria-expanded:bg-accent"
      >
        <Bell className="size-[18px]" />
        {unread > 0 && (
          <span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-white tabular-nums">
            {unread}
          </span>
        )}
      </button>
      {open && (
        <div
          id={id}
          role="dialog"
          aria-label="Notifications"
          className={cn(
            "absolute top-full z-20 mt-2 w-80 animate-in overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg duration-150 fade-in zoom-in-95",
            align === "end" ? "right-0 origin-top-right" : "left-0 origin-top-left"
          )}
        >
          <div className="flex items-center justify-between border-b px-4 py-3">
            <h2 className="text-sm font-semibold">Notifications</h2>
            <button
              type="button"
              disabled={unread === 0}
              onClick={() => setList(list.map((item) => ({ ...item, unread: false })))}
              className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground disabled:pointer-events-none disabled:opacity-40 focus-visible:underline focus-visible:outline-none"
            >
              <CheckCheck className="size-3.5" /> Mark all read
            </button>
          </div>
          <div role="tablist" aria-label="Filter" className="flex gap-1 border-b px-3 py-2">
            {(["all", "unread"] as const).map((value) => (
              <button
                key={value}
                type="button"
                role="tab"
                aria-selected={tab === value}
                onClick={() => setTab(value)}
                className="rounded-md px-2.5 py-1 text-xs font-medium capitalize text-muted-foreground transition-colors hover:text-foreground aria-selected:bg-accent aria-selected:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
              >
                {value}
              </button>
            ))}
          </div>
          <ul className="max-h-72 overflow-y-auto">
            {visible.length === 0 && <li className="px-4 py-8 text-center text-sm text-muted-foreground">You&apos;re all caught up.</li>}
            {visible.map((item) => (
              <li key={item.id} className="border-b last:border-b-0">
                <button
                  type="button"
                  onClick={() => setList(list.map((entry) => (entry.id === item.id ? { ...entry, unread: false } : entry)))}
                  className="flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
                >
                  <span aria-hidden className={cn("mt-1.5 size-2 shrink-0 rounded-full", item.unread ? "bg-primary" : "bg-transparent")} />
                  <span className="grid min-w-0 flex-1 gap-0.5">
                    <span className="text-sm font-medium">{item.title}</span>
                    <span className="truncate text-xs text-muted-foreground">{item.body}</span>
                    <span className="text-[11px] text-muted-foreground/70">{item.time}</span>
                  </span>
                </button>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  )
}
```
