Notification pills
A bell that keeps your unread notifications as glassy pills: they spring out of it, swipe away and fold back in.
notification-pills/notification-pills.tsxNotifications
Press to mark as read, or Delete to dismiss.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A handful of unread notifications kept behind a bell, in an app header or a corner of a dashboard.
- Short, glanceable items: a title and one line each, where reading one is the same as clearing it.
- Touch and pointer alike: a press marks a pill read, a swipe to the right dismisses it.
- A product with a soft, tactile look, where the arrival of a notification may be felt (the bell swings, the badge jumps).
When not to use
- A long history to scroll, search or filter. Use an inbox or an activity page; the stack holds up to
maxpills and drops the oldest. - A notification that needs an action of its own (approve, reply, undo). Use a toast with its button, or a card in the inbox.
- Urgent or blocking news, such as a failed payment or lost connection. Use a banner or an alert that stays in view.
- Feedback on what the reader just did (saved, copied). Use a toast near the action, not the bell.
- Dense or long text: each pill shows one line and cuts the rest.
You can add this component with the shadcn CLI or by hand:
Into components/notification-pills of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/notification-pills.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { NotificationPills } from './notification-pills';import type { Notice } from './notices';const inbox: Notice[] = [ { id: 'invoice', kind: 'reminder', title: 'Invoice due', text: 'Tomorrow at 5 PM' }, { id: 'deploy', kind: 'system', title: 'Deploy finished', text: 'The preview is ready' },];export function Inbox() { // Your notifications, four at most, and no demo. return <NotificationPills notifications={inbox} max={4} autoplay={false} />;}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A notification tray drawn as soft, glassy pills stacked above a bell. Open it and the pills fly out of the bell one after another, small and blurred at first, full size on a spring; swipe one to the right, or press it, and it leaves while the others close the gap. New notifications drop in at the top as the bell swings and its badge jumps.
- Each pill starts inside the bell, shrunk around the middle of its round end where its icon is, faint and a little blurred. It grows to its place on a spring, the nearest first, 55 ms apart. Closing plays it the other way, with a small lift before the dive.
- The springs are real springs: the motion of a damped spring is sampled into a CSS linear() easing and played with the Web Animations API. Browsers without linear() get a cubic-bezier that overshoots a little.
- When a pill leaves, a copy takes its place over the list and flies off to the right at the swipe’s speed, while the pills that stay move to their new places with FLIP. A read pill turns green and shows a check for 600 ms before leaving. The reader’s confirmation plays a soft ascending chime; “Mark all as read” plays it once for the batch.
- A swipe follows the pointer. The pills take touch-action: pan-y, so sideways moves dismiss and up-and-down still scrolls the page. Past a third of the pill’s width, or flung fast, it goes; otherwise it springs back.
- The glass is drawn with gradients, a rim of light and soft shadows, without backdrop-filter. Only transform, opacity and a little blur move, only while something happens, and nothing runs at rest.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. At night each pill is translucent dark glass over a near-black stage, so the bell’s glow shows through, with a faint rim and deeper shadows; the badge stays red, its rim cut from the stage.
| Prop | Type | Default | Description |
|---|---|---|---|
notifications | Notice[] | NOTICES | The list at first, newest first. A notice has an id, a kind (comment, reminder, system or mention, which sets its icon and colour), a title, one line of text and, if you like, how many minutes ago it arrived. |
incoming | Notice[] | INCOMING | What comes in, in turn: from the demo or from the “Send a notification” button. |
max | number | 5 | The most pills the list holds; past it, the oldest leaves. The stage is as tall as that many pills. |
label | string | 'Notifications' | The list’s heading, and the bell’s name. |
defaultOpen | boolean | true | Whether the list starts open; false starts with the bell alone and turns the demo off. |
defaultRead | string[] | — | The ids of pills drawn read, green with their tick, and held there. Turns the demo off. |
onRead | (notice: Notice) => void | — | Called when the reader marks a notification as read by pressing its pill. |
onDismiss | (notice: Notice) => void | — | Called when the reader dismisses a notification, by a swipe or Delete. |
onReadAll | (notices: Notice[]) => void | — | Called with every notification left when the reader presses “Mark all as read”. |
onOpenChange | (open: boolean) => void | — | Called when the reader opens (true) or closes (false) the list. The demo’s own changes are not reported. |
autoplay | boolean | true | Plays on its own while on screen and untouched. |
sound | boolean | true | A short success chime on a reader’s confirmation, once for “Mark all as read”. Set false to silence it. The demo and dismissals are silent; unsupported audio never prevents confirmation. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root, a stage of its own. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent,} from 'react';import { BellIcon, CheckIcon, KindIcon, PlusIcon } from './icons';import { TICK, bump, dropIn, flip, fold, leave, measure, reducedMotion,/* Notification pills (notification-pills.tsx): a stage with the bell at the bottom and the list of pills stacked above it. Sizes are in em: the stage sets the font size, smaller in a narrow container (a container query), and everything follows. The glass is drawn with gradients, a rim of light and soft shadows. Colours are the tokens below, each light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). By day the glass is white; at night it is a dark, translucent pane (white at a low alpha over the stage) with a faint rim. --pill, --gap, --disc and --bell are sizes, and --max comes from the component, so no colour takes those names. */.root { /* The stage, top to bottom, lit from below. */ --surface: #dcdcdf; --surface-low: #d2d2d5; --stage-glow: rgb(255 255 255 / 0.32); /* A pill's glass, top to bottom, its rim (the hairline round it) and the shade inside its bottom edge. */ --surface-2: #fdfdfd; --glass-mid: #f4f4f5; --glass-low: #ededef; --line: rgb(255 255 255 / 0.9); --rim-shade: rgb(0 0 0 / 0.025); /* A pill's shadows, near to far, and the deeper one of a lifted pill. *//* * The notifications' motion, on transform, opacity and a touch of blur, with the Web Animations * API. Every animation ends and leaves nothing behind, so nothing runs at rest. * * - `spring`: the response of a damped spring, sampled into a CSS linear() easing. * - `flip`: the pills that change place play from where they were to where they are (FLIP). * - `rise` and `fold`: the pills fly out of the bell to their places, and back into it. * - `dropIn`: a new pill drops into its place at the top. * - `leave`: a copy of a pill flies off to the right, over the list, while the list closes the gap. * - `ring` and `bump`: the bell swings, the badge jumps. */export const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;/** Screen pixels to an element's own: 1, unless it sits in a scaled container. */export const scaleOf = (element: HTMLElement) => element.getBoundingClientRect().width / element.offsetWidth || 1;const springs = new Map<number, string>();/** * A damped spring from 0 to 1, as an easing. `damping` is between 0 and 1: 0.6 overshoots by * about 9%, 0.8 by 1.5%. The spring settles (to 1%) at the end of the animation, whatever its * duration. Browsers without linear() get a cubic-bezier that overshoots a little.'use client';import { useRef, type PointerEvent } from 'react';/* * Swipe to dismiss. A pill follows the pointer to the right (to the left it resists); let go far * enough or fast enough and it is dismissed, otherwise it springs back (a CSS transition). Only * sideways moves are taken: the pills have `touch-action: pan-y`, so a phone still scrolls the * page from them. The pill element carries the notification's id in `data-id`. *//** How far a press travels, in px, before it turns into a swipe (or a scroll). */const SLOP = 6;/** A swipe dismisses past this share of the pill's width... */const DISTANCE = 0.32;/** ...or when let go this fast, in px per ms. */const SPEED = 0.45;type Swipe = { pointerId: number; startX: number; startY: number; /** How far the pill has moved, in its own px. */ dx: number;/* * What a notification holds, and the samples the demo starts with and delivers. Each kind has its * icon (icons.tsx) and its pastel colour (the CSS, by `data-kind`). */export type Kind = 'comment' | 'reminder' | 'system' | 'mention';export type Notice = { id: string; kind: Kind; title: string; /** One line under the title. */ text: string; /** How long ago it arrived, in minutes; just now when left out. */ minutes?: number;};/** The time on a pill ("now", "4m", "2h") and the way a screen reader says it. */export function ago(minutes = 0) { if (minutes < 1) return { short: 'now', long: 'just now' }; if (minutes < 60) { return { short: `${minutes}m`, long: `${minutes} minute${minutes === 1 ? '' : 's'} ago` }; } const hours = Math.round(minutes / 60);import type { JSX, SVGProps } from 'react';import type { Kind } from './notices';/* * The bell and the icons of the four kinds of notification, drawn on a 24 × 24 grid with 1.75px * strokes in `currentColor`, so they take the colour of the text around them. */type IconProps = SVGProps<SVGSVGElement>;function Icon({ children, ...props }: IconProps) { return ( <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props} >'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;/* A soft ascending confirmation chime, made only after the reader confirms a notification. No file, autoplay audio or permanent audio thread: suspend at rest and close on unmount. */export function successSound() { let context: AudioContext | null = null; let rest = 0; let disposed = false; return { play() { if (disposed || typeof AudioContext === 'undefined') return; try { context ??= new AudioContext(); const audio = context; if (audio.state === 'suspended') void audio.resume().catch(() => {}); window.clearTimeout(rest); const start = audio.currentTime + 0.005; [660, 880].forEach((frequency, index) => { const at = start + index * 0.11; const tone = audio.createOscillator(); const gain = audio.createGain(); tone.type = 'sine'; tone.frequency.setValueAtTime(frequency, at); gain.gain.setValueAtTime(0, at);- The bell is a button with aria-expanded, and its name carries the count (“Notifications, 4 unread”). Escape closes the list and puts focus back on the bell.
- The list is a list of buttons. Enter or Space marks one as read, Delete or Backspace dismisses it, and the arrow keys, Home and End move between them. When a pill leaves, focus moves to its neighbour.
- New notifications, dismissals and “Mark all as read” are announced politely. The demo’s own changes are not, and it stops as soon as the reader points, types or focuses inside it.
- Under reduced motion nothing flies and the demo never plays. A read pill still shows a static green confirmation briefly before disappearing. Confirmation sounds are triggered by the reader and can be disabled with sound={false}.