Banners
StableIn-page messages for things that happen on their own: a timer that finished, a reminder that came due.
FinishedBanner is the card a countdown shows at zero: it gives the clock time, or says how long ago it ended when the page noticed late (a sleeping laptop, a reload). DueBanner is the inverse “Due now” bar from Reminders, with Done, Snooze 10 min and Dismiss. Both live in the page; neither asks for system notifications.
Due now and finished
import { useRef, useState } from 'react'import { DueBanner } from '@rchursin/product/notes'import { FinishedBanner } from '@rchursin/product/timer'import { Button } from '@rchursin/product/ui'
// Sample times, fixed so the page renders the same everywhereconst nine = new Date(2026, 9, 21, 9, 0).getTime()const twoTwentyFive = new Date(2026, 9, 21, 14, 25).getTime()
/** * Two in-page banners. “Due now” is the inverse bar from Reminders; “finished” is the card from the * timers. live={false} here so nothing is announced when the page loads; in an app, leave it on. */export default function BannersOverview() { const [due, setDue] = useState([{ id: 'r1', text: 'Rerun the scan after merging skills', at: nine, repeat: null, done: false }]) const [finished, setFinished] = useState(true) const again = useRef<HTMLButtonElement>(null) const clear = () => { setDue([]) window.requestAnimationFrame(() => again.current?.focus())Usage
import { FinishedBanner } from '@rchursin/product/timer'import { DueBanner } from '@rchursin/product/notes'<FinishedBanner title="25-minute focus finished" finishedAt={s.finishedAt} lateMs={s.noticedLateMs} actions={<Button size="sm" onClick={restart}>Restart</Button>}/><DueBanner due={reminders.dueNow} onDone={reminders.toggle} onSnooze={reminders.snooze} onDismiss={reminders.dismiss}/>Examples
A real finish
Start the 10-second countdown: the banner appears at zero and is read out once. If the page wasn’t open at zero (a closed tab, a sleeping laptop), it says how long ago.
import { useRef } from 'react'import { Button } from '@rchursin/product/ui'import { FinishedBanner, TimeDisplay, useCountdown } from '@rchursin/product/timer'
/** * The real thing: start a 10-second countdown and the banner appears at zero, read out once as * an alert. The run is saved in this browser: if the page wasn't open at zero (a closed tab, a * sleeping laptop), it says how long ago it ended instead of pretending it just did. * Restart and Dismiss hand focus back to the clock's button. */export default function BannersCountdown() { const cd = useCountdown({ storageKey: 'library:banner-countdown', initialMs: 10_000, milestones: [] }) const startRef = useRef<HTMLButtonElement>(null) const running = cd.status === 'running' const refocus = () => window.requestAnimationFrame(() => startRef.current?.focus())
return ( <div className="flex w-full max-w-md flex-col items-center gap-4"> <TimeDisplay size="md" mode="countdown" read={cd.read} live={running} state={cd.status} label="10-second countdown" /> <Button ref={startRef} icon={running ? 'pause' : 'play'} variant={running ? 'secondary' : 'primary'} onClick={cd.toggle} className="min-w-32">On time and noticed late
On time it gives the clock time; noticed late, how long ago it really ended.
import { useEffect, useState } from 'react'import { FinishedBanner } from '@rchursin/product/timer'
const MINUTE = 60_000const twoTwentyFive = new Date(2026, 9, 21, 14, 25).getTime() // sample time
/** * On time it gives the clock time. Noticed late (more than 3 seconds after the end), it says how * long ago, keeps that “ago” current on screen, and gives screen readers a fixed sentence once. * The late one is measured against your clock, so it is created after the page loads. */export default function BannersLate() { const [lateEnd, setLateEnd] = useState<number | null>(null) useEffect(() => setLateEnd(Date.now() - 3 * MINUTE), [])
return ( <div className="flex w-full max-w-md flex-col gap-3"> <FinishedBanner live={false} title="5-minute countdown finished" finishedAt={twoTwentyFive} lateMs={0} /> {lateEnd === null ? ( <div aria-hidden="true" className="h-14 rounded-xl ring-1 ring-line ring-dashed ring-inset" /> ) : ( <FinishedBanner live={false} title="25-minute countdown finished" finishedAt={lateEnd} lateMs={3 * MINUTE} /> )} </div> )}A queue of due reminders
The first one shows and the rest are counted. Done, Snooze and Dismiss act on the one shown.
import { useRef, useState } from 'react'import { DueBanner } from '@rchursin/product/notes'import { Button } from '@rchursin/product/ui'
const at = (h: number, m: number) => new Date(2026, 9, 21, h, m).getTime() // sample times
const sample = [ { id: 'r1', text: 'Rerun the scan after merging skills', at: at(9, 0), repeat: null, done: false }, { id: 'r2', text: 'Check the cost report', at: at(9, 15), repeat: 'Every Monday', done: false }, { id: 'r3', text: 'Reply to the docs review', at: at(9, 30), repeat: null, done: false },]
/** * Full width along the top of an app. It shows the first due reminder and counts the rest; Done, * Snooze 10 min and Dismiss act on the one shown, and the next one takes its place. */export default function BannersDue() { const [due, setDue] = useState(sample) const again = useRef<HTMLButtonElement>(null) const drop = (id: string) => {API
FinishedBanner props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | 'Timer finished' | What finished: “25-minute focus finished”. |
finishedAtrequired | number | – | When it ended, in epoch ms. Shown as a clock time. |
lateMsrequired | number | – | How long after the end the page noticed. Over 3 seconds it reads “Finished 3 min ago · 2:25 PM” instead of “Finished at 2:25 PM”. |
actions | ReactNode | – | Buttons on the right, such as Restart and Dismiss. |
live | boolean | true | role="alert" on the words, read once when the banner appears. false for still examples. |
className | string | – | Classes on the card. |
DueBanner props
| Prop | Type | Default | Description |
|---|---|---|---|
duerequired | Reminder[] | – | Reminders due now ({ id, text, at, repeat, done }). The first is shown and the rest counted; empty shows nothing. |
onDonerequired | (id: string) => void | – | The Done button. |
onSnoozerequired | (id: string, until: Date) => void | – | Snooze 10 min: until is ten minutes from the press. |
onDismissrequired | (id: string) => void | – | The × button, named “Dismiss” plus the reminder’s text. |
compact | boolean | false | Rounded with tighter padding, for cards and widgets. Without it, a full-width bar. |
className | string | – | Classes on the bar. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Tab | Reaches the banner’s buttons in page order. A banner appearing never moves focus. |
| EnterSpace | Presses the focused button. When that removes the banner, move focus on yourself, as the examples do. |
- FinishedBanner’s words are a role="alert", read once when it appears (live={false} turns that off). The buttons sit outside the alert, so they aren’t read with it.
- Noticed late, screen readers get a fixed sentence worked out when it appears (“3 minutes ago, at 2:25 PM”), while the “ago” on screen keeps updating every 15 seconds, hidden from them, so the alert is never repeated.
- DueBanner’s words sit in a role="status" region that stays on the page even when nothing is due, so a reminder coming due is read politely. Its buttons sit outside that region.
- When Done, Snooze or Dismiss clears the last due reminder, the bar goes away and focus moves to the first visible control after it, so it never drops to the top of the page.
- The Due bar is inverse, and its quiet buttons and their focus rings take the bar’s colours, so contrast holds in both themes.
- Both mark themselves with an icon and words (alarm, bell), never a colour.
Guidelines
Do
- Say what happened and when: “Finished at 2:25 PM”.
- Offer the next step as a button: Restart, Done.
- Tell the truth when the page noticed late.
Don’t
- Don’t stack several banners; DueBanner shows one and counts the rest.
- Don’t use a banner for the result of the person’s own action; that is a toast.
- Don’t move focus to a banner when it appears.
In use
Related
- RemindersReminders grouped from Overdue to Done, checked off, snoozed or deleted with Undo, plus a compact widget for a dashboard.
- CountdownA countdown with presets or a typed length, +1 min while it runs, a closing square and an honest finished banner.
- ToastShort past-tense results with at most one action, like “Run archived · Undo”. Read out politely; they wait while hovered or focused.
- Screen-reader announcementsuseAnnounce makes screen readers say a result like “Moved to Done”, politely or, for urgent things, at once. Nothing shows on screen.
- TimerThe Focus screen: a focus session, a stopwatch and a countdown one switch apart, with the time log beside them.
Source
The component’s own files, exactly as they run on this site.
/** * Small timer pieces shared by the stopwatch, countdown, focus session, widget and specimens. */import { useRef, type KeyboardEvent, type ReactNode } from "react"import { cx } from "../../components/cx"import { Icon } from "../icons/Icon"import { formatTime } from "../lib/dates"import { KeyCombo } from "../ui/button"import { Chip } from "../ui/misc"import { phaseName, type Phase } from "./engine"import { useCoarseNow, useFrame } from "./ticker"
/* ---------- Phase chip: shape tells the phases apart (solid focus, outline short break, dashed long break) ---------- */
const phaseVariant = { focus: "solid", short: "outline", long: "dashed" } as const
export function PhaseChip({ phase, size = "sm", className,