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" />
    </>
  );
}
toast.success('Deployed', { description: '3 services in 42s' });
toast.error('Deploy failed', { description: 'Build exited with 1' });
toast.warning('Two services are unhealthy');

const id = toast.loading('Deploying…');
toast.success('Deployed', { id });   // same slot, no exit, no re-entry

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 value

Examples

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

PropType
classNamestringAdded to the component’s own classes, so yours wins
styleCSSPropertiesMerged 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.

OptionTypeDefault
idnumbernew idReuse an existing toast instead of pushing one
descriptionstringSecond line
durationnumberToaster'sSeconds. Infinity never expires
modeStripModeToaster'snone drops the strip for this toast only
iconReactNodetone'sOverrides the state’s icon
cells[string, string, string]tone'sOverrides the strip’s three shades
actionToastAction | nulltone'snull drops the one the state offers
PropTypeDefault
positionToastPosition'bottom-right'Six anchors
durationnumber5Default seconds of life
mode'plain' | 'snake' | 'none''plain'How the lit part of the strip moves
widthnumber360
offsetnumber16Distance from the viewport edges
gapnumber12Between cards once expanded
peeknumber14How much of each card behind shows when collapsed
scaleStepnumber0.05Scale shed per card going back
visiblenumber3Cards on screen while collapsed
stiffnessnumber420Spring strength
dampingnumber34Spring friction
tonesRecord<string, Tone>built-insPer-state look, merged over the defaults
labelstring'Notifications'Accessible name of the region
closeLabelstring'Dismiss'
theme'dark' | 'light''dark'