Notification bell
FreeThe bell that opens notifications: the unread count sits on it and rolls to its new value, the bell swings once when the count rises, and its name always says how many are unread.
pnpm dlx shadcn@latest add https://caliperui.site/r/notification-bell.jsonWhen to use #
Use it for
- The notifications entry in an app header.
- Anywhere a count of unread items opens a list.
Skip it when
- Counts that change every second; show a live indicator instead.
Decisions #
Why it is built this way, so you know what to keep when you change your copy.
Behaviour
- Swing once when the count rises, never when it falls
- A new arrival is worth a glance; reading notifications is not. One swing, then stillness, so the header never keeps moving.
- The name carries the count
- The visible badge is hidden from assistive technology and the button is named “Notifications, 3 unread”, so the number is never read twice or missed.
Styling
- Ink badge, not red
- Unread is not an error. The badge uses the text colour with a ring of page colour so it reads on any header.
Usage
- Use it as the trigger
- It takes a ref and spreads props, so it can be the child of a popover or menu trigger directly.
Installation #
pnpm dlx shadcn@latest add https://caliperui.site/r/notification-bell.json
Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage #
This is the code behind the preview above.
import { useState } from "react" import { Button } from "@/components/ui/button" import { NotificationBell } from "@/components/ui/notification-bell" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" const incoming = ["Marta Silva replied to “Bank feed stopped syncing”", "Invoice INV-2041 was paid", "Tomás assigned you “VAT return period”", "Your export is ready to download"]; export function NotificationBellDemo() { const [items, setItems] = useState([{ id: 1, text: "Priya mentioned you in “Q3 close”", read: false }, { id: 2, text: "Deploy to production finished", read: false }]); const [paused, setPaused] = useState(false); const unread = items.filter((item) => !item.read).length; return ( <div className="flex w-full max-w-md flex-wrap items-center justify-between gap-3 rounded-[12px] px-3 py-2 shadow-[inset_0_0_0_1px_var(--border)]"> <span className="text-sm font-medium">Halden</span> <div className="flex items-center gap-2"> <Button size="sm" variant="ghost" onClick={() => setItems((list) => [{ id: list.length + 1, text: incoming[list.length % incoming.length]!, read: false }, ...list])}>Simulate one</Button> <Popover> <PopoverTrigger asChild> <NotificationBell count={unread} muted={paused} /> </PopoverTrigger> <PopoverContent align="end" className="w-80 p-2"> <div className="flex items-center justify-between gap-2 px-2 py-1.5"> <span className="text-sm font-semibold">Notifications</span> <span className="flex gap-1"> <Button size="sm" variant="ghost" onClick={() => setPaused((value) => !value)}>{paused ? "Resume" : "Pause"}</Button> <Button size="sm" variant="ghost" disabled={!unread} onClick={() => setItems((list) => list.map((item) => ({ ...item, read: true })))}>Mark all read</Button> </span> </div> <ul className="grid"> {items.slice(0, 6).map((item) => ( <li key={item.id}> <button type="button" className="flex w-full items-start gap-2 rounded-[10px] px-2 py-2 text-left text-[13px] hover:bg-muted" onClick={() => setItems((list) => list.map((other) => (other.id === item.id ? { ...other, read: true } : other)))}> <span aria-hidden="true" className={`mt-1.5 size-1.5 shrink-0 rounded-full ${item.read ? "bg-transparent" : "bg-foreground"}`} /> <span className={item.read ? "text-muted-foreground" : undefined}>{item.text}{item.read ? "" : <span className="sr-only"> (unread)</span>}</span> </button> </li> ))} </ul> </PopoverContent> </Popover> </div> </div> ); }
API reference #
NotificationBell
component
Also accepts the native props of its root element and a ref.
Accessibility #
It is built on native elements and keeps aria-label and aria-hidden in step with state.
No custom key handling; Tab and the native activation keys do the work.
Motion #
It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.