ui-051

Notification Bell Dropdown

£5

A notification bell dropdown: an unread badge opens an inbox with All and Unread tabs, mark as read, dismiss and a rolling badge count.

Flip intermediate
6 more details
flip-layoutodometer-counterstaggertabskeyboard-navigationfocus-management
Notification Bell Dropdown - UI component preview

This element reads better at your own screen size than in the frame below:

About this component

A notification inbox for a product header: a bell button with an unread count badge opens a 380px popover anchored under it, with All and Unread tabs, a Mark all as read action and a scrolling list of notifications. The panel lands first and its rows stagger in a frame later. Marking a row read fades its dot and, on the Unread tab, Flip-collapses the row while the others close the gap; dismissing slides it out the same way; a new notification Flip-inserts at the top. The badge rolls digit by digit like an odometer whenever the count changes. On a phone the popover becomes a full-width sheet from the top. One HTML file, one stylesheet, one script, vanilla JavaScript with GSAP and Flip, no framework, in light and dark themes on one data-variant attribute.

What's included

8 items
  • Bell trigger with aria-expanded and aria-controls; focus moves into the panel on open and back to the bell on Escape
  • All and Unread tabs on the tablist pattern, with a measured sliding indicator and arrow, Home and End keys
  • Mark as read, mark as unread and dismiss per row, plus Mark all as read, each reflowing the list with Flip
  • Odometer badge: every digit is a reel that rolls to its new value, including the carry from 9 to 10
  • push() inserts a new notification at the top, rolls the badge and announces it in a polite live region
  • Empty state built from stroke icons with the line "You're all caught up"
  • Full-width sheet from the top on phones, modal there with a focus trap and scroll lock
  • Two themes, light and dark, on one set of custom properties

Perfect for

3 use cases
  • Notification centre in the header of a web app or admin dashboard
  • Activity inbox for mentions, assignments, billing and security alerts
  • In-product announcements and release notes feed

How it works

3 sections

Opening and closing

The panel is shown with the hidden attribute and fades and settles in from its top-right corner, and stays focusable throughout so focus moves into it on the same frame. The visible rows stagger in 60ms later. Closing reverses it and sets hidden again in onComplete, so no invisible layer is left over the page. A press outside closes it without moving focus; Escape closes it and returns focus to the bell.

The Flip reflow

Every change to the list (switching tabs, marking a row read on the Unread tab, dismissing, a new arrival) records the rows and the scrolling body with Flip.getState, applies the change, then runs Flip.from with the rows positioned absolutely. Leaving rows fade or slide out where they were while the rest move up to close the gap, and the body tweens its height so the panel shrinks with them. Rows are never scaled, so their text never squashes.

The odometer badge

Each digit of the count is a column holding a reel of 0 to 9 twice over. A change rolls each reel to the new digit, and when a digit wraps (9 to 0 on the way up) it rolls on to the second copy and snaps back on complete, so the count always rolls in the direction it changed. Columns are added or removed from the left as the number of digits changes, and the count caps at 99+.

Plugins Flip
Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, 15 September 2026. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Notification Bell Dropdown opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects.

£5 Standard license, unlimited projects

Browse UI elements

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for every colour, radius and shadow

  • assets/script.js: readable, commented source with an onReady guard

  • Layered open. The panel lands first (opacity, 6px of travel and a 0.98 scale from its top-right corner), then its rows stagger in 60ms later. It is shown and hidden with the hidden attribute, so a closed inbox never leaves an invisible layer over the page.

  • Flip reflow on every list change. Switching tabs, marking a row read on the Unread tab, dismissing and a new arrival all record the rows with Flip.getState, apply the change and animate from the old layout. Leaving rows fade or slide out where they were while the rest close the gap; the list's height tweens with them.

  • Odometer badge. Each digit is a reel that rolls to its new value, and a carry (9 to 10) keeps rolling the same way. Caps at 99+, hides at zero.

  • Complete states. Read, unread (toggle back per row), dismissed, Mark all as read (disabled at zero), a new notification pushed in, and an empty state built from stroke icons with "You're all caught up".

  • Phone sheet. At 560px and below the popover becomes a full-width sheet from the top, modal there: scrim, focus trap and scroll lock.

  • ARIA. aria-expanded / aria-controls on the trigger, role="dialog" on the panel, the tablist / tab / tabpanel pattern, a list of rows with real buttons, and a polite live region.

Quick Start

1. Add to your <head>:

Code snippet omitted: it ships with the download.

2. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

3. Copy the component markup from index.html into your page. It is everything inside <div class="inbox" data-inbox>, plus the icon <symbol>s it uses from the sprite at the top of <body>.

The script finds every [data-inbox] on the page and reads only data- hooks inside it:

Hook On
data-inbox The root. Positioned; the panel anchors to it
data-inbox-trigger The bell button
data-inbox-badge The badge (holds .inbox__badge-digits, filled by the script)
data-inbox-panel The popover; starts hidden
data-inbox-tab="all" / "unread" The two tabs
data-inbox-indicator The sliding underline
data-inbox-body The scrolling tab panel
data-inbox-list The <ul> of rows
data-inbox-row Each <li>; add is-unread for unread
data-inbox-read / data-inbox-dismiss The two row actions
data-inbox-state Visually hidden "Unread." prefix, kept in sync
data-inbox-mark-all Mark all as read
data-inbox-unread-count The count on the Unread tab (optional)
data-inbox-empty The empty state (optional)
data-inbox-scrim The phone sheet's scrim (optional)
data-inbox-live A polite live region (optional)
data-inbox-keep-open Anywhere outside the component: presses and focus inside it do not close the popover

Render the relative times ("2 min ago") on your server or with your own formatter; the component leaves .inbox__time alone. New rows from push() read "Just now" unless you pass time.

API and events

Code snippet omitted: it ships with the download.

push() takes actor and object (rendered bold), action, time, and either initials or icon; pass unread: false to add a read row.

The toolbar strip, the Push and Reset buttons, the auto-open on load and the .stage wrapper in index.html are demo furniture, not part of the component; leave them behind.

Keyboard & Accessibility

Key What it does
Enter / Space on the bell Opens the inbox and moves focus to the selected tab
Escape Closes, focus returns to the bell
ArrowLeft / ArrowRight Moves between All and Unread and selects (roving tabindex)
Home / End First or last tab
Tab Tabs, then each row, then its Mark as read and Dismiss buttons
ArrowUp / ArrowDown on a row Previous or next row
Delete / Backspace on a row Dismisses it
  • Roles: the trigger has aria-haspopup="dialog", aria-expanded and aria-controls, and its label carries the count ("Notifications, 4 unread"); the badge itself is aria-hidden. The panel is role="dialog" labelled by its heading. Tabs are role="tablist" / role="tab" with aria-selected and aria-controls; the list body is the role="tabpanel", labelled by the selected tab. Rows are a role="list" of <li> with a main button (the notification) and two action buttons described by the row's copy.
  • Desktop is a non-modal popover. Focus moves in on open; a press outside, or tabbing out of it, closes it without taking focus back. The phone sheet is modal (aria-modal="true"): Tab cycles inside it, the page behind is scroll-locked on both <html> and <body>, and the scrim closes it.
  • When a row leaves (read on Unread, dismissed), focus moves to the next row, or the previous one, or the tab, so keyboard users are never dropped on <body>.
  • Unread rows carry a visually hidden "Unread." prefix; dismissals, Mark all and new arrivals are announced in the polite live region.
  • prefers-reduced-motion: every duration is multiplied by zero, so the same end states arrive instantly. Nothing is disabled. The preference is read on each action, so changing it while the page is open takes effect straight away.

Themes

Two themes, one design, on one attribute:

Code snippet omitted: it ships with the download.

In your own page the attribute can sit on any ancestor of the component. Nothing in script.js reads it; the demo's switcher and the ?variant=light URL parameter only set it.

Both theme blocks in assets/style.css define the same tokens:

  • Ground and surfaces: --ground, --ground-2, --raised, --hover, --panel-bg, --row-hover
  • Ink: --ink, --ink-2, --ink-3, --avatar-ink, --chip-ink
  • Lines and depth: --line, --line-strong, --panel-border, --shadow, --panel-shadow, --scrim
  • Accent: --accent (the unread dot, the badge, the tab underline and focus rings, nothing else) and --accent-ink (text on it)
  • Shape and size: --radius, --radius-sm, --radius-xs, --inbox-width, --inbox-list-height, --inbox-sheet-top

To match your brand, re-value --accent and --accent-ink (check 4.5:1 between them: white on this orange is only 3.3:1, which is why the badge text is ink), then the surfaces and ink. Every text colour is a solid hex so contrast stays measurable.

How It Works

Opening and closing. open() removes hidden, then tweens the panel from opacity: 0, y: -6, scale: 0.98 over 180ms and the visible rows with a 30ms stagger 60ms later. It animates opacity, never autoAlpha: visibility: hidden on the frame focus moves in would leave focus nowhere. close() tweens out over 140ms and sets hidden in onComplete. On the phone the same calls slide the sheet on yPercent from -100, never a CSS transform, which GSAP would read back as pixels.

The Flip reflow. One function, reflow(mutate, leave), handles every list change: it records the rows and the body with Flip.getState, runs mutate() (toggle hidden, insert a row), and calls Flip.from with the rows absolutely positioned for the tween. onLeave fades a row (or slides it 40px right for a dismiss), onEnter fades a new one down into place, and the body tweens its height so the panel shrinks or grows with the list. Rows are never scaled, which would squash their text. A dismissed row is removed from the DOM on complete and stops counting as unread at once. The CSS rule hiding rows is .inbox__row[hidden], deliberately not !important: Flip shows a leaving row with an inline display so it can animate it, and an !important rule would win.

The odometer. Each digit column is a reel of 0-9 twice over, clipped to one digit's height. A change tweens the reel's yPercent to the new digit; when a digit wraps on the way up (9 to 0) it rolls on to the second copy and snaps back to the first on complete, so the count always rolls in the direction it moved. Columns are added or removed on the left as the number of digits changes.

The sliding indicator sits at left: 0 and moves by x and width measured with offsetLeft / offsetWidth (not getBoundingClientRect, which a zoomed page multiplies). It re-measures on open, on resize and after document.fonts.ready.

Why the phone sheet comes from the top. A notification bell lives in a top bar. A sheet dropping from that edge keeps the panel attached to the control the finger just touched, puts the tabs and Mark all as read right beside it, and mirrors where phones show notifications. A bottom sheet would be easier to reach one-handed, but it separates the list from its trigger by the whole screen, and it sits under the browser's own bottom toolbar on iOS. If your trigger lives in a bottom tab bar, flip it: set top: auto; bottom: 0 and border-radius: 14px 14px 0 0 on .inbox__panel in the 560px media query and change the yPercent: -100 values in open() / close() to 100.

Customisation

  • Width: --inbox-width (380px). List height before it scrolls: --inbox-list-height (360px).
  • Phone sheet offset under a fixed header: --inbox-sheet-top (the demo sets 44px for its toolbar).
  • Where it becomes a sheet: the max-width: 560px media query in assets/style.css and phoneMQ at the top of assets/script.js; keep the two in step.
  • Anchor to the left edge instead of the right: left: 0; right: auto; transform-origin: top left on .inbox__panel.
  • Durations and easing are the gsap.to / gsap.fromTo / Flip.from calls in assets/script.js; each is multiplied by motion().
  • Wire it to your data by listening for inbox:change and inbox:select, and calling push() from your socket or polling loop.

Requirements

  • GSAP 3.12+ with Flip (both free on the CDN)
  • No build step, no framework
  • Without Flip the inbox still works; list changes apply without the reflow animation

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.

Your cart

Your cart is empty

The Vault £99

The Vault library, plus future additions to the library.