Snackbar
betav0.1.0Transient feedback message: a severity-tinted surface with a matching left rule, a severity glyph, the message, an action slot and a close control. It replaces the global message both products build by hand — a `@mui/material` `Snackbar` wrapped around a `@mui/material` `Alert`, in `components/Message.tsx` (OpenCTI) and `components/Message.jsx` (OpenAEV). THREE PARTS, and all three are needed: `SnackbarProvider` wraps the app and owns the shared duration, the swipe axis and the live region; `SnackbarViewport` is where messages land, placed 24px from the top and right of the window and portalled into `document.body` so no host layout can bury it; `Snackbar` is the card. Built on `@radix-ui/react-toast`, which is what brings the auto-dismiss timer that pauses on hover and focus, swipe-to-dismiss, the F8 hotkey that moves focus to the messages, and the announcement each severity earns. Messages slide in from the edge the viewport sits against and leave the same way (220ms in, 160ms out, following MUI's Slide), collapsing to a plain fade under prefers-reduced-motion. The message goes in `title` — a one-line Snackbar is a title, never a lone description — and an optional second line in `description`. For a message that stays in the document flow, use `Alert`: same anatomy on an elevation surface, with no close control.
Variants
raise a message — real placement, 24px from the top and right of the window
Messages slide in from the edge the viewport sits against and leave the same way. They land 24px from the top and right of the window, over the page itself — which is where the translucent wash and the 16px backdrop blur become visible. Raise several to see them stack. Each retires after 6s, pausing while the pointer or the keyboard is on it; F8 moves focus to them, and they can be swiped to the right.
every severity — title, description, action and close
one line — the title stays a title, and everything centres on the glyph
no action and no close — the message alone
wrapping description, both slots
Examples
// Once, at the app root.
<SnackbarProvider>
<App />
<SnackbarViewport />
</SnackbarProvider>// Then raise a message from anywhere inside it.
<Snackbar severity="success" title="Report saved." /><Snackbar
severity="error"
title="Import failed"
description="Three observables could not be parsed."
closeLabel={t("Close")}
action={<Button priority="secondary" size="sm" onClick={retry}>Retry</Button>}
actionAltText="Reopen the import panel to try again"
/>// Keep it on screen until it is dismissed by hand.
<Snackbar severity="warning" title="Connection lost." duration={Infinity} />// Drive it from your own state. onOpenChange reports EVERY dismissal — the
// close control, the timer and a swipe alike. Flip `open`; do NOT unmount the
// Snackbar here, or the exit animation has nothing left to play on. Prune
// closed messages later — the next raise is a fine moment, and costs no timer.
<Snackbar
severity={message.severity}
title={message.text}
open={message.open}
onOpenChange={(open) => { if (!open) markClosed(message.id); }}
/>Props
| Prop | Type |
|---|---|
severity | "error" | "info" | "success" | "warning" | "ia" — REQUIRED. Selects the wash, the left rule and the glyph, and decides how the message is announced: error and warning interrupt (Radix `type="foreground"`), the other three wait for a pause. Deliberately has no default: a wrong severity is a wrong message. |
title | ReactNode — REQUIRED. The message, and the primary line. A one-line Snackbar is a title, never a lone description. |
description | ReactNode — optional second line, below the title. |
action | ReactNode — trailing control slot (Figma `actionButton`). Pass a `Button` with `priority="secondary" size="sm"` to match the frame. |
actionAltText | string — what the action does, in words, for a screen-reader user who cannot reach a message that has already gone. Required whenever `action` is set: without it the control is rendered outside Radix's `Toast.Action`, so it no longer dismisses the message, and a dev-only warning says so. |
closable | boolean — render the close control. Defaults to true; the control dismisses the message itself, so there is no handler to wire. |
closeLabel | string — accessible name of the close control, default "Close". Pass the host's translation; it is the card's only user-facing string. |
duration | number — milliseconds before this message retires itself, overriding the provider's. `Infinity` keeps it until it is dismissed. The timer pauses while the pointer or the keyboard is on the message. |
open / defaultOpen / onOpenChange | Radix `Toast.Root` passthrough. `onOpenChange(false)` fires for every dismissal — the close control, the timer and a swipe alike. FLIP `open` there; do NOT unmount the Snackbar. A closing card is only animated while it is still rendered, so removing it from your own state on that callback deletes the exit animation with nothing to warn you. Prune closed messages later — the next raise is a fine moment. |
className | string — merged onto the card through `cn()`. Placement belongs to the viewport, not here. |
SnackbarProvider: duration / swipeDirection / label | The shared defaults, set once at the app root. `duration` is 5000ms unless you say otherwise, `swipeDirection` is "right", and `label` names the live region for screen readers. |
SnackbarViewport: portal / hotkey / label / className | `portal` defaults to true and renders the viewport into `document.body` — measured as load-bearing: inside this repo's own docs layout the viewport was buried under the header at every z-index until it left the host's tree. `hotkey` defaults to F8. `className` is where a different placement goes. |
Accessibility
Ratios are the computed output of pnpm check:contrast. Every surface this component paints is TRANSLUCENT (30 % for the four feedback severities, 10 % for ia), so each one is composited over the page background before measuring — the gate uses --bg-elevation-default, i.e. elevation layer 0, which is the regime this component is designed for (see the layer caveat below). Title (--text-alert), dark then light: on error 15.80:1 / 11.94:1; info 12.77:1 / 13.26:1; success 15.24:1 / 14.10:1; warning 14.49:1 / 14.06:1; ia 14.31:1 / 12.99:1. Description (--text-default-secondary), worst case 5.99:1 (error, light) against a 4.5 floor. Severity glyph and rule, dark then light: error 4.71:1 / 4.49:1; info 7.57:1 / 3.68:1; success 5.59:1 / 4.46:1; warning 5.16:1 / 3.86:1; ia 5.05:1 / 5.38:1. Close glyph (--icon-default) tracks the title values above, 11.94:1 at worst. LAYER CAVEAT, measured and stated because a green gate only covers what was declared to it: the numbers above hold on elevation layers 0, 1 and 2. On a layer-3 surface in dark mode the composited wash lightens enough that the ia glyph falls to 2.83:1, under the 3:1 floor for a graphical object (the worst text pair is still 7.99:1). A Snackbar is a top-level transient surface and belongs over the page background, not nested inside a layer-3 panel — see the usage contract. 1.4.1: severity is never carried by colour alone — each severity has its own glyph shape and the message states the condition; the ia glyph sitting under 3:1 on layer 3 is therefore a redundancy loss, not an information loss. 1.1.1: the severity glyph is decorative, aria-hidden on its wrapper. The close control is an IconButton with a required accessible name (`closeLabel`), so its glyph is decorative too while the button itself is named. 4.1.2 / 4.1.3: the root is a live region — role="alert" (assertive) for error and warning, role="status" (polite) otherwise, overridable through `role`. A live region announces content that arrives after it is in the DOM, which is exactly how a snackbar is mounted. 2.1.1 / 2.4.7: the root is not focusable and does not take focus on mount — a snackbar that stole focus would interrupt whatever is being typed. The close control is a real `<button>` through IconButton and carries the library's standard focus ring; the component adds none of its own because it has nothing else to focus. The component has no disabled state, so no WCAG 1.4.3 Note 1 exemption is claimed.