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

Snackbar

System messages — Warning / Error / Info with stack data-scaling.

Preview

Live preview
@oshon-ai/components
Default
Saved.
Tones — info / success / warning / error
A new operator joined the policy.
Permissions saved. Applied to 12 operators.
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 snackbar

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

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

Tones — info / success / warning / error

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

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) => (
              <Snackbar
                key={tone}
                size={size}
                tone={tone}
                message="Message goes here."
                action={{ label: 'Action', onClick: () => {} }}
                onDismiss={() => {}}
              />
            ))}
          </div>
        </section>
      ))}
    </div>

Anatomy — slot combinations

Message only
Message goes here.
Message + dismiss (× only)
Message goes here.
Message + action (no ×)
Message goes here.
Message + action + dismiss (Figma full surface)
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 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=&#123;null&#125; — 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

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.
tsx
<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
          &quot;Burst 4&quot; 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

Hover the snackbar 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
<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

Errors default to 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 ×.
tsx
<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

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

The provider portal renders a 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.
tsx
<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.

PropTypeDefaultDescription
message*ReactNodeThe 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).
actionSnackbarActionSpecOptional single action button (Polaris + Carbon precedent — at most one action per snackbar). The provider auto-dismisses the snackbar when the action fires.
classNamestringAdditional classes merged after the component defaults.
dismissLabelstringDismissAccessible label for the close button. Default `'Dismiss'`. Override for i18n: `dismissLabel={t('snackbar.dismiss')}`.
iconReactNodeOptional 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.
pauseOnInteractionbooleanPause 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.
roleenumARIA 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).
sizeenummVisual size (scales typography + padding). Default `'m'`.
toneenuminfoStatus 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.

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

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. Lato SemiBold 14/18 at size `m` (Figma literal). Accepts ReactNode for inline emphasis or trailing avatar.

icon

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

action

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

dismiss

Close 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

Inline success snackbar with action
<Snackbar
  tone="success"
  message="Invoice sent."
  action={{ label: "Undo", onClick: () => undoSend() }}
  onDismiss={() => hideSnackbar()}
/>
Provider-managed (the typical app-level pattern)
// In your root layout:
<SnackbarProvider placement="bottom-center">
  <App />
</SnackbarProvider>

// Anywhere downstream:
const snackbar = useSnackbar();
snackbar.show({ tone: "success", message: "Saved." });
Persistent error with retry action
snackbar.show({
  tone: "error",
  message: "Couldn't save — check your connection.",
  action: { label: "Retry", onClick: () => save() },
  // Errors are persistent by default; no duration needed.
});
Custom icon + extended duration
snackbar.show({
  tone: "info",
  message: "5 new messages",
  icon: <BellIcon />,
  duration: 10000,
});

✗ Don't

Multiple actions on one snackbar
<Snackbar message="Delete this file?" action={{ label: "Delete" }} ... />
// + a second "Cancel" action

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

Auto-dismissing errors
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).

Pause-on-hover as the only timing-adjustable mechanism
// 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 for navigation
<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.