Alerts

Compose notices and actions with caller-owned data, markup, and layout.

import { Alerts, AlertsList, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertSeverity } from "@/components/ui/alerts"

export default function AlertsDemo() {
  return (
    <Alerts className="w-lg max-w-full">
      <AlertsList>
        <AlertItem>
          <AlertHeader>
            <AlertSeverity>fill</AlertSeverity>
            <AlertTitle>Order filled</AlertTitle>
          </AlertHeader>
          <AlertBody>5mm UST at 99-16+</AlertBody>
        </AlertItem>
      </AlertsList>
    </Alerts>
  )
}

Installation

pnpm dlx shadcn@latest add tradecn/ui/alerts#v2.0.0
npx shadcn@latest add tradecn/ui/alerts#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/alerts#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/alerts#v2.0.0

Usage

import { Alerts, AlertsList, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertSeverity } from "@/components/ui/alerts"

export default function AlertsDemo() {
  return (
    <Alerts className="w-lg max-w-full">
      <AlertsList>
        <AlertItem>
          <AlertHeader>
            <AlertSeverity>fill</AlertSeverity>
            <AlertTitle>Order filled</AlertTitle>
          </AlertHeader>
          <AlertBody>5mm UST at 99-16+</AlertBody>
        </AlertItem>
      </AlertsList>
    </Alerts>
  )
}

Compose a single notice directly in JSX. Alerts renders its children; it does not create notices, buttons, announcements, or a dialog.

Composition

Alerts
├── AlertsAnnouncer (optional store announcements)
├── AlertsList
│   └── AlertItem
│       ├── AlertHeader
│       │   ├── AlertSeverity
│       │   ├── AlertTitle
│       │   └── AlertDismiss
│       ├── AlertBody
│       └── AlertActions
│           └── AlertActionButton
└── AlertsEmpty (when your collection is empty)

Map your collection into AlertItem children. Place, omit, or reorder the other parts as needed. AlertBody supports rich content and wraps by default. Use an accessible link or button for interactive content, and pair tone with a severity word or another non-color cue.

For a live store, use useRowIds(useAlertView(alerts)) to read newest-first IDs, then map them into your own row component. Call useAlert(alerts, id) inside that row so a notice update rerenders its subscriber. The store coalesces repeated keys and enforces its cap; see alert-store.

Local collection

Map local data into notice parts and supply your own dismiss handlers. Dismiss either notice, then choose Restore notices to replay. When the collection is empty, render AlertsEmpty in place of the list. No alert store is needed.

import { useRef, useState } from "react"
import { Alerts, AlertsList, AlertsEmpty, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertSeverity, AlertDismiss } from "@/components/ui/alerts"

const initial = [
  { id: "fill", severity: "fill", title: "Order filled", message: "5mm UST at 99-16+" },
  { id: "feed", severity: "info", title: "Feed connected", message: "Market data is available." },
]

export default function AlertsCollectionDemo() {
  const [notices, setNotices] = useState(initial)
  // Removal never moves focus, so the dismiss handler does: the first remaining notice's
  // dismiss, or Restore notices when the list empties.
  const region = useRef<HTMLDivElement>(null)
  const restore = useRef<HTMLButtonElement>(null)
  const refocus = () => queueMicrotask(() => (region.current?.querySelector<HTMLElement>("[data-slot='tradecn-alert-dismiss']") ?? restore.current)?.focus())
  return (
    <>
      <div data-demo-controls className="text-xs">
        <button ref={restore} type="button" className="rounded border border-border px-2 py-1" onClick={() => setNotices(initial)}>Restore notices</button>
      </div>
      <Alerts ref={region} className="w-lg max-w-full">
        {notices.length > 0 && <AlertsList>
          {notices.map((notice) => (
            <AlertItem key={notice.id}>
              <AlertHeader>
                <AlertSeverity>{notice.severity}</AlertSeverity>
                <AlertTitle>{notice.title}</AlertTitle>
                <AlertDismiss aria-label={`Dismiss: ${notice.title}`} onClick={() => { setNotices((rows) => rows.filter((row) => row.id !== notice.id)); refocus() }} />
              </AlertHeader>
              <AlertBody>{notice.message}</AlertBody>
            </AlertItem>
          ))}
        </AlertsList>}
        {notices.length === 0 && <AlertsEmpty>No notices.</AlertsEmpty>}
      </Alerts>
    </>
  )
}

Allowed actions and repeated notices

This layout moves severity after the title and puts actions below a multiline body. Each row subscribes through useAlert; repeating a notice updates its count even when its position stays the same. The action handlers record the request and dismiss the notice explicitly. Replace them with your application handlers.

Choose Receive slow feed or Receive rejection to restore or repeat a notice. AlertActionButton renders only when its action ID appears in allowedActions. One AlertsAnnouncer announces the first displayed notice, using the assertive region for critical notices.

import { useRef, useState } from "react"
import { createAlertStore, type Alert, type AlertInput, type AlertStore } from "@/lib/alert-store"
import { useRowIds } from "@/hooks/use-row-store"
import { Alerts, AlertsList, AlertsEmpty, AlertsAnnouncer, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertSeverity, AlertActions, AlertActionButton, AlertDismiss, useAlert, useAlertView } from "@/components/ui/alerts"

const slow: AlertInput = { key: "feed:slow", severity: "warning", tone: "stale", title: "Feed slow", message: "Market data is 1.2 seconds behind. Reconnect to request a fresh subscription.", allowedActions: ["reconnect"] }
const rejected: AlertInput = { key: "order:rejected", severity: "critical", tone: "destructive", title: "Order rejected", message: "Price away from market. Review the order before submitting again.", allowedActions: ["ack"] }

function Notice({ alerts, id, onAction, onDismiss }: { alerts: AlertStore; id: string; onAction: (action: string, alert: Alert) => void; onDismiss: (id: string) => void }) {
  const alert = useAlert(alerts, id)
  if (!alert) return null
  return (
    <AlertItem tone={alert.tone}>
      <AlertHeader>
        <AlertTitle>{alert.title}</AlertTitle>
        <AlertSeverity tone={alert.tone}>{alert.severity}</AlertSeverity>
      </AlertHeader>
      <AlertBody>
        <p>{alert.message}</p>
        <p className="mt-1">Received {alert.count} {alert.count === 1 ? "time" : "times"}.</p>
      </AlertBody>
      <AlertActions>
        <AlertActionButton alert={alert} action="reconnect" onAction={(row) => onAction("Reconnect requested", row)}>Reconnect</AlertActionButton>
        <AlertActionButton alert={alert} action="ack" onAction={(row) => onAction("Acknowledged", row)}>Acknowledge</AlertActionButton>
        <AlertDismiss className="ml-auto" aria-label={`Dismiss: ${alert.title}`} onClick={() => onDismiss(id)}>Dismiss</AlertDismiss>
      </AlertActions>
    </AlertItem>
  )
}

export default function AlertsActionsDemo() {
  const [alerts] = useState(() => {
    const store = createAlertStore()
    store.push(slow)
    store.push(rejected)
    return store
  })
  const ids = useRowIds(useAlertView(alerts))
  const [acted, setActed] = useState("No action yet")
  // Removal never moves focus, so the demo restores it: the first remaining notice's
  // dismiss — never a server-action button, which a held Enter would fire — or a receive
  // button when the list empties; the controls row is the column's previous sibling.
  const region = useRef<HTMLDivElement>(null)
  const refocus = () => queueMicrotask(() => (region.current?.querySelector<HTMLElement>("[data-slot='tradecn-alert-dismiss']") ?? region.current?.previousElementSibling?.querySelector<HTMLElement>("button"))?.focus())
  const onAction = (action: string, alert: Alert) => {
    setActed(`${action}: ${alert.title}`)
    alerts.dismiss(alert.id)
    refocus()
  }
  return (
    <>
      <div data-demo-controls className="flex flex-wrap gap-2 text-xs">
        <button type="button" className="rounded border border-border px-2 py-1" onClick={() => alerts.push(slow)}>Receive slow feed</button>
        <button type="button" className="rounded border border-border px-2 py-1" onClick={() => alerts.push(rejected)}>Receive rejection</button>
      </div>
      <div ref={region} className="flex w-lg max-w-full flex-col gap-3 text-xs">
        <Alerts>
          <AlertsAnnouncer alerts={alerts} id={ids[0] ?? null} assertive={["critical"]} />
          {ids.length > 0 && <AlertsList>{ids.map((id) => <Notice key={id} alerts={alerts} id={id} onAction={onAction} onDismiss={(noticeId) => { alerts.dismiss(noticeId); refocus() }} />)}</AlertsList>}
          {ids.length === 0 && <AlertsEmpty>No notices.</AlertsEmpty>}
        </Alerts>
        <p role="status">{acted}</p>
      </div>
    </>
  )
}

History in your own dialog

This example displays two notices and puts the full history in a caller-owned dialog. You decide the slice, history button, clear control, and history height. The button uses DialogTrigger and stays mounted so closing the dialog returns focus to it even if notices expire or are cleared. It reads "History" when nothing overflows. The history passes announceRowCount="off", keeping the announcer the one announcement path while the dialog is open. Install shadcn's dialog component separately before copying this example. AlertHistory also works in an always-open panel with a height.

import { useRef, useState } from "react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { createAlertStore, type AlertStore } from "@/lib/alert-store"
import { useRowIds } from "@/hooks/use-row-store"
import { Alerts, AlertsList, AlertsEmpty, AlertsAnnouncer, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertDismiss, AlertHistory, useAlert, useAlertView } from "@/components/ui/alerts"

function Notice({ alerts, id, onDismiss }: { alerts: AlertStore; id: string; onDismiss: (id: string) => void }) {
  const alert = useAlert(alerts, id)
  if (!alert) return null
  return (
    <AlertItem>
      <AlertHeader>
        <AlertTitle>{alert.title}</AlertTitle>
        <AlertDismiss aria-label={`Dismiss: ${alert.title}`} onClick={() => onDismiss(id)} />
      </AlertHeader>
      <AlertBody>{alert.message}</AlertBody>
    </AlertItem>
  )
}

export default function AlertsHistoryDemo() {
  const [alerts] = useState(() => {
    const store = createAlertStore()
    store.push({ severity: "info", title: "Feed connected", message: "Market data is available." })
    store.push({ severity: "info", title: "Session open", message: "The trading session has begun." })
    store.push({ severity: "fill", title: "Order filled", message: "5mm UST at 99-16+." })
    store.push({ severity: "info", title: "Report ready", message: "The execution report is available." })
    return store
  })
  const ids = useRowIds(useAlertView(alerts))
  const shown = ids.slice(0, 2)
  // Removal never moves focus, so the demo restores it: the first remaining notice's
  // control, or the History trigger, which stays rendered whatever the list holds.
  const region = useRef<HTMLDivElement>(null)
  const refocus = () => queueMicrotask(() => (region.current?.querySelector<HTMLElement>("[data-slot='tradecn-alert-dismiss']") ?? region.current?.querySelector<HTMLElement>("button"))?.focus())
  return (
    <Dialog>
      <Alerts ref={region} className="w-lg max-w-full">
        <AlertsAnnouncer alerts={alerts} id={shown[0] ?? null} />
        {ids.length > 0 && <AlertsList>{shown.map((id) => <Notice key={id} alerts={alerts} id={id} onDismiss={(noticeId) => { alerts.dismiss(noticeId); refocus() }} />)}</AlertsList>}
        {ids.length === 0 && <AlertsEmpty>No notices.</AlertsEmpty>}
        <div className="flex gap-2">
          <DialogTrigger className={buttonVariants({ variant: "outline", size: "sm" })}>{ids.length > shown.length ? `${ids.length - shown.length} more` : "History"}</DialogTrigger>
          {ids.length > 0 && <Button variant="ghost" size="sm" className="ml-auto" onClick={() => { alerts.clear(); refocus() }}>Clear all</Button>}
        </div>
      </Alerts>
      <DialogContent className="flex max-h-[calc(100dvh-2rem)] min-w-0 flex-col sm:max-w-3xl">
        <DialogHeader className="shrink-0">
          <DialogTitle>Notice history</DialogTitle>
          <DialogDescription>Every notice, newest first.</DialogDescription>
        </DialogHeader>
        <div className="h-80 min-h-0 min-w-0"><AlertHistory alerts={alerts} announceRowCount="off" /></div>
      </DialogContent>
    </Dialog>
  )
}

Forwarding new notices

useToastBridge forwards new IDs after subscribing. The seeded notice is skipped. Choose Receive slow notice once to forward it, then again to increase its repeat count without another callback. After Clear all, receiving it creates a new notice and forwards it again.

The readout is a polite live region that announces the callback count and last title. In an application, replace this callback with your toast adapter. Mount one announcement path for new notices; an adapter that already announces them can take that role.

import { useRef, useState } from "react"
import { createAlertStore, type AlertStore } from "@/lib/alert-store"
import { useRowIds } from "@/hooks/use-row-store"
import { Alerts, AlertsList, AlertsEmpty, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertDismiss, useAlert, useAlertView, useToastBridge } from "@/components/ui/alerts"

function Notice({ alerts, id, onDismiss }: { alerts: AlertStore; id: string; onDismiss: (id: string) => void }) {
  const alert = useAlert(alerts, id)
  if (!alert) return null
  return (
    <AlertItem>
      <AlertHeader>
        <AlertTitle>{alert.title}</AlertTitle>
        <AlertDismiss aria-label={`Dismiss: ${alert.title}`} onClick={() => onDismiss(id)} />
      </AlertHeader>
      <AlertBody>{alert.message} Received {alert.count} {alert.count === 1 ? "time" : "times"}.</AlertBody>
    </AlertItem>
  )
}

export default function AlertsBridgeDemo() {
  const [alerts] = useState(() => {
    const store = createAlertStore()
    store.push({ severity: "info", title: "Feed connected" })
    return store
  })
  const ids = useRowIds(useAlertView(alerts))
  const [forwarded, setForwarded] = useState({ count: 0, title: "None yet" })
  useToastBridge(alerts, (alert) => setForwarded((previous) => ({ count: previous.count + 1, title: alert.title })))
  // Removal never moves focus, so the handlers do: the first remaining dismiss, or Receive
  // slow notice when the list empties — the controls row is the column's previous sibling.
  const region = useRef<HTMLDivElement>(null)
  const refocus = () => queueMicrotask(() => (region.current?.querySelector<HTMLElement>("[data-slot='tradecn-alert-dismiss']") ?? region.current?.previousElementSibling?.querySelector<HTMLElement>("button"))?.focus())
  return (
    <>
      <div data-demo-controls className="text-xs">
        <button type="button" className="rounded border border-border px-2 py-1" onClick={() => alerts.push({ key: "feed:slow", severity: "warning", title: "Feed slow", message: "1.2 s behind." })}>Receive slow notice</button>
      </div>
      <div ref={region} className="flex w-lg max-w-full flex-col gap-3 text-xs lining-nums tabular-nums">
        <Alerts>
          {ids.length > 0 && <AlertsList>{ids.map((id) => <Notice key={id} alerts={alerts} id={id} onDismiss={(noticeId) => { alerts.dismiss(noticeId); refocus() }} />)}</AlertsList>}
          {ids.length === 0 ? <AlertsEmpty>No notices.</AlertsEmpty> : <button type="button" className="self-end rounded border border-border px-2 py-1" onClick={() => { alerts.clear(); refocus() }}>Clear all</button>}
        </Alerts>
        <div role="status" aria-atomic="true">
          <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
            <dt>Forwarded</dt><dd>{forwarded.count}</dd>
            <dt>Last forwarded</dt><dd>{forwarded.title}</dd>
          </dl>
        </div>
      </div>
    </>
  )
}

API Reference

Previous call shapes and every removed prop are mapped in Migrating to v2.

Presentation parts

All presentation parts accept their underlying element's props, including children, className, events, and ref. The styling classes are defaults you can extend at each part. None reads an alert store or chooses content.

Part Underlying element Defaults and additional props
Alerts div role="group"; defaults to aria-label="Notices" unless aria-labelledby is supplied. Set either naming prop to name your collection.
AlertsList ul role="list"; supply AlertItem children.
AlertItem li Optional tone: AlertTone; sets data-tone and a colored start border.
AlertHeader div Wrapping header layout.
AlertTitle div Flexible, wrapping title.
AlertBody div Wrapping content; accepts block elements and links.
AlertActions div Wrapping controls.
AlertsEmpty p Muted text; visibility and content belong to you.
AlertSeverity Your Badge Optional tone: AlertTone; default variant="outline"; data-alert-severity marker. With a tone, the text takes the tone's color on the surface beneath the chip, which clears any fill your style gives an outline badge.
AlertDismiss Your Button type="button", variant="ghost", size="sm", and a × child. The default icon is named "Dismiss" unless aria-labelledby is supplied. Custom children provide their own name; explicit naming props take precedence. Supply onClick and a notice-specific name for icon buttons.

Each piece has a data-slot matching its kebab-case name with the tradecn- prefix, such as tradecn-alert-header.

AlertActionButton

AlertActionButton accepts Button props, including children and a ref, except onClick. It renders only when alert.allowedActions contains action. Missing or empty permissions render nothing. It defaults to type="button", variant="outline", and size="sm", with data-alert-action set to the action ID. It doesn't hold repeated presses: a handler that waits for the server should ignore presses while its request is out; otherwise a held Enter sends one request per key repeat. Removing the action from allowedActions instead unmounts the focused button, so move focus first, as Removal never moves focus describes.

Prop Type Default Purpose
alert Alert Required Current notice, normally supplied by useAlert.
action string Required ID checked against allowedActions.
onAction (alert: Alert) => void Required Called with the rendered notice when clicked.
children ReactNode Unset Button content, including its visible label.
Button props ComponentProps<typeof Button> excluding onClick See above Styling, disabled state, accessible naming, and other native behavior.

The button checks the supplied alert when it renders; it does not subscribe or re-read the store on click. Use the current row from useAlert for changing permissions. The callback does not dismiss the notice. Send requests and call alerts.dismiss(id) explicitly when appropriate. UI permissions do not replace server authorization.

useAlert

useAlert(alerts, id, options?) returns the current Alert, or undefined when the ID is absent. The row subscribes to that ID; switching the store or ID replaces its subscription.

Input Type Default Purpose
alerts AlertStore Required Store to subscribe to.
id RowId Required Notice ID.
options.ttlMs number Off Automatic dismissal while this hook is mounted.
options.now () => number Date.now Clock in milliseconds since the epoch, on the same basis as the store.

Expiry waits max(0, alert.at + ttlMs - now()) milliseconds, and the remaining delay must stay within the platform's timer range — about twenty-five days — or the timer fires early. A notice whose at isn't a number, or a delay that comes out as NaN or Infinity from at, ttlMs, or now(), schedules no expiry, so the notice stays until it is dismissed. A repeat with a new at reschedules it. Any nonempty allowedActions disables expiry, including action IDs for which you render no button. Removing those permissions enables the remaining timer, or schedules immediate dismissal if already overdue. Unmounting, changing stores or IDs, or disabling TTL cancels the old timer.

Timers belong to the hook invocation. Put TTL on the displayed notice row only. Hidden notices and history-only rows have no timer unless your application mounts another expiry-enabled hook for them — and expiry measures from the notice's at, not from when it was shown, so an already overdue notice is dismissed the moment it is promoted into view, each dismissal removing its row from history, and a backlog can cascade. When displaying one notice in several places, choose one owner for automatic dismissal; omit TTL from the other subscriptions.

Removal never moves focus: dismissal, TTL expiry, the cap, clear(), and an arrival displacing a shown notice in a sliced list unmount the notice's controls where they stand, and focus falls to the body when one of them held it. Restore focus yourself when you remove the notice under the user — the dismiss handler knows where to send it — and keep TTL off notices whose controls expect to hold focus.

AlertsAnnouncer

Mount one announcer for the collection. It keeps two visually hidden regions: polite role="status" and assertive role="alert". Only one contains the selected notice's announcement, including severity, title, optional message, and repeat count above one. It does not take focus or open UI. An announcer mounted with a notice already selected renders its text at once, so a screen reader may speak that notice as the region appears.

Prop Type Default Purpose
alerts AlertStore Required Store to subscribe to.
id RowId | null Required Notice to announce; usually the first displayed ID. Null leaves both regions empty.
assertive readonly string[] [] Severities announced assertively.

The announcer follows the selected row, including repeats; it is not a queue of every arrival. It announces whatever becomes first — a removal promotes an older notice and announces it as if it were new, assertively when its severity is listed in assertive — and a keyed notice repeating across tasks announces each time, with no debounce; pushes within one synchronous run render once. Replacing the store, or reusing an id with identical words, announces again. Its region markers are data-alerts-polite and data-alerts-assertive. Avoid duplicate announcements when a toast adapter already announces the same notices.

The whole list

AlertHistory is an optional DataGrid presentation, ordered newest first by at, then seq, with its time column frozen. Give its container a height. It does not supply action or dismiss controls, create a dialog, or start TTL timers. Its default blotter preset adjusts scroll position when notices arrive above the first visible row.

The history's view owns its order, so the grid renders without sort affordances, whatever the column definitions say. Under the default blotter preset the grid also announces its row count politely, about a second after the count stops changing, including on mount; folded repeats and arrivals into a full store leave the count unchanged and announce nothing. Pass announceRowCount="off" to silence it, and keep one announcement path when the history sits beside an AlertsAnnouncer or a toast adapter.

Prop Type Default Purpose
alerts AlertStore Required Shared store.
columns ColumnDef<Alert>[] alertColumns({ labels }) Add, remove, reorder, or replace grid columns.
preset DataGridPreset "blotter" Grid behavior and presentation.
announceRowCount "off" | "debounced" Preset's setting The embedded grid's row-count announcements.
label string labels.title Accessible grid name.
labels Partial<AlertHistoryLabels> DEFAULT_ALERT_HISTORY_LABELS Empty state, grid name, and default column labels.
renderContextMenu (rows: Alert[], ids: RowId[], target?: RowId | null) => ReactNode Unset Caller-composed context menu. target is the notice the menu opened on, which can sit inside a larger selection, and can also name a notice the store has since removed. The grid reads the rows when the menu opens, so check allowedActions again when an item runs, and find each notice by its id rather than pairing rows and ids by index: ids can still hold a notice the store has since removed.
className string Unset History wrapper classes.

Keep labels referentially stable when the grid builds its default columns, since they rebuild whenever it changes, and keep a custom columns array stable for the same reason; with custom columns, a labels change deliberately rebuilds nothing.

alertColumns({ time?, labels? }) returns time, severity, title, message, and count columns, imported from @/components/ui/alerts with Alert and AlertTone coming from the installed @/lib/alert-store. time accepts (ms: number) => string and defaults to local 24-hour time with seconds. A time a Date can't hold, such as NaN, prints – instead of calling it. The default reads the runtime's time zone, so server-rendered times use the server's. Pass labels explicitly when constructing custom columns. Its time, severity, title, and count columns are marked sortable for grids that build their own view: omit view and the grid orders rows from its sort. A supplied view owns its order and ignores sort, so derive the view you supply from your controlled sort state. useAlertView's is fixed newest-first, and the history removes those affordances.

useAlertView(alerts) returns a RowView<Alert> and replaces it synchronously when the store changes. It owns the view's connection: cleanup stops feed work, and effect replay reconnects the same handle. Read it through useRowIds from the installed use-row-store hooks. Do not dispose it yourself; see view ownership.

Seven v1 labels moved or fell away; Migrating to v2 maps each, and v1's title named the whole strip where the history's title names its grid.

History label Default
title All notices
empty No notices.
time Time
severity Severity
noticeTitle Title
message Message
count Count

The toast bridge

useToastBridge(alerts, toast) returns void and imports no toast package. Pass your own toast adapter. The adapter runs inside the store's push, so one that throws throws from your producer's push call and skips the store's later listeners: catch inside the adapter.

Argument Type Purpose
alerts AlertStore Required store.
toast ((alert: Alert) => void) | null | undefined Callback for newly observed IDs, or no callback.

Existing IDs are skipped on subscription. Newly observed IDs are forwarded oldest first within a batch; a repeat folded into an existing ID does not toast again. Removed IDs are forgotten, so a later reused ID can toast. A null or undefined callback still marks IDs seen; enabling it later does not replay them. Changing stores starts a new subscription, and unmounting unsubscribes.

Tokens

AlertTone accepts up, down, flat, stale, expiring, primary, or destructive. ALERT_TONE_BAR supplies background classes for custom bars or decoration; ALERT_TONE_TEXT supplies text classes. Item tone colors its start border; severity tone colors its text independently, so pass the tone to both when desired. Include a non-color cue in custom compositions. The install adds the five trading tokens and their soft variants if absent, along with the shared font tokens and the hyperlegible remap the grid columns use; primary and destructive come from your theme.

Built on shadcn's badge, button, checkbox, context-menu, and dropdown-menu.

This page is docs/alerts.md at v2.0.0.