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

Toaster

Toaster — Success / Info / Warning / Error · Desktop + Mobile.

Preview

Live preview
@oshon-ai/components
Default
Saved.
Tones — info / success / warning / error
A new operator joined the policy.
Saved. Auto-syncing to the audit log.
With action button
Record archived.
Dismissable (close X)
Drafts auto-save every 30 seconds.
Action + dismiss together
Sizes
Size xs
Size s
Size m
Size l
Size mobile

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 toaster

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 { Toaster } from '@oshon-ai/components';

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

Tones — info / success / warning / error

Info message goes here.
Success message goes here.
tsx
<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"

Message goes here.
Message goes here.

size="s"

Message goes here.
Message goes here.

size="m"

Message goes here.
Message goes here.

size="l"

Message goes here.
Message goes here.

size="mobile"

Message goes here.
Message goes here.
tsx
<div style={{ ...col, gap: '1.25rem' }}>
      {SIZES.map((size) => (
        <section key={size} style={col}>
          <h3 style={heading}>size=&quot;{size}&quot;</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)

Message goes here.
Message goes here.

Mobile vertical card (304 × 159 Figma frame)

Info message goes here. Use this when the action needs more breathing room.
Success message goes here. Use this when the action needs more breathing room.
tsx
<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

Message + dismiss (× only)
Message goes here.
Message + action + dismiss (Figma full surface)
Message goes here.
Message-only (provider-managed)
Custom icon override
Message goes here.
icon={null} — drops the icon entirely
Message goes here.
Disabled action
Message goes here.
tsx
<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=&#123;null&#125; — 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

Click any button to enqueue a dark toaster. The provider keeps a FIFO queue with a 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.
tsx
<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 &quot;Burst 5&quot; 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

Hover the toaster to halt its 5-second timer (WCAG SC 2.2.1 Timing Adjustable). On mouse-leave the timer resumes with a 50% extension so you get a second window if you almost dismissed it.
tsx
<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

Toaster auto-dismisses every tone by default (5s info/success, 8s warning/error) because the close X is always visible. Pass 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 ×.
tsx
<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

tsx
{
    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.

PropTypeDefaultDescription
message*ReactNodeThe message text. Required. Accepts ReactNode for inline emphasis.
actionToasterActionSpecOptional CTA pill (Figma "Button" slot). Single action — Carbon / Sonner precedent.
classNamestringAdditional classes merged after the component defaults.
dismissLabelstringDismissAccessible label for the close button. Default `'Dismiss'`.
iconReactNodeOptional 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.
layoutenumdesktopLayout 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).
pauseOnInteractionbooleanPause auto-dismiss on hover/focus. Honored by the provider; no-op on the primitive. Surfaced for the unified contract.
roleenumARIA role override. Auto-derived from `tone` per SC 4.1.3.
sizeenummVisual size (scales typography + padding). Default `'m'`.
toneenuminfoStatus 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.

tsx
<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.

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.

message

Required body text. Accepts ReactNode for inline emphasis. Text is auto-tinted to the tone -400 shade.

icon

Optional leading glyph. Tone-default icon (Info / CircleCheck / Warning / Alert) renders unless overridden. Pass `null` to drop the glyph entirely.

action

Optional 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.

close

Always-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

Provider at app root
<ToasterProvider placement="bottom-right" maxVisible={3}>
  <App />
</ToasterProvider>
Imperative show via hook
const toaster = useToaster();
toaster.show({
  tone: 'success',
  message: 'Profile updated successfully.',
  action: { label: 'View', onClick: () => goToProfile() },
});
Inline desktop primitive
<Toaster
  tone="info"
  message="Saved your draft."
  onDismiss={hide}
/>
Mobile card with CTA
<Toaster
  layout="mobile"
  tone="warning"
  message="Changes will be lost if you leave this page."
  action={{ label: 'Stay', onClick: () => stay() }}
  onDismiss={hide}
/>

✗ Don't

Stacking unbounded toasts
// 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.

Putting two CTAs on one toast
<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>`.

Hardcoded color override on the dark bg
<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.