Skip to examples
Bento / Kitchen sink
Bento / primitives

Feedback

Messages about state. A message is announced when it arrives, never merely because it is on the page.

Alert

tone · title · action · live · dismiss

Heads up

Your trial ends in 5 days. Choose a plan to keep your workspace.

Saved

Your changes are live.

Usage at 90%

You have used 45 of 50 seats.

Payment failed

We could not charge the card ending 4242.

New in Bento

Workspaces can now have up to 50 members on the Team plan.

Live only when it arrives. These alerts were on the page when it loaded, so they have no live role. A message that appears after an action — a failed sign-in — passes live="assertive" or "polite".

Sourcecomponents/feedback/alert/doc.ts · components/feedback/alert/alert.tsx · components/feedback/alert/alert.variants.ts · components/feedback/alert/alert.module.css

components/feedback/alert/doc.ts

/**
 * Alert — a message in the flow of the page.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     tone?          "neutral" | "accent" | "info" | "warn" | "crit"
 *     title?         content
 *     children       the message
 *     action?        what to do about it
 *     live?          "polite" | "assertive"
 *     onDismiss?     shows a close button when given
 *     dismissLabel?  default "Dismiss"
 *
 * # Behaviour
 *
 * R1  Framed in the tone's line and tint; the title in the tone's colour, the
 *     message in `--ink` for contrast.
 * R2  No live role unless `live` is set. "polite" is a status (announced when
 *     the reader is free); "assertive" is an alert (announced at once). Use
 *     them for messages that appear after an action, like a failed sign-in.
 * R3  The dismiss button is named by `dismissLabel`. Dismissing is the
 *     caller's state: the alert does not hide itself.
 */
export {};

components/feedback/alert/alert.tsx

import type { ReactNode } from "react";

import { X } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./alert.module.css";
import { alertVariants, type AlertVariants } from "./alert.variants";

export type AlertProps = AlertVariants & {
  title?: ReactNode;
  children: ReactNode;
  /** What to do about it. */
  action?: ReactNode;
  /** How the alert is announced when it APPEARS. Absent for a message that is
   *  part of the page: it is read in order like any prose. "polite" waits for
   *  the reader to finish; "assertive" interrupts, for what needs action now. */
  live?: "polite" | "assertive";
  onDismiss?: () => void;
  dismissLabel?: string;
  className?: string;
};

export function Alert({
  tone,
  title,
  children,
  action,
  live,
  onDismiss,
  dismissLabel = "Dismiss",
  className,
}: AlertProps) {
  return (
    <div
      role={
        live === "assertive"
          ? "alert"
          : live === "polite"
            ? "status"
            : undefined
      }
      className={cn(alertVariants({ tone }), className)}
    >
      <div className={styles.body}>
        {title ? <p className={styles.title}>{title}</p> : null}
        <div className={styles.text}>{children}</div>
        {action ? <div className={styles.action}>{action}</div> : null}
      </div>
      {onDismiss ? (
        <button
          type="button"
          className={styles.dismiss}
          aria-label={dismissLabel}
          onClick={onDismiss}
        >
          <X aria-hidden="true" />
        </button>
      ) : null}
    </div>
  );
}

components/feedback/alert/alert.variants.ts

import { cva, type VariantProps } from "class-variance-authority";

import styles from "./alert.module.css";

export const alertVariants = cva(styles.root, {
  variants: {
    tone: {
      neutral: styles.neutral,
      accent: styles.accent,
      info: styles.info,
      warn: styles.warn,
      crit: styles.crit,
    },
  },
  defaultVariants: { tone: "neutral" },
});

export type AlertVariants = VariantProps<typeof alertVariants>;

components/feedback/alert/alert.module.css

@layer primitive {
  .root {
    display: flex;
    align-items: flex-start;
    gap: var(--space-5);
    padding: var(--space-5) var(--space-6);
    border: 1px solid;
    border-radius: var(--radius-2);
    font-size: var(--text-body, var(--text-13));
    line-height: var(--leading-snug);
  }
  .body {
    display: grid;
    min-width: 0;
    flex: 1;
    gap: var(--space-2);
  }
  .title {
    margin: 0;
    font-weight: var(--weight-strong);
  }
  /* Body text stays in ink for contrast; the tone lives in the frame and
     title. */
  .text {
    color: var(--ink);
  }
  .action {
    margin-top: var(--space-3);
  }
  .dismiss {
    display: grid;
    width: var(--control-sm);
    height: var(--control-sm);
    flex: none;
    place-items: center;
    margin: calc(var(--space-2) * -1) calc(var(--space-3) * -1) 0 0;
    padding: 0;
    border-radius: var(--radius-1);
    color: inherit;
    cursor: pointer;
  }
  .dismiss:hover {
    background: var(--surface-hover-2);
  }
  .dismiss:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .dismiss svg {
    width: 14px;
    height: 14px;
  }

  .neutral {
    border-color: var(--line);
    background: var(--surface-panel-2);
    color: var(--ink-2);
  }
  .accent {
    border-color: var(--accent-line);
    background: var(--accent-tint);
    color: var(--accent);
  }
  .info {
    border-color: var(--info-line);
    background: var(--info-tint);
    color: var(--info);
  }
  .warn {
    border-color: var(--warn-line);
    background: var(--warn-tint);
    color: var(--warn);
  }
  .crit {
    border-color: var(--crit-line);
    background: var(--crit-tint);
    color: var(--crit);
  }
}

Progress

label · value · unknown

value
unknown
Sourcecomponents/feedback/progress/doc.ts · components/feedback/progress/progress.tsx · components/feedback/progress/progress.module.css

components/feedback/progress/doc.ts

/**
 * Progress — how far along a task is.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label   string, REQUIRED — what is progressing
 *     value   number, or null when the amount is unknown
 *     max?    number, default 100
 *
 * # Behaviour
 *
 * R1  A full-width pill track on `--surface-sunk`, filled with `--fill`.
 * R2  The value is clamped between 0 and max; a non-finite max becomes 100.
 * R3  `null` shows moving stripes instead of a bar, rather than a bar that
 *     claims a position nobody knows.
 * R4  Announced as a progress bar with its label and percentage.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The native progress element: its role, value, and indeterminate state are
 * built in and announced without any ARIA.
 */
export {};

components/feedback/progress/progress.tsx

import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils/cn";
import styles from "./progress.module.css";

export type ProgressProps = Omit<
  ComponentPropsWithRef<"progress">,
  "value" | "max" | "children"
> & {
  /** What is progressing. Required: a bare bar says nothing to a reader. */
  label: string;
  /** The amount done, or null when the amount is unknown. */
  value: number | null;
  max?: number;
};

export function Progress({
  label,
  value,
  max = 100,
  className,
  ...props
}: ProgressProps) {
  const limit = Number.isFinite(max) && max > 0 ? max : 100;
  const amount =
    value === null || !Number.isFinite(value)
      ? undefined
      : Math.max(0, Math.min(value, limit));
  return (
    <progress
      {...props}
      aria-label={label}
      max={limit}
      value={amount}
      className={cn(styles.root, className)}
    />
  );
}

components/feedback/progress/progress.module.css

@layer primitive {
  .root {
    display: block;
    width: 100%;
    height: var(--space-4);
    overflow: hidden;
    appearance: none;
    border: 0;
    border-radius: var(--radius-pill);
    background: var(--surface-sunk);
    color: var(--fill);
  }
  .root::-webkit-progress-bar {
    border-radius: inherit;
    background: var(--surface-sunk);
  }
  .root::-webkit-progress-value {
    border-radius: inherit;
    background: var(--fill);
    transition: width var(--dur-3) var(--ease);
  }
  .root::-moz-progress-bar {
    border-radius: inherit;
    background: var(--fill);
  }
  /* Unknown amount: moving stripes rather than a bar that lies about how far
     along it is. */
  .root:indeterminate {
    background: repeating-linear-gradient(
        110deg,
        var(--fill) 0 10px,
        var(--accent-tint) 10px 20px
      )
      0 0 / 200% 100%;
    animation: stripes 1.6s linear infinite;
  }
  .root:indeterminate::-webkit-progress-bar {
    background: transparent;
  }
  .root:indeterminate::-moz-progress-bar {
    background: transparent;
  }
  @keyframes stripes {
    to {
      background-position: -100% 0;
    }
  }
}

Skeleton

placeholders; the region is busy

Skeletons are silent. Each is hidden from assistive technology; the region around them is aria-busy, which is one announcement instead of one per bar.

Sourcecomponents/feedback/skeleton/doc.ts · components/feedback/skeleton/skeleton.tsx · components/feedback/skeleton/skeleton.module.css

components/feedback/skeleton/doc.ts

/**
 * Skeleton — a placeholder shaped like content that has not arrived.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Skeleton       width?, height? (CSS lengths), circle?
 *     SkeletonText   lines? (default 3)
 *
 * # Behaviour
 *
 * R1  Always hidden from assistive technology. The region it fills carries
 *     `aria-busy` instead: one announcement, not one per placeholder.
 * R2  A skeleton fills its container unless sized, and is 1em tall.
 * R3  A shimmer moves across it; under reduced motion it stops and the flat
 *     colour still reads as "not yet".
 * R4  SkeletonText's last line is short, because paragraphs end mid-line.
 */
export {};

components/feedback/skeleton/skeleton.tsx

import type { CSSProperties } from "react";

import { cn } from "@/lib/utils/cn";
import styles from "./skeleton.module.css";

export type SkeletonProps = {
  /** Any CSS length; fills its container by default, which is usually the
   *  size of the thing that is coming. */
  width?: string;
  height?: string;
  circle?: boolean;
  className?: string;
  style?: CSSProperties;
};

/** A placeholder for content that has not arrived. Always hidden from
 *  assistive technology: put aria-busy on the region instead, which is one
 *  announcement rather than one per skeleton. */
export function Skeleton({
  width,
  height,
  circle,
  className,
  style,
}: SkeletonProps) {
  return (
    <span
      aria-hidden="true"
      className={cn(styles.root, circle && styles.circle, className)}
      style={{ width, height, ...style }}
    />
  );
}

export type SkeletonTextProps = { lines?: number; className?: string };

/** Text-shaped placeholder. The last line is short, because paragraphs end
 *  mid-line and a block of equal bars reads as a table. */
export function SkeletonText({ lines = 3, className }: SkeletonTextProps) {
  return (
    <span aria-hidden="true" className={cn(styles.text, className)}>
      {Array.from({ length: lines }, (_, index) => (
        <span
          key={index}
          className={styles.root}
          style={{ width: index === lines - 1 ? "62%" : "100%" }}
        />
      ))}
    </span>
  );
}

components/feedback/skeleton/skeleton.module.css

@layer primitive {
  .root {
    display: block;
    width: 100%;
    height: 1em;
    border-radius: var(--radius-1);
    background: var(--surface-hover-2)
      linear-gradient(90deg, transparent, var(--surface-hover), transparent)
      no-repeat;
    background-size: 200% 100%;
    animation: shimmer 1.4s ease-in-out infinite;
  }
  /* Height follows width; the default 1em would flatten it into a pill. */
  .circle {
    height: auto;
    flex: none;
    aspect-ratio: 1;
    border-radius: var(--radius-pill);
  }
  .text {
    display: grid;
    gap: var(--space-3);
  }
  /* Reduced motion stops the shimmer; the colour still says "not yet". */
  @media (prefers-reduced-motion: reduce) {
    .root {
      animation: none;
    }
  }
  @keyframes shimmer {
    from {
      background-position: 200% 0;
    }
    to {
      background-position: -200% 0;
    }
  }
}

Toast

tones · undo · errors stay · F8

Archived projects: 0

Call it from anywhere. toast() is a plain function over a store both apps share; one Toaster, mounted at the root, renders it.

Announced, and never lost. Toasts are read out through live regions that are always on the page; errors interrupt. An error stays until dismissed, and timers pause while the pointer or focus is on the toasts. F8 moves focus to them; Escape dismisses the focused one.

They leave, too. A dismissed toast animates out before it is removed — the exit half of the motion helper.

Sourcecomponents/feedback/toast/doc.ts · components/feedback/toast/toaster.tsx · components/feedback/toast/toast.module.css · lib/toast/store.ts

components/feedback/toast/doc.ts

/**
 * Toast — a short message about something that just happened.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     toast({ title, description?, tone?, action?, duration?, id? }) → id
 *     toast.dismiss(id)
 *     <Toaster label? />   mounted once, near the root
 *
 *     tone      "neutral" (default) | "accent" | "warn" | "crit"
 *     action    { label, onAction } — one, such as Undo
 *     duration  ms, or null to stay; defaults 5s, 8s with an action, and
 *               errors stay
 *
 * # Behaviour
 *
 * R1  Every toast is announced through live regions that are always present:
 *     politely, or assertively for errors.
 * R2  An error stays until dismissed. Nothing important may live only in a
 *     toast that disappears on its own.
 * R3  Timers pause while the pointer or focus is on the toasts, or the page
 *     is hidden; time already spent is kept.
 * R4  F8 moves focus to the toasts; Escape dismisses the focused one; every
 *     toast has a dismiss button named after it.
 * R5  Running the action dismisses the toast.
 * R6  At most four show; a fifth dismisses the oldest. Reusing an id
 *     replaces that toast.
 * R7  A dismissed toast animates out before it is removed (instantly under
 *     reduced motion).
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * lib/toast/store.ts is framework-free and shared by both apps; it holds the
 * list and the timers. The Toaster subscribes, writes announcements into its
 * two live regions, and on dismissal runs the motion helper's exitElement
 * before telling the store to remove the toast.
 */
export {};

components/feedback/toast/toaster.tsx

"use client";

import { useEffect, useRef, useSyncExternalStore } from "react";

import { Button } from "@/components/forms/button";
import {
  CircleAlert,
  CircleCheck,
  Info,
  TriangleAlert,
  X,
} from "@/components/utility/icon";
import { VisuallyHidden } from "@/components/utility/visually-hidden";
import { exitElement } from "@/lib/motion";
import { toasts, type Toast } from "@/lib/toast";
import { cn } from "@/lib/utils/cn";
import surface from "../../surface.module.css";
import styles from "./toast.module.css";

const EMPTY: readonly Toast[] = [];
const ICONS = {
  neutral: Info,
  accent: CircleCheck,
  warn: TriangleAlert,
  crit: CircleAlert,
};

export type ToasterProps = {
  /** Names the region; the F8 shortcut is added to it. */
  label?: string;
};

/**
 * Where toasts appear. Mount once, near the root; call `toast()` anywhere.
 * F8 moves focus to the toasts. Timers pause while the pointer or focus is
 * on them, or the page is hidden.
 */
export function Toaster({ label = "Notifications" }: ToasterProps) {
  const list = useSyncExternalStore(
    toasts.subscribe,
    toasts.snapshot,
    () => EMPTY,
  );
  const region = useRef<HTMLElement>(null);

  // Two live regions that always exist, so every toast is announced; a
  // toast appearing is not reliably announced on its own. Errors interrupt.
  // Written directly: the regions are empty elements React never renders
  // into, and the text is an announcement, not state.
  const polite = useRef<HTMLSpanElement>(null);
  const assertive = useRef<HTMLSpanElement>(null);
  const announced = useRef(new Set<string>());
  useEffect(() => {
    for (const t of list) {
      if (t.closing || announced.current.has(t.id)) continue;
      announced.current.add(t.id);
      const node = t.tone === "crit" ? assertive.current : polite.current;
      if (!node) continue;
      const text = [t.title, t.description].filter(Boolean).join(". ");
      // A trailing space alternates, so a repeated message is announced again.
      node.textContent = node.textContent === text ? `${text} ` : text;
    }
  }, [list]);

  useEffect(() => {
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "F8") {
        event.preventDefault();
        region.current?.focus();
      }
    };
    const onVisibility = () =>
      document.hidden ? toasts.pause() : toasts.resume();
    window.addEventListener("keydown", onKey);
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      window.removeEventListener("keydown", onKey);
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  return (
    <section
      ref={region}
      aria-label={`${label} (F8)`}
      tabIndex={-1}
      className={styles.region}
      onPointerEnter={toasts.pause}
      onPointerLeave={toasts.resume}
      onFocus={toasts.pause}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget as Node | null))
          toasts.resume();
      }}
    >
      <ol className={styles.list}>
        {list.map((t) => (
          <ToastItem key={t.id} toast={t} />
        ))}
      </ol>
      <VisuallyHidden ref={polite} role="status" aria-live="polite" />
      <VisuallyHidden ref={assertive} role="alert" aria-live="assertive" />
    </section>
  );
}

function ToastItem({ toast }: { toast: Toast }) {
  const ref = useRef<HTMLLIElement>(null);
  const Icon = ICONS[toast.tone];

  // Dismissed: animate out, then leave the list.
  useEffect(() => {
    if (!toast.closing || !ref.current) return;
    let live = true;
    exitElement(ref.current, "slide-right").then(() => {
      if (live) toasts.remove(toast.id);
    });
    return () => {
      live = false;
    };
  }, [toast.closing, toast.id]);

  return (
    <li
      ref={ref}
      data-tone={toast.tone}
      className={cn(surface.elevated, styles.toast)}
      onKeyDown={(event) => {
        if (event.key === "Escape") toasts.dismiss(toast.id);
      }}
    >
      <span className={styles.icon} aria-hidden="true">
        <Icon />
      </span>
      <div className={styles.text}>
        <span className={styles.title}>{toast.title}</span>
        {toast.description ? (
          <span className={styles.description}>{toast.description}</span>
        ) : null}
      </div>
      <div className={styles.actions}>
        {toast.action ? (
          <Button
            size="sm"
            onClick={() => {
              toast.action?.onAction();
              toasts.dismiss(toast.id);
            }}
          >
            {toast.action.label}
          </Button>
        ) : null}
        <Button
          size="icon"
          variant="quiet"
          aria-label={`Dismiss: ${toast.title}`}
          onClick={() => toasts.dismiss(toast.id)}
        >
          <X aria-hidden="true" />
        </Button>
      </div>
    </li>
  );
}

components/feedback/toast/toast.module.css

@layer composition {
  /* The region always exists (live regions must, to be announced), but only
     its toasts take the pointer. */
  .region {
    position: fixed;
    z-index: var(--z-toast);
    right: var(--space-7);
    bottom: var(--space-7);
    width: min(24rem, calc(100vw - var(--space-7) * 2));
    pointer-events: none;
  }
  .region:focus-visible {
    outline: none;
  }
  .list {
    display: grid;
    gap: var(--space-4);
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .toast {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    align-items: start;
    gap: var(--space-4);
    padding: var(--space-5) var(--space-5) var(--space-5) var(--space-6);
    border-left: 3px solid var(--tone, var(--line-strong));
    pointer-events: auto;
    animation: toast-in var(--dur-3) var(--ease);
  }
  .toast[data-tone="accent"] {
    --tone: var(--accent);
  }
  .toast[data-tone="warn"] {
    --tone: var(--warn);
  }
  .toast[data-tone="crit"] {
    --tone: var(--crit);
  }
  .icon {
    display: grid;
    width: 18px;
    height: 20px;
    place-items: center;
    color: var(--tone, var(--ink-3));
  }
  .icon svg {
    width: 16px;
    height: 16px;
  }
  .text {
    display: grid;
    gap: var(--space-1);
    min-width: 0;
  }
  .title {
    color: var(--ink);
    font-size: var(--text-13);
    font-weight: var(--weight-strong);
    line-height: var(--leading-snug);
  }
  .description {
    color: var(--ink-2);
    font-size: var(--text-12);
    line-height: var(--leading-snug);
    overflow-wrap: anywhere;
  }
  .actions {
    display: flex;
    align-items: center;
    gap: var(--space-2);
  }
  @keyframes toast-in {
    from {
      opacity: 0;
      transform: translateY(12px);
    }
  }
  @media (max-width: 40rem) {
    .region {
      right: var(--space-4);
      bottom: var(--space-4);
      left: var(--space-4);
      width: auto;
    }
  }
}

lib/toast/store.ts

/* Toasts: a framework-free store both apps share. It holds the list and the
   timers; a Toaster component renders it (React through
   useSyncExternalStore, Svelte through the store contract — subscribe calls
   back at once with the current value). */

export type ToastTone = "neutral" | "accent" | "warn" | "crit";

export type ToastInput = {
  title: string;
  description?: string;
  tone?: ToastTone;
  /** One action, such as Undo. Running it dismisses the toast. */
  action?: { label: string; onAction: () => void };
  /** Milliseconds on screen; null stays until dismissed. Defaults: 5s, 8s
   *  with an action (time to undo), and errors stay. */
  duration?: number | null;
  /** Reuse an id to replace a toast rather than stack another. */
  id?: string;
};

export type Toast = Required<Pick<ToastInput, "title" | "tone">> &
  Omit<ToastInput, "title" | "tone" | "duration" | "id"> & {
    id: string;
    duration: number | null;
    /** Dismissed, and animating out; the Toaster removes it after. */
    closing: boolean;
  };

/** More than this and the oldest is dismissed. */
export const MAX_TOASTS = 4;

class ToastStore {
  #toasts: Toast[] = [];
  #listeners = new Set<(toasts: readonly Toast[]) => void>();
  #timers = new Map<
    string,
    {
      handle?: ReturnType<typeof setTimeout>;
      remaining: number;
      started: number;
    }
  >();
  #paused = false;
  #count = 0;

  subscribe = (run: (toasts: readonly Toast[]) => void) => {
    this.#listeners.add(run);
    run(this.#toasts);
    return () => {
      this.#listeners.delete(run);
    };
  };

  /** The current list: React's useSyncExternalStore snapshot. */
  snapshot = () => this.#toasts;

  #emit() {
    this.#toasts = [...this.#toasts];
    for (const run of this.#listeners) run(this.#toasts);
  }

  show = (input: ToastInput): string => {
    const tone = input.tone ?? "neutral";
    const toast: Toast = {
      ...input,
      id: input.id ?? `toast-${++this.#count}`,
      tone,
      duration:
        input.duration !== undefined
          ? input.duration
          : tone === "crit"
            ? null
            : input.action
              ? 8000
              : 5000,
      closing: false,
    };
    this.#clear(toast.id);
    const existing = this.#toasts.findIndex((t) => t.id === toast.id);
    if (existing >= 0) this.#toasts[existing] = toast;
    else this.#toasts.push(toast);
    const open = this.#toasts.filter((t) => !t.closing);
    if (open.length > MAX_TOASTS) this.dismiss(open[0].id);
    this.#start(toast);
    this.#emit();
    return toast.id;
  };

  /** Start closing: the Toaster animates it out, then calls remove. */
  dismiss = (id: string) => {
    const toast = this.#toasts.find((t) => t.id === id);
    if (!toast || toast.closing) return;
    this.#clear(id);
    toast.closing = true;
    this.#emit();
  };

  remove = (id: string) => {
    this.#clear(id);
    this.#toasts = this.#toasts.filter((t) => t.id !== id);
    this.#emit();
  };

  /** Hold every timer: the pointer or focus is on the toasts, or the page is
   *  hidden. Time already spent is kept. */
  pause = () => {
    if (this.#paused) return;
    this.#paused = true;
    for (const [id, timer] of this.#timers) {
      if (timer.handle === undefined) continue;
      clearTimeout(timer.handle);
      timer.handle = undefined;
      timer.remaining -= Date.now() - timer.started;
      this.#timers.set(id, timer);
    }
  };

  resume = () => {
    if (!this.#paused) return;
    this.#paused = false;
    for (const [id, timer] of this.#timers) this.#arm(id, timer);
  };

  #start(toast: Toast) {
    if (toast.duration === null || typeof window === "undefined") return;
    const timer = { remaining: toast.duration, started: Date.now() };
    this.#timers.set(toast.id, timer);
    if (!this.#paused) this.#arm(toast.id, timer);
  }

  #arm(
    id: string,
    timer: {
      handle?: ReturnType<typeof setTimeout>;
      remaining: number;
      started: number;
    },
  ) {
    timer.started = Date.now();
    timer.handle = setTimeout(
      () => this.dismiss(id),
      Math.max(0, timer.remaining),
    );
  }

  #clear(id: string) {
    const timer = this.#timers.get(id);
    if (timer?.handle !== undefined) clearTimeout(timer.handle);
    this.#timers.delete(id);
  }
}

export const toasts = new ToastStore();

/** Show a toast; returns its id. `toast.dismiss(id)` closes one early. */
export const toast = Object.assign((input: ToastInput) => toasts.show(input), {
  dismiss: (id: string) => toasts.dismiss(id),
});