Composites
Banner
A dismissible notice across the top of the app — the trial is ending, maintenance is scheduled, a payment failed. One banner where you put it, or a queue of them ordered by priority, shown a few at a time.
- Import
@comitor/ui— the main entry. No optional peer to install.- Runtime
- A client component.
useBannersanduseBannerare hooks behind that boundary — calling them from a Server Component throws. A staticBannerwith no callbacks still renders from one. - Origin
- Ported in
2.1.0from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own Button, tokens and strings. Every deviation from upstream is listed in the changelog.
Anatomy
The same parts serve two shapes. A Banner on its own renders where it is declared. Inside <Banners>, a banner joins a queue instead — declared as a <Banner> or added with useBanners() — and the queue renders it.
import {
Banner,
BannerActions,
BannerClose,
BannerContent,
BannerDescription,
BannerIcon,
BannerTitle,
Banners,
useBanners,
} from '@comitor/ui'
// One notice, rendered where you put it:
<Banner variant="warning" onOpenChange={onOpenChange}> // role="status", aria-live="polite"
<BannerIcon><Clock aria-hidden="true" /></BannerIcon> // sizes a direct svg to 16px
<BannerContent> // the flexible middle column
<BannerTitle>Your trial ends in 3 days</BannerTitle>
<BannerDescription>Upgrade to keep your projects.</BannerDescription>
</BannerContent>
<BannerActions>…</BannerActions> // your buttons
<BannerClose /> // X, named "Close notification"
</Banner>
// A queue of workspace notices, shown by priority, maxVisible at a time:
<Banners strategy="sticky">
<App /> {/* anywhere inside: useBanners().onBannerAdd({ content, variant, priority, duration, onDismiss }) */}
</Banners>A notice for the whole workspace
The trial is ending: something every page of the app should say until the user acts on it or waves it away. It goes first in the app frame, above the header, as a full-width strip with a bottom border. Close it and watch the callback line under the frame.
open: true
A standalone banner reports closing through onOpenChange(false), and only that. onDismiss is a queue event: it fires for banners inside <Banners> and is never called here. To remember that a user dismissed this week’s notice, store it in onOpenChange — keyed by the notice, not by “banner closed”, so next week’s notice still shows.
Five tones, one family with Alert
default, info, success, warning, destructive — the three Alert has, plus info and success built on the same pattern. The payment failure has no BannerClose: it should stay until the card is fixed. dismissible={false} would not do that — it only disables the X, which stays on screen.
Every tone is a solid bg-card with the title, description and icon in the tone’s -ink step — the exact recipe of Alert, not a tinted panel. Solid on purpose: with strategy="fixed" or "absolute" the banner lies over the page, and a translucent fill would let the page’s text show through the banner’s. The description is the title’s colour at 90% opacity. Measured on --card in the high-contrast palette, title / description:
| Tone | Light | Dark |
|---|---|---|
| info | 6.68 / 5.31 | 9.54 / 7.92 |
| success | 6.52 / 5.20 | 10.19 / 8.44 |
| warning | 6.51 / 5.17 | 11.60 / 9.56 |
| destructive | 7.15 / 5.77 | 6.42 / 5.41 |
On the default palette, destructive drops to 4.38 / 3.81 in light and 4.06 / 3.50 in dark, and info in dark to 4.28 / 3.71 — recorded token debt, at the same level as Alert. The tone is never the only signal: pick an icon that says it, and words that say it louder.
A queue of notices
<Banners> holds a queue for everything under it. useBanners().onBannerAdd puts a banner in from anywhere — an effect that reads the billing status, a websocket message — and maxVisible (1 by default, 2 here) decides how many show; the rest wait behind them, highest priority first. A <Banner> declared inside joins the same queue and renders nothing where it is written. Things to try:
- Payment failed jumps the queue: priority 10 beats the billing notice’s 5 and everyone else’s 0. Its action is a render prop calling
onClose. - Sync complete closes itself after 5 seconds — and that is logged as a dismissal.
- The switch sets the declared banner’s
open. Turning it off takes the banner out without anonDismiss: the app closed it, not the user. Close it with its X instead and both the log and the switch hear about it. - Re-render it renders the declared banner again — so does every line in the log, and nothing in the demo is memoised. The banner stays where it is: no second entrance, nothing logged. Upstream logged a dismissal every time and closed the banner, and before 3.1.0 the port still took it out of the queue and put it back, replaying its entrance.
- Clear all empties the queue without a single
onDismiss.
Callback log
Nothing yet. Close a banner or let the sync notice run out — Re-render it adds nothing here, and that is the fix.
The demo uses strategy="static" so the stack stays inside its frame. In an app, the default fixed pins it to the top of the viewport over the page — offset your layout by its height — while sticky keeps it in the flow of a scrolling container, pushing the content down. fixed and absolute also portal the stack to the end of <body>: first on screen, last in the tab order. static and sticky keep the two in step.
Banner, Alert or toast?
Three ways to tell the user something, told apart by scope and lifetime.
| Banner | Alert | Toast | |
|---|---|---|---|
| Scope | The whole workspace or app | One page, form or record | The action the user just took |
| Where | Top of the app frame, above every page | Inline, in the content | An overlay in a corner |
| Lifetime | Until dismissed, or until the condition ends | As long as the content shows it | A few seconds |
| Dismiss | BannerClose, optional; a queue with priority | Not dismissible | Times out; can be closed |
| Announced as | role="status", polite | role="alert", immediately | Sonner’s live region |
| Example | Trial ends in 3 days · maintenance · payment failed | Form errors · “This deal is archived” | “3 tasks archived” |
The test: if the message would still be true on another page of the app, it is a banner. If it is about this page, it is an Alert. If it is about the click that just happened, it is a toast.
What the port guarantees
Behaviour you can rely on that the upstream component did not give you.
onDismissmeans somebody dismissed it. Upstream, a<Banner>inside<Banners>took itself out of the queue in an effect cleanup, and that removal calledonDismissandonOpenChange(false)— on every render of its parent (its children are new JSX each time) and on unmount, a route change for instance. Apps recorded dismissals nobody made, and a controlled banner closed itself. The port silences the cleanup path; the X, a run-outdurationandonBannerRemovestill report.- A dismissed banner stays dismissed. Upstream, closing an uncontrolled declared banner through the queue did not lower its own
open, so the next render put back the banner the user had just closed. - A declared banner keeps its place when it re-renders. Upstream queued it from an effect that depended on its children — new JSX on every render of the parent — so every render took it out and queued it again: the entrance replayed, it dropped to the back of its priority group, its
durationstarted over and any state inside it was lost, and a component that calleduseBanners()and rendered a<Banner>looped until React stopped it with “Maximum update depth exceeded”. The port queues it once each time it opens and updates it in place: new children show in the banner already on screen, and its height is measured again whenever it resizes. - Closing hands focus back. Upstream let focus fall to
<body>when a banner left the DOM with focus inside it, so after Enter on the X the next Tab started from the top of the page. The rules are under Accessibility. - It works on a plain-http internal host. Upstream made ids with
crypto.randomUUID(), which exists only in secure contexts — onhttp://10.x.x.xevery add threw. Ids are now a counter prefixed withuseId(), unique per<Banners>. - It respects
prefers-reduced-motion. Upstream wrote the slide and fade into inlinestyle, and an inline style beats any class, so the reduced-motion class never won. The duration now travels in--banner-duration, which is 0ms under reduced motion. - The tones follow the palette. Upstream painted Tailwind’s palette —
bg-blue-50 text-blue-900and friends, each with adark:twin — which the high-contrast palette cannot reach. The port uses Alert’s role tokens. - The X has a name — “Close notification”, from the
bannernamespace. Upstream’s was an icon-only button with none. - The server render is clean. The portal target is read after mount, not during render, so the server pass and hydration never touch
document.
Language
The package owns exactly one string here: closeLabel in the banner namespace, the X button’s name — “Close notification”, or comitorMessagesVi.banner’s “Đóng thông báo”. It follows the nearest ComitorLocaleProvider, set once at the root as Get started shows. Everything visible is your own copy and switches with your own i18n, as in the demo. There is no labels prop and no DEFAULT_*_LABELS constant for this one: override per button with aria-label, or for the whole app through the provider’s messages.
Close button’s accessible name: Close notification
Props
Banner
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'default' | 'info' | 'success' | 'warning' | 'destructive' | 'default' | The tone. Every variant sits on bg-card; info, success, warning and destructive colour the text and icon with their -ink step. |
open | boolean | — | Controlled visibility. Inside Banners, false takes the banner out of the queue — silently, without onDismiss. A banner that leaves the queue while open is still true — closed by the user while the app ignored onOpenChange(false), or dropped by onBannersClear — stays out until open goes false and back to true. |
defaultOpen | boolean | true | Initial visibility when uncontrolled. |
onOpenChange | (open: boolean) => void | — | Called with false when the banner is closed for real: BannerClose standalone; inside Banners also when its duration runs out or the queue removes it. Never for a re-render or an unmount. Focus you move here wins over the package’s focus return. |
onDismiss | () => void | — | Inside Banners only: fires when this banner leaves the queue because the user closed it, its duration ran out, or onBannerRemove / onRemove removed it. Not for a re-render, an unmount, open={false} or onBannersClear. A standalone Banner never calls it — use onOpenChange. |
priority | number | 0 | Inside Banners: higher goes further forward. Equal priorities keep the order they were added in; a changed priority moves the banner to the back of its new group. |
duration | number | — | Inside Banners: milliseconds before it closes itself, which counts as a dismissal. A new value starts the count again from the change; a re-render with the same value leaves it running. Ignored standalone. |
dismissible | boolean | true | false renders BannerClose disabled — still visible. For a banner that cannot be closed, leave BannerClose out. |
asChild / className / ...rest | boolean / string / React.ComponentProps<"div"> | — | Standalone only, and spread after the defaults — role and aria-live are yours to change. Inside Banners the queue renders its own element and these are dropped. |
Banners
| Prop | Type | Default | Description |
|---|---|---|---|
maxVisible | number | 1 | How many banners from the front of the queue are shown at once. The rest wait their turn. |
side | 'top' | 'bottom' | 'top' | Which edge the stack grows from; with static or sticky, also whether it renders before or after children. |
strategy | 'fixed' | 'static' | 'sticky' | 'absolute' | 'fixed' | fixed and absolute portal the stack into container and lay it over the page, so offset your layout by its height. static and sticky render it in place, before (or after) children, and push the content — and keep DOM order equal to visual order. |
container | Element | DocumentFragment | null | document.body | The portal target for fixed and absolute, read after mount. Unused by static and sticky. |
children | React.ReactNode | — | Your app. useBanners and declared Banners work anywhere below. |
useBanners() — inside <Banners> only; it throws elsewhere. Returns:
| Prop | Type | Default | Description |
|---|---|---|---|
banners | BannerData[] | — | The whole queue, highest priority first — including the ones not visible yet. A declared Banner’s content here is an internal element that renders its latest children. |
onBannerAdd | (banner: Omit<BannerData, 'id'>) => string | — | Queues a banner and returns its id. Ids are unique within this Banners. |
onBannerRemove | (id: string) => void | — | Removes at once, without the exit animation, and fires that banner's onDismiss. |
onBannersClear | () => void | — | Empties the queue and cancels every duration timer. Fires no onDismiss — and declared Banners are not told: their open stays true, and they stay out until it goes false and back to true. |
BannerData — what onBannerAdd takes, minus id.
| Prop | Type | Default | Description |
|---|---|---|---|
contentrequired | ReactNode | (props: BannerRenderProps) => ReactNode | — | The inside of the banner: the same parts as a standalone one. A function receives the render props below. |
variant / priority / duration / dismissible | as on Banner | — | Same meaning and defaults as the Banner props of the same names. |
onDismiss | () => void | — | Fires when this banner is closed by the user, runs out, or is removed with onBannerRemove. |
BannerRenderProps — passed to a content function. useBanner() returns the same fields to any component inside a banner.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | The queue id. Undefined from useBanner() inside a standalone Banner. |
onClose | () => void | — | Animates the banner out, then removes it and fires onDismiss — what BannerClose calls. |
onRemove | () => void | — | Removes at once, no animation, and fires onDismiss. Undefined from useBanner() outside a queue. |
variant / dismissible | BannerVariant / boolean | — | What the banner was created with. |
BannerClose — a package Button (ghost, icon-sm); every Button prop is accepted.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | banner.closeLabel | “Close notification” by default, from the provider. If you give the button visible text, pass the same text here (WCAG 2.5.3). |
disabled | boolean | !dismissible | An explicit value wins over the banner's dismissible. |
children | React.ReactNode | X icon | A 14px X in a ghost icon-sm Button — a 32 × 32 px target. |
onClick | (event) => void | — | Runs first; call event.preventDefault() to keep the banner open. |
The layout parts.
| Prop | Type | Default | Description |
|---|---|---|---|
BannerIcon | div + asChild | — | Sizes a direct svg child to 16px and keeps it from shrinking. The icon inherits the tone's ink. |
BannerContent | div + asChild | — | The flexible middle: flex-1, min-w-0, a column with a 4px gap. Long text wraps here. |
BannerTitle | div | — | text-sm, font-medium, leading-none. A div, not a heading — pass your own if the page outline needs one. |
BannerDescription | div | — | text-xs at 90% opacity — the same weight as AlertDescription's /90. |
BannerActions | div + asChild | — | A row of buttons with an 8px gap. Size them yourself — Button size='sm' keeps the strip slim. |
Known limitations
All of these follow from how the source is built, and each has a way round.
- Inside a queue, only the banner’s data travels.
variant,priority,duration,dismissible, the children and the two callbacks reach the rendered banner;className,role,id,data-*on a declared<Banner>are dropped, and every queued banner isrole="status". Style the parts inside instead, and render a banner that must be an alert on its own. dismissible={false}disables, it does not hide. The X stays, greyed out. LeaveBannerCloseout for a banner that cannot be closed.onBannersClear()is silent. NoonDismissfires, and declared banners are not told — theiropenstays true while they are out of the queue, and they come back only after it goes false and then true again. Set it false yourself, as the demo’s Clear all does.- A close the app cuts short is silent. If a declared banner is unmounted or set to
open={false}during the 400ms exit after its X is pressed, the close counts as the app’s removal: neitheronDismissnoronOpenChangefires. Where the record matters, write it inBannerClose’sonClickas well. - A
durationruns from the moment the banner is queued, not from when it shows, and it keeps running while other notices hold the banner out of view. Behind them it can run out unseen; it then leaves — firingonDismiss— as soon as it reaches the front, without ever appearing. Give timed banners a higherpriority, or add them when the queue is short. fixedcovers the page. The default strategy lays the stack over the top of the viewport and does not move anything. Pad your layout by its height, or usesticky.
Accessibility
- Every banner root, standalone or queued, is
role="status"witharia-live="polite": a banner that appears while the user works is read out when they pause, not over what they are doing. Screen readers are uneven about live regions that mount with their content already inside; if a notice must be heard, raise a toast as well — its region is always mounted. - Standalone, the root’s props are yours — they are spread after the defaults. For a failure that appears mid-session and must interrupt, change both:payment-failed.tsx
// Standalone, the root's props are yours — they are spread after the defaults. // A failure that appears mid-session and must interrupt: pass BOTH, because the // built-in aria-live="polite" would otherwise override role="alert"'s assertive. <Banner variant="destructive" role="alert" aria-live="assertive">…</Banner> - Put banners first in the app frame. With
staticorstickythey are met first by Tab and by a screen reader reading in order;fixedandabsoluteportal them to the end of<body>, where both meet them last. - The X is a real button with a name from the provider (“Close notification”), a 32 × 32 px target, and the Button focus ring. Its icon is
aria-hidden. - Closing hands focus back. If focus is inside a banner when it leaves the DOM — by its X, a run-out
duration,open={false},onBannerRemove,onBannersClear, or a higher priority pushing it pastmaxVisible— it returns to the element that had it just before focus entered the banner (WCAG 2.4.3). Focus the app moves itself, inonOpenChange,onDismissor an effect, wins, so a handler that picks a better target can stay; one that only sent focus back where it came from can go. With nothing to return to — that element is gone, or focus did not come from one — the package does not guess: move focus inonOpenChange, or inonDismissfor a banner added withonBannerAdd. BannerTitleis adiv, not a heading — a banner is not a section of the page outline.- Colour is the -ink step of each tone on
--card; the numbers are in the tones section. Pair every tone with an icon and plain words, so it survives greyscale and colour blindness (WCAG 1.4.1). - Under
prefers-reduced-motionthe queue’s slide and fade take 0ms; a standalone banner never animates at all. - Nothing closes on Escape. A banner is not a layer over the page, so the X — or an action — is the way out.
Related
- Alert — the in-page sibling, same tone recipe.
- Toast — for the result of an action, not the state of the workspace.
- App pattern — the app frame a workspace banner sits above; App Shell is the frame itself.
- Landing Page pattern — where the trial starts; the banner is where it ends.