Banner

A status message that sits inline in the page and stays until it's dismissed or the condition it reports clears.

React Aria has no Banner primitive — a banner isn't an overlay, so it needs none of the focus trapping, portalling, or queueing that Dialog and Toast provide. What it does need is a live region and a real button: Banner.Root renders an ARIA live region and Banner.Close is a React Aria Button, which is where its hover, press, and focus-visible styling comes from.

Reach for Toast instead when the message is transient and not tied to a place on the page.

import { Banner } from "@pockets-ui/components"

Anatomy

PartRole
Banner.RootThe surface and live region. Owns intent, variant, size, and dismissal state.
Banner.HeaderThe first line: Icon, Title, Close.
Banner.IconLeading glyph. Defaults to the Lucide icon for the intent; decorative.
Banner.TitleThe headline. A <p> by default — pass as="h2" where it opens a section.
Banner.CloseDismiss button (React Aria Button).
Banner.ContentThe body below the header — description, or whatever else you stack.
Banner.DescriptionSupporting copy.
Banner.ActionsButtons or links, below the body.

Root is a column: the header row sits over Content and Actions, which run the full width of the banner rather than indenting under the title. A one-line banner is just a Header — no Content needed.

<Banner.Root intent="warning">
  <Banner.Header>
    <Banner.Icon />
    <Banner.Title>Heads up</Banner.Title>
    <Banner.Close />
  </Banner.Header>
  <Banner.Content>
    <Banner.Description>Your trial ends Friday.</Banner.Description>
  </Banner.Content>
</Banner.Root>

Basic

Your trial ends Friday

Add a payment method to keep your team's projects active.

Code
import { Banner, Button } from "@pockets-ui/components"

export default function BannerBasic() {
  return (
    <Banner.Root intent="warning">
      <Banner.Header>
        <Banner.Icon />
        <Banner.Title>Your trial ends Friday</Banner.Title>
        <Banner.Close />
      </Banner.Header>
      <Banner.Content>
        <Banner.Description>
          Add a payment method to keep your team&apos;s projects active.
        </Banner.Description>
      </Banner.Content>
      <Banner.Actions>
        <Button size="sm" variant="outline">
          Add payment
        </Button>
      </Banner.Actions>
    </Banner.Root>
  )
}

Intents

intent picks the semantic color set and the default icon. All four carry _dark values, so dark mode needs nothing extra.

Scheduled maintenance runs Sunday at 02:00 UTC.

Your changes were published.

Two of your integrations need reauthorization.

We couldn't process your last payment.

Code
import { Banner, type BannerIntent } from "@pockets-ui/components"

const MESSAGES: Record<BannerIntent, string> = {
  info: "Scheduled maintenance runs Sunday at 02:00 UTC.",
  success: "Your changes were published.",
  warning: "Two of your integrations need reauthorization.",
  danger: "We couldn't process your last payment.",
}

export default function BannerIntents() {
  return (
    <div style={{ display: "grid", gap: "0.75rem", width: "100%" }}>
      {(Object.keys(MESSAGES) as BannerIntent[]).map((intent) => (
        <Banner.Root key={intent} intent={intent}>
          <Banner.Header>
            <Banner.Icon />
            <Banner.Title>{MESSAGES[intent]}</Banner.Title>
          </Banner.Header>
        </Banner.Root>
      ))}
    </div>
  )
}

Variants

variant is orthogonal to intent: subtle tints the surface, solid fills it, outline draws only the border and inherits whatever page surface it sits on.

variant="subtle"

variant="solid"

variant="outline"

Code
import { Banner } from "@pockets-ui/components"

export default function BannerVariants() {
  return (
    <div style={{ display: "grid", gap: "0.75rem", width: "100%" }}>
      {(["subtle", "solid", "outline"] as const).map((variant) => (
        <Banner.Root key={variant} intent="danger" variant={variant} size="sm">
          <Banner.Header>
            <Banner.Icon />
            <Banner.Title>variant=&quot;{variant}&quot;</Banner.Title>
          </Banner.Header>
        </Banner.Root>
      ))}
    </div>
  )
}

Dismissal

Banner.Close hides the banner on its own — no state needed for the common case. Pass isOpen to Root to take control; Banner.Close then only calls onOpenChange(false) and leaves visibility to you.

Dismissing this fires onOpenChange; the parent decides.

Code
import { Banner, Button } from "@pockets-ui/components"
import { useState } from "react"

export default function BannerControlled() {
  const [isOpen, setIsOpen] = useState(true)

  return (
    <div style={{ display: "grid", gap: "0.75rem", width: "100%" }}>
      <Banner.Root
        intent="info"
        isOpen={isOpen}
        onOpenChange={setIsOpen}
        size="sm"
      >
        <Banner.Header>
          <Banner.Icon />
          <Banner.Title>
            Dismissing this fires <code>onOpenChange</code>; the parent decides.
          </Banner.Title>
          <Banner.Close />
        </Banner.Header>
      </Banner.Root>
      {!isOpen && (
        <Button size="sm" variant="outline" onPress={() => setIsOpen(true)}>
          Bring it back
        </Button>
      )}
    </div>
  )
}

Announcement

Banner.Root is role="status" by default, which waits for a pause in speech before announcing. Pass role="alert" to interrupt — reserve it for messages the user has to act on right now, such as a failed payment blocking checkout.

<Banner.Root intent="danger" role="alert">
  <Banner.Header>
    <Banner.Icon />
    <Banner.Title>Payment failed</Banner.Title>
  </Banner.Header>
</Banner.Root>

Theming

intent sets five custom properties that variant reads, so you can retheme a single banner without forking the recipe:

<Banner.Root
  css={{
    "--pk-banner-bg": "token(colors.bg.premium)",
    "--pk-banner-fg": "token(colors.fg.premium)",
    "--pk-banner-border": "token(colors.border.premium)",
  }}
>
  …
</Banner.Root>

The solid variant reads --pk-banner-solid-bg / --pk-banner-solid-fg instead. Those pairs are verified for contrast as units — override both or neither.