Skip to content

Toast

A fixed toast notification container with Time Machine-style stacking animation. Stacks toasts with a 3D effect when collapsed; expands the full list on hover or focus. Built on top of bit-alert.

Features

  • 📚 Time Machine Stacking: 3D stacking with a 3-tier perspective effect
  • 🖱️ Hover & Focus Expand: expand to list on mouse hover or keyboard focus
  • ⏸️ Pause on Hover/Focus: auto-dismiss timers pause on both hover and focusin (WCAG 2.1)
  • 📍 6 Positions: top-left, top-center, top-right, bottom-left, bottom-center, bottom-right
  • ⏱️ Auto-dismiss: configurable per-toast duration (default 5 s)
  • 🔄 Live Updates: update a toast's content or duration in-place
  • 🔗 Promise Integration: toast.promise() ties a toast to an async operation
  • 🔔 onDismiss Callback: per-toast callback fired after removal completes
  • 🔊 Smart Urgency: error toasts automatically use assertive live region; all others use polite
  • 🎯 Singleton Service: toast.add() — no DOM queries required
  • 🔢 Max Limit: configurable maximum number of toasts in the DOM

Source Code

View Source Code
ts
import { defineComponent, html, onMount, ref, signal } from '@vielzeug/craftit';
import { classes, each } from '@vielzeug/craftit/directives';

import type { ComponentSize, RoundedSize, ThemeColor, VisualVariant } from '../../types';

import { reducedMotionMixin } from '../../styles';
import { awaitExit } from '../../utils/animation';
import componentStyles from './toast.css?inline';

/** Toast container properties */

export type BitToastEvents = {
  add: { id: string };
  dismiss: { id: string };
};

export type BitToastProps = {
  max?: number;
  position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
};

/** Individual toast notification */
export type ToastItem = {
  actions?: Array<{
    color?: ThemeColor;
    label: string;
    onClick?: () => void;
    variant?: VisualVariant;
  }>;
  color?: ThemeColor;
  dismissible?: boolean;
  /** Auto-dismiss delay in ms. Set to 0 for persistent toasts (default: 5000) */
  duration?: number;
  heading?: string;
  /** Show message and actions side-by-side (horizontal layout) */
  horizontal?: boolean;
  /** Auto-generated via crypto.randomUUID() if omitted */
  id?: string;
  message: string;
  /** Metadata text (e.g. timestamp) shown in the alert meta slot */
  meta?: string;
  /** Called after the toast is fully dismissed and removed */
  onDismiss?: () => void;
  rounded?: RoundedSize | '';
  size?: ComponentSize;
  /**
   * Urgency level for screen readers.
   * - `'polite'` (default): uses `aria-live="polite"` — announced after the user finishes their current action.
   * - `'assertive'`: uses `aria-live="assertive"` — interrupts the user immediately. Use only for critical errors.
   */
  urgency?: 'polite' | 'assertive';
  variant?: 'solid' | 'flat' | 'bordered';
};

type NormalizedToast = ToastItem & { id: string };

/** Public API of the bit-toast element */
export interface ToastElement extends HTMLElement {
  add: (toast: ToastItem) => string;
  update: (id: string, updates: Partial<ToastItem>) => void;
  dismiss: (id: string) => void;
  clear: () => void;
}

/**
 * A toast notification container with Time Machine-style stacking animation.
 * Stacks up to 3 notifications with a 3D effect. Hover to expand all toasts.
 *
 * @element bit-toast
 *
 * @attr {string} position - 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
 * @attr {number} max - Max toasts in DOM at once (default: 5)
 *
 * @fires add - When a toast is added `{ id }`
 * @fires dismiss - When a toast is dismissed `{ id }`
 *
 * @slot - Manually placed bit-alert elements
 *
 * @cssprop --toast-position - Position type (default: fixed)
 * @cssprop --toast-inset-top - Top inset
 * @cssprop --toast-inset-bottom - Bottom inset
 * @cssprop --toast-inset-left - Left inset
 * @cssprop --toast-inset-right - Right inset
 * @cssprop --toast-z-index - Z-index (default: 9999)
 * @cssprop --toast-max-width - Max width (default: 400px)
 * @cssprop --toast-gap - Gap between expanded toasts (default: 0.5rem)
 *
 * @example
 * ```html
 * <!-- Declarative: place once in HTML -->
 * <bit-toast position="bottom-right"></bit-toast>
 *
 * <!-- Programmatic: use the singleton service -->
 * <script type="module">
 *   import { toast } from '@vielzeug/buildit';
 *   toast.add({ message: 'Changes saved!', color: 'success' });
 * </script>
 * ```
 */
/** Renders the actions slot for a toast item */
function renderToastActions(toast: NormalizedToast, onDismiss: () => void) {
  if (!toast.actions?.length) return '';

  return html`
    <div slot="actions" class="toast-actions">
      ${toast.actions.map(
        (action) => html`
          <bit-button
            size="sm"
            color=${action.color || toast.color || 'primary'}
            variant=${action.variant || 'solid'}
            @click=${() => {
              action.onClick?.();
              onDismiss();
            }}
            >${action.label}</bit-button
          >
        `,
      )}
    </div>
  `;
}

export const TOAST_TAG = defineComponent<BitToastProps, BitToastEvents>({
  props: {
    max: { default: 5 },
    position: { default: 'bottom-right' },
  },
  setup({ emit, host, props }) {
    const toasts = signal<NormalizedToast[]>([]);
    const exitingIds = signal<Set<string>>(new Set());
    const containerRef = ref<HTMLDivElement>();
    const timers = new Map<
      string,
      {
        remaining: number;
        startedAt: number;
        timeoutId: number;
      }
    >();
    // Sequential dismiss queue — only one toast exits at a time so animations never overlap.
    const dismissQueue: string[] = [];
    let isDismissing = false;
    const setExiting = (id: string, value: boolean) => {
      const next = new Set(exitingIds.value);

      if (value) next.add(id);
      else next.delete(id);

      exitingIds.value = next;
    };
    const scheduleRemoval = (id: string, duration: number) => {
      const timeoutId = window.setTimeout(() => {
        removeToast(id);
        timers.delete(id);
      }, duration);

      timers.set(id, { remaining: duration, startedAt: Date.now(), timeoutId });
    };
    let isPaused = false;
    const pauseTimers = () => {
      if (isPaused) return;

      isPaused = true;
      for (const [id, t] of timers) {
        clearTimeout(t.timeoutId);
        timers.set(id, { ...t, remaining: Math.max(0, t.remaining - (Date.now() - t.startedAt)) });
      }
    };
    const resumeTimers = () => {
      if (!isPaused) return;

      isPaused = false;
      for (const [id, t] of timers) {
        if (t.remaining <= 0) continue;

        scheduleRemoval(id, t.remaining);
      }
    };
    const addToast = (toast: ToastItem): string => {
      const id = toast.id || crypto.randomUUID();
      const item: NormalizedToast = { dismissible: true, duration: 5000, ...toast, id };

      toasts.value = [...toasts.value, item].slice(-(props.max.value ?? 5));
      emit('add', { id });

      if (item.duration! > 0) scheduleRemoval(id, item.duration!);

      return id;
    };
    const removeToast = (id: string) => {
      // Cancel the auto-dismiss timer if one is running.
      const timer = timers.get(id);

      if (timer) {
        clearTimeout(timer.timeoutId);
        timers.delete(id);
      }

      // Skip if already exiting or already queued.
      if (exitingIds.value.has(id) || dismissQueue.includes(id)) return;

      if (isDismissing) {
        dismissQueue.push(id);

        return;
      }

      isDismissing = true;
      executeRemoval(id);
    };
    // Internal: actually animate and remove. Always called from processNextInQueue or directly
    // when the queue is empty.
    const executeRemoval = (id: string) => {
      // Guard: could have been removed by clearAll between queue entry and execution.
      if (exitingIds.value.has(id)) {
        processNextInQueue();

        return;
      }

      const item = toasts.value.find((t) => t.id === id);
      const wrapper = containerRef.value?.querySelector<HTMLElement>(`[data-toast-id="${id}"]`);
      const finalize = () => {
        setExiting(id, false);
        toasts.value = toasts.value.filter((t) => t.id !== id);
        item?.onDismiss?.();
        emit('dismiss', { id });
        processNextInQueue();
      };

      if (wrapper) {
        setExiting(id, true);
        awaitExit(wrapper, finalize);
      } else {
        finalize();
      }
    };
    const processNextInQueue = () => {
      if (dismissQueue.length === 0) {
        isDismissing = false;

        return;
      }

      const nextId = dismissQueue.shift()!;

      executeRemoval(nextId);
    };
    const updateToast = (id: string, updates: Partial<ToastItem>) => {
      toasts.value = toasts.value.map((t) => (t.id === id ? { ...t, ...updates, id } : t));

      if (updates.duration === undefined) return;

      const timer = timers.get(id);

      if (timer) clearTimeout(timer.timeoutId);

      timers.delete(id);

      if (updates.duration > 0) scheduleRemoval(id, updates.duration);
    };
    const clearAll = () => {
      for (const [, t] of timers) clearTimeout(t.timeoutId);
      timers.clear();
      // Drain any pending queue entries and replace with the full current list so
      // they exit one-by-one in order.
      dismissQueue.length = 0;

      const ids = toasts.value.map((t) => t.id).filter((id) => !exitingIds.value.has(id));

      if (!ids.length) return;

      dismissQueue.push(...ids);

      if (!isDismissing) processNextInQueue();
    };

    onMount(() => {
      const el = host as ToastElement;

      el.add = addToast;
      el.update = updateToast;
      el.dismiss = removeToast;
      el.clear = clearAll;
    });

    const urgencyOf = (t: NormalizedToast) => t.urgency ?? (t.color === 'error' ? 'assertive' : 'polite');
    let focusPaused = false;
    let hoverPaused = false;
    const maybePause = () => pauseTimers();
    const maybeResume = () => {
      if (!focusPaused && !hoverPaused) resumeTimers();
    };
    const setHovered = (hovered: boolean) => {
      hoverPaused = hovered;
      host.classList.toggle('hovered', hovered);

      if (hovered) maybePause();
      else maybeResume();
    };
    const renderToastItem = (toast: NormalizedToast) => html`
      <div
        class=${classes({ exiting: () => exitingIds.value.has(toast.id), 'toast-wrapper': true })}
        data-toast-id=${toast.id}
        part="toast-wrapper">
        <bit-alert
          color=${toast.color || (toast.urgency === 'assertive' ? 'error' : 'primary')}
          variant=${toast.variant || 'solid'}
          size=${toast.size || 'md'}
          rounded=${toast.rounded || 'md'}
          ?dismissible=${toast.dismissible}
          ?horizontal=${toast.horizontal}
          heading=${toast.heading || ''}
          @dismiss=${() => removeToast(toast.id)}>
          ${toast.meta ? html`<span slot="meta">${toast.meta}</span>` : ''} ${toast.message}
          ${renderToastActions(toast, () => removeToast(toast.id))}
        </bit-alert>
      </div>
    `;

    return html`
      <div
        class="toast-container"
        ref=${containerRef}
        @pointerenter=${() => setHovered(true)}
        @pointerleave=${() => setHovered(false)}
        @focusin=${() => {
          focusPaused = true;
          maybePause();
        }}
        @focusout=${() => {
          focusPaused = false;
          maybeResume();
        }}
        part="container">
        <!-- Polite live region: normal informational toasts -->
        <div
          role="region"
          aria-live="polite"
          aria-relevant="additions removals"
          aria-atomic="false"
          aria-label="Notifications"
          class="toast-live-region">
          ${each(() => toasts.value.filter((t) => urgencyOf(t) === 'polite'), renderToastItem, undefined, {
            key: (toast) => toast.id,
          })}
        </div>
        <!-- Assertive live region: critical errors that interrupt immediately -->
        <div
          role="region"
          aria-live="assertive"
          aria-relevant="additions removals"
          aria-atomic="false"
          aria-label="Critical notifications"
          class="toast-live-region">
          ${each(() => toasts.value.filter((t) => urgencyOf(t) === 'assertive'), renderToastItem, undefined, {
            key: (toast) => toast.id,
          })}
        </div>
        <slot></slot>
      </div>
    `;
  },
  styles: [reducedMotionMixin, componentStyles],
  tag: 'bit-toast',
});

// ─── Singleton toast service ─────────────────────────────────────────────────

const getHost = (): ToastElement => {
  let el = document.querySelector<ToastElement>('bit-toast');

  if (!el) {
    el = document.createElement('bit-toast') as ToastElement;
    document.body.appendChild(el);
  }

  return el;
};

/**
 * Singleton service for triggering toasts without direct DOM references.
 *
 * @example
 * ```ts
 * import { toast } from '@vielzeug/buildit';
 *
 * toast.add({ message: 'Saved!', color: 'success' });
 *
 * const id = toast.add({ message: 'Uploading…', duration: 0, dismissible: false });
 * toast.update(id, { message: 'Done!', color: 'success', duration: 3000, dismissible: true });
 *
 * await toast.promise(uploadFile(), {
 *   loading: 'Uploading…',
 *   success: (url) => `Uploaded to ${url}`,
 *   error: 'Upload failed',
 * });
 * ```
 */
export const toast = {
  /** Add a toast and return its id */
  add(item: ToastItem): string {
    return getHost().add(item);
  },

  /** Dismiss all toasts (animated) */
  clear(): void {
    getHost().clear();
  },
  /** Configure the auto-created container. Call before the first `add()` if the defaults need to change. */
  configure(config: BitToastProps): void {
    const el = getHost();

    if (config.position) el.setAttribute('position', config.position);

    if (config.max != null) el.setAttribute('max', String(config.max));
  },

  /**
   * Shows a loading toast tied to a promise.
   * Updates to success/error when the promise settles.
   */
  async promise<T>(
    promise: Promise<T>,
    messages: {
      error: string | ((err: unknown) => string);
      loading: string;
      success: string | ((data: T) => string);
    },
  ): Promise<T> {
    const id = toast.add({ color: 'primary', dismissible: false, duration: 0, message: messages.loading });

    try {
      const data = await promise;

      toast.update(id, {
        color: 'success',
        dismissible: true,
        duration: 5000,
        message: typeof messages.success === 'function' ? messages.success(data) : messages.success,
      });

      return data;
    } catch (err) {
      toast.update(id, {
        color: 'error',
        dismissible: true,
        duration: 5000,
        message: typeof messages.error === 'function' ? messages.error(err) : messages.error,
      });
      throw err;
    }
  },

  /** Dismiss a toast by id */
  remove(id: string): void {
    getHost().dismiss(id);
  },

  /** Update an existing toast in-place */
  update(id: string, updates: Partial<ToastItem>): void {
    getHost().update(id, updates);
  },
};

Basic Usage

The recommended approach is the toast singleton service — no element reference needed.

html
<bit-toast position="bottom-right"></bit-toast>

<script type="module">
  import '@vielzeug/buildit/toast';
  import '@vielzeug/buildit/alert';
  import { toast } from '@vielzeug/buildit/toast';

  toast.add({ message: 'Changes saved!', color: 'success' });
</script>

If no <bit-toast> element exists in the page, the service creates and appends one automatically.

Position Options

PreviewCode
RTL

Variants & Colors

Toasts inherit all bit-alert variants and colors.

PreviewCode
RTL

Heading & Meta

Use heading to add a bold title above the message, and meta for secondary info (e.g. a timestamp).

PreviewCode
RTL

Action Buttons

Toasts can carry action buttons. Each button auto-dismisses the toast when clicked (after running onClick).

PreviewCode
RTL

Auto-dismiss & Pause on Hover

Set duration (ms) to auto-dismiss. The timer pauses on both hover and keyboard focus, and resumes when focus or hover leaves.

javascript
toast.add({
  color: 'info',
  message: 'Read me carefully!',
  duration: 8000, // 8 second window
});
// Hovering or focusing the container pauses the countdown.
// Moving away resumes from exactly where it left off.

Set duration: 0 for persistent toasts that require manual dismissal.

Updating Toasts In-Place

toast.update() lets you mutate any field of a live toast — useful for progress updates or resolving an async state.

javascript
const id = toast.add({
  message: 'Uploading file…',
  color: 'primary',
  duration: 0,
  dismissible: false,
});

// Later…
toast.update(id, {
  message: 'Upload complete!',
  color: 'success',
  duration: 4000,
  dismissible: true,
});

Passing a new duration also reschedules (or cancels) the auto-dismiss timer.

Promise Helper

toast.promise() manages the full lifecycle of an async operation — loading, success, and error — from a single call.

javascript
import { toast } from '@vielzeug/buildit/toast';

await toast.promise(uploadFile(), {
  loading: 'Uploading…',
  success: (url) => `Uploaded to ${url}`,
  error: (err) => `Upload failed: ${err.message}`,
});

The loading toast is persistent and non-dismissible. On settle it transitions to the success or error state with a 5 s auto-dismiss.

onDismiss Callback

Execute code after a toast is fully removed (after the exit animation completes).

javascript
toast.add({
  color: 'success',
  message: 'Profile saved.',
  duration: 3000,
  onDismiss: () => router.push('/dashboard'),
});

Urgency (Screen Reader Interruption)

Toasts are routed to one of two ARIA live regions:

  • polite (default): announced after the user finishes their current action — appropriate for success, info, and warning.
  • assertive: interrupts the user immediately — reserved for critical failures.

Urgency is auto-derived from color: error uses assertive; everything else uses polite. Override only when needed:

typescript
// A non-error toast that still needs to interrupt (e.g. session expiry)
toast.add({ message: 'Session expires in 2 minutes', color: 'warning', urgency: 'assertive' });

Stacking Effect

When more than one toast is present, they stack with a 3D perspective. Only the front toast is interactive; the others are dimmed and scaled back.

  • Hover or focus the container to expand the full list
  • Toasts beyond the 3rd are hidden until the stack is expanded
  • Enter animation is handled by CSS @starting-style (no JS class toggling)
  • Exit animation fires from animationend, no hardcoded timeouts

toast Singleton Service

The toast export is the recommended imperative API. It finds the first <bit-toast> element in the document, or creates one if none exists.

typescript
import { toast } from '@vielzeug/buildit/toast';

// Add — returns the auto-generated id
const id = toast.add({ message: 'Hello!', color: 'primary' });

// Update in-place
toast.update(id, { message: 'Updated!', color: 'success', duration: 3000 });

// Remove by id
toast.remove(id);

// Dismiss all (animated)
toast.clear();

// Tie a toast to a promise
await toast.promise(fetchData(), {
  loading: 'Loading…',
  success: 'Data loaded',
  error: 'Failed to load',
});

toast.configure()

Set container options before the first add() call — useful when using the singleton service but needing non-default placement or limits:

typescript
import { toast } from '@vielzeug/buildit/toast';

toast.configure({ position: 'top-center', max: 3 });
toast.add({ message: 'Ready!', color: 'success' });

Options mirror the element attributes. Has no effect if a <bit-toast> already exists in the DOM.

Element API

The <bit-toast> element itself exposes the same operations for cases where you hold a direct reference.

javascript
const toaster = document.querySelector('bit-toast');

const id = toaster.add({ message: 'Hello!', color: 'success' }); // returns id
toaster.update(id, { message: 'Updated!' });
toaster.remove(id);
toaster.clear(); // animated clear

ToastItem Properties

PropertyTypeDefaultDescription
messagestringRequired. The notification text
idstringauto UUIDUnique id. Auto-generated via crypto.randomUUID()
colorThemeColor'primary'Alert color theme
headingstringBold heading above the message
variant'solid' | 'flat' | 'bordered''solid'Visual style
size'sm' | 'md' | 'lg''md'Alert size
roundedRoundedSize | '''md'Border radius
durationnumber5000Auto-dismiss delay in ms. 0 = persistent
dismissiblebooleantrueShow close (×) button
metastringSecondary text alongside the heading (e.g. timestamp)
horizontalbooleanfalseRender action buttons inline (to the right) instead of below
urgency'polite' | 'assertive'autoScreen reader urgency. Auto-derived: error color → assertive, others → polite
actionsActionItem[]Array of action buttons (each auto-dismisses the toast on click)
onDismiss() => voidCallback fired after the exit animation completes

ActionItem Properties

PropertyTypeDefaultDescription
labelstringButton text
colorThemeColorinherits toast colorButton color
onClick() => voidClick handler; toast auto-dismissed after

Attributes

AttributeTypeDefaultDescription
positionstring'bottom-right'Screen position
maxnumber5Max toasts in the DOM at once

Events

EventDetailDescription
add{ id }Fired when a toast is added
dismiss{ id }Fired when a toast is removed
javascript
document.querySelector('bit-toast').addEventListener('dismiss', (e) => {
  console.log('dismissed:', e.detail.id);
});

CSS Custom Properties

PropertyDefaultDescription
--toast-positionfixedCSS position value
--toast-inset-topautoTop inset
--toast-inset-bottom1remBottom inset
--toast-inset-leftautoLeft inset
--toast-inset-right1remRight inset
--toast-z-index9999Z-index
--toast-max-width400pxMax width
--toast-gap0.5remGap between toasts when expanded

Accessibility

The toast component follows WAI-ARIA best practices.

bit-toast

Screen Readers

  • bit-alert carries role="alert" with aria-live="polite" (assertive for error color) — screen readers announce new toasts automatically.

Keyboard Navigation

  • Auto-dismiss timers pause on both mouseenter and focusin, satisfying WCAG 2.1 SC 2.2.3.
  • Dismiss buttons are keyboard-reachable and labelled "Dismiss alert".

Best Practices

  • Match color to message severity: success, error, warning, info
  • Keep messages short and actionable — one idea per toast
  • Use duration: 0 for errors and confirmations that require user action
  • Use toast.promise() instead of manually managing loading/success/error toasts
  • Use max to prevent overwhelming users during high-frequency events