Preview
Installation
Install the runtime packages:
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add banner
Wire the tokens into your Tailwind v4 stylesheet:
/* 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.
'use client';
import { Banner } from '@oshon-ai/components';
export default function Example() {
return <Banner />;
}Strip — desktop (4 states)
<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)
panel, size=m (Figma 288 × 116)
panel, size=m (Figma 288 × 116)
panel, size=m (Figma 288 × 116)
panel, size=m (Figma 288 × 116)
<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)
mobile, size=mobile (Figma 288 × 118)
mobile, size=mobile (Figma 288 × 118)
mobile, size=mobile (Figma 288 × 118)
mobile, size=mobile (Figma 288 × 118)
<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
<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
onDismiss to a local useState; a real app persists the signal (localStorage / server flag / CMS).{
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
{
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.
| Prop | Type | Default | Description |
|---|---|---|---|
title* | ReactNode | — | Banner 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. |
className | string | — | Additional classes merged after the component defaults. |
description | ReactNode | — | Secondary message body. Rendered below (panel / mobile) or to the right (strip). Omitted ⇒ Banner collapses to a title-only notice. |
dismissLabel | string | Dismiss banner | Accessible label for the close button. Default `'Dismiss banner'`. Override for i18n: `dismissLabel={t('banner.dismiss')}`. |
icon | ReactNode | — | Optional 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. |
layout | enum | strip | Layout family. Default `'strip'` (the Figma DesktopTablet row). `'panel'` matches Desktop Panel; `'mobile'` matches Mobile. |
link | ReactNode | BannerLinkSpec | — | Optional 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). |
role | enum | — | ARIA 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). |
size | enum | m | Visual size (scales typography). Default `'m'`. |
state | enum | info | One 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.
<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.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* 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 thedisabledprop
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.
titlePrimary message. Bold, colored by state. Required — a banner without a title should be a `<Card>` instead.
descriptionSecondary message body. Optional. Renders to the right of the title on `strip`; stacks below on `panel` / `mobile`.
iconLeading 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.
linkTrailing 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.
dismissClose 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
<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)}
/><Banner
layout="panel"
state="warning"
title="Trial ending soon"
description="Upgrade to keep your saved dashboards."
link={{ label: "Upgrade plan", href: "/billing" }}
onDismiss={() => hideTrialBanner()}
/><Banner
layout="mobile"
size="mobile"
state="error"
title="Payment failed"
description="Update your card to continue."
link={{ label: "Update card", href: "/billing" }}
/><Banner
state="info"
title="Welcome back"
description="Pick up where you left off."
icon={<SparkleIcon />}
/>✗ Don't
<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.
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 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.