Banner
betav0.1.0Application-level message strip: full width, a gradient fill per variant, one message with an optional leading icon, an optional action and an optional close control. It sits OUTSIDE the page content — above or below the application shell — and replaces the hand-rolled top-of-app strips of OpenCTI and OpenAEV (licence and trial countdowns, start-trial and register-platform prompts, SMTP token expiry, system messages). Anything inside a page, a card, a form, a drawer or a dialog is `Alert`, not Banner. The banner sets ONE ink on its root and everything inside inherits it — message, inline links, icon, the action's border and label, the close glyph — so a product never picks a colour for a gradient. The action is therefore a `BannerAction`, never a plain `Button`. Several banners go in one `BannerStack`, which stacks them in order inside a single landmark and reports its height through `onHeightChange`; the host shell keeps the positioning (fixed, sticky or in flow) and offsets its own layout by that height. Dismissal persistence stays in the product: `onDismiss` only reports the click, and the close control renders only when it is set.
Variants
default — action and close
warning — action with a leading icon, not dismissible
success — message only
error — dismissible
default — start icon and inline link
wrapped — a narrow host
stack — one landmark, height reported to the host: 0px
Examples
<Banner>Explore OpenCTI or OpenAEV with a 30-day free trial.</Banner><Banner
variant="warning"
icon="users"
action={<BannerAction startIcon={<Icon name="users" size={16} />}>Reach sales</BannerAction>}
>
You have <strong>7 days</strong> left in your trial period.
</Banner>// Dismissible: persist the dismissal yourself, inside onDismiss.
<Banner variant="error" onDismiss={() => setDismissed(true)} dismissLabel={t("Close")}>
You are viewing the development environment.
</Banner>// A banner that appears after page load announces itself politely.
<Banner variant="warning" live>
The SMTP refresh token expires in 3 days. <a href="/settings/smtp">Configure SMTP</a>
</Banner>// Several banners: one stack, one landmark, one height for the shell.
<BannerStack className="fixed inset-x-0 top-0" onHeightChange={setBannerOffset}>
<Banner variant="error">Your licence has expired.</Banner>
<Banner action={<BannerAction onClick={register}>Connect product</BannerAction>}>
Register this platform on XTM Hub.
</Banner>
</BannerStack>// Migrating a TopBanner: bannerColor → variant, buttonText/onButtonClick → action.
// <TopBanner bannerColor="gradient_yellow" bannerText={text} buttonText="Contact us" onButtonClick={open} />
<Banner variant="warning" action={<BannerAction onClick={open}>Contact us</BannerAction>}>
{text}
</Banner>Props
| Prop | Type |
|---|---|
variant | "default" | "warning" | "error" | "success" — Figma `Type`. Defaults to "default", the brand gradient. OpenAEV system levels map debug→success, info→default, warn→warning, error and fatal→error. TopBanner presets map gradient_blue→default, gradient_yellow and yellow→warning, gradient_green→success, red→error. |
children | ReactNode — REQUIRED. The message: text, a bold run, inline links. A bare <a> or a router <Link> is styled by the banner (its ink, Medium weight, underlined); pass no colour. |
icon | IconName — Figma `hasStartIcon`. A decorative 24px glyph before the message, in the ink. |
action | ReactNode — Figma `hasAction`. Pass a `<BannerAction>`; a plain `Button`'s colours are not designed for a gradient. |
onDismiss | (event) => void — Figma `hasCloseOpt`. The close control renders only when this is set. Persist the dismissal yourself (localStorage key, server flag): the banner only reports the click. |
dismissLabel | string — accessible name of the close control. Defaults to "Close"; translate it. |
live | boolean — gives the banner role="status" (polite). For a banner that appears AFTER page load (an expiry detected later, a broadcast received by subscription). A banner rendered with the page is read in document order and needs nothing. The region mounts empty and its content follows right after mount, so the message arrives as a change the screen reader announces. |
role | string — overrides the role `live` resolves to. Never "alert" for a persistent banner. |
className | string — merged onto the root through `cn()`. |
BannerAction | Button default × secondary at size sm, border and label in the banner ink. Takes Button's props except variant, priority, size and color (startIcon, endIcon, asChild, onClick, …). |
BannerStack | <section> landmark. `aria-label` defaults to "Notifications"; `onHeightChange(px)` fires on mount and on every height change. Positioning is the host's className. |
Accessibility
Gradient stops (2026-10-20 export, Banner.rfc.md §9 Q12): `default` ends on --color-filigran-tonic-accent and `warning` starts on --orange-500. Both match the previous stops in dark and fix the two light-mode ends that failed before (3.42:1 and 3.64:1), which a narrow viewport or a wrapped message reaches. Text pairs, light / dark: default start 11.22 / 9.32, end 7.67 / 11.89; warning start 5.60 / 5.60, end 10.22 / 10.22; error start 8.81 / 6.27, end 5.92 / 4.69; success start 8.91 / 12.95, end 4.98 / 5.76. The icon, the action border and the close glyph paint the same ink through currentColor, so their 3:1 requirement is covered by these 4.5:1 pairs. Focus indicator — `default` (arbitration Q6): a 1px brand ring inside a 2px --bg-elevation-default band, the band bordering the gradient: 11.22 / 10.30 against the brand stop, 7.67 / 13.14 against the tonic-accent stop. The three other variants keep the standard ring (2px band, then 2px brand ring): the band borders the control's own gradient interior, 4.98 to 14.32 on every stop except warning in light, where the band measures 2.81:1 on the orange start and 1.54:1 on the yellow end, and the brand ring, which borders the same gradient, carries the indicator at 4.00:1 and 7.29:1. Ring against band: 11.22 / 10.30. Hover and press (arbitration Q10): --color-feedback-contrast-primary over the gradient. Measured over each stop, label in the banner ink, light / dark: default 13.17 / 10.48 start, 9.78 / 12.41 end; warning 7.03 / 7.03 start, 11.38 / 11.38 end; error 10.97 / 7.68 start, 8.01 / 5.87 end; success 10.97 / 13.69 start, 6.88 / 7.15 end. EXCEPTION, light warning (arbitration Q11): the light wash is black 20 %, which would take the dark label on the orange start to 3.76:1, so that banner hovers in the DARK value of the token, white 20 % (--white-300) — the warning figures above. Every hovered label clears 4.5:1. These figures are computed over each gradient stop; check:contrast cannot compute them (it composites a translucent colour over the page), so the wash is covered by a scripts/contrast-coverage-allowlist.json entry rather than a contrast pair (arbitration Q13). Semantics: the banner is a plain <div> with no role by default — a banner rendered with the page is read in document order. `live` gives role="status" (polite) and inserts the content after the empty region has mounted, since a region and its text entering the DOM together are not reliably announced; role="alert" is never the default, because a persistent banner is context, not an interruption. BannerStack is a <section> with an accessible name, i.e. a region landmark. The start icon is decorative (aria-hidden): the message states the condition, so neither the colour nor the glyph is the only cue (1.4.1). The close control is an IconButton with an accessible name from `dismissLabel`. The banner is not focusable; its action, its links and its close control are, in DOM order.