MynduiMyndui
0

Anatomy of the Toast

How toast() reaches any component without context, and how a stack of plain divs becomes a hoverable, swipeable pile.

01/04Anatomy of the stack
Front
index 0 · y 0 · scale 1
Peek 1
index 1 · y −16 · scale 0.95
Peek 2
index 2 · y −32 · scale 0.90
tsx
// Stacking tuning.const GAP = 14; // px between toasts when expandedconst PEEK = 16; // px each toast peeks out behind the front when collapsedconst SCALE_STEP = 0.05; // scale lost per depth when collapsedconst MAX_VISIBLE = 3; // toasts shown behind the front when collapsedconst dir = isBottom ? -1 : 1; // stack grows away from the anchored edgeconst hidden = !expanded && index >= MAX_VISIBLE;const y = expanded ? dir * expandedOffset : dir * index * PEEK;const scale = expanded ? 1 : Math.max(0, 1 - index * SCALE_STEP);
01

Anatomy of the stack

toast() works from anywhere — an event handler, a fetch callback, a non-component utility function — with no <ToastContext> to wrap your app in. That's because there isn't one. The whole system is a module-level array and a Set of subscribers.

tsx
 
// Minimal external store so `toast()` can be called from anywhere, no context needed.
let counter = 0;
let records: ToastRecord[] = [];
const listeners = new Set<(r: ToastRecord[]) => void>();

function emit() {
  for (const l of listeners) l(records);
}

function addToast(options: ToastOptions): number {
  const id = ++counter;
  records = [{ id, variant: "default", ...options }, ...records];
  emit();
  return id;
}

ToastProvider is the only thing that subscribes: it calls setItems inside a useEffect, adding itself to listeners. Call toast() from a plain function with no React tree in scope, and it mutates records and notifies every mounted ToastProvider — typically one, rendered once near your app root.

The stack isn't a <ul> with gap; it's absolutely-positioned cards that compute their own offset from their index.

Every card is full-size and positioned at the same inset-x-0; it's the y offset and scale shrinking with depth that make only a sliver of each back card visible above the front one. Past MAX_VISIBLE, a toast still exists in the DOM — it's just opacity: 0, ready the instant it's promoted.

02/04Expand on hover
Collapsed
y = dir × index × PEEK, timers run
Expanded
hover → real offsets, timers pause
tsx
<motion.olonMouseEnter={() => setExpanded(true)}onMouseLeave={() => setExpanded(false)}animate={{ height: regionHeight }}transition={TOAST_SPRING}>
02

Expand on hover

ToastProvider tracks one expanded boolean, flipped by the mouse leaving or entering the stack region.

Expanding swaps every card's y from the collapsed peek formula to a real cumulative offset — measured, not guessed, by a ResizeObserver on each ToastItem reporting its height up through onHeight. The same expanded flag also gates the auto-dismiss timer:

tsx
 
React.useEffect(() => {
  if (expanded) return;
  const id = setTimeout(
    () => dismissToast(record.id),
    record.duration ?? defaultDuration,
  );
  return () => clearTimeout(id);
}, [expanded, record.id, record.duration, defaultDuration]);

Because the setTimeout is inside an effect keyed on expanded, hovering doesn't pause a running countdown — it cancels it and re-arms a fresh one on mouse-leave. Close enough for a toast, and far simpler than a real pause/resume clock.

03/04Swipe to dismiss
Card
drag="x", elastic 0.6
Threshold
|offset.x| > 80
Dismissed
no spring back, just exits
tsx
const handleDragEnd = (_e: MouseEvent | TouchEvent | PointerEvent,info: PanInfo,) => {if (Math.abs(info.offset.x) > 80 || Math.abs(info.velocity.x) > 500) {  dismissToast(record.id);}};
03

Swipe to dismiss

Every toast is also drag="x", and onDragEnd makes the dismiss call.

It's an OR, not an AND — a slow, deliberate 80px drag dismisses, and so does a fast flick that never gets that far. dragElastic={0.6} gives the card a little resistance under the pointer before it commits, and there's no snap-back branch: cross either bar and dismissToast fires immediately, same as the swipe-and-let-go you'd expect from a native notification.

04/04Result
04

The result

An external store instead of context, absolutely-positioned cards instead of a list, and one drag gesture instead of a dismiss button — the stack is three small decisions, not one big animation.

Entrance, exit, and semantics

Every toast enters the same way regardless of how it leaves:

tsx
 
<motion.li
  initial={{ opacity: 0, y: dir * -40, scale: 0.9 }}
  animate={{ opacity: hidden ? 0 : 1, y, scale }}
  exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
  transition={TOAST_SPRING}
>

TOAST_SPRING (stiffness: 320, damping: 32, mass: 0.9) is the same tuning as the morph dialog's spring — Myndui's signature "premium overshoot" feel reused across components. The stack itself is aria-live="polite", so screen readers announce new toasts without interrupting whatever the user was doing, and the whole provider renders through a portal into document.body so it's never clipped by an overflow-hidden ancestor.

Motion Score

ToastDDLayout-triggering
Dheighttoast stack expand/collapse — height to measured px
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →