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 snackbar
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 { Snackbar } from '@oshon-ai/components';
export default function Example() {
return <Snackbar />;
}Tones — info / success / warning / error
<div style={{ ...col, gap: '0.75rem', maxWidth: '720px' }}>
<Snackbar
tone="info"
message="Info message goes here."
onDismiss={() => {}}
/>
<Snackbar
tone="success"
message="Success message goes here."
action={{ label: 'Action', onClick: () => {} }}
onDismiss={() => {}}
/>
<Snackbar
tone="warning"
message="Warning message goes here."
action={{ label: 'Action', onClick: () => {} }}
onDismiss={() => {}}
/>
<Snackbar
tone="error"
message="Error message goes here."
action={{ label: 'Action', onClick: () => {} }}
onDismiss={() => {}}
/>
</div>Size matrix — QA grid
size="xs"
size="s"
size="m"
size="l"
size="mobile"
<div style={{ ...col, gap: '1.25rem' }}>
{SIZES.map((size) => (
<section key={size} style={col}>
<h3 style={heading}>size="{size}"</h3>
<div style={{ ...col, gap: '0.5rem' }}>
{TONES.map((tone) => (
<Snackbar
key={tone}
size={size}
tone={tone}
message="Message goes here."
action={{ label: 'Action', onClick: () => {} }}
onDismiss={() => {}}
/>
))}
</div>
</section>
))}
</div>Anatomy — slot combinations
<div style={{ ...col, gap: '1rem', maxWidth: '720px' }}>
<section style={col}>
<div style={caption}>Message only</div>
<Snackbar tone="info" message="Message goes here." />
</section>
<section style={col}>
<div style={caption}>Message + dismiss (× only)</div>
<Snackbar
tone="success"
message="Message goes here."
onDismiss={() => {}}
/>
</section>
<section style={col}>
<div style={caption}>Message + action (no ×)</div>
<Snackbar
tone="info"
message="Message goes here."
action={{ label: 'Action', onClick: () => {} }}
/>
</section>
<section style={col}>
<div style={caption}>Message + action + dismiss (Figma full surface)</div>
<Snackbar
tone="warning"
message="Message goes here."
action={{ label: 'Action', onClick: () => {} }}
onDismiss={() => {}}
/>
</section>
<section style={col}>
<div style={caption}>Custom icon override</div>
<Snackbar
tone="info"
message="Message goes here."
icon={<StarIcon />}
onDismiss={() => {}}
/>
</section>
<section style={col}>
<div style={caption}>icon={null} — drops the icon entirely</div>
<Snackbar
tone="info"
message="Message goes here."
icon={null}
onDismiss={() => {}}
/>
</section>
<section style={col}>
<div style={caption}>Disabled action</div>
<Snackbar
tone="info"
message="Message goes here."
action={{ label: 'Action', onClick: () => {}, disabled: true }}
onDismiss={() => {}}
/>
</section>
</div>Provider — useSnackbar() FIFO queue
useSnackbar() hook — imperative .show / .dismiss
<SnackbarProvider placement="bottom-center" maxVisible={1}>
<div style={{ ...col, gap: '1rem', minHeight: 360 }}>
<h3 style={heading}>useSnackbar() hook — imperative .show / .dismiss</h3>
<div style={{ ...caption, maxWidth: 540 }}>
Click any button to enqueue a snackbar. The provider keeps a FIFO
queue and surfaces one at a time (Material M3 default). Click
"Burst 4" to see the queue cycle through 5s timers.
Hover any visible snackbar to pause its auto-dismiss timer.
Errors are persistent — close them with the × button.
</div>
<QueueDemoButtons />
</div>
</SnackbarProvider>Auto-dismiss + pause on hover
Hover the snackbar to pause its timer
<SnackbarProvider placement="bottom-center">
<div style={{ ...col, minHeight: 320 }}>
<h3 style={heading}>Hover the snackbar to pause its timer</h3>
<CountdownInner />
</div>
</SnackbarProvider>Errors are persistent (no auto-dismiss)
Persistent error — never auto-dismisses
duration: 0 per the WAI-ARIA APG Alert pattern (alerts must not auto-disappear). The snackbar stays until the user clicks Retry or the close ×.<SnackbarProvider placement="bottom-center">
<div style={{ ...col, minHeight: 280 }}>
<h3 style={heading}>Persistent error — never auto-dismisses</h3>
<PersistentErrorInner />
</div>
</SnackbarProvider>Placement — six viewport positions
Pick a placement, then trigger
{
const [placement, setPlacement] = useState<SnackbarPlacement>('bottom-center');
return (
<div style={{ ...col, minHeight: 320 }}>
<h3 style={heading}>Pick a placement, then trigger</h3>
<div style={row}>
{PLACEMENTS.map((p) => (
<button
key={p}
type="button"
style={{
...demoButton,
background:
p === placement
? 'var(--oshon-color-primary-100, #e0fafa)'
: demoButton.background,
borderColor:
p === placement
? 'var(--oshon-color-primary-500, #00afb3)'
: demoButton.border as string,
}}
onClick={() => setPlacement(p)}
>
{p}
</button>
))}
</div>
<SnackbarProvider key={placement} placement={placement} offset={24}>
<PlacementInner placement={placement} />
</SnackbarProvider>
</div>
);
}Region landmark — screen-reader navigation
Region landmark wiring
role="region" landmark with the configured regionLabel. Screen-reader users can jump to the snackbar stack via landmark navigation without breaking their focus context — the snackbar itself never steals focus per the WAI-ARIA APG Alert pattern.<SnackbarProvider placement="top-right" regionLabel="App notifications">
<div style={{ ...col, minHeight: 280 }}>
<h3 style={heading}>Region landmark wiring</h3>
<RegionLandmarkInner />
</div>
</SnackbarProvider>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 |
|---|---|---|---|
message* | ReactNode | — | The message text. Required — a snackbar without a message is meaningless. Accepts ReactNode for inline emphasis or a trailing glyph (e.g. an avatar of the user who left the comment). |
action | SnackbarActionSpec | — | Optional single action button (Polaris + Carbon precedent — at most one action per snackbar). The provider auto-dismisses the snackbar when the action fires. |
className | string | — | Additional classes merged after the component defaults. |
dismissLabel | string | Dismiss | Accessible label for the close button. Default `'Dismiss'`. Override for i18n: `dismissLabel={t('snackbar.dismiss')}`. |
icon | ReactNode | — | Optional leading glyph. Replaces the default tone icon. Pass any 24 × 24 React element; it paints in the tone color via `currentColor`. Pass `null` to drop the icon entirely. |
onDismiss | (() => void) | — | Fired when the close button is activated. Omitted ⇒ no close button is rendered. Provider-managed snackbars wire this automatically. |
pauseOnInteraction | boolean | | Pause auto-dismiss when the snackbar is hovered or focused. Default `true`. Honored by the provider; the primitive alone has no timer to pause, so this prop is a no-op outside the provider. Surfaced here so the contract is one shape. |
role | enum | — | ARIA role override. Auto-derived from `tone` per WCAG SC 4.1.3: `status` for info/success, `alert` for warning/error. Set to `'none'` if another live region already announces the same message (avoids duplicate announcements). |
size | enum | m | Visual size (scales typography + padding). Default `'m'`. |
tone | enum | info | Status tone — drives the border color, the default icon, and the default `role` (info/success → status; warning/error → alert). Default `'info'`. |
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.
<Snackbar 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.
messageRequired body text. Lato SemiBold 14/18 at size `m` (Figma literal). Accepts ReactNode for inline emphasis or trailing avatar.
iconLeading 24 × 24 glyph. Optional — the snackbar picks a sensible default per tone (info circle / check / warning triangle / alert circle). Pass any React element to override, or `null` to drop the icon entirely.
actionAt-most-one trailing action button (Polaris + Carbon precedent). Pass `{ label, onClick, disabled? }`. The provider auto-dismisses the snackbar when the action fires. For multi-action confirmations, use `<Modal>` instead.
dismissClose X button — only renders when `onDismiss` is supplied. Provider-managed snackbars wire this automatically. 20 × 20 with `mix-blend-multiply` per Figma.
Keyboard
Snackbar renders on `role="alert"` (warning/error) or `role="status"` (info/success) per WCAG SC 4.1.3 — consumers can override via the `role` prop if another live region already announces the same message. The provider portal carries `role="region"` + `aria-label="Notifications"` so screen-reader users can land on the stack via landmark navigation. The optional close button is a `<button>` reachable via Tab; the action button (when present) is also a `<button>`. Focus is NEVER stolen on mount — toasts that move focus interrupt the user's task. WCAG SC 2.2.1 Timing Adjustable is satisfied through `pauseOnInteraction` (hover + focus halts the auto-dismiss timer with a 50% extension on resume) plus the provider's ability to disable timers entirely via `durations` overrides.
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-28
Do / Don't
✓ Do
<Snackbar
tone="success"
message="Invoice sent."
action={{ label: "Undo", onClick: () => undoSend() }}
onDismiss={() => hideSnackbar()}
/>// In your root layout:
<SnackbarProvider placement="bottom-center">
<App />
</SnackbarProvider>
// Anywhere downstream:
const snackbar = useSnackbar();
snackbar.show({ tone: "success", message: "Saved." });snackbar.show({
tone: "error",
message: "Couldn't save — check your connection.",
action: { label: "Retry", onClick: () => save() },
// Errors are persistent by default; no duration needed.
});snackbar.show({
tone: "info",
message: "5 new messages",
icon: <BellIcon />,
duration: 10000,
});✗ Don't
<Snackbar message="Delete this file?" action={{ label: "Delete" }} ... />
// + a second "Cancel" actionSnackbars allow at most ONE action (Polaris + Carbon precedent). Confirmations that need user choice belong in `<Modal>` or `<Dialog>` — those surfaces trap focus and have explicit confirm/cancel semantics. A snackbar that asks a destructive question ships an a11y failure: screen readers announce the message but the user has no way to cancel without finding the close X.
snackbar.show({ tone: "error", message: "Save failed", duration: 3000 })WAI-ARIA APG Alert pattern: alerts should NOT auto-disappear. Users need time to read the message + decide what to do. Oshon defaults `tone: "error"` to `duration: 0` (persistent) for this reason. Override only if the underlying data has truly recovered (e.g. a flicker of network unavailability that auto-resolved).
// pauseOnInteraction is the ONLY timing affordance.
WCAG 2.2 SC 2.2.1 Timing Adjustable explicitly calls pause-on-hover insufficient (Failure F40). Oshon satisfies the SC by also offering provider-level `durations` overrides (turn-off mechanism) AND a 50% extension on resume from pause. Apps that ship snackbars with only pause-on-hover and no other timing affordance fail the SC.
<Snackbar message="Visit our help docs" action={{ label: "Open" }} />Snackbars are transient status notices, not navigation surfaces. The user dismisses or ignores; they do not browse. For destination links, use `<Banner>` (persistent, has a `link` slot) or inline page UI.
Design rationale
Two-layer API mirroring Skeleton: primitive `<Snackbar>` for inline use (status row under a form's submit) + `<SnackbarProvider>` + `useSnackbar()` for the typical app-level imperative `snackbar.show({...})` pattern. The primitive is controlled (caller decides mount/unmount) so it composes inside any ancestor (Banner row, Toast region, embedded card). The provider stacks on top of the same primitive — exact render tree, no duplicate component tree. Tone drives THREE things at once: border color, default icon, and live-region role (per WCAG SC 4.1.3). This collapses the typical "should I use status or alert?" footgun by making the right choice the default. Errors are persistent (`duration: 0`) by default per WAI-ARIA APG — errors must not auto-disappear. WCAG SC 2.2.1 Timing Adjustable is satisfied via pauseOnInteraction (hover + focus + 50% extension on resume) AND provider-level `durations` overrides (the "turn off" mechanism). Pause-on-hover alone would not satisfy the SC (Failure F40). At most ONE action (Polaris + Carbon precedent) — multi-action confirmations belong in `<Modal>`. Focus is never stolen — toasts that move focus interrupt the user's task. The provider portal lives in `document.body` so positioning isn't affected by ancestor `transform` / `overflow` containers, with a `role="region"` + `aria-label="Notifications"` landmark so screen-reader users can navigate into the stack.