Skip to content
WANCRAFT

G.120Data & Feedback

Notification Bell

A bell with a rolling unread badge, a wobble on new arrivals and a popover panel with read states.

NewReactMotionTailwindintermediate

Overview

Notification Bell pairs a bell button with an unread badge whose digits roll up or down as the count changes, capped at "99+" (or any max), with a dot variant for quieter interfaces. When the count goes up, never on mount, the bell rings with a short rotational wobble. The button opens a native popover panel, which gives top-layer stacking, light dismiss and Escape for free; the panel is a labelled dialog that receives focus when it opens and hands it back to the bell when it closes. It lists notifications with unread markers, marks one as read when it is activated, offers "Mark all as read" and shows an empty state. The button's accessible name always includes the unread count.

01Installation

Method 01 · Registry

CLI verified

Uses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/notification-bell.tsx and adds its dependencies.

npx shadcn@latest add https://wancraft.my/r/notification-bell.json

Method 02 · Copy and paste

  1. 1. Install the dependencies below.
  2. 2. Copy the source from the Code tab into components/ui/notification-bell.tsx.
  3. 3. Import it and adjust props.
npm install motion
Dependencies
motion
Peer
react ≥ 18, tailwindcss ≥ 4
Added
13 Oct 2026
Updated
—

02Usage

The default configuration. Change properties above to generate a tailored example in the Usage tab.

example.tsx
import { NotificationBell, type BellNotification } from "@/components/ui/notification-bell";
import { useState } from "react";

export function NotificationBellExample() {
  const [items, setItems] = useState<BellNotification[]>([
    { id: "1", title: "Aisyah commented on Homepage", body: "Love the new hero.", time: "2m" },
    { id: "2", title: "Deploy finished", time: "1h", read: true },
  ]);

  return (
    <NotificationBell
      notifications={items}
      onMarkRead={(id) => setItems((list) => list.map((n) => (n.id === id ? { ...n, read: true } : n)))}
      onMarkAllRead={() => setItems((list) => list.map((n) => ({ ...n, read: true })))}
    />
  );
}

03Props

PropTypeDefaultDescription
notificationsBellNotification[]—{ id, title, body?, time?, read? } — newest first.
onMarkRead(id: string) => void—Called when an unread notification is activated.
onMarkAllRead() => void—Called by the "Mark all as read" button.
onOpenChange(open: boolean) => void—Called when the panel opens or closes.
variant"count" | "dot""count"Number badge or a dot.
maxnumber99Badge cap; higher counts show as "{max}+".
ringbooleantrueWobble when the unread count increases (not on mount).
align"start" | "end""end"Panel edge aligned with the bell.
accentstring"#FF7448"Badge, unread markers and focus ring.
labelstring"Notifications"Panel heading and the base of the button name.
emptyMessagestring"You’re all caught up."Shown when there are no notifications.

04Features

  • 01Badge digits roll up or down with the count, capped at max+
  • 02Count or dot badge that springs in and out
  • 03Rotational wobble when the unread count increases, never on mount
  • 04Native popover panel: top layer, light dismiss and Escape, kept attached on scroll
  • 05Unread markers, mark one or all as read, empty state
motion · springmotion · tweeninput · clickinput · keyboardinput · focus

05Accessibility

  • The bell is a button named with its count ("Notifications, 3 unread") with aria-haspopup="dialog", aria-expanded and aria-controls.
  • The panel is role="dialog" labelled by its heading; opening moves focus to its first control, and closing (Escape, light dismiss or the bell) returns focus to the bell.
  • Each notification is a button; unread items carry an "Unread:" prefix for screen readers, and new arrivals are announced politely.
  • prefers-reduced-motion disables the wobble, the rolling digits and the panel spring.

06Limitations

  • Times are passed in as preformatted text; relative-time updates are left to the caller.