Skip to content
Star on GitHub
Agent statusToast
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

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.

$npx designme add toast
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add toast

React

$npx designme add toast --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 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.
03

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>
  );
}
04

Props

Everything Toast takes.

PropTypeWhat it does
toastrequiredToastData | nullThe toast to show, or null for none
onDismiss(id: ToastData['id']) => voidCalled by the close button. The toast leaves either way
classNamestring
05

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'); });
});
06

Questions

What people ask about it.

Does the toast close itself after a few seconds?
No, the component has no timer. Set toast to 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 id and change tone, title and description. The card stays in place and the description crossfades. Use a new id if you want a fresh toast to slide in instead.
How do I show a spinner on Retry while the agent tries again?
Set action.busy to true. The button shows a spinner, gets aria-busy, and ignores further clicks until you clear it.
What does onDismiss need to do?
The toast hides itself when its close button is pressed, so onDismiss is for your own state. Clearing toast there keeps the two in sync.
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 →