Agent statusToast
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callToast
One notification that changes shape with its state. 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 toastReact
npx designme add toast --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 shows one notification at a time and lets it change as the agent's work moves on. A loading toast can turn into a success, and an error can offer Retry, all in the same spot on screen.
- When an agent action needs a short confirmation, like "Changes applied" with an Undo button.
- When a task starts as loading and you want the same toast to report the result.
- When a failure needs a single clear next step, such as Retry.
- When only the latest message matters and older ones should step aside.
- Four tones:
success,error,infoandloading. Each picks its own icon and colour, and loading shows a spinner. - Give the toast a new
idand the new one rises in while the old one slips away. Keep theidto update the text in place. - The
descriptionline crossfades when it changes, so progress text can update without a jump. - An optional
actionadds a button before the close button, in aghostorprimarystyle (primarysits on the accent). - Set
action.busyand the button label turns into a spinner. Clicks are ignored while it is busy. - The close button hides the toast straight away and calls
onDismiss, even if your state has not cleared it yet.
- Each toast has
role="status"inside anaria-live="polite"slot, so changes are announced without interrupting. - The action button sets
aria-busywhilebusyis true. - The close button has
aria-label="Dismiss", and a toast that is leaving isinert. - With reduced motion turned on, the exit animation is skipped and the text swap happens at once.
Usage
Import it and drive it from your own state.
Import
import { Toast } from '@/components/designme/toast/Toast';Example
The demo above as a React component, 65 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 { useEffect, useRef, useState } from 'react';
import { Toast, type ToastData } from './Toast';
import { Seg } from '../lib/motion';
import { Demo } from '../lib/Demo';
import { useTakeover } from '../lib/useScript';
/* ---------- Toast: four states; Undo and Retry change the toast, loading turns into the preview link, and a toast with a timer leaves when it runs out ---------- */
type Kind = 'ok' | 'err' | 'info' | 'load';
export default function ToastDemo() {
const [kind, setKind] = useState<Kind>('ok');
const [toast, setToast] = useState<ToastData | null>(null);
const current = useRef<ToastData['id'] | null>(null);
const next = useRef(0);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const ref = useRef<HTMLDivElement>(null), mine = useTakeover(ref);
const later = (ms: number, fn: () => void) => { timers.current.push(setTimeout(fn, ms)); };
useEffect(() => () => { timers.current.forEach(clearTimeout); if (life.current) clearTimeout(life.current.t); }, []);
const update = (id: number, patch: Partial<ToastData>) => setToast(t => (t && t.id === id ? { ...t, ...patch } : t));
// success and info time out like real toasts: hovering holds the timer, and when it runs out the toast leaves and comes
// back (until you take over: then it stays gone until you pick one)
const life = useRef<{ t?: ReturnType<typeof setTimeout>; id: number; k: Kind; left: number; at: number } | null>(null);
const arm = (id: number, k: Kind, ms: number) => {
if (life.current) clearTimeout(life.current.t);
life.current = { id, k, left: ms, at: Date.now(), t: setTimeout(() => {
if (current.current !== id) return;
current.current = null; setToast(null); life.current = null;
later(1100, () => { if (current.current === null && !mine()) show(k); });
}, ms) };
};
const hold = (on: boolean) => {
const l = life.current; if (!l || current.current !== l.id) return;
if (on) { clearTimeout(l.t); l.left -= Date.now() - l.at; } else arm(l.id, l.k, Math.max(400, l.left));
};
const show = (k: Kind) => {
const id = ++next.current;
current.current = id; setKind(k);
if (k === 'ok') setToast({ id, tone: 'success', title: 'Changes applied', description: '4 files updated in PricingCard',
action: { label: 'Undo', onClick: () => update(id, { title: 'Changes undone', description: 'PricingCard is back as it was', action: undefined }) } });
if (k === 'err') setToast({ id, tone: 'error', title: 'Could not push to main', description: 'The branch is protected',
action: { label: 'Retry', variant: 'primary', onClick: () => {
setToast(t => (t && t.id === id && t.action ? { ...t, action: { ...t.action, busy: true } } : t));
later(1200, () => { if (current.current === id) show('ok'); });
} } });
if (k === 'info') setToast({ id, tone: 'info', title: 'Preview is ready', description: 'pricing-spacing.preview.app', action: { label: 'Open', onClick: () => {} } });
if (k === 'ok' || k === 'info') arm(id, k, 5200); else if (life.current) { clearTimeout(life.current.t); life.current = null; }
if (k === 'load') {
setToast({ id, tone: 'loading', title: 'Deploying preview', description: 'Building 3 pages' });
later(2600, () => { if (current.current === id) show('info'); });
}
};
useEffect(() => { const t = setTimeout(() => show('ok'), 400); return () => clearTimeout(t); }, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Demo ref={ref} switcher={<Seg label="State" value={kind} onChange={show} options={[{ value: 'ok', label: 'Success' }, { value: 'err', label: 'Error' }, { value: 'info', label: 'Info' }, { value: 'load', label: 'Loading' }]} />}>
<div style={{ display: 'contents' }} onPointerEnter={() => hold(true)} onPointerLeave={() => hold(false)}>
<Toast toast={toast} onDismiss={() => { current.current = null; setToast(null); }} />
</div>
</Demo>
);
}
Props
Everything Toast takes.
| Prop | Type | What it does |
|---|---|---|
toastrequired | ToastData | null | The toast to show, or null for none |
onDismiss | (id: ToastData['id']) => void | Called by the close button. The toast leaves either way |
className | string |
Code
The full source, as the CLI installs it.
Toast.tsxReact · 79 lines
'use client';
import { useEffect, useState } from 'react';
import { cx } from '../lib/cx';
import { useReducedMotion } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Spin, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './toast.css';
export type ToastTone = 'success' | 'error' | 'info' | 'loading';
export interface ToastData {
/** A new id slides a new toast in and the old one away. Keep the id to change the text in place */
id: string | number;
/** Picks the icon and its colour. loading spins */
tone?: ToastTone;
title: string;
/** The quieter line under the title. Crossfades when it changes */
description?: string;
/** A button before the close button, like Undo or Retry: ghost, or primary on the accent (ink, the old name, still works). busy swaps its label for a spinner */
action?: { label: string; onClick: () => void; variant?: 'ghost' | 'primary' | 'ink'; busy?: boolean };
}
export interface ToastProps {
/** The toast to show, or null for none */
toast: ToastData | null;
/** Called by the close button. The toast leaves either way */
onDismiss?: (id: ToastData['id']) => void;
className?: string;
}
const TONES: Record<ToastTone, { icon: 'ok' | 'alert' | 'info' | null; color: string; bg: string }> = {
success: { icon: 'ok', color: 'var(--dm-ok)', bg: 'var(--dm-ok-bg)' },
error: { icon: 'alert', color: 'var(--dm-bad)', bg: 'var(--dm-bad-bg)' },
info: { icon: 'info', color: 'var(--dm-pb)', bg: 'var(--dm-pb-bg)' },
loading: { icon: null, color: 'var(--dm-fg)', bg: 'var(--dm-chip)' }
};
function Card({ toast, leaving, onDismiss }: { toast: ToastData; leaving: boolean; onDismiss: () => void }) {
const tone = TONES[toast.tone ?? 'success'], a = toast.action;
return (
<div className={cx('dm-tst-t', leaving ? 'dm-tst-out' : 'dm-tst-in')} role="status" inert={leaving}>
<span className="dm-g" style={{ color: tone.color, background: tone.bg }}>{tone.icon ? <Icon name={tone.icon} /> : <Spin />}</span>
<div className="dm-tx"><b>{toast.title}</b>{toast.description !== undefined && <Swap>{toast.description}</Swap>}</div>
<div className="dm-acts">
{a && <button type="button" className={cx('dm-pill dm-sm', a.variant === 'primary' || a.variant === 'ink' ? 'dm-primary' : 'dm-ghost')} aria-busy={a.busy || undefined} onClick={() => { if (!a.busy) a.onClick(); }}>{a.busy ? <Spin /> : a.label}</button>}
<Tip label="Dismiss"><button type="button" className="dm-ib" aria-label="Dismiss" onClick={onDismiss}><Icon name="x" /></button></Tip>
</div>
</div>
);
}
/** One toast at a time: a new one rises in while the last one slips away. */
export function Toast({ toast, onDismiss, className }: ToastProps) {
const reduce = useReducedMotion();
// the close button hides the toast straight away, whether or not the parent clears it
const [closed, setClosed] = useState<ToastData['id']>();
const shown = toast && toast.id !== closed ? toast : null;
// the toast on screen, kept so it can play its exit after the prop moves on
const [cur, setCur] = useState(shown);
const [leaving, setLeaving] = useState<ToastData[]>([]);
if ((shown?.id ?? null) !== (cur?.id ?? null)) {
if (cur) setLeaving(l => [...l, cur]);
setCur(shown);
} else if (shown !== cur) setCur(shown);
useEffect(() => {
if (!leaving.length) return;
const t = setTimeout(() => setLeaving(l => l.slice(1)), reduce ? 0 : 300);
return () => clearTimeout(t);
}, [leaving, reduce]);
return (
<div data-dm="" className={cx('dm-tst-slot', className)} aria-live="polite">
{leaving.filter(t => t.id !== cur?.id).map(t => <Card key={'t' + t.id} toast={t} leaving onDismiss={() => {}} />)}
{cur && <Card key={'t' + cur.id} toast={cur} leaving={false} onDismiss={() => { setClosed(cur.id); onDismiss?.(cur.id); }} />}
</div>
);
}
toast.cssReact · 18 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Toast ---------- */
/* the toast on its way out and the one coming in share one cell, so each keeps its full width while they cross */
.dm-tst-slot{min-height:64px;width:100%;display:grid;justify-items:center;align-items:end}
.dm-tst-slot>*{grid-area:1/1}
/* the same size as a toast in Toast stack: width, padding, type and icons */
.dm-tst-t{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);max-width:380px;width:100%;position:relative;overflow:hidden}
.dm-tst-t .dm-g{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;flex:none}
.dm-tst-t .dm-tx{flex:1;min-width:0;font-size:13px;line-height:1.35}
.dm-tst-t .dm-tx b{font-weight:500;display:block}
.dm-tst-t .dm-tx span{font-size:12px;color:var(--dm-muted)}
.dm-tst-t .dm-acts{display:flex;align-items:center;gap:2px}
.dm-tst-t .dm-life{display:none}
.dm-tst-in{animation:dm-tstin .6s var(--dm-ease) both}
@keyframes dm-tstin{from{opacity:0;transform:translateY(14px) scale(.97);filter:blur(5px)}to{opacity:1;transform:none;filter:none}}
.dm-tst-out{animation:dm-tstout .35s var(--dm-ease) both}
@keyframes dm-tstout{to{opacity:0;transform:translateY(8px) scale(.98);filter:blur(4px)}}
toast.htmlHTML · 9 lines
<div class="card" data-c="toast">
<div class="stage-in">
<div class="tst dm">
<div class="dm-main"><div class="tst-slot" aria-live="polite"></div></div>
<div class="demo"><div class="seg2" role="group" aria-label="State"><button aria-pressed="true" data-v="ok">Success</button><button aria-pressed="false" data-v="err">Error</button><button aria-pressed="false" data-v="info">Info</button><button aria-pressed="false" data-v="load">Loading</button></div></div>
</div>
</div>
</div>
toast.cssHTML · 17 lines
/* ---------- Toast ---------- */
/* the toast on its way out and the one coming in share one cell, so each keeps its full width while they cross */
.tst-slot{min-height:64px;width:100%;display:grid;justify-items:center;align-items:end}
.tst-slot>*{grid-area:1/1}
/* the same size as a toast in Toast stack: width, padding, type and icons */
.tst-t{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);max-width:380px;width:100%;position:relative;overflow:hidden}
.tst-t .g{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;flex:none}
.tst-t .tx{flex:1;min-width:0;font-size:13px;line-height:1.35}
.tst-t .tx b{font-weight:500;display:block}
.tst-t .tx span{font-size:12px;color:var(--muted)}
.tst-t .acts{display:flex;align-items:center;gap:2px}
.tst-t .life{display:none}
.tst-in{animation:tstin .6s var(--ease) both}
@keyframes tstin{from{opacity:0;transform:translateY(14px) scale(.97);filter:blur(5px)}to{opacity:1;transform:none;filter:none}}
.tst-out{animation:tstout .35s var(--ease) both}
@keyframes tstout{to{opacity:0;transform:translateY(8px) scale(.98);filter:blur(4px)}}
toast.jsHTML · 33 lines
DM.def('toast', (r, c) => {
const slot = $('.tst-slot', r);
const V = {
ok:{ g:'ok', col:'var(--ok)', bg:'var(--ok-bg)', b:'Changes applied', s:'4 files updated in PricingCard', act:'<button class="pill ghost sm tst-undo">Undo</button>' },
err:{ g:'alert', col:'var(--bad)', bg:'var(--bad-bg)', b:'Could not push to main', s:'The branch is protected', act:'<button class="pill primary sm tst-retry">Retry</button>' },
info:{ g:'info', col:'var(--pb)', bg:'var(--pb-bg)', b:'Preview is ready', s:'pricing-spacing.preview.app', act:'<button class="pill ghost sm">Open</button>' },
load:{ g:'load', col:'var(--fg)', bg:'var(--chip)', b:'Deploying preview', s:'Building 3 pages', act:'' }
};
let cur = null;
let show = function(k){
const v = V[k];
const t = html(`<div class="tst-t tst-in" role="status"><span class="g" style="color:${v.col};background:${v.bg}">${k === 'load' ? '<span class="spin"></span>' : I(v.g)}</span><div class="tx"><b>${v.b}</b><span>${v.s}</span></div><div class="acts">${v.act}<button class="ib" aria-label="Dismiss" data-tip="Dismiss">${I('x')}</button></div><span class="life"></span></div>`);
if (cur){ const old = cur; old.classList.add('tst-out'); setTimeout(() => old.remove(), 300); }
slot.appendChild(t); cur = t;
$('.ib', t).onclick = () => { t.classList.add('tst-out'); setTimeout(() => t.remove(), 300); cur = null; };
const u = $('.tst-undo', t); if (u) u.onclick = () => { $('.tx b', t).textContent = 'Changes undone'; swap($('.tx span', t), 'PricingCard is back as it was'); u.remove(); };
const rt = $('.tst-retry', t); if (rt) rt.onclick = () => { rt.innerHTML = '<span class="spin"></span>'; setTimeout(() => { if (t.isConnected) show('ok'); }, 1200); };
if (k === 'load') c.later(2600, () => { if (cur === t) show('info'); });
if (k === 'ok' || k === 'info'){
// it times out like a real toast: hovering holds it, and when it runs out it leaves and the same one comes back (until
// you take over: then it stays gone until you pick one)
const life = $('.life', t).animate([{ transform:'scaleX(1)' }, { transform:'scaleX(0)' }], { duration:5200, fill:'forwards' });
t.addEventListener('pointerenter', () => life.pause()); t.addEventListener('pointerleave', () => life.play());
life.finished.then(() => { if (cur !== t || c.dead) return; t.classList.add('tst-out'); cur = null; setTimeout(() => t.remove(), 300); c.auto(1100, () => { if (!cur) show(k); }); }).catch(() => {});
}
}
const sg = $('.seg2', r), knob = seg(sg, v => show(v));
const sync = k => { $$('button', sg).forEach(b => b.setAttribute('aria-pressed', b.dataset.v === k)); knob.place(); };
const show0 = show; show = k => { sync(k); show0(k); };
// a state picked before this first one lands stays
c.later(400, () => { if (!cur) show('ok'); });
});
Questions
What people ask about it.
- Does the toast close itself after a few seconds?
- No, the component has no timer. Set
toastto null from your own timeout when you want it gone. The demo does this for success and info, and pauses the timer on hover. - How do I turn a loading toast into a success toast?
- Keep the same
idand changetone,titleanddescription. The card stays in place and the description crossfades. Use a newidif you want a fresh toast to slide in instead. - How do I show a spinner on Retry while the agent tries again?
- Set
action.busyto true. The button shows a spinner, getsaria-busy, and ignores further clicks until you clear it. - What does
onDismissneed to do? - The toast hides itself when its close button is pressed, so
onDismissis for your own state. Clearingtoastthere keeps the two in sync.
More in Agent status
Show what the agent is doing, how far along it is, and what it is working with.