Feedback
Toast
A notification stack. Cards pile up in a corner with the newest in front, hovering fans them out, and each card carries a strip of cells that burns down as its time runs out — the countdown is the decoration.
Usage
import { Toaster, toast } from '@carabine/ui/toast';
function App() {
return (
<>
<YourApp />
<Toaster position="bottom-right" />
</>
);
}Mount one <Toaster />. The store is a singleton, so a second viewport draws every card twice.
The imperative API
A toast is fired from wherever the thing happened — a submit handler, a websocket message, a catch block — and none of those places has a React tree to render into. So the API is a function, the state is a store, and the viewport is mounted once at the root where its live region can exist before the content it announces.
toast.success(title, options?) // → id
toast.error(title, options?)
toast.warning(title, options?)
toast.loading(title, options?) // sticky: it ends when you end it
toast(state, title, options?) // any state, including one you invented
toast.dismiss(id?) // no id dismisses everything
toast.promise(work, copy) // → the promise's valueExamples
Updating in place
Pushing with an existing id replaces the contents and keeps the slot — no exit, no re-entry, no jump. toast.promise is that pattern wrapped up, and it re-throws: the toast reports the failure, it does not swallow it.
await toast.promise(deploy(), {
loading: { title: 'Deploying…' },
success: (result) => ({ title: 'Deployed', description: `${result.count} services` }),
error: (err) => ({ title: 'Deploy failed', description: String(err) }),
});Tones
A tone is what a state looks like: an icon, a colour per theme, three shades for the strip, and optionally an action it offers. The key set is open, so the same prop restyles a built-in state or invents one.
import { Toaster, defaultTones, toast } from '@carabine/ui/toast';
<Toaster
tones={{
// the merge is per state, so a state you change spreads the default
success: { ...defaultTones.success, cells: ['#34d399', '#10b981', '#059669'] },
deploy: {
icon: <Rocket className="size-4" />,
color: { dark: '#a78bfa', light: '#7c3aed' },
cells: ['#c4b5fd', '#a78bfa', '#8b5cf6'],
sticky: true,
action: { label: 'Logs', onClick: (id) => open(id) },
},
}}
/>
toast('deploy', 'Shipping to production…');Customization
<Toaster
position="top-center" // six anchors: top/bottom × left/center/right
duration={7} // seconds of life
mode="snake" // how the lit part of the strip moves. none drops it
width={400}
offset={20} // from the viewport edges
gap={12} // between cards once expanded
peek={14} // how much of each card behind shows when collapsed
scaleStep={0.05} // scale shed per card going back
visible={4} // cards on screen while collapsed
theme="light"
/>Styling reference
The card's core is opaque, and it is the one exception to the house's glass density. A card is the only surface here that floats over another copy of itself: what would show through is the card behind it, offset by the peek and scaled down — a duplicate of the same object, which reads as a rendering fault rather than as material.
The blur cannot rescue that case either. A transform on an ancestor establishes a backdrop root, and backdrop-filter inside one has nothing left to sample — so anything living inside an animated motion.div is getting no blur at all, whatever the class says. A front card looking see-through and then settling opaque is that, not an entrance. The shell above it stays glass, because a shell is only ever seen as a ring, and the ring is over the page.
API reference
| Prop | Type | |
|---|---|---|
| className | string | Added to the component’s own classes, so yours wins |
| style | CSSProperties | Merged after the component’s own inline styles, so yours wins |
Colours are data and dimensions are props, and neither covers a margin, a font, or a class from your own system. That is what these are for. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.
| Option | Type | Default | |
|---|---|---|---|
| id | number | new id | Reuse an existing toast instead of pushing one |
| description | string | — | Second line |
| duration | number | Toaster's | Seconds. Infinity never expires |
| mode | StripMode | Toaster's | none drops the strip for this toast only |
| icon | ReactNode | tone's | Overrides the state’s icon |
| cells | [string, string, string] | tone's | Overrides the strip’s three shades |
| action | ToastAction | null | tone's | null drops the one the state offers |
| Prop | Type | Default | |
|---|---|---|---|
| position | ToastPosition | 'bottom-right' | Six anchors |
| duration | number | 5 | Default seconds of life |
| mode | 'plain' | 'snake' | 'none' | 'plain' | How the lit part of the strip moves |
| width | number | 360 | |
| offset | number | 16 | Distance from the viewport edges |
| gap | number | 12 | Between cards once expanded |
| peek | number | 14 | How much of each card behind shows when collapsed |
| scaleStep | number | 0.05 | Scale shed per card going back |
| visible | number | 3 | Cards on screen while collapsed |
| stiffness | number | 420 | Spring strength |
| damping | number | 34 | Spring friction |
| tones | Record<string, Tone> | built-ins | Per-state look, merged over the defaults |
| label | string | 'Notifications' | Accessible name of the region |
| closeLabel | string | 'Dismiss' | |
| theme | 'dark' | 'light' | 'dark' |