Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

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. useBanners and useBanner are hooks behind that boundary — calling them from a Server Component throws. A static Banner with no callbacks still renders from one.
Origin
Ported in 2.1.0 from 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.

anatomy.tsx
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.

Your trial ends in 3 days
Upgrade before October 1 to keep your 14 projects and their history.

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.

Shared inboxes are here
Route support@ and sales@ to a team, with owners and reply targets.
Maintenance on Saturday, 3 October
02:00–03:00 UTC. Comitor will be read-only for up to an hour.
Import finished
1,284 contacts were added to CRM from contacts-2026.csv.
Storage is 92% full
Uploads stop at 100%. Archive old files or add space.
Payment failed
We couldn’t charge your card on September 26. Update it by October 3.

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:

ToneLightDark
info6.68 / 5.319.54 / 7.92
success6.52 / 5.2010.19 / 8.44
warning6.51 / 5.1711.60 / 9.56
destructive7.15 / 5.776.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 an onDismiss: 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.
In the queue: 0 · showing up to 2

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.

BannerAlertToast
ScopeThe whole workspace or appOne page, form or recordThe action the user just took
WhereTop of the app frame, above every pageInline, in the contentAn overlay in a corner
LifetimeUntil dismissed, or until the condition endsAs long as the content shows itA few seconds
DismissBannerClose, optional; a queue with priorityNot dismissibleTimes out; can be closed
Announced asrole="status", politerole="alert", immediatelySonner’s live region
ExampleTrial ends in 3 days · maintenance · payment failedForm 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.

  • onDismiss means somebody dismissed it. Upstream, a <Banner> inside <Banners> took itself out of the queue in an effect cleanup, and that removal called onDismiss and onOpenChange(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-out duration and onBannerRemove still 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 duration started over and any state inside it was lost, and a component that called useBanners() 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 — on http://10.x.x.x every add threw. Ids are now a counter prefixed with useId(), unique per <Banners>.
  • It respects prefers-reduced-motion. Upstream wrote the slide and fade into inline style, 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-900 and friends, each with a dark: twin — which the high-contrast palette cannot reach. The port uses Alert’s role tokens.
  • The X has a name — “Close notification”, from the banner namespace. 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.

Your trial ends in 3 days
Upgrade before October 1 to keep your projects and their history.

Close button’s accessible name: Close notification

Props

Banner

PropKiểuMặc địnhMô tả
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.
openboolean—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.
defaultOpenbooleantrueInitial 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.
prioritynumber0Inside 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.
durationnumber—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.
dismissiblebooleantruefalse renders BannerClose disabled — still visible. For a banner that cannot be closed, leave BannerClose out.
asChild / className / ...restboolean / 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

PropKiểuMặc địnhMô tả
maxVisiblenumber1How 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.
containerElement | DocumentFragment | nulldocument.bodyThe portal target for fixed and absolute, read after mount. Unused by static and sticky.
childrenReact.ReactNode—Your app. useBanners and declared Banners work anywhere below.

useBanners() — inside <Banners> only; it throws elsewhere. Returns:

PropKiểuMặc địnhMô tả
bannersBannerData[]—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.

PropKiểuMặc địnhMô tả
contentbắt buộcReactNode | (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 / dismissibleas 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.

PropKiểuMặc địnhMô tả
idstring—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 / dismissibleBannerVariant / boolean—What the banner was created with.

BannerClose — a package Button (ghost, icon-sm); every Button prop is accepted.

PropKiểuMặc địnhMô tả
aria-labelstringbanner.closeLabel“Close notification” by default, from the provider. If you give the button visible text, pass the same text here (WCAG 2.5.3).
disabledboolean!dismissibleAn explicit value wins over the banner's dismissible.
childrenReact.ReactNodeX iconA 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.

PropKiểuMặc địnhMô tả
BannerIcondiv + asChild—Sizes a direct svg child to 16px and keeps it from shrinking. The icon inherits the tone's ink.
BannerContentdiv + asChild—The flexible middle: flex-1, min-w-0, a column with a 4px gap. Long text wraps here.
BannerTitlediv—text-sm, font-medium, leading-none. A div, not a heading — pass your own if the page outline needs one.
BannerDescriptiondiv—text-xs at 90% opacity — the same weight as AlertDescription's /90.
BannerActionsdiv + 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 is role="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. Leave BannerClose out for a banner that cannot be closed.
  • onBannersClear() is silent. No onDismiss fires, and declared banners are not told — their open stays 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: neither onDismiss nor onOpenChange fires. Where the record matters, write it in BannerClose’s onClick as well.
  • A duration runs 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 — firing onDismiss — as soon as it reaches the front, without ever appearing. Give timed banners a higher priority, or add them when the queue is short.
  • fixed covers 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 use sticky.

Accessibility

  • Every banner root, standalone or queued, is role="status" with aria-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 static or sticky they are met first by Tab and by a screen reader reading in order; fixed and absolute portal 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 past maxVisible — it returns to the element that had it just before focus entered the banner (WCAG 2.4.3). Focus the app moves itself, in onOpenChange, onDismiss or 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 in onOpenChange, or in onDismiss for a banner added with onBannerAdd.
  • BannerTitle is a div, 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-motion the 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.