FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup
  • Select
  • Combobox
  • Toast
  • Slider
  • Field & Form

Resources

  • Roadmap & changelog
  • GitHub ↗

Toast

Toasts report on something that already happened or is happening elsewhere. The input is a function, toast(), callable from anywhere: an action body, a store, an effect. The output is yours: a Toast.Viewport rendered once maps the queue to parts. The queue is the library's first state that is urgent by design: React holds every transition while an async action is pending, so a toast pushed as a transition would appear only after the action it reports on. Toast spends its Async React budget elsewhere: a toast that follows a promise, an Undo that is an ordinary action prop with data-pending, a closeAction that knows whether the Undo window was used, and a Suspense guard so a toast whose content suspends does not blank the page.

Quarterly report.pdf
Show code
ToastArchive.tsx
"use client";
import { useState } from "react";
import { Menu } from "@funstack/ui/menu";
import { toast } from "@funstack/ui/toast";
import { fakeSave } from "./fake-api.js";

// One promise feeds both the menu item and the toast: `toast.promise` shows "Archiving…" at once
// (data-pending on the toast), swaps to the success or error toast when the fake server call
// settles, and returns a promise that settles with it, so the item stays pending exactly as long
// and the menu closes when it is done. The success toast's Undo is an action prop: pending while
// it runs, then the toast closes. With `error` given, a failure is the toast's to show: the
// returned promise resolves and nothing reaches an Error Boundary.
export function ToastArchive() {
  const [fail, setFail] = useState(false);
  return (
    <div className="demo-stack">
      <div className="demo-row">
        <span>Quarterly report.pdf</span>
        <Menu.Root>
          <Menu.Trigger className="demo-button" aria-label="Actions for Quarterly report.pdf">
            {({ pending }) => (pending ? <span className="demo-spinner" aria-hidden /> : "⋯")}
          </Menu.Trigger>
          <Menu.Popup className="demo-menu">
            <Menu.Item
              action={() =>
                toast.promise(fakeSave("the archive", { ms: 1500, fail }), {
                  pending: { title: "Archiving…" },
                  success: {
                    title: "Archived",
                    description: "Quarterly report.pdf",
                    type: "success",
                    action: {
                      label: "Undo",
                      action: () => fakeSave("the undo", { ms: 800 }),
                    },
                  },
                  error: (e) => ({
                    title: "Could not archive",
                    description: e instanceof Error ? e.message : String(e),
                    type: "error",
                    priority: "high",
                  }),
                })
              }
            >
              {({ pending }) => (pending ? "Archiving…" : "Archive")}
            </Menu.Item>
            <Menu.Item action={() => toast({ title: "Link copied" })}>Copy link</Menu.Item>
          </Menu.Popup>
        </Menu.Root>
      </div>
      <label className="demo-row">
        <input type="checkbox" checked={fail} onChange={(e) => setFail(e.target.checked)} />
        Make Archive fail
      </label>
    </div>
  );
}
toast.css
/* Toast: the Viewport is a <section popover="manual">, shown in the top layer while it has toasts
   (`:popover-open`, `data-paused`). Roots carry `data-pending`, `data-ending` and `data-type`.
   Enter with @starting-style, exit on [data-ending]: the library keeps the toast until its
   transitions finish. */

.demo-toasts {
  position: fixed;
  inset: auto 1rem 1rem auto;
  margin: 0;
  padding: 0;
  border: 0;
  inline-size: min(22rem, calc(100vw - 2rem));
  overflow: visible;
  background: transparent;
  color: var(--color-text);
  /* The region's box never blocks the page; the toasts take pointer events. */
  pointer-events: none;
}
.demo-toast-slot {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows 200ms;
}
/* Collapse the gap a leaving toast leaves behind. */
.demo-toast-slot:has([data-ending]) {
  grid-template-rows: 0fr;
}
.demo-toast {
  display: flex;
  align-items: start;
  gap: 0.5rem;
  min-block-size: 0;
  overflow: hidden;
  margin-block-start: 0.5rem;
  padding: 0.75rem 0.75rem 0.75rem 1rem;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  background: var(--color-bg);
  box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
  pointer-events: auto;
  transition:
    opacity 200ms,
    translate 200ms;
}
@starting-style {
  .demo-toast {
    opacity: 0;
    translate: 0 1rem;
  }
}
.demo-toast[data-ending] {
  opacity: 0;
  translate: 0 1rem;
}
.demo-toast[data-pending] {
  cursor: progress;
}
.demo-toast[data-pending] .demo-toast-title::after {
  content: " ⏳";
}
.demo-toast[data-type="success"] {
  border-inline-start: 4px solid #16a34a;
}
.demo-toast[data-type="error"] {
  border-inline-start: 4px solid #dc2626;
}
.demo-toast:focus-visible {
  outline: 2px solid var(--color-accent, #4f46e5);
  outline-offset: 2px;
}
.demo-toast-text {
  flex: 1;
}
.demo-toast-title {
  font-weight: 600;
}
.demo-toast-description {
  margin: 0.15rem 0 0;
  font-size: 0.875em;
  opacity: 0.8;
}
.demo-toast-action[data-pending] {
  cursor: progress;
  opacity: 0.7;
}
.demo-toast-close {
  border: 0;
  padding: 0 0.4em;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 1.2em;
  line-height: 1.4;
  cursor: pointer;
}
.demo-toast-queued {
  margin: 0.25rem 0 0;
  text-align: end;
  font-size: 0.8em;
  opacity: 0.8;
}
.demo-toast-list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.demo-toast-list li {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  max-inline-size: 20rem;
  padding: 0.25rem 0;
}
@media (prefers-reduced-motion: reduce) {
  .demo-toast,
  .demo-toast-slot {
    transition: none;
  }
}

Anatomy

import { Toast, toast } from "@funstack/ui/toast";

// Once, near the root of the app (from a Client Component: children is a function).
<Toast.Viewport>                          {/* section[popover=manual][aria-label="Notifications (F8)"] */}
  {({ toasts }) =>
    toasts.map((t) => (
      <Toast.Root key={t.id} toast={t}>   {/* div[role=dialog|alertdialog][tabindex=0] */}
        <Toast.Title />                   {/* div: t.title */}
        <Toast.Description />             {/* p: t.description, nothing when absent */}
        <Toast.Action />                  {/* button: t.action, nothing when absent */}
        <Toast.Close aria-label="Close">×</Toast.Close>
      </Toast.Root>
    ))
  }
</Toast.Viewport>

// Anywhere on the client:
toast({ title: "Link copied" });

This page's Viewport, which every example on it feeds:

ToastNotifications.tsx
"use client";
import { Toast } from "@funstack/ui/toast";
import type { ToastItem } from "@funstack/ui/toast";

// The toast region, rendered once (here: once on this page; in an app, near the root). It is a
// `<section popover="manual">` in the top layer, shown while it has toasts, and moves into an
// open modal Dialog by itself. The Viewport's children map the queue to parts.
function renderToast(t: ToastItem) {
  return (
    <div className="demo-toast-slot" key={t.id}>
      <Toast.Root toast={t} className="demo-toast">
        <div className="demo-toast-text">
          <Toast.Title className="demo-toast-title" />
          <Toast.Description className="demo-toast-description" />
        </div>
        <Toast.Action className="demo-button demo-toast-action">
          {({ pending }) => (pending ? "Undoing…" : t.action?.label)}
        </Toast.Action>
        <Toast.Close className="demo-toast-close" aria-label="Close">
          ×
        </Toast.Close>
      </Toast.Root>
    </div>
  );
}

export function ToastNotifications() {
  return (
    <Toast.Viewport className="demo-toasts">
      {({ toasts, queued }) => (
        <>
          {toasts.map(renderToast)}
          {queued > 0 && <p className="demo-toast-queued">+{queued} more</p>}
        </>
      )}
    </Toast.Viewport>
  );
}
  • No Provider. toast is bound to a module-level default manager; a Viewport without manager renders it. A second Viewport on the same manager warns and renders nothing. createToastManager() makes an isolated queue (a second React root, a test) for <Toast.Viewport manager={m}>.
  • No list element, no Portal part. Toasts are focusable dialogs (alertdialog for priority: "high") named by their Title and described by their Description. The region is wherever the Viewport is rendered; its only move (into a modal dialog) is the library's. On the client it sits inside two display: contents wrappers: an anchor where the Viewport is rendered, and the element the library moves.
  • The Title is a div, not a heading: transient notifications stay out of heading navigation.
  • Calls made before the Viewport mounts are buffered and shown when it does. A Viewport that unmounts leaves its toasts with the manager for the next one (timers stop; no closeAction runs).

The urgent queue

Every queue change (add, update, dismiss, the drop after an exit animation) is a normal, urgent update. toast() dispatches from a microtask, so a call inside a startTransition scope, the sync prefix of an action included, escapes the scope and shows at once; calls keep their order. Everything a toast drives is a transition as usual: its Action's action and its closeAction.

Show code
ToastUrgent.tsx
"use client";
import { useTransition } from "react";
import { toast } from "@funstack/ui/toast";
import { sleep } from "./fake-api.js";

// React holds every transition update while an async action is pending anywhere in the app. A
// toast pushed as a transition would therefore appear only after the action it reports on. The
// queue is urgent instead: `toast()` dispatches from a microtask, so the "Saving…" toast shows at
// once, from inside the action's own transition, while the button is still pending.
export function ToastUrgent() {
  const [pending, startTransition] = useTransition();
  return (
    <div className="demo-row">
      <button
        type="button"
        className="demo-button"
        data-pending={pending ? "" : undefined}
        onClick={() =>
          startTransition(async () => {
            const id = toast({ title: "Saving…", duration: null });
            await sleep(2000);
            toast.update(id, { title: "Saved", type: "success", duration: 3000 });
          })
        }
      >
        {pending ? "Saving (2 s)…" : "Save"}
      </button>
      <button
        type="button"
        className="demo-button"
        onClick={() => {
          for (let i = 1; i <= 5; i++) toast({ title: `Message ${i} of 5` });
        }}
      >
        Burst of five
      </button>
    </div>
  );
}

limit (default 3) toasts render at once; later ones wait in order and show as earlier ones leave. Nothing is dropped, so an Undo is never lost to a burst. state.queued counts the waiting ones. A toast() with an existing id updates that toast in place ("one toast per key").

Promise toasts

toast.promise(promise, { pending, success, error }) shows pending at once with data-pending and no timer, and replaces it with success or error (options, or functions of the value / the reason) when the promise settles: same toast, same element, announced again. It returns a promise that settles with the input in the same tick, so action={() => toast.promise(save(), …)} keeps the part that started the work pending for exactly as long.

The promiseToastReturned promise
fulfils, success givenreplaced by successresolves with the value
fulfils, no successdismissed (reason: "dismiss")resolves with the value
rejects, error givenreplaced by errorresolves with undefined: the toast is the handler
rejects, no errordismissedrejects with the reason (the action prop's Error Boundary gets it)
a success / error function throwsdismissed (never left pending)rejects with what the function threw

Undo and closeAction

Toast.Action is a pure-trigger action part, like Menu.Item. While its action runs the Action and the Root carry data-pending and the toast's timer is paused; when it resolves the toast closes with reason: "activate" (unless closeOnSettle={false}). A rejection keeps the toast and reaches the nearest Error Boundary above the Viewport: put one around the list.

A caught error closes the toast

A catch inside the action resolves it, and a resolved action closes the toast, so a "Could not undo" written with toast.update in the catch is never seen. Rethrow after the update (the toast stays; the boundary above the Viewport gets the error), or use closeOnSettle={false} and toast.dismiss(details.id) on success.

A toast's closeAction runs once when it leaves, in the Viewport's transition, after the toast is already gone: "activate" (its Action settled), "dismiss" (Close, Escape, toast.dismiss(), a promise toast with no message for its outcome) or "timeout". That is the "do later, unless undone" pattern:

  • Groceries
  • Dentist
  • Call Ada
    Show code
    ToastUndoWindow.tsx
    "use client";
    import { startTransition, useState } from "react";
    import { toast } from "@funstack/ui/toast";
    import { sleep } from "./fake-api.js";
    
    const initial = ["Groceries", "Dentist", "Call Ada"];
    
    // "Do later, unless undone": the item leaves the list at once (local state), and the toast is the
    // grace period. `closeAction` runs when the toast leaves, with the reason: "activate" when Undo
    // was used, otherwise ("timeout", or "dismiss" through Close, Escape or toast.dismiss()) the
    // deletion is committed. Hover or focus the toast to pause its 6 second timer.
    export function ToastUndoWindow() {
      const [items, setItems] = useState(initial);
      const [log, setLog] = useState<readonly string[]>([]);
      const note = (line: string) => startTransition(() => setLog((l) => [line, ...l].slice(0, 3)));
    
      const remove = (name: string) => {
        setItems((list) => list.filter((item) => item !== name));
        toast({
          title: `Deleted “${name}”`,
          duration: 6000,
          action: {
            label: "Undo",
            action: () => startTransition(() => setItems((list) => [...list, name])),
          },
          closeAction: async ({ reason }) => {
            if (reason === "activate") {
              note(`Restored ${name}`);
              return;
            }
            await sleep(300); // the real delete request
            note(`Deleted ${name} for good (${reason})`);
          },
        });
      };
    
      return (
        <div className="demo-stack">
          <ul className="demo-toast-list">
            {items.map((name) => (
              <li key={name}>
                <span>{name}</span>
                <button type="button" className="demo-button" onClick={() => remove(name)}>
                  Delete
                </button>
              </li>
            ))}
            {items.length === 0 && (
              <li>
                <button type="button" className="demo-button" onClick={() => setItems(initial)}>
                  Reset list
                </button>
              </li>
            )}
          </ul>
          <ul className="demo-readout">
            {log.map((line, i) => (
              <li key={i}>{line}</li>
            ))}
          </ul>
        </div>
      );
    }

    Props

    toast(options) and the manager

    PropTypeDefaultDescription
    toast(options)(options: ToastOptions) => string–Adds a toast and returns its id (or updates the toast with options.id). Also toast.add.
    toast.promise<T>(promise: PromiseLike<T>, options: ToastPromiseOptions<T>) => Promise<T | undefined>–{ pending, success?, error?, id? }; success / error are options or functions of the value / reason. See the table above.
    toast.update(id: string, options: Partial<ToastOptions>) => void–Changes a toast in place; undefined removes a field. Ignored for a closed toast; an unknown id warns in development.
    toast.dismiss(id?: string) => void–Closes one toast, or all, with reason: "dismiss". Idempotent.
    createToastManager() => ToastManager–An isolated queue with the same shape, for Toast.Viewport manager.

    On the server every method is a no-op with a development warning, and nothing is buffered (a module-level queue would be shared between requests). Return data from a Server Function and call toast() on the client.

    ToastOptions

    PropTypeDefaultDescription
    titleReactNode–Rendered by Toast.Title; part of the default announcement.
    descriptionReactNode–Rendered by Toast.Description; part of the default announcement.
    idstringgeneratedAn existing id updates that toast.
    typestring–Free-form ("success", "error"), rendered as data-type on the Root.
    priority"normal" | "high""normal""high": announced with ariaNotify priority "high", Root role alertdialog.
    durationnumber | nullViewport `duration`; `null` with an `action`Milliseconds until the toast closes with reason: "timeout"; null never. A toast with an action is persistent unless you set it; if you do, keep the action reachable elsewhere too (WCAG 2.2.1).
    action{ label: ReactNode; action: Action<[ToastActionDetails]> }–What Toast.Action renders and runs. details.id is the toast's id, so an inline action can update or dismiss its own toast.
    closeActionAction<[ToastCloseDetails]>–Runs once when the toast leaves, in the Viewport's transition; details.reason is "activate", "dismiss" or "timeout". Not called when the Viewport unmounts.
    announce(toast: ToastItem) => string | null–The text ariaNotify speaks for this toast; null for nothing. Wins over the Viewport's announce.
    dataunknown–Anything your Viewport children want to render.

    ToastItem, what state.toasts holds: the options plus id, pending (its promise is unsettled or its Action runs) and ending (closed, kept for its exit animation).

    Toast.Viewport

    PropTypeDefaultDescription
    managerToastManagertoastThe queue to render.
    limitnumber3Toasts rendered at once; the rest wait in order.
    durationnumber | null5000Default duration of a toast without an action.
    hotkeystring[] | null["F8"]event.code values and modifier names ("altKey", "ctrlKey", "metaKey", "shiftKey") that focus the newest toast, with no other modifier held (Ctrl+F8 is not F8). Also names the region ("Notifications (Alt+T)"). null installs no listener.
    announce(toast: ToastItem) => string | null–The announcement text for every toast (localisation). Default: the text of its Title and Description.
    aria-labelstring"Notifications (F8)"The region's name; yours is used verbatim.
    classNamestring | ((state: ToastViewportState) => string | undefined)–A class name, or a function of the part's state.
    styleCSSProperties | ((state: ToastViewportState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
    childrenReactNode | ((state: ToastViewportState) => ReactNode)–Usually a function of the state that maps state.toasts to Toast.Roots.
    renderReactElement | ((props, state: ToastViewportState) => ReactNode)–Replace the default <section> (element form) or take over rendering (function form).

    ToastViewportState: { toasts, queued, paused }. toasts is chronological (oldest first) and includes the ones animating out; paused is true while a viewport-wide pause holds.

    Toast.Root

    PropTypeDefaultDescription
    toastToastItem–Required. An item of state.toasts; key the Root (or its wrapper) on toast.id.
    fallbackReactNodenullShown inside the Root while its children suspend (a skeleton). The timer and the announcement wait for the reveal; the Root is not data-pending meanwhile. Parts inside it (a visually hidden Toast.Title) name the toast but gate nothing.
    classNamestring | ((state: ToastRootState) => string | undefined)–A class name, or a function of the part's state.
    styleCSSProperties | ((state: ToastRootState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
    childrenReactNode | ((state: ToastRootState) => ReactNode)–Content, or a function of the part's state.
    renderReactElement | ((props, state: ToastRootState) => ReactNode)–Replace the default <div> (element form) or take over rendering (function form).

    ToastRootState: { toast, pending, ending, type, priority }. Toast.Title (div) and Toast.Description (p) take the same function props against the Root's state; their children default to toast.title / toast.description, and the Description renders nothing when it has neither.

    Toast.Action

    PropTypeDefaultDescription
    actionAction<[ToastActionDetails]>toast.action.actionRuns in this part's transition. The part renders nothing when neither it nor the toast has an action.
    closeOnSettlebooleantrueClose the toast (reason: "activate") when the action resolves.
    classNamestring | ((state: ToastActionState) => string | undefined)–A class name, or a function of the part's state.
    styleCSSProperties | ((state: ToastActionState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
    childrenReactNode | ((state: ToastActionState) => ReactNode)–Default: toast.action.label.
    renderReactElement | ((props, state: ToastActionState) => ReactNode)–Replace the default <button> (element form) or take over rendering (function form).

    ToastActionState: { pending, toast }. Never disabled while pending: a second click is a second dispatch and the first resolve closes the toast.

    Toast.Close

    PropTypeDefaultDescription
    classNamestring | ((state: ToastCloseState) => string | undefined)–A class name, or a function of the part's state.
    styleCSSProperties | ((state: ToastCloseState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
    childrenReactNode | ((state: ToastCloseState) => ReactNode)–Content, or a function of the part's state.
    renderReactElement | ((props, state: ToastCloseState) => ReactNode)–Replace the default <button> (element form) or take over rendering (function form).

    Closes the toast with reason: "dismiss", like Escape. Give it a name (text or aria-label); development warns otherwise. ToastCloseState: { toast }.

    Data attributes

    AttributePartMeaning
    data-pendingRoot, ActionThe toast's promise is unsettled, or its Action's action runs (not set while its content merely suspends)
    data-endingRootClosed, kept (and inert) until the animations the ending state started finish
    data-typeRootThe toast's type
    data-pausedViewportA viewport-wide timer pause holds; pause a progress bar with it
    data-emptyViewportNo toasts rendered

    Timers

    A toast's timer starts once it is visible (within limit), its content has rendered and it is not pending, and resumes with the remaining time after a pause. Timers pause while the pointer is over the region, focus is inside it, the document is hidden or the window is blurred (data-paused), and per toast while it is pending. Elapsing closes the toast with reason: "timeout". Pausing helps sighted users; the persistent default for action toasts and the hotkey are what give screen-reader users time to reach an Undo.

    Keyboard, focus and ARIA

    KeyWhereBehaviour
    F8 (hotkey)anywhereFocus the newest toast; nothing (and no preventDefault) without toasts
    Tab / Shift+TabregionThrough each toast and its buttons in DOM order; nothing is trapped
    ArrowDown / ArrowUpa toastNext / previous toast, no wrap
    Escapea toastDismiss it
    Enter / SpaceAction, CloseNative button activation
    • When the toast holding focus closes, focus moves to the next toast, else the previous one, else back to where it was before the region was entered (by F8 or Tab), else the body.
    • F8, not F6: F6 is the browsers' pane-cycling key. Change it with hotkey.
    • Announcements go through document.ariaNotify(), never a live region: after the toast's content has rendered, again when an update changes its text, with priority: "high" for high-priority toasts; several toasts in one tick become one announcement. Dismissals are silent. A queued toast is announced when it is added.

    Top layer and modal dialogs

    The region is popover="manual": shown while it has toasts, it neither closes an open menu or popover nor is closed by one, and it has no light dismiss. A toast added while a later menu or popover covers the region raises it to the top again. A top-layer element outside an open modal <dialog> is inert, so while a Dialog is open as a modal the region moves into it (and back when it closes): an Undo stays clickable. The move keeps the toasts' React state (an Undo in flight stays pending), but it is a DOM move: the toasts' enter animation plays again and focus inside a toast is lost. Dialogs the library did not open are not detected.

    Styling and animation

    Enter with @starting-style, exit on [data-ending]: the library keeps a closed toast mounted until the animations (transitions included) that [data-ending] started finish. Animations already running in the toast, such as a progress bar paused with data-paused, are not waited for. Toasts never use <ViewTransition> and no part has a transition prop: every Toast commit is urgent, and react-dom starts view transitions for transition commits only. Make the region's box click-through with pointer-events: none and give it back to the toasts; stack visually with flex-direction: column-reverse and :nth-last-child().

    Suspense

    Toast content may suspend (title: <UserName id={id} />). Each Root wraps its children in its own Suspense boundary, so a suspending toast does not pull the Viewport into the nearest boundary above it (a toast mounts urgently, where that boundary would show its fallback). While the content suspends the Root shows its fallback prop (nothing by default), in the same frame as the toast() call; the timer does not run and nothing is announced. On the reveal the toast announces its Title and Description and its timer starts. An update whose new content suspends shows the fallback again and pauses the timer until the next reveal.

    <Toast.Root key={t.id} toast={t} fallback={<ToastSkeleton />}>
      <Toast.Title />
      <Toast.Description />
    </Toast.Root>

    Use fallback rather than your own <Suspense> inside the Root: the toast is still announced when your boundary reveals its Title and Description, but its timer starts at once, because the library cannot see your boundary's fallback.

    Server Components

    <Toast.Viewport> takes a function child, so render it from a Client Component file that a Server Component layout includes. On the server it renders the empty region. toast() is client code: a Server Function returns data and the client calls toast() with it (in the action that called it, or in an effect on useActionState's result).

    Browser notes

    • ariaNotify is in Chrome 141, Firefox 150 and Safari 27; where it is missing nothing is announced and nothing throws.
    • No swipe to dismiss yet: Close, Escape and the timer cover WCAG 2.5.1.