# Banner

A message across the full width of the page, about the page or the whole product, with optional actions and a dismiss button.

- **Import:** `import { Banner, BannerTitle, BannerDescription, BannerActions } from "@booleanpress/ui/banner"`
- **Page:** <https://ui.booleanpress.com/components/banner> · @booleanpress/ui 0.2.0

## Usage

Place a banner at the top of the page, or of the area it is about, for news that stays true while the page is open: a paused service, an expiring licence, new limits. For a message about one form or one card, use [Alert](/components/alert); for news that comes and goes, a [toast](/components/sonner).

```tsx
import { Banner, BannerActions, BannerDescription, BannerTitle } from "@booleanpress/ui/banner"
import { Button } from "@booleanpress/ui/button"

export function LicenceBanner() {
  return (
    <Banner tone="warning" dismissible>
      <BannerTitle>Your licence expires in 5 days</BannerTitle>
      <BannerDescription>Renew it to keep receiving updates.</BannerDescription>
      <BannerActions>
        <Button size="sm">Renew licence</Button>
      </BannerActions>
    </Banner>
  )
}
```

`tone` sets the colours and the icon: `neutral`, `info` (the default), `success`, `warning` or `destructive`. `icon` replaces the icon, and `icon={null}` removes it. `BannerTitle`, `BannerDescription` and `BannerActions` sit on one line when there is room and wrap when there is not.

`dismissible` adds a × that removes the banner and calls `onDismiss`; remember the choice yourself if it should stay gone. `position="sticky"` keeps it at the top of the scrolling box while the content scrolls under it. In the WordPress admin, where the admin bar covers the top of the window, add `className="top-(--wp-admin--admin-bar--height)"` when the page itself scrolls.

## Examples

### Tones

`neutral`, `info`, `success`, `warning` and `destructive`, each with its own icon.

```tsx
import { Banner, BannerDescription, BannerTitle } from "@booleanpress/ui/banner"

export default function BannerTones() {
  return (
    <div className="flex w-full flex-col gap-3">
      <Banner tone="neutral">
        <BannerDescription>Scheduled maintenance on 12 October 2026, 02:00–03:00 UTC.</BannerDescription>
      </Banner>
      <Banner tone="info">
        <BannerTitle>New sending limits</BannerTitle>
        <BannerDescription>Free plans can send 500 emails a day from 1 November 2026.</BannerDescription>
      </Banner>
      <Banner tone="success">
        <BannerDescription>Your domain example.com is verified. Emails are signed with DKIM.</BannerDescription>
      </Banner>
      <Banner tone="warning">
        <BannerDescription>The Primary mailer has used 90% of this month’s quota.</BannerDescription>
      </Banner>
      <Banner tone="destructive">
        <BannerTitle>Sending is paused</BannerTitle>
        <BannerDescription>The SMTP server refused the last 25 connections.</BannerDescription>
      </Banner>
    </div>
  )
}
```

### With actions

`BannerActions` at the end of the text: Renew licence and Remind me later.

```tsx
import { Banner, BannerActions, BannerDescription, BannerTitle } from "@booleanpress/ui/banner"
import { Button } from "@booleanpress/ui/button"

export default function BannerWithActions() {
  return (
    <Banner tone="warning" className="w-full">
      <BannerTitle>Your licence expires in 5 days</BannerTitle>
      <BannerDescription>Renew it to keep receiving updates and support.</BannerDescription>
      <BannerActions>
        <Button size="sm" variant="outline">
          Remind me later
        </Button>
        <Button size="sm">Renew licence</Button>
      </BannerActions>
    </Banner>
  )
}
```

### Dismissible

`dismissible` adds a ×; when it removes the banner, `returnFocusTo` sends focus to the button that shows it again.

```tsx
import { useEffect, useRef, useState } from "react"
import { Banner, BannerDescription } from "@booleanpress/ui/banner"
import { Button } from "@booleanpress/ui/button"

export default function BannerDismissible() {
  const [key, setKey] = useState(0)
  const [dismissed, setDismissed] = useState(false)
  const showAgain = useRef<HTMLButtonElement>(null)
  const wrapper = useRef<HTMLDivElement>(null)

  // The banner came back from "Show the banner again", which is gone: focus its ×.
  useEffect(() => {
    if (key > 0) wrapper.current?.querySelector<HTMLElement>("[data-slot=banner-dismiss]")?.focus()
  }, [key])

  return (
    <div ref={wrapper} className="flex w-full flex-col items-start gap-3">
      <Banner key={key} tone="info" dismissible onDismiss={() => setDismissed(true)} returnFocusTo={showAgain}>
        <BannerDescription>Version 2.4 adds per-site sending limits. Read what changed in the release notes.</BannerDescription>
      </Banner>
      {dismissed && (
        <Button
          ref={showAgain}
          variant="outline"
          size="sm"
          onClick={() => {
            setDismissed(false)
            setKey((value) => value + 1)
          }}
        >
          Show the banner again
        </Button>
      )}
    </div>
  )
}
```

### Sticky

`position="sticky"` keeps the banner at the top of a scrolling log.

```tsx
import { Banner, BannerActions, BannerDescription } from "@booleanpress/ui/banner"
import { Button } from "@booleanpress/ui/button"

const ENTRIES = Array.from({ length: 12 }, (_, index) => ({
  id: 4120 - index,
  to: `customer${index + 1}@example.com`,
}))

export default function BannerSticky() {
  return (
    <div className="h-64 w-full overflow-y-auto rounded-md border" role="region" tabIndex={0} aria-label="Email log">
      <Banner tone="destructive" position="sticky">
        <BannerDescription>12 emails bounced in the last hour.</BannerDescription>
        <BannerActions>
          <Button size="sm" variant="outline">
            Review bounces
          </Button>
        </BannerActions>
      </Banner>
      <ul className="divide-y text-sm">
        {ENTRIES.map((entry) => (
          <li key={entry.id} className="flex justify-between px-4 py-2.5">
            <span>{entry.to}</span>
            <span className="text-muted-foreground tabular-nums">#{entry.id}</span>
          </li>
        ))}
      </ul>
    </div>
  )
}
```

## Accessibility

**Semantics.** A `div` with `role="status"` for the `neutral`, `info` and `success` tones and `role="alert"` for `warning` and `destructive`, so a banner added while the page is open is announced; pass `role` to change it. The icon is decorative.

**Labels.** Say the whole message in the text: the colour and the icon only repeat it. The × is named by the provider's `dismiss` string.

**Focus.** The banner itself takes no focus; its actions and × are in the page's tab order. When the × removes it with focus on it, focus moves to `returnFocusTo`, or else to the first element after the banner that Tab reaches (the last one before it when nothing follows). `returnFocusTo` is read once the banner has gone, so it can name an element the dismissal shows.

**Known limits.**

- A banner present when the page loads is not announced; screen-reader users find it in reading order. Put it first.
- Show one banner at a time per area; several in a row compete for attention.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Enter or Space | On the ×, removes the banner and moves focus on. |
| Tab | Moves through the banner's actions and its ×, in reading order. |

## API

### Banner

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `dismissible` | `boolean` | `false` | Render a × button that removes the banner. |
| `icon` | `ReactNode` |  | The icon before the text. Each tone has its own; pass another, or `null` for none. |
| `onDismiss` | `(() => void)` |  | Called when the × removes the banner. |
| `position` | `"static" \| "sticky" \| null` | `static` | `static` (default) or `sticky`: sticks to the top of the scrolling box. |
| `returnFocusTo` | `ReturnFocusTarget` |  | Where focus goes when the × removes the banner. By default, the first element after the banner that Tab reaches (the last one before it when nothing follows). Read once the banner has gone, so it can name an element that the dismissal itself shows, such as a "Show again" button. |
| `tone` | `"destructive" \| "success" \| "info" \| "warning" \| "neutral" \| null` | `info` | `neutral`, `info` (default), `success`, `warning` or `destructive`: the colours, the default icon and the live role. |

### BannerTitle

Renders a `p` and passes it every other prop.

### BannerDescription

Renders a `p` and passes it every other prop.

### BannerActions

Renders a `div` and passes it every other prop.

**Also exported:** `bannerVariants`, the class names of Banner's variants and sizes (`cva`), to give another element the same look.

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="banner"` (Banner), `data-slot="banner-title"` (BannerTitle), `data-slot="banner-description"` (BannerDescription), `data-slot="banner-actions"` (BannerActions), and `data-tone`, `data-position`.

**Provider strings:** `dismiss` (`BooleanUIProvider`'s `strings`).

## Theming

Each tone is the Message colours: `--{tone}-subtle` fill, `--{tone}-border` edge and `--{tone}-strong` text; `neutral` uses `--secondary`, `--secondary-foreground` and `--border`.

| Token | Used for |
| --- | --- |
| `--border` | border |
| `--destructive-border` | border |
| `--destructive-strong` | text |
| `--destructive-subtle` | background |
| `--info-border` | border |
| `--info-strong` | text |
| `--info-subtle` | background |
| `--secondary` | background |
| `--secondary-foreground` | text |
| `--success-border` | border |
| `--success-strong` | text |
| `--success-subtle` | background |
| `--warning-border` | border |
| `--warning-strong` | text |
| `--warning-subtle` | background |
