Skip to content

Feedback

Choosing between inline messages, alerts, toasts and dialogs, so people notice what matters and aren't interrupted by what doesn't.

Every message competes for attention. The right component depends on how urgent the message is and whether it relates to something on screen.

Choosing a component

MessageComponentExample
About a specific fieldFieldErrorEnter an email address like ada@acme.co.
About a region, persistentAlertYour trial ends in 3 days.
Confirmation of an actiontoastSettings saved.
Result of a background tasktoast with actionExport ready — Download.
Requires a decision nowAlertDialogDelete “acme-web”?
Global, system-wideAlert in the page headerScheduled maintenance tonight at 22:00 UTC.

Principles

  • Closest to the cause. Show feedback where people are looking: errors next to fields, results next to the button that caused them.
  • Interrupt only for decisions. Dialogs stop everything; reserve them for choices that can't wait. Everything else is inline or a toast.
  • Transient means non-essential. Toasts disappear. Never put information in a toast that people can't find elsewhere, and never put the only way to recover from an error in one.
  • Status needs more than color. Every status message carries an icon and words.

Toasts

  • One line if possible; two at most. Lead with the outcome: Invoice sent, not Success!
  • Offer one action — usually Undo or View.
  • Every toast dismisses itself after 5 seconds by default, errors included, and pauses while hovered or focused. To keep one until it's dismissed, pass timeout: 0; do it for a condition that stays true, not to make an error harder to miss.
  • Toasts are announced politely by screen readers, after the current speech; priority: "high" interrupts, and suits errors. They never steal focus.
Invoice #2041 is ready
Ada commented on “Homepage v3”
Weekly usage report
"use client"

import * as React from "react"
import { ArchiveIcon, InboxIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemTitle,
} from "@/components/ui/item"
import { toast } from "@/components/ui/toast"

const initial = [
  { id: 1, title: "Invoice #2041 is ready", from: "Billing" },
  { id: 2, title: "Ada commented on “Homepage v3”", from: "Comments" },
  { id: 3, title: "Weekly usage report", from: "Reports" },
]

export default function UndoToast() {
  const [items, setItems] = React.useState(initial)

  function archive(id: number) {
    const index = items.findIndex((item) => item.id === id)
    const removed = items[index]
    setItems((current) => current.filter((item) => item.id !== id))
    const toastId = toast.add({
      title: "Conversation archived",
      actionProps: {
        children: "Undo",
        onClick: () => {
          // Closed with the click: a second one would restore the row twice.
          toast.close(toastId)
          setItems((current) => {
            const next = [...current]
            next.splice(index, 0, removed)
            return next
          })
        },
      },
    })
  }

  return (
    <ItemGroup className="w-full max-w-md gap-2">
      {items.map((item) => (
        <Item key={item.id} variant="outline" size="sm">
          <ItemContent>
            <ItemTitle>{item.title}</ItemTitle>
            <ItemDescription>{item.from}</ItemDescription>
          </ItemContent>
          <ItemActions>
            {/* A word, not a lone icon: archiving isn't universal enough
                to go unlabeled. The accessible name adds which message. */}
            <Button
              variant="ghost"
              size="sm"
              aria-label={`Archive “${item.title}”`}
              onClick={() => archive(item.id)}
            >
              <ArchiveIcon data-icon="inline-start" />
              Archive
            </Button>
          </ItemActions>
        </Item>
      ))}
      {items.length === 0 && (
        <div className="flex flex-col items-center gap-2 py-8 text-sm text-muted-foreground">
          <InboxIcon className="size-5" aria-hidden="true" />
          Inbox zero.
          <Button variant="link" size="sm" onClick={() => setItems(initial)}>
            Restore examples
          </Button>
        </div>
      )}
    </ItemGroup>
  )
}