# Notification Bell Dropdown

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

Canonical: https://gsapvault.com/ui-elements/notification-inbox-popover
Live demo: https://gsapvault.com/demos/notification-inbox-popover/index.html

| Property | Value |
|----------|-------|
| Type | ui-element |
| Tier | paid |
| Price | £5 |
| Difficulty | intermediate |
| Plugins | Flip |
| Techniques | flip-layout, odometer-counter, stagger, tabs, keyboard-navigation, focus-management |
| Uses Lenis | No |

## Lighthouse, as measured

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

| Category | Score |
|----------|-------|
| Accessibility | 100 |
| Best practices | 100 |

No performance score is published: it depends on the deployment (server compression, caching, CDN, connection and device) rather than on the code, so measure it where it will live.

## Overview

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.

## Features

- 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

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

## Vibe-Code Ready Setup

This UI element includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

### 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+.

## Documentation

How this UI element works and how it goes into a page. The reference you use once you own the files (worked examples, events, the programmatic API, the class list) ships with the download.

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

## What You Get

- `index.html`: working demo page
- `assets/script.js`: commented, readable source
- `assets/style.css`: effect styles
- `README.md`: full documentation with examples and framework integration notes
- `START-HERE-AI.md`: product-specific copy-paste prompt for AI-assisted setup
- `LICENSE.txt`: standard license terms
- Lifetime updates: re-download anytime from your library

## Get the Code

This is a premium UI element. The standard license costs £5 one-time and covers unlimited personal and commercial projects with no attribution required for our code. Bundled third-party assets retain their own licences and attribution requirements. The only restrictions: no redistribution of the code itself and no competing effect libraries.

- [Buy Notification Bell Dropdown](https://gsapvault.com/ui-elements/notification-inbox-popover)
- [The Vault (£99 one-time, best value): every collection in the Vault library, plus future items added to those collections](https://gsapvault.com/effects)

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
