Toast

Confirms that something happened, out of the user's way. Use it for results of actions

Feedback Toast.jsx Toast.d.ts Toast.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Toast is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Toast } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

Confirms that something happened, out of the user's way. Use it for results of actions the user just took.

Rules

  • Render toasts inside a ToastRegion. It owns the fixed positioning, stacking, and the accessible region name.
  • Toasts are for outcomes, not errors that need a decision. A failed save that the user must resolve belongs in an Alert next to the form.
  • One action, and it should be Undo or Retry. A toast that disappears while the user reaches for a link is a broken control.
  • danger announces assertively and should not auto-dismiss. Everything else can.
  • Never stack more than three. Collapse the rest into a count.

Surfaces

surface="glass" or "glass-strong" for a toast over a picture or a busy screen. The tone still sets its edge and icon.

Tradeoffs

Toasts are easy to miss: they appear away from the point of action and vanish. For anything the user must acknowledge, use a Dialog or an inline Alert.

Props

The typed contract, from Toast.d.ts.

import * as React from "react";

/** Transient confirmation or failure notice. */
export interface ToastProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
  /** @default "neutral" */
  tone?: "neutral" | "success" | "warning" | "danger";
  /** "glass" lets what's behind show through, blurred (--dt-surface-glass with --dt-backdrop-glass); "glass-strong" lets less through. Use over a picture or a busy screen. @default "raised" */
  surface?: "raised" | "glass" | "glass-strong";
  title?: React.ReactNode;
  children?: React.ReactNode;
  icon?: React.ReactNode;
  /** One action, usually Undo. */
  action?: React.ReactNode;
  onDismiss?: () => void;
  /** @default "Dismiss" */
  dismissLabel?: string;
}

/** Fixed container that stacks toasts in a corner. */
export interface ToastRegionProps extends React.HTMLAttributes<HTMLDivElement> {
  children?: React.ReactNode;
  /** @default "bottom-right" */
  placement?: "bottom-right" | "bottom-left" | "top-right" | "top-center";
  /** @default "Notifications" */
  label?: string;
}

export declare function Toast(props: ToastProps): React.JSX.Element;
export declare function ToastRegion(props: ToastRegionProps): React.JSX.Element;

Tokens it reads

Every custom property Toast.jsx resolves, read from the source: 30 semantic, 1 primitive. Component tier first, because that is the one to reach for.

  • semantic Shared roles. Override one and everything using that role moves with it.
  • primitive Raw values. A component reading one directly is a deliberate exception.
TokenTierDeclared as
--dt-backdrop-glasssemanticsaturate(1.6) blur(var(--dt-blur-glass))
--dt-border-dangersemanticvar(--dt-color-red-200)
--dt-border-defaultsemanticvar(--dt-color-neutral-200)
--dt-border-successsemanticvar(--dt-color-green-200)
--dt-border-warningsemanticvar(--dt-color-amber-200)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-elevation-3semanticvar(--dt-shadow-raw-3)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-radius-overlaysemanticvar(--dt-radius-raw-24)
--dt-space-inline-smsemanticvar(--dt-dim-3)
--dt-space-inset-2xssemanticvar(--dt-dim-1)
--dt-space-inset-lgsemanticvar(--dt-dim-6)
--dt-space-inset-mdsemanticvar(--dt-dim-4)
--dt-space-inset-smsemanticvar(--dt-dim-3)
--dt-space-stack-xssemanticvar(--dt-dim-2)
--dt-surface-glasssemanticcolor-mix(in oklab, var(--dt-surface-overlay) 72%, transparent)
--dt-surface-glass-strongsemanticcolor-mix(in oklab, var(--dt-surface-overlay) 88%, transparent)
--dt-surface-raisedsemanticvar(--dt-color-white)
--dt-text-body-md-sizesemanticvar(--dt-font-size-md)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-dangersemanticvar(--dt-color-red-800)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-successsemanticvar(--dt-color-green-800)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-text-warningsemanticvar(--dt-color-amber-900)
--dt-font-weight-semiboldprimitive600

Source

React only, no dependencies, every value a token.

import React from "react";

const TONES = {
  neutral: { bd: "var(--dt-border-default)", fg: "var(--dt-text-secondary)" },
  success: { bd: "var(--dt-border-success)", fg: "var(--dt-text-success)" },
  warning: { bd: "var(--dt-border-warning)", fg: "var(--dt-text-warning)" },
  danger: { bd: "var(--dt-border-danger)", fg: "var(--dt-text-danger)" },
};

/* Glass: the overlay surface let through, blurred, for a panel over a
   picture or a busy screen. Always paired with the backdrop filter. */
const GLASS = { glass: "var(--dt-surface-glass)", "glass-strong": "var(--dt-surface-glass-strong)" };

export function Toast({ tone = "neutral", title, children, icon, action, onDismiss, dismissLabel = "Dismiss", surface = "raised", style, ...rest }) {
  const t = TONES[tone] || TONES.neutral;
  return (
    <div role={tone === "danger" ? "alert" : "status"} aria-live={tone === "danger" ? "assertive" : "polite"} style={{
      display: "flex", alignItems: "flex-start", gap: "var(--dt-space-inline-sm)",
      padding: "var(--dt-space-inset-sm) var(--dt-space-inset-md)",
      background: GLASS[surface] || "var(--dt-surface-raised)", color: "var(--dt-text-primary)",
      backdropFilter: GLASS[surface] ? "var(--dt-backdrop-glass)" : undefined,
      WebkitBackdropFilter: GLASS[surface] ? "var(--dt-backdrop-glass)" : undefined,
      border: `var(--dt-border-width-default) solid ${t.bd}`,
      borderRadius: "var(--dt-radius-overlay)", boxShadow: "var(--dt-elevation-3)",
      minWidth: 260, maxWidth: 420, boxSizing: "border-box", ...style,
    }} {...rest}>
      {icon && <span aria-hidden="true" style={{ color: t.fg, flex: "none", display: "flex", marginTop: 1 }}>{icon}</span>}
      <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 }}>
        {title && <span style={{ fontFamily: "var(--dt-text-label-md-family)", fontSize: "var(--dt-text-label-md-size)", fontWeight: "var(--dt-font-weight-semibold)" }}>{title}</span>}
        {children && <span style={{ fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)", lineHeight: "var(--dt-text-body-sm-line)", color: "var(--dt-text-secondary)", textWrap: "pretty" }}>{children}</span>}
      </div>
      {action && <span style={{ flex: "none" }}>{action}</span>}
      {onDismiss && (
        <button type="button" onClick={onDismiss} aria-label={dismissLabel} style={{
          flex: "none", appearance: "none", background: "transparent", border: "none", cursor: "pointer",
          color: "var(--dt-text-tertiary)", fontSize: "var(--dt-text-body-md-size)", lineHeight: 1,
          padding: "var(--dt-space-inset-2xs, 4px)", borderRadius: "var(--dt-radius-control)",
        }}>×</button>
      )}
    </div>
  );
}

export function ToastRegion({ children, placement = "bottom-right", label = "Notifications", style, ...rest }) {
  const pos = {
    "bottom-right": { bottom: "var(--dt-space-inset-lg)", right: "var(--dt-space-inset-lg)", alignItems: "flex-end" },
    "bottom-left": { bottom: "var(--dt-space-inset-lg)", left: "var(--dt-space-inset-lg)", alignItems: "flex-start" },
    "top-right": { top: "var(--dt-space-inset-lg)", right: "var(--dt-space-inset-lg)", alignItems: "flex-end" },
    "top-center": { top: "var(--dt-space-inset-lg)", left: "50%", transform: "translateX(-50%)", alignItems: "center" },
  }[placement];
  return (
    <div role="region" aria-label={label} style={{
      position: "fixed", zIndex: 50, display: "flex", flexDirection: "column",
      gap: "var(--dt-space-stack-xs)", pointerEvents: "none", ...pos, ...style,
    }} {...rest}>
      {React.Children.map(children, c => <div style={{ pointerEvents: "auto" }}>{c}</div>)}
    </div>
  );
}

In context

Toast also appears on the feedback reference cards, alongside the rest of its family.