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 toaster
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 { Toaster } from '@oshon-ai/components';
export default function Example() {
return <Toaster />;
}Tones — info / success / warning / error
<div style={{ ...col, gap: '0.75rem', maxWidth: '720px' }}>
<Toaster
tone="info"
message="Info message goes here."
onDismiss={() => {}}
/>
<Toaster
tone="success"
message="Success message goes here."
action={{ label: 'View', onClick: () => {} }}
onDismiss={() => {}}
/>
<Toaster
tone="warning"
message="Warning message goes here."
action={{ label: 'Action', onClick: () => {} }}
onDismiss={() => {}}
/>
<Toaster
tone="error"
message="Error message goes here."
action={{ label: 'Retry', 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) => (
<Toaster
key={tone}
size={size}
tone={tone}
message="Message goes here."
action={{ label: 'Action', onClick: () => {} }}
onDismiss={() => {}}
/>
))}
</div>
</section>
))}
</div>Layout flavors — desktop pill vs. mobile card
Desktop horizontal pill (max-w 600px)
Mobile vertical card (304 × 159 Figma frame)
<div style={{ ...col, gap: '2rem' }}>
<section style={col}>
<h3 style={heading}>Desktop horizontal pill (max-w 600px)</h3>
<div style={{ ...col, gap: '0.75rem' }}>
{TONES.map((tone) => (
<Toaster
key={tone}
tone={tone}
layout="desktop"
message="Message goes here."
action={{ label: 'Action', onClick: () => {} }}
onDismiss={() => {}}
/>
))}
</div>
</section>
<section style={col}>
<h3 style={heading}>Mobile vertical card (304 × 159 Figma frame)</h3>
<div style={row}>
{TONES.map((tone) => (
<Toaster
key={tone}
tone={tone}
layout="mobile"
message={`${tone.charAt(0).toUpperCase()}${tone.slice(1)} message goes here. Use this when the action needs more breathing room.`}
action={{ label: 'Stay', onClick: () => {} }}
onDismiss={() => {}}
/>
))}
</div>
</section>
</div>Anatomy — slot combinations
<div style={{ ...col, gap: '1rem', maxWidth: '720px' }}>
<section style={col}>
<div style={caption}>Message + dismiss (× only)</div>
<Toaster
tone="info"
message="Message goes here."
onDismiss={() => {}}
/>
</section>
<section style={col}>
<div style={caption}>Message + action + dismiss (Figma full surface)</div>
<Toaster
tone="success"
message="Message goes here."
action={{ label: 'View', onClick: () => {} }}
onDismiss={() => {}}
/>
</section>
<section style={col}>
<div style={caption}>Message-only (provider-managed)</div>
<Toaster tone="warning" message="Message goes here." />
</section>
<section style={col}>
<div style={caption}>Custom icon override</div>
<Toaster
tone="info"
message="Message goes here."
icon={<StarIcon />}
onDismiss={() => {}}
/>
</section>
<section style={col}>
<div style={caption}>icon={null} — drops the icon entirely</div>
<Toaster
tone="info"
message="Message goes here."
icon={null}
onDismiss={() => {}}
/>
</section>
<section style={col}>
<div style={caption}>Disabled action</div>
<Toaster
tone="info"
message="Message goes here."
action={{ label: 'Action', onClick: () => {}, disabled: true }}
onDismiss={() => {}}
/>
</section>
</div>Provider — useToaster() FIFO queue
useToaster() hook — imperative .show / .dismiss
maxVisible cap of 3 (Sonner / Carbon convention). Click "Burst 5" to see two toasts wait while the first three age out via the 5-second timer. Hover any visible toaster to pause its auto-dismiss timer.<ToasterProvider placement="bottom-right" maxVisible={3}>
<div style={{ ...col, gap: '1rem', minHeight: 360 }}>
<h3 style={heading}>useToaster() hook — imperative .show / .dismiss</h3>
<div style={{ ...caption, maxWidth: 540 }}>
Click any button to enqueue a dark toaster. The provider keeps a
FIFO queue with a <code>maxVisible</code> cap of 3 (Sonner /
Carbon convention). Click "Burst 5" to see two toasts
wait while the first three age out via the 5-second timer.
Hover any visible toaster to pause its auto-dismiss timer.
</div>
<QueueDemoButtons />
</div>
</ToasterProvider>Auto-dismiss + pause on hover
Hover the toaster to pause its timer
<ToasterProvider placement="bottom-right">
<div style={{ ...col, minHeight: 320 }}>
<h3 style={heading}>Hover the toaster to pause its timer</h3>
<CountdownInner />
</div>
</ToasterProvider>Persistent error (duration: 0)
Persistent error — never auto-dismisses
duration: 0 per call (or override at the provider level via the durations prop) to make a toast persistent. The toaster stays until the user clicks Retry or the close ×.<ToasterProvider placement="bottom-right">
<div style={{ ...col, minHeight: 280 }}>
<h3 style={heading}>Persistent error — never auto-dismisses</h3>
<PersistentErrorInner />
</div>
</ToasterProvider>Placement — six viewport positions
Pick a placement, then trigger
{
const [placement, setPlacement] = useState<ToasterPlacement>('bottom-right');
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 as string),
borderColor:
p === placement
? 'var(--oshon-color-primary-500, #00afb3)'
: (demoButton.border as string),
}}
onClick={() => setPlacement(p)}
>
{p}
</button>
))}
</div>
<ToasterProvider key={placement} placement={placement} offset={24}>
<PlacementInner placement={placement} />
</ToasterProvider>
</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 |
|---|---|---|---|
message* | ReactNode | — | The message text. Required. Accepts ReactNode for inline emphasis. |
action | ToasterActionSpec | — | Optional CTA pill (Figma "Button" slot). Single action — Carbon / Sonner precedent. |
className | string | — | Additional classes merged after the component defaults. |
dismissLabel | string | Dismiss | Accessible label for the close button. Default `'Dismiss'`. |
icon | ReactNode | — | Optional leading glyph. Replaces the tone default. Pass any React element; it paints in the tone color via `currentColor`. Pass `null` to drop the icon entirely. |
layout | enum | desktop | Layout flavor. `'desktop'` = horizontal; `'mobile'` = vertical card. |
onDismiss | (() => void) | — | Fired when the close X is activated. Provider wires this automatically; the primitive renders the close X iff this prop is supplied (matches Snackbar's contract — Figma authors X on every variant, but the consumer opts in). |
pauseOnInteraction | boolean | | Pause auto-dismiss on hover/focus. Honored by the provider; no-op on the primitive. Surfaced for the unified contract. |
role | enum | — | ARIA role override. Auto-derived from `tone` per SC 4.1.3. |
size | enum | m | Visual size (scales typography + padding). Default `'m'`. |
tone | enum | info | Status tone. Drives icon + body text color + default `role`. 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.
<Toaster 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. Accepts ReactNode for inline emphasis. Text is auto-tinted to the tone -400 shade.
iconOptional leading glyph. Tone-default icon (Info / CircleCheck / Warning / Alert) renders unless overridden. Pass `null` to drop the glyph entirely.
actionOptional CTA pill (Figma "Button" slot). Single action — `{ label, onClick, disabled? }`. Provider auto-dismisses the toast when fired. Desktop pill: 24px tall, capitalize, max-w 140px. Mobile: 32px tall, max-w 220px.
closeAlways-rendered 16×16 close X (Figma authors X on every variant). Provider wires `onDismiss` automatically; consumers using the primitive directly opt in by passing `onDismiss`.
Keyboard
Toast container is a live region (role="status" for info/success, role="alert" for warning/error per SC 4.1.3). The CTA + close X are reachable via Tab in DOM order. Provider does NOT steal focus — toasts that grab focus interrupt the user's task. SC 2.2.1 satisfied via pause-on-hover/focus + 50% extension on resume + provider-level `durations` override (the "turn off" mechanism).
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-29
Do / Don't
✓ Do
<ToasterProvider placement="bottom-right" maxVisible={3}>
<App />
</ToasterProvider>const toaster = useToaster();
toaster.show({
tone: 'success',
message: 'Profile updated successfully.',
action: { label: 'View', onClick: () => goToProfile() },
});<Toaster
tone="info"
message="Saved your draft."
onDismiss={hide}
/><Toaster
layout="mobile"
tone="warning"
message="Changes will be lost if you leave this page."
action={{ label: 'Stay', onClick: () => stay() }}
onDismiss={hide}
/>✗ Don't
// 200 successive .show() calls during a bulk operation
rows.forEach((r) => toaster.show({ tone: "success", message: r.name }));Toaster ships a FIFO queue, but the `maxVisible` cap is 3 — flooding the queue makes every toast wait minutes to surface. For bulk operations, surface ONE toast with a counter ("12 rows imported") and link to the full log. Use Snackbar (inline) or Banner (persistent) when you need finer-grained per-row feedback.
<Toaster action={[
{ label: "Undo", onClick: undo },
{ label: "Redo", onClick: redo }
]} />Figma authors exactly one CTA. Two CTAs on a transient surface confuse the action hierarchy — users miss the close X, screen readers announce them as siblings of equal weight. If you need two actions, the right surface is `<Modal>` or `<Dialog>`.
<Toaster className="bg-blue-900" tone="info" message="..." />
Breaks white-labeling (principle #6). The dark slab is `--oshon-color-neutral-900`; `applyTheme()` retints it via the var. If a brand needs a different toast bg, extend the manifest + add a token mapping — never reach for a one-off class.
Design rationale
Toaster sits next to Snackbar in the Oshon vocabulary as the dark, viewport-floating variant — Snackbar is the pale, inline variant. Both share the same FIFO queue + portal + pause-on-hover + 50% resume-extension scaffolding (WCAG SC 2.2.1) but the visual chrome is intentionally different so apps can mount both without confusing the two surfaces. The dark `--gray-900` slab is constant across all four tones; only the leading icon + body text recolor (per the Figma `*-400` shade family). Mobile gets a vertical 304×159 card flavor with the CTA pinned beneath the message — Figma authoring matches this exactly. Default placement is `bottom-right` (Sonner / Carbon notification stack convention) and default duration auto-dismisses every tone (5s info/success, 8s warning/error) because the close X is always visible — a Toaster never ships without a way to dismiss it. Five-size axis covers the rule #2 contract; only `m` and `mobile` are Figma-anchored, others interpolate proportionally.