Skip to main content
Markdown

Toast

Toast displays transient feedback without interrupting the current workflow. The queue, timers, pause/resume behavior, hotkey focus, and live-region semantics come from Spar's headless toast controller; React Spar renders each item with Takeoff Alert anatomy by default.

Use variants to show the visual tone of the toast.

Usage

import { Toaster, createToaster } from '@takeoff-ui/react-spar';
const toaster = createToaster({ placement: 'bottom-end' });

function App() {
return (
<>
<button
type="button"
onClick={() =>
toaster.success({
title: 'Booking saved',
description: 'Passenger details were updated successfully.',
})
}
>
Save
</button>
<Toaster toaster={toaster} />
</>
);
}

Playground


const toaster = createToaster({duration: 6000, placement: 'top-end' });

function PlaygroundDemo() {
  const [count, setCount] = React.useState(0);

  const notify = () => {
    const nextCount = count + 1;

    toaster.success({
      title: 'Booking saved #' + nextCount,
      description: 'Passenger details update #' + nextCount + ' completed successfully.',
      action: {
        label: 'Undo',
        altText: 'Undo booking update',
      },
    });

    setCount(nextCount);
  };

  return (
    <div>
      <Button onClick={notify}>Save booking</Button>
      <Toaster toaster={toaster} />
    </div>
  );
}

render(<PlaygroundDemo />);

Type/Variants

Toast uses type for semantic intent. React Spar maps that intent to the Alert variant internally, so consumers do not pass a separate variant prop.


const toaster = createToaster({ placement: 'top-end' });

function TypeVariantDemo() {
  const notify = (type, title) => {
    toaster.create({
      type,
      title,
      description: 'Toast variant preview.',
    });
  };

  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Button onClick={() => notify('default', 'Neutral')} variant="neutral">Neutral</Button>
      <Button onClick={() => notify('success', 'Success')} variant="success">Success</Button>
      <Button onClick={() => notify('info', 'Info')} variant="info">Info</Button>
      <Button onClick={() => notify('warning', 'Warning')} variant="warning">Warning</Button>
      <Button onClick={() => notify('error', 'Danger')} variant="danger">Danger</Button>
      <Toaster toaster={toaster} />
    </div>
  );
}

render(<TypeVariantDemo />);

Appearance


const filled = createToaster({ placement: 'top-end' });
const filledLight = createToaster({ placement: 'top-end' });
const outlined = createToaster({ placement: 'top-end' });
const gradient = createToaster({ placement: 'top-end' });

function AppearanceDemo() {
  const notify = (controller, label) => {
    controller.success({
      title: label,
      description: 'Toast appearance preview.',
    });
  };

  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Button onClick={() => notify(filled, 'Filled')}>Filled</Button>
      <Button onClick={() => notify(filledLight, 'Filled light')}>Filled light</Button>
      <Button onClick={() => notify(outlined, 'Outlined')}>Outlined</Button>
      <Button onClick={() => notify(gradient, 'Gradient')}>Gradient</Button>

      <Toaster toaster={filled} appearance="filled" />
      <Toaster toaster={filledLight} appearance="filledLight" />
      <Toaster toaster={outlined} appearance="outlined" />
      <Toaster toaster={gradient} appearance="gradient" />
    </div>
  );
}

render(<AppearanceDemo />);

Positions


const topStart = createToaster({ placement: 'top-start' });
const top = createToaster({ placement: 'top' });
const topEnd = createToaster({ placement: 'top-end' });
const bottomStart = createToaster({ placement: 'bottom-start' });
const bottom = createToaster({ placement: 'bottom' });
const bottomEnd = createToaster({ placement: 'bottom-end' });

const placements = [
  ['top-start', topStart],
  ['top', top],
  ['top-end', topEnd],
  ['bottom-start', bottomStart],
  ['bottom', bottom],
  ['bottom-end', bottomEnd],
];

function PositionDemo() {
  const showToast = (placement, controller) => {
    controller.info({
      title: placement,
      description: 'Rendered by the toaster with this placement.',
    });
  };

  return (
    <div className="flex flex-wrap justify-center gap-2">
      {placements.map(([placement, controller]) => (
        <Button key={placement} onClick={() => showToast(placement, controller)}>
          {placement}
        </Button>
      ))}

      {placements.map(([placement, controller]) => (
        <Toaster key={placement} toaster={controller} />
      ))}
    </div>
  );
}

render(<PositionDemo />);

Persistent Toast


const toaster = createToaster({ placement: 'top-end' });

function PersistentDemo() {
  const showToast = () => {
    toaster.info({
      title: 'Persistent toast',
      description: 'This toast stays visible until it is dismissed.',
      duration: null,
    });
  };

  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Button onClick={showToast}>Show persistent</Button>
      <Button onClick={() => toaster.dismiss()}>Dismiss all</Button>
      <Toaster toaster={toaster} />
    </div>
  );
}

render(<PersistentDemo />);

Update Toast


const toaster = createToaster({ placement: 'top-end' });

function UpdateDemo() {
  const [toastId, setToastId] = React.useState(null);

  const createToast = () => {
    const id = toaster.loading({
      title: 'Draft created',
      description: 'Waiting for the next update.',
    });

    setToastId(id);
  };

  const updateToast = () => {
    if (!toastId) return;
    toaster.update(toastId, {
      title: 'Draft saved',
      description: 'The existing toast was updated.',
      type: 'success',
      duration: 3000,
    });
    setToastId(null);
  };

  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Button onClick={createToast}>Create</Button>
      <Button disabled={!toastId} onClick={updateToast}>Update</Button>
      <Toaster toaster={toaster} />
    </div>
  );
}

render(<UpdateDemo />);

Promise Toasts


const toaster = createToaster({ placement: 'top-end' });

function PromiseDemo() {
  const notify = () => {
    const request = new Promise((resolve) => {
      window.setTimeout(() => resolve({ pnr: 'TK42X7' }), 1200);
    });

    toaster.promise(request, {
      loading: {
        title: 'Saving booking',
        description: 'Passenger details are being updated.',
      },
      success: (booking) => ({
        title: 'Booking saved',
        description: 'PNR ' + booking.pnr + ' is ready.',
      }),
      error: {
        title: 'Booking could not be saved',
        description: 'Please try again.',
      },
    });
  };

  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Button onClick={notify}>Save async</Button>
      <Toaster toaster={toaster} />
    </div>
  );
}

render(<PromiseDemo />);

Overlap


const toaster = createToaster({ placement: 'top-end', duration: 5000 });

function OverlapDemo() {
  const [count, setCount] = React.useState(0);

  const showToast = () => {
    const number = count + 1;
    toaster.info({
      title: 'Stacked toast ' + number,
      description: 'The viewport is styled to overlap visible items.',
    });

    setCount(number);
  };

  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Button onClick={showToast}>Show toast</Button>
      <Toaster toaster={toaster} overlap />
    </div>
  );
}

render(<OverlapDemo />);

Max Visible


const oneToaster = createToaster({
  placement: 'top-start',
  duration: 5000,
  maxVisibleToasts: 1,
});
const fiveToaster = createToaster({
  placement: 'top',
  duration: 5000,
  maxVisibleToasts: 5,
});
const eightToaster = createToaster({
  placement: 'top-end',
  duration: 5000,
  maxVisibleToasts: 8,
});

function MaxVisibleDemo() {
  const showToast = (label, controller) => {
    for (let index = 1; index <= 10; index += 1) {
      controller.info({
        title: label + ' / Toast ' + index,
        description: 'Queued items wait until a visible slot opens.',
      });
    }
  };

  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Button onClick={() => showToast('Max 1', oneToaster)}>Max 1</Button>
      <Button onClick={() => showToast('Max 5', fiveToaster)}>Max 5</Button>
      <Button onClick={() => showToast('Max 8', eightToaster)}>Max 8</Button>
      <Toaster toaster={oneToaster} />
      <Toaster toaster={fiveToaster} />
      <Toaster toaster={eightToaster} />
    </div>
  );
}

render(<MaxVisibleDemo />);

Duration


const threeSecondToaster = createToaster({ placement: 'top-start', duration: 3000 });
const fiveSecondToaster = createToaster({ placement: 'top', duration: 5000 });
const eightSecondToaster = createToaster({ placement: 'top-end', duration: 8000 });

function DurationDemo() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Button
        onClick={() =>
          threeSecondToaster.info({
            title: '3 seconds',
            description: 'This toast closes first.',
          })
        }
      >
        3s
      </Button>
      <Button
        onClick={() =>
          fiveSecondToaster.info({
            title: '5 seconds',
            description: 'This toast stays a bit longer.',
          })
        }
      >
        5s
      </Button>
      <Button
        onClick={() =>
          eightSecondToaster.info({
            title: '8 seconds',
            description: 'This toast stays visible the longest.',
          })
        }
      >
        8s
      </Button>
      <Toaster toaster={threeSecondToaster} />
      <Toaster toaster={fiveSecondToaster} />
      <Toaster toaster={eightSecondToaster} />
    </div>
  );
}

render(<DurationDemo />);

Page Idle


const toaster = createToaster({
  duration: 8000,
  pauseOnPageIdle: true,
  placement: 'top-end',
});

function PageIdleDemo() {
  const [seconds, setSeconds] = React.useState(0);
  const [running, setRunning] = React.useState(false);

  const showToast = () => {
    setSeconds(8);
    setRunning(true);
    toaster.info({
      title: 'Timer pauses while the page is idle',
      description: 'Switch tabs and return before the countdown finishes.',
    });
  };

  React.useEffect(() => {
    if (!running || seconds <= 0) return;
    const interval = window.setInterval(() => {
      setSeconds((value) => Math.max(0, value - 1));
    }, 1000);

    return () => window.clearInterval(interval);
  }, [running, seconds]);

  React.useEffect(() => {
    const handleVisibilityChange = () => {
      setRunning(document.visibilityState === 'visible' && seconds > 0);
    };

    document.addEventListener('visibilitychange', handleVisibilityChange);
    return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
  }, [seconds]);

  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <Button onClick={showToast}>Show timed toast</Button>
      <span className="min-w-12 rounded-md border px-3 py-2 text-center font-mono text-lg font-semibold">
        {seconds}s
      </span>
      <Toaster toaster={toaster} />
    </div>
  );
}

render(<PageIdleDemo />);

External Close


const toaster = createToaster({ placement: 'top-end' });

function ExternalCloseDemo() {
  const [toastId, setToastId] = React.useState(null);

  const showToast = () => {
    const id = toaster.info({
      title: 'Manual review required',
      description: 'This notification stays visible until it is dismissed.',
      duration: null,
    });

    setToastId(id);
  };

  const closeToast = () => {
    if (!toastId) return;
    toaster.dismiss(toastId);
    setToastId(null);
  };

  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Button onClick={showToast}>Show toast</Button>
      <Button disabled={!toastId} onClick={closeToast}>Close from outside</Button>
      <Toaster toaster={toaster} />
    </div>
  );
}

render(<ExternalCloseDemo />);

Custom Rendering


const toaster = createToaster({ placement: 'bottom' });

function CustomRenderDemo() {
  const showBoardingToast = () => {
    toaster.clear();
    toaster.info({
      title: 'TK 1845',
      description: 'Boarding starts at Gate A12.',
      duration: null,
      data: {
        gate: 'A12',
        group: 'Group 2',
        time: '18:45',
      },
    });
  };

  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Button onClick={showBoardingToast}>Show custom toast</Button>
      <Toaster toaster={toaster}>
        {(toast) => {
          const details = toast.data || {};

          return (
            <Toast
              toast={toast}
              toaster={toaster}
              className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-4 text-slate-900 shadow-lg dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100"
            >
              <div className="grid gap-4">
                <div className="flex items-start justify-between gap-3">
                  <div className="min-w-0">
                    <span className="block text-xs font-medium uppercase text-slate-500 dark:text-slate-400">Boarding pass</span>
                    <strong className="mt-1 block text-base">{toast.title}</strong>
                    <span className="mt-1 block text-sm text-slate-600 dark:text-slate-300">{toast.description}</span>
                  </div>
                  <button
                    type="button"
                    className="shrink-0 rounded-md border border-slate-200 px-2 py-1 text-xs dark:border-slate-700"
                    onClick={() => toaster.dismiss(toast.id)}
                  >
                    Dismiss
                  </button>
                </div>
                <div className="grid grid-cols-3 gap-2 text-xs">
                  <div className="rounded-md bg-slate-100 p-2 dark:bg-slate-800">
                    <span className="block text-slate-500 dark:text-slate-400">Gate</span>
                    <strong>{details.gate}</strong>
                  </div>
                  <div className="rounded-md bg-slate-100 p-2 dark:bg-slate-800">
                    <span className="block text-slate-500 dark:text-slate-400">Group</span>
                    <strong>{details.group}</strong>
                  </div>
                  <div className="rounded-md bg-slate-100 p-2 dark:bg-slate-800">
                    <span className="block text-slate-500 dark:text-slate-400">Time</span>
                    <strong>{details.time}</strong>
                  </div>
                </div>
                <div className="h-1 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
                  <div className="h-full w-2/3 rounded-full bg-sky-500" />
                </div>
              </div>
            </Toast>
          );
        }}
      </Toaster>
    </div>
  );
}

render(<CustomRenderDemo />);

API Reference

Toaster

Props

NameTypeDefaultDescription
toasterToasterController-Toast controller returned by createToaster.
childrenReact.ReactNode-Optional render function for custom toast item rendering.
appearanceToastAppearance'filled'Alert appearance used by the default toast renderer.
closeLabelstring'Dismiss notification'Accessible label for the default close control.
overlapbooleanfalseStacks visible toasts and expands the stack on hover or focus.
classNamesPartial<Record<"root", string>>-
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-
labelstring'Notifications (F8)'Accessible label for the toast viewport region.
hotkeystring[]['F8']Keyboard shortcut that focuses the toast viewport.
classNamestring-Appends custom classes to the root slot of this part.

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-placementAlwaysReflects the toaster placement for viewport positioning.
data-overlapWhen overlap is trueEnables the overlapped visual stack recipe.
data-expandedWhen an overlapping toaster is hovered or focusedIndicates that the overlapped stack is expanded for interaction.

Toast

Props

NameTypeDefaultDescription
toastToastData-Toast item supplied by the headless toaster controller.
childrenReact.ReactNode-Optional custom toast content. When omitted, Toast renders the Takeoff Alert anatomy.
toasterToasterController-Controller used to pause, resume, and dismiss the toast.
appearanceToastAppearance'filled'Alert appearance used by the default toast renderer.
closeLabelstring'Dismiss notification'Accessible label for the default close control.
classNamesPartial<Record<"root", string>>-
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-
classNamestring-Appends custom classes to the root slot of this part.

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-toast-idAlwaysStable toast item identifier used for layout motion.
data-statusAlwaysReflects the toast lifecycle status.
data-typeAlwaysReflects the headless toast type used for styling hooks.

Type Definitions

NameDefinition
ToasterController{ placement: ToastPlacement; maxVisibleToasts: number; create: (options: ToastOptions) => string; success: (options: ToastOptions) => string; error: (options: ToastOptions) => string; warning: (options: ToastOptions) => string; info: (options: ToastOptions) => string; loading: (options: ToastOptions) => string; update: (id: string, options: ToastUpdateOptions) => void; dismiss: (id?: string) => void; pause: (id?: string) => void; resume: (id?: string) => void; clear: () => void; destroy: () => void; promise: <TData>(promise: Promise<TData>, options: ToastPromiseOptions<TData>) => Promise<TData>; subscribe: (listener: () => void) => () => void; getSnapshot: () => ToastData[] }
ToastAppearance'filled' | 'filledLight' | 'outlined' | 'gradient'
ToastData{ id: string; title?: ReactNode; description?: ReactNode; type: ToastType; duration: number | null; createdAt: number; remaining: number | null; status: ToastStatus; announcement: ToastAnnouncement; action?: ToastActionOptions | undefined; dismissible: boolean; data?: unknown }

Controller Values

OptionDefaultBehavior
placement'bottom-end'Reflected on data-placement for viewport positioning.
duration5000Auto-dismiss duration for ordinary toasts.
maxVisibleToasts24Maximum number of toasts to display at once. Extra items stay queued; lower this for denser product screens.
removeDelay200Time a dismissing toast remains mounted for exit motion.
pauseOnPageIdlefalseTimers continue unless this option is enabled.