caliper uidocs

Notification bell

Free

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

Installs components/ui/notification-bell.tsxDepends on lucide-reactMotion none in component source
pnpm dlx shadcn@latest add https://caliperui.site/r/notification-bell.json
Halden

When 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 #

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

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

PropTypeDefaultDescription
count *number—Unread notifications.
maxnumber9Above this the badge reads "max+".
mutedbooleanfalseNotifications are paused: the bell is crossed out and the badge hidden.
labelstring"Notifications"

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.