Skip to content
Star on GitHub
Agent statusToast stack
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Toast stack

Agent notifications that stack and fan out on hover. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add toast-stack
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add toast-stack

React

$npx designme add toast-stack --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Toast stack collects the notifications an agent sends while it works and piles them into a small deck, newest in front. Hover the deck and it fans out into a list so a person can read each one and close the ones they are done with.

  • When an agent finishes several things in a row, like tests passing, a PR opening and frames syncing.
  • When notifications should stay on screen until someone reads them, without covering the page.
  • When you want a quiet corner of the app that shows the last few events at a glance.
  • When each event needs its own icon colour so people can tell good news from warnings.
03

Usage

Import it and drive it from your own state.

Import

import { ToastStack } from '@/components/designme/toast-stack/ToastStack';

Example

The demo above as a React component, 43 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { ToastStack, type StackToast } from './ToastStack';
import { Icon } from '../lib/icons';
import { useScript, useTakeover } from '../lib/useScript';

/* ---------- Toast stack: three notifications arrive, then one now and then; the button sends more, hover fans them out ---------- */
const POOL: Omit<StackToast, 'id'>[] = [
  { icon: 'ok', tone: 'ok', title: 'Tests passed', detail: '42 of 42 in 38s' },
  { icon: 'pr', tone: 'merge', title: 'PR #412 is ready', detail: 'Use shared spacing tokens' },
  { icon: 'figma', tone: 'purple', title: 'Frames synced', detail: '6 frames from Pricing.fig' },
  { icon: 'warn', tone: 'warn', title: 'Budget at 80%', detail: '1,600 of 2,000 credits' },
  { icon: 'spark', tone: 'blue', title: 'Draft written', detail: 'Launch email, 2 subject lines' }
];
export default function ToastStackDemo() {
  const [toasts, setToasts] = useState<StackToast[]>([]);
  const sent = useRef(0);
  const add = () => {
    const n = sent.current++;
    setToasts(list => {
      // keeps the six newest; the newest says "now" and the older ones age by two minutes each
      const next = [...list, { ...POOL[n % POOL.length], id: n }].slice(-6);
      return next.map((t, i) => ({ ...t, time: i === next.length - 1 ? 'now' : (next.length - 1 - i) * 2 + 'm' }));
    });
  };
  const hover = useRef(false);
  const ref = useRef<HTMLDivElement>(null), mine = useTakeover(ref);
  useScript(async ({ wait }) => {
    for (let i = 0; i < 3; i++) { await wait(i ? 700 : 400); add(); }
    // a new one now and then, held back while the stack is fanned out under the pointer, until you take over
    for (;;) { await wait(3800); if (mine()) return; if (!hover.current) add(); }
  });
  return (
    <div ref={ref} style={{ width: '100%', minHeight: 260, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 32 }}>
      <div style={{ marginBlock: 'auto', width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }} onPointerEnter={() => { hover.current = true; }} onPointerLeave={() => { hover.current = false; }}>
        <ToastStack toasts={toasts} onDismiss={id => setToasts(list => list.filter(t => t.id !== id))} />
      </div>
      <div data-dm=""><button type="button" className="dm-pill dm-soft dm-sm" onClick={add}><Icon name="bell" />Send notification</button></div>
    </div>
  );
}
04

Props

Everything ToastStack takes.

PropTypeWhat it does
toastsrequiredStackToast[]Oldest first. The last one sits in front
onDismiss(id: StackToast['id']) => voidCalled when a toast's close button is pressed. Remove it from `toasts` and it slides away
expandedbooleanFans the stack out. Leave it out to fan it while the pointer is over it
maxnumberHow many toasts stay mounted. Older ones drop off the back
classNamestring
05

Code

The full source, as the CLI installs it.

ToastStack.tsxReact · 137 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useIsoLayoutEffect } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { Tip } from '../lib/motion';
import '../designme.css';
import './toast-stack.css';

export type ToastStackTone = 'neutral' | 'ok' | 'warn' | 'bad' | 'merge' | 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'yellow' | 'pink' | 'teal';

export interface StackToast {
  id: string | number;
  /** The icon in the round badge */
  icon: IconName;
  /** Colours the badge. Defaults to neutral */
  tone?: ToastStackTone;
  title: ReactNode;
  /** The quiet line under the title */
  detail?: ReactNode;
  /** Like "now" or "2m" */
  time?: string;
}

export interface ToastStackProps {
  /** Oldest first. The last one sits in front */
  toasts: StackToast[];
  /** Called when a toast's close button is pressed. Remove it from `toasts` and it slides away */
  onDismiss?: (id: StackToast['id']) => void;
  /** Fans the stack out. Leave it out to fan it while the pointer is over it */
  expanded?: boolean;
  /** How many toasts stay mounted. Older ones drop off the back */
  max?: number;
  className?: string;
}

const TONE: Record<ToastStackTone, [string, string]> = {
  neutral: ['--dm-fg2', '--dm-chip'], ok: ['--dm-ok', '--dm-ok-bg'], warn: ['--dm-warn', '--dm-warn-bg'], bad: ['--dm-bad', '--dm-bad-bg'],
  merge: ['--dm-merge', '--dm-merge-bg'], blue: ['--dm-pb', '--dm-pb-bg'], purple: ['--dm-pl', '--dm-pl-bg'], green: ['--dm-pm', '--dm-pm-bg'],
  orange: ['--dm-pp', '--dm-pp-bg'], red: ['--dm-pr', '--dm-pr-bg'], yellow: ['--dm-py', '--dm-py-bg'], pink: ['--dm-pk', '--dm-pk-bg'], teal: ['--dm-pt', '--dm-pt-bg']
};
// a new toast rises in from below, blurred, before it takes its place
const ARRIVING: CSSProperties = { transform: 'translateY(24px) scale(.96)', opacity: 0, filter: 'blur(6px)' };

/** Where each toast sits: stacked into a deck, or fanned out into a list. `i` counts from the front */
function place(i: number, fan: boolean, heights: number[]): CSSProperties {
  if (fan) {
    let y = 0;
    for (let k = 0; k < Math.min(i, 3); k++) y += heights[k] + 8;
    return { transform: `translateY(${-y}px)`, opacity: i > 3 ? 0 : 1, filter: 'none', zIndex: 100 - i };
  }
  const k = Math.min(i, 3);
  return { transform: `translateY(${-k * 10}px) scale(${1 - k * .05})`, opacity: i > 2 ? 0 : 1 - k * .15, filter: i ? 'blur(.3px)' : 'none', zIndex: 100 - i };
}

/** Notifications that stack into a deck and fan out when you hover them. */
export function ToastStack({ toasts, onDismiss, expanded, max = 6, className }: ToastStackProps) {
  const live = toasts.slice(-max);
  // it fans out under the pointer, or while the focus is in it; a toast that can't be seen can't take the focus
  const [hover, setHover] = useState(false), [focused, setFocused] = useState(false);
  const fan = expanded ?? (hover || focused);

  // heights of the toasts, front first, so the fanned list leaves the right gaps
  const els = useRef(new Map<StackToast['id'], HTMLDivElement>());
  const [heights, setHeights] = useState<Record<string, number>>({});
  const front = live.slice().reverse();
  const hs = front.map(t => heights[t.id] ?? 0);
  const ids = live.map(t => t.id).join('\u0000');
  useIsoLayoutEffect(() => {
    const measure = () => setHeights(prev => {
      const next: Record<string, number> = {};
      els.current.forEach((el, id) => { next[id] = el.offsetHeight; });
      return Object.keys(next).length === Object.keys(prev).length && Object.keys(next).every(k => prev[k] === next[k]) ? prev : next;
    });
    measure();
    const ro = new ResizeObserver(measure);
    els.current.forEach(el => ro.observe(el));
    return () => ro.disconnect();
  }, [ids]);

  // new toasts wait two frames in their arriving pose, so the move to their place animates
  const [placed, setPlaced] = useState<Set<StackToast['id']>>(() => new Set());
  useEffect(() => {
    const fresh = live.filter(t => !placed.has(t.id)).map(t => t.id);
    if (!fresh.length) return;
    let raf = requestAnimationFrame(() => { raf = requestAnimationFrame(() => setPlaced(p => new Set([...p, ...fresh]))); });
    return () => cancelAnimationFrame(raf);
  }, [ids]); // eslint-disable-line react-hooks/exhaustive-deps

  // a toast that leaves keeps its place for a moment and slides off to the right as it fades.
  // It stays at the same spot in the DOM, so its transition is not cut short by a move
  type Leaving = { toast: StackToast; style: CSSProperties; at: number };
  const [prev, setPrev] = useState({ ids, live, fan, hs });
  const [leaving, setLeaving] = useState<Leaving[]>([]);
  if (prev.ids !== ids) {
    const now = new Set(live.map(t => t.id)), n = prev.live.length;
    const gone = prev.live.flatMap((t, at): Leaving[] => {
      if (now.has(t.id) || leaving.some(l => l.toast.id === t.id)) return [];
      const was = place(n - 1 - at, prev.fan, prev.hs);
      return [{ toast: t, at, style: { ...was, transform: was.transform + ' translateX(30px)', opacity: 0 } }];
    });
    setPrev({ ids, live, fan, hs });
    if (gone.length) setLeaving(l => [...l, ...gone]);
  } else if (prev.fan !== fan || prev.hs.join() !== hs.join()) setPrev({ ids, live, fan, hs });
  const leavingIds = leaving.map(l => l.toast.id).join('\u0000');
  useEffect(() => {
    if (!leaving.length) return;
    const t = setTimeout(() => setLeaving(l => l.filter(x => !leaving.includes(x))), 400);
    return () => clearTimeout(t);
  }, [leavingIds]); // eslint-disable-line react-hooks/exhaustive-deps

  // the stack grows to fit the four front toasts when it fans out
  const fanh = live.length ? hs.slice(0, 4).reduce((a, b) => a + b + 8, 0) - 8 || 96 : undefined;

  const card = (t: StackToast, style: CSSProperties, gone = false) => {
    const [fg, bg] = TONE[t.tone ?? 'neutral'];
    return (
      <div key={t.id} className="dm-tsk-c" style={style} inert={gone || (fan ? front.indexOf(t) > 3 : front.indexOf(t) > 0)} ref={el => { if (gone) return; if (el) els.current.set(t.id, el); else els.current.delete(t.id); }}>
        <span className="dm-g" style={{ color: `var(${fg})`, background: `var(${bg})` }}><Icon name={t.icon} /></span>
        <div className="dm-tx"><b>{t.title}</b>{t.detail && <span>{t.detail}</span>}</div>
        {t.time && <span className="dm-t">{t.time}</span>}
        <Tip label="Dismiss"><button type="button" className="dm-ib" aria-label="Dismiss" onClick={() => onDismiss?.(t.id)}><Icon name="x" /></button></Tip>
      </div>
    );
  };

  return (
    <div data-dm="" className={cx('dm-tsk-stack', fan && 'dm-fan', className)} aria-live="polite"
      style={fanh !== undefined ? { '--dm-fanh': fanh + 'px' } as CSSProperties : undefined}
      onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)}
      onFocus={() => setFocused(true)} onBlur={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false); }}>
      {leaving.slice().sort((a, b) => a.at - b.at).reduce((all, l) => { all.splice(Math.min(l.at, all.length), 0, card(l.toast, l.style, true)); return all; },
        live.map(t => card(t, placed.has(t.id) ? place(front.indexOf(t), fan, hs) : ARRIVING)))}
    </div>
  );
}
toast-stack.cssReact · 21 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Toast stack ---------- */
/* each toast is the same size as the one in Toast: width, padding, type and icons */
.dm-tsk-stack{position:relative;width:100%;max-width:380px;height:96px;isolation:isolate}
.dm-tsk-stack.dm-fan{height:var(--dm-fanh,96px)}
@media (hover:hover) and (pointer:fine){.dm-tsk-stack:hover{height:var(--dm-fanh,96px)}}
.dm-tsk-stack{transition:height .55s var(--dm-ease)}
.dm-tsk-c{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;gap:var(--dm-s-3);padding:var(--dm-s-3);background:var(--dm-surface);border-radius:var(--dm-r-card);box-shadow:var(--dm-shadow-toast);transition:transform .55s var(--dm-ease),opacity .45s var(--dm-ease),filter .45s;transform-origin:bottom center}
.dm-tsk-c .dm-g{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;flex:none}
.dm-tsk-c .dm-tx{flex:1;min-width:0;font-size:13px;line-height:1.35}
.dm-tsk-c .dm-tx b{font-weight:500;display:block}
.dm-tsk-c .dm-tx span{font-size:12px;color:var(--dm-muted)}
.dm-tsk-c .dm-t{font-size:11.5px;color:var(--dm-muted);align-self:flex-start;margin-top:2px}
/* the time sits at the right edge; the close button takes its place on hover */
.dm-tsk-c .dm-t{transition:opacity .2s}
.dm-tsk-c .dm-ib{position:absolute;top:50%;right:var(--dm-s-3);margin-top:-15px;opacity:0;transition:opacity .2s}
.dm-tsk-c .dm-ib:focus-visible{opacity:1}
@media (hover:hover) and (pointer:fine){.dm-tsk-c:hover .dm-ib{opacity:1}}
.dm-tsk-c:has(.dm-ib:focus-visible) .dm-t{opacity:0}
@media (hover:hover) and (pointer:fine){.dm-tsk-c:hover .dm-t{opacity:0}}
toast-stack.htmlHTML · 9 lines
<div class="card" data-c="toast-stack">
  <div class="stage-in">
    <div class="tsk dm">
      <div class="dm-main"><div class="tsk-stack" aria-live="polite"></div></div>
      <div class="demo"><button class="pill soft sm tsk-add"><svg class="i s"><use href="#i-bell"/></svg>Send notification</button></div>
    </div>
  </div>
</div>
toast-stack.cssHTML · 20 lines
/* ---------- Toast stack ---------- */
/* each toast is the same size as the one in Toast: width, padding, type and icons */
.tsk-stack{position:relative;width:100%;max-width:380px;height:96px;isolation:isolate}
.tsk-stack.fan{height:var(--fanh,96px)}
@media (hover:hover) and (pointer:fine){.tsk-stack:hover{height:var(--fanh,96px)}}
.tsk-stack{transition:height .55s var(--ease)}
.tsk-c{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;gap:var(--s-3);padding:var(--s-3);background:var(--surface);border-radius:var(--r-card);box-shadow:var(--shadow-toast);transition:transform .55s var(--ease),opacity .45s var(--ease),filter .45s;transform-origin:bottom center}
.tsk-c .g{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;flex:none}
.tsk-c .tx{flex:1;min-width:0;font-size:13px;line-height:1.35}
.tsk-c .tx b{font-weight:500;display:block}
.tsk-c .tx span{font-size:12px;color:var(--muted)}
.tsk-c .t{font-size:11.5px;color:var(--muted);align-self:flex-start;margin-top:2px}
/* the time sits at the right edge; the close button takes its place on hover */
.tsk-c .t{transition:opacity .2s}
.tsk-c .ib{position:absolute;top:50%;right:var(--s-3);margin-top:-15px;opacity:0;transition:opacity .2s}
.tsk-c .ib:focus-visible{opacity:1}
@media (hover:hover) and (pointer:fine){.tsk-c:hover .ib{opacity:1}}
.tsk-c:has(.ib:focus-visible) .t{opacity:0}
@media (hover:hover) and (pointer:fine){.tsk-c:hover .t{opacity:0}}
toast-stack.jsHTML · 44 lines
DM.def('toast-stack', (r, c) => {
  const stack = $('.tsk-stack', r);
  const pool = [
    ['ok','var(--ok)','var(--ok-bg)','Tests passed','42 of 42 in 38s'],
    ['pr','var(--merge)','var(--merge-bg)','PR #412 is ready','Use shared spacing tokens'],
    ['figma','var(--pl)','var(--pl-bg)','Frames synced','6 frames from Pricing.fig'],
    ['warn','var(--warn)','var(--warn-bg)','Budget at 80%','1,600 of 2,000 credits'],
    ['spark','var(--pb)','var(--pb-bg)','Draft written','Launch email, 2 subject lines']
  ];
  // the stack fans out under the pointer, or while the focus is in it; a toast that can't be seen can't take the focus
  let items = [], n = 0, hover = false, focused = false;
  function layout(){
    const fan = hover || focused; let y = 0;
    const hs = items.map(it => it.offsetHeight);
    stack.style.setProperty('--fanh', (hs.slice(-4).reduce((a, b) => a + b + 8, 0) - 8 || 96) + 'px');
    stack.classList.toggle('fan', fan);
    items.slice().reverse().forEach((it, i) => {
      if (fan){ it.style.transform = `translateY(${-y}px)`; it.style.opacity = i > 3 ? 0 : 1; it.style.filter = 'none'; if (i < 3) y += hs[items.length - 1 - i] + 8; }
      else { const k = Math.min(i, 3); it.style.transform = `translateY(${-k * 10}px) scale(${1 - k * .05})`; it.style.opacity = i > 2 ? 0 : 1 - k * .15; it.style.filter = i ? 'blur(.3px)' : 'none'; }
      it.style.zIndex = 100 - i; it.inert = fan ? i > 3 : i > 0;
    });
  }
  function add(){
    const p = pool[n++ % pool.length];
    const el = html(`<div class="tsk-c"><span class="g" style="color:${p[1]};background:${p[2]}">${I(p[0])}</span><div class="tx"><b>${p[3]}</b><span>${p[4]}</span></div><span class="t">now</span><button class="ib" aria-label="Dismiss" data-tip="Dismiss">${I('x')}</button></div>`);
    el.style.transform = 'translateY(24px) scale(.96)'; el.style.opacity = 0; el.style.filter = 'blur(6px)';
    stack.appendChild(el); items.push(el);
    $('.ib', el).onclick = () => { items = items.filter(x => x !== el); el.style.opacity = 0; el.style.transform += ' translateX(30px)'; setTimeout(() => el.remove(), 400); layout(); };
    if (items.length > 6){ const old = items.shift(); old.remove(); }
    requestAnimationFrame(() => requestAnimationFrame(layout));
    items.forEach((it, i) => { if (i < items.length - 1){ const t = $('.t', it); t.textContent = (items.length - 1 - i) * 2 + 'm'; } });
  }
  c.on(stack, 'pointerenter', () => { hover = true; layout(); });
  c.on(stack, 'pointerleave', () => { hover = false; layout(); });
  c.on(stack, 'focusin', () => { if (!focused){ focused = true; layout(); } });
  c.on(stack, 'focusout', e => { if (!stack.contains(e.relatedTarget)){ focused = false; layout(); } });
  $('.tsk-add', r).onclick = add;
  c.run(async () => {
    for (let i = 0; i < 3; i++){ await c.wait(i ? 700 : 400); add(); }
    // then a new one now and then, held back while the stack is fanned out under the pointer, until you take over
    for (;;){ await c.step(3800); if (!hover && !focused) add(); }
  });
});
06

Questions

What people ask about it.

How do I add a new notification?
Append an object with a unique id, an icon and a title to the end of your toasts array. It rises in from below and becomes the front card.
Does the stack remove a toast when I press its close button?
No. The button only calls onDismiss(id). Filter that toast out of toasts in your state and the component animates it away.
Can I keep the stack fanned out all the time?
Yes. Pass expanded={true} to keep it open, or expanded={false} to keep it stacked. Leave it out and it fans while the pointer is over it.
What happens when more toasts arrive than fit?
Only the newest max toasts stay mounted, six by default. In the stacked view only the front three are visible, and the fanned list shows the front four.
07

More in Agent status

Show what the agent is doing, how far along it is, and what it is working with.

ThinkingThe live trace, written as the agent works, then folded away.
Reasoning trailThe agent's plan, notes, sources and edits, after it thinks.
Agent lanesSeveral agents working in parallel, each with its own lane and hand-offs.
Agent treeAn orchestrator and its sub-agents, with cost rolled up per branch.
Agent inboxEvery run that needs you, is running or is done, in one list.
Trace viewOne agent run as a waterfall of spans, with latency and tokens.
Browser sessionThe agent browsing for you, step by step, with Pause and Take over.
Run summaryWhat the agent did, what changed and what needs you.
All 18 in Agent status →