Overlay

Toast

Stackable floating notification with expiry

Preview

color
position
children
onDismiss

Code

import css from "./Toast.module.css";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";

import { XIcon } from "@phosphor-icons/react/dist/csr/X";

type Color = "success" | "error" | "info";
type Position =
  | "top-left"
  | "top-center"
  | "top-right"
  | "bottom-left"
  | "bottom-center"
  | "bottom-right";

interface ToastProps {
  color: Color;
  position?: Position;
  dismissAriaLabel?: string;
  children: React.ReactNode;
  onDismiss?: () => void;
}

export default function Toast({
  color,
  position,
  dismissAriaLabel,
  children,
  onDismiss,
}: ToastProps) {
  // No <body> to portal into until the client has it; in flow there is nothing to wait for.
  const [mounted, setMounted] = useState(false);

  useEffect(() => setMounted(true), []);

  // The toast is its own live region; a viewport stacking these must not wrap them in another.
  const card = (
    <div
      className={`
        ${css.toast}
        ${css[color]}
        ${position ? `${css.float} ${css[position.replace("-", "_")]}` : ""}
      `}
      role={color === "error" ? "alert" : "status"}
      style={
        { "--toast-color": `var(--color-${color})` } as React.CSSProperties
      }
    >
      <div className={css.toast_content}>{children}</div>

      {onDismiss && (
        <button
          type="button"
          className={css.toast_dismiss}
          aria-label={dismissAriaLabel}
          onClick={onDismiss}
        >
          <XIcon weight="bold" />
        </button>
      )}
    </div>
  );

  if (!position) return card;
  if (!mounted) return null;

  return createPortal(card, document.body);
}

Props

PropTypeRequiredDescription
color"success" | "error" | "info"requiredSemantic status that tints the toast with the Foundations status palette. error announces with role="alert", the rest with role="status".
position"top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"—Corner or center it floats in on its own; without it the card renders in flow (which is how ToastProvider stacks it).
dismissAriaLabelstring—With onDismiss: name of the close button for screen readers.
childrenReact.ReactNoderequiredNotification content.
onDismiss() => void—X button callback; without it none is drawn.

Imperative API

Optional sugar over the declarative <Toast>. Wrap your app in ToastProvider and fire it with a call.

press it a few times

position
duration
color
dismissAriaLabel

Props

PropTypeRequiredDescription
position"top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"requiredToastProvider prop. Corner or center where the stack lives.
maxVisiblenumberrequiredToastProvider prop. Cap on visible toasts; past it, the oldest one is evicted.
color"success" | "error" | "info"requiredSemantic status that tints the toast with the Foundations status palette. error announces with role="alert", the rest with role="status".
contentReact.ReactNoderequiredNotification content.
durationnumber—Milliseconds until it auto-expires; without it, it never expires and only the X, the dismisser or maxVisible remove it.
dismissAriaLabelstring—Name of the close button for screen readers; without it, no X is drawn.
→ show()() => void—Returns the dismisser for the toast just fired; calling it retracts it (idempotent).