Agent statusToast stack
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callToast 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add toast-stackReact
npx designme add toast-stack --reactThe 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.
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.
- Pass
toastsoldest first. The last item sits at the front and older ones step back, shrink and fade. - Hovering the stack fans it into a list that grows to fit the four front toasts. Set
expandedto fan it yourself. - Each toast has an icon badge, a bold
title, an optionaldetailline and an optionaltimelike "now" or "2m". tonecolours the badge, with status tones (ok, warn, bad, merge) and colour names like blue, purple and teal.- The close button calls
onDismisswith the toast id. Remove it fromtoastsand it slides off to the right as it fades. maxcaps how many toasts stay mounted (6 by default). Older ones drop off the back, and new ones rise in from below.
- The stack is an
aria-live="polite"region, so screen readers announce new toasts. - Each close button is a real button with
aria-label="Dismiss"and a Dismiss tooltip. - A toast that is sliding away is marked
inert, so it cannot be focused or clicked during its exit. - The stack fans out while the focus is in it, as it does under the pointer. Toasts that can't be seen (behind the front one in the deck, or past the fourth when fanned out) are
inert, so Tab only reaches toasts on show. - Under
prefers-reduced-motion: reduce, the shared stylesheet cuts animations and transitions to almost nothing.
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>
);
}
Props
Everything ToastStack takes.
| Prop | Type | What it does |
|---|---|---|
toastsrequired | StackToast[] | Oldest first. The last one sits in front |
onDismiss | (id: StackToast['id']) => void | Called when a toast's close button is pressed. Remove it from `toasts` and it slides away |
expanded | boolean | Fans the stack out. Leave it out to fan it while the pointer is over it |
max | number | How many toasts stay mounted. Older ones drop off the back |
className | string |
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(); }
});
});
Questions
What people ask about it.
- How do I add a new notification?
- Append an object with a unique
id, aniconand atitleto the end of yourtoastsarray. 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 oftoastsin 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, orexpanded={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
maxtoasts stay mounted, six by default. In the stacked view only the front three are visible, and the fanned list shows the front four.
More in Agent status
Show what the agent is doing, how far along it is, and what it is working with.