Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
CommunicationUpdatedFreeWCAG 2.2 AA

Banner

Page-level notification banner.

Preview

Live preview
@oshon-ai/components
Strip — 6 states
Pro features are now available
Per-seat pricing with annual commit.
Workspace migrated
Schema 2.0 is live on all replicas.
Draft saved
You have unpublished changes.
Brand accent
The Oshon-themed banner — same surface as the secondary button.
Panel layout — Desktop card
Pro features
Per-seat pricing with annual commit.
Sync complete
3,241 records moved successfully.
Mobile card layout
New product
Oshon Pro launches with white-label theming.
Dismissable
Cookies
We use minimal cookies to keep you signed in.
Sizes
Size xs
Same content across the size axis.
Size s
Same content across the size axis.
Size m
Same content across the size axis.
Size l
Same content across the size axis.
Size mobile
Same content across the size axis.

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add banner

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { Banner } from '@oshon-ai/components';

export default function Example() {
  return <Banner />;
}

Strip — desktop (4 states)

Message Title
Message details.
Blue Stone — Informational · strip, size=m (Figma 1032 × 48)
Message Title
Message details.
Leafy — Success · strip, size=m (Figma 1032 × 48)
Tradewinds — Warning · strip, size=m (Figma 1032 × 48)
Oops — Error · strip, size=m (Figma 1032 × 48)
tsx
<div style={col}>
      {BANNER_STATES.map((b) => (
        <div
          key={b.state}
          style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
        >
          <Banner
            state={b.state}
            layout="strip"
            title={b.titleStrip}
            description={b.description}
            link={{ label: b.link, href: '#' }}
            onDismiss={() => {}}
          />
          <div style={caption}>
            <strong style={{ color: 'var(--oshon-color-neutral-900)' }}>
              {b.family}
            </strong>{' '}
            · strip, size=m (Figma 1032 × 48)
          </div>
        </div>
      ))}
    </div>

Panel — desktop card (4 states)

Title Message
Message details.
Blue Stone — Informational
panel, size=m (Figma 288 × 116)
Message Title
Message details.
Leafy — Success
panel, size=m (Figma 288 × 116)
Tradewinds — Warning
panel, size=m (Figma 288 × 116)
Oops — Error
panel, size=m (Figma 288 × 116)
tsx
<div style={col}>
      <div style={{ ...row, gap: '1.5rem' }}>
        {BANNER_STATES.map((b) => (
          <div
            key={b.state}
            style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
          >
            <Banner
              state={b.state}
              layout="panel"
              title={b.titlePanel}
              description={b.description}
              link={{ label: b.link, href: '#' }}
              onDismiss={() => {}}
            />
            <div style={caption}>
              <strong style={{ color: 'var(--oshon-color-neutral-900)' }}>
                {b.family}
              </strong>
              <br />
              panel, size=m (Figma 288 × 116)
            </div>
          </div>
        ))}
      </div>
    </div>

Mobile — touch card (4 states)

Title Message
Message details.
Blue Stone — Informational
mobile, size=mobile (Figma 288 × 118)
Message Title
Message details.
Leafy — Success
mobile, size=mobile (Figma 288 × 118)
Tradewinds — Warning
mobile, size=mobile (Figma 288 × 118)
Oops — Error
mobile, size=mobile (Figma 288 × 118)
tsx
<div style={col}>
      <div style={{ ...row, gap: '1.5rem' }}>
        {BANNER_STATES.map((b) => (
          <div
            key={b.state}
            style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
          >
            <Banner
              state={b.state}
              layout="mobile"
              size="mobile"
              title={b.titlePanel}
              description={b.description}
              link={{ label: b.link, href: '#' }}
              onDismiss={() => {}}
            />
            <div style={caption}>
              <strong style={{ color: 'var(--oshon-color-neutral-900)' }}>
                {b.family}
              </strong>
              <br />
              mobile, size=mobile (Figma 288 × 118)
            </div>
          </div>
        ))}
      </div>
    </div>

Size matrix — QA grid

layout = strip
size=xs
Message Title
Message details.
size=s
Message Title
Message details.
size=m
Message Title
Message details.
size=l
Message Title
Message details.
size=mobile
Message Title
Message details.
layout = panel
size=xs
Message Title
Message details.
size=s
Message Title
Message details.
size=m
Message Title
Message details.
size=l
Message Title
Message details.
size=mobile
Message Title
Message details.
layout = mobile
size=xs
Message Title
Message details.
size=s
Message Title
Message details.
size=m
Message Title
Message details.
size=l
Message Title
Message details.
size=mobile
Message Title
Message details.
tsx
<div style={col}>
      {LAYOUTS.map((layout) => (
        <div
          key={layout}
          style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
        >
          <div style={caption}>
            <strong style={{ color: 'var(--oshon-color-neutral-900)' }}>
              layout = {layout}
            </strong>
          </div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
            {SIZES.map((size) => (
              <div
                key={size}
                style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem' }}
              >
                <span style={{ ...caption, minWidth: '72px', paddingTop: '14px' }}>
                  size={size}
                </span>
                <Banner
                  state="info"
                  layout={layout}
                  size={size}
                  title="Message Title"
                  description="Message details."
                  link={{ label: 'Optional Link', href: '#' }}
                  onDismiss={() => {}}
                />
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>

Dismiss — live interaction

Click × on any banner to dismiss it. Banner does NOT track dismissed state internally — the consumer owns visibility. This story wires onDismiss to a local useState; a real app persists the signal (localStorage / server flag / CMS).
Message Title
Message details.
Message Title
Message details.
tsx
{
    const [remaining, setRemaining] = useState<ReadonlyArray<BannerState>>([
      'info',
      'success',
      'warning',
      'error',
    ]);
    return (
      <div style={col}>
        <div style={caption}>
          Click × on any banner to dismiss it. Banner does NOT track
          dismissed state internally — the consumer owns visibility. This
          story wires <code>onDismiss</code> to a local <code>useState</code>;
          a real app persists the signal (localStorage / server flag / CMS).
        </div>
        {remaining.length === 0 ? (
          <div
            style={{
              ...caption,
              padding: '1rem',
              textAlign: 'center',
              border: '1px dashed var(--oshon-color-neutral-300, #d9d9d9)',
              borderRadius: '8px',
            }}
          >
            All banners dismissed.
          </div>
        ) : (
          remaining.map((state) => {
            const def = BANNER_STATES.find((b) => b.state === state)!;
            return (
              <Banner
                key={state}
                state={state}
                layout="strip"
                title={def.titleStrip}
                description={def.description}
                link={{ label: def.link, href: '#' }}
                onDismiss={() =>
                  setRemaining((prev) => prev.filter((s) => s !== state))
                }
              />
            );
          })
        )}
        <button
          type="button"
          onClick={() =>
            setRemaining(['info', 'success', 'warning', 'error'])
          }
          style={{
            alignSelf: 'flex-start',
            padding: '0.5rem 0.75rem',
            borderRadius: '6px',
            border: '1px solid var(--oshon-color-neutral-300, #d9d9d9)',
            background: 'var(--oshon-color-neutral-100, #f3f4f4)',
            cursor: 'pointer',
            font: 'inherit',
          }}
        >
          Restore all banners
        </button>
      </div>
    );
  }

Onboarding — working demo

Working SaaS onboarding-banner demo. Pick a scenario to see the Banner swap state, title, description, and link in lock-step. Each banner is independently dismissable.
tsx
{
    const [scenarioId, setScenarioId] = useState<string>('trial');
    const [dismissed, setDismissed] = useState<Record<string, boolean>>({});
    const active = SCENARIOS.find((s) => s.id === scenarioId)!;
    const isDismissed = !!dismissed[scenarioId];

    return (
      <div style={col}>
        <div style={caption}>
          Working SaaS onboarding-banner demo. Pick a scenario to see the
          Banner swap state, title, description, and link in lock-step.
          Each banner is independently dismissable.
        </div>
        <div style={row}>
          <label
            style={{
              ...caption,
              display: 'flex',
              gap: '0.5rem',
              alignItems: 'center',
            }}
          >
            Scenario
            <select
              value={scenarioId}
              onChange={(e) => setScenarioId(e.target.value)}
              style={{
                padding: '0.35rem 0.5rem',
                borderRadius: '6px',
                border: '1px solid var(--oshon-color-neutral-300, #d9d9d9)',
                background: 'var(--oshon-color-neutral-50, #fafafa)',
                font: 'inherit',
              }}
            >
              {SCENARIOS.map((s) => (
                <option key={s.id} value={s.id}>
                  {s.label}
                </option>
              ))}
            </select>
          </label>
          <button
            type="button"
            onClick={() => setDismissed({})}
            style={{
              padding: '0.35rem 0.75rem',
              borderRadius: '6px',
              border: '1px solid var(--oshon-color-neutral-300, #d9d9d9)',
              background: 'var(--oshon-color-neutral-100, #f3f4f4)',
              cursor: 'pointer',
              font: 'inherit',
            }}
          >
            Restore dismissed
          </button>
        </div>
        {isDismissed ? (
          <div
            style={{
              ...caption,
              padding: '1rem',
              textAlign: 'center',
              border: '1px dashed var(--oshon-color-neutral-300, #d9d9d9)',
              borderRadius: '8px',
            }}
          >
            Scenario "{active.label}" dismissed. Pick another scenario or
            click "Restore dismissed".
          </div>
        ) : (
          <Banner
            state={active.state}
            layout="strip"
            title={active.title}
            description={active.description}
            link={{ label: active.link, href: '#' }}
            onDismiss={() =>
              setDismissed((prev) => ({ ...prev, [scenarioId]: true }))
            }
          />
        )}
      </div>
    );
  }

API

Every prop is documented here directly from the component's TypeScript interface. Inherited DOM attributes (aria-*, onClick, style, etc.) work as usual but are omitted from this table.

PropTypeDefaultDescription
title*ReactNodeBanner title text. Bold, status-colored. Required — a banner without a title is a panel; use `<Card>` instead. Accepts ReactNode for consumers that need inline emphasis or a trailing glyph.
classNamestringAdditional classes merged after the component defaults.
descriptionReactNodeSecondary message body. Rendered below (panel / mobile) or to the right (strip). Omitted ⇒ Banner collapses to a title-only notice.
dismissLabelstringDismiss bannerAccessible label for the close button. Default `'Dismiss banner'`. Override for i18n: `dismissLabel={t('banner.dismiss')}`.
iconReactNodeOptional leading glyph. Replaces the default status icon. Pass any 20 × 20 React element; it paints in the state's text color via `currentColor`. Pass `null` to drop the icon entirely.
layoutenumstripLayout family. Default `'strip'` (the Figma DesktopTablet row). `'panel'` matches Desktop Panel; `'mobile'` matches Mobile.
linkReactNode | BannerLinkSpecOptional trailing link. Either a `{ label, href, onClick }` descriptor (Banner renders the anchor) or a ReactNode (consumer renders their own — e.g. a Next.js `<Link>` or a `<button>` that opens a Dialog).
onDismiss(() => void)Fired when the close button is activated. Omitted ⇒ no close button is rendered (static banner).
roleenumARIA landmark role. Banner picks a sensible default per state (`alert` for warning/error; `status` for info/success/neutral/plum) — set this prop to override (e.g. to silence an error banner in a context where another live region already announces it).
sizeenummVisual size (scales typography). Default `'m'`.
stateenuminfoOne of six canonical status palettes. Default `'info'`. The four Figma-authored states (info / success / warning / error) pick the default icon; neutral / plum fall back to the info icon — override with `icon` if the consumer wants a custom glyph.

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<Banner
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

title

Primary message. Bold, colored by state. Required — a banner without a title should be a `<Card>` instead.

description

Secondary message body. Optional. Renders to the right of the title on `strip`; stacks below on `panel` / `mobile`.

icon

Leading 20 × 20 glyph. Optional — Banner picks a sensible default per state (info circle / check / warning triangle / alert circle). Pass any React element to override, or `null` to drop the icon entirely.

link

Trailing link. Accepts a `{ label, href, onClick, target }` descriptor (Banner renders a themed `<a>`) or any ReactNode (consumer owns the markup — e.g. a Next.js `<Link>` or a `<button>` that opens a Dialog). Link inherits the state color so it tints with the surface.

dismiss

Close button. Only renders when `onDismiss` is supplied. Banner does NOT track local dismissed state — consumers persist the signal themselves (CMS, localStorage, server flag). Accessible label defaults to `"Dismiss banner"`; override via `dismissLabel` for i18n.

Keyboard

Close button is a <button> — reachable via Tab, activated by Enter / Space. Focus ring follows the Oshon token. The banner itself renders on `role="alert"` (warning / error) or `role="status"` (info / success / neutral / plum); consumers can override via the `role` prop if another live region already announces the same message. The link slot renders an `<a>` when a `BannerLinkSpec` descriptor is supplied; `target="_blank"` auto-attaches `rel="noopener noreferrer"`.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-21

Do / Don't

✓ Do

Info strip with link + dismiss
<Banner
  state="info"
  title="New features available"
  description="Version 2.0 brings six new components."
  link={{ label: "See what's new", href: "/changelog" }}
  onDismiss={() => setDismissed(true)}
/>
Warning panel (card layout)
<Banner
  layout="panel"
  state="warning"
  title="Trial ending soon"
  description="Upgrade to keep your saved dashboards."
  link={{ label: "Upgrade plan", href: "/billing" }}
  onDismiss={() => hideTrialBanner()}
/>
Mobile error card (live region fires on mount)
<Banner
  layout="mobile"
  size="mobile"
  state="error"
  title="Payment failed"
  description="Update your card to continue."
  link={{ label: "Update card", href: "/billing" }}
/>
Custom icon override
<Banner
  state="info"
  title="Welcome back"
  description="Pick up where you left off."
  icon={<SparkleIcon />}
/>

✗ Don't

Hardcoded status color
<Banner state="info" className="bg-teal-50 text-teal-800" title="..." />

Breaks white-labeling (principle #6). Every state surface flows through `@oshon-ai/tokens` so `applyTheme({ primarySeed })` retints every banner in one DOM write. If you need a palette outside the six-state union, extend `banner-shared.tsx` and add the token mapping — never reach for a Tailwind color shortcut.

Tracking dismissed state inside Banner
Banner does NOT track dismiss state internally.

Onboarding and announcement banners need to stay dismissed across sessions. Banner fires `onDismiss` and lets the consumer persist the signal (CMS flag, localStorage key, server preference). A component-local `useState` would hide the banner on this render only and resurrect it on the next mount — a known support footgun.

Banner as a dialog / modal
<Banner state="error" title="Are you sure?" onDismiss={close} />

Banner is a non-blocking status notice on `role="status"` or `role="alert"`. Confirmations that interrupt the user flow belong in `<Dialog>` (modal, focus trap, Escape to close). Misusing Banner here ships an accessibility failure — screen readers announce the message but the user has no way to confirm or cancel.

Design rationale

Single Banner component with a `layout` axis instead of three separate components (BannerStrip / BannerPanel / BannerMobile) because the three Figma layouts share 95% of the same props surface — duplicating them would triple the API surface without adding capability. The state + size vocabularies mirror Badge exactly so the two components compose naturally in status rows and empty states. Banner renders as a <div> with a semantic `role` picked from the state (alert for destructive / cautionary, status for the rest) so the correct live-region politeness applies without consumer plumbing. The close button only renders when `onDismiss` is supplied — making dismissibility explicit prevents accidental "can this banner be dismissed?" support tickets. Themed link inherits the state color (teal for info, green for success, etc.) so links never require per-state styling upstream.