Alert
A bordered notice with an icon, a title, a description and an action.
shadcn's base-nova alert with three changes. The action takes a column of its own at the inline end, where shadcn floats it over the last 72px of the text, so a label like "Reconnect" no longer runs over the words; under 24rem of alert a labelled action moves under the description, while an icon button keeps its corner. The recipe names no physical side, so an alert mirrors whole in right-to-left text. And the role follows the variant, as Banner's does: a default notice is a polite status, a destructive one an alert that interrupts. In Vibra's theme the destructive variant reads --destructive, which every palette points at --danger. For a notice washed in the success, warning or info tone, or a page-wide strip with a dismiss, reach for Banner; for an aside inside a card, Callout.
Install
npx shadcn@latest add @vibra/alertNeeds the @vibra registry in your components.json — set it up once.
Examples
import { CircleAlertIcon, InfoIcon } from "lucide-react"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
export default function AlertDemo() {
return (
<div className="flex w-full max-w-lg flex-col gap-3">
<Alert>
<InfoIcon aria-hidden="true" />
<AlertTitle>Usage resets on October 1</AlertTitle>
<AlertDescription>Northwind has used 84,120 of its 100,000 events this cycle.</AlertDescription>
</Alert>
<Alert variant="destructive">
<CircleAlertIcon aria-hidden="true" />
<AlertTitle>The payments sync stopped</AlertTitle>
<AlertDescription>Payments since 09:40 UTC are missing from Revenue.</AlertDescription>
<AlertAction>
<Button variant="outline" size="xs">
Reconnect
</Button>
</AlertAction>
</Alert>
</div>
)
}Parts
Text alone, an icon and a line, and the full set. The icon column exists only when there is an icon.
import { LockIcon, ReceiptTextIcon } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
// Three builds of one component. Text alone is a single column; an icon adds
// the column it sits in, and the title and the description share the second.
// These are page furniture, there before the reader was, so they drop the
// live role: nothing needs reading out the moment the page draws.
export default function AlertAnatomy() {
return (
<div className="flex w-full max-w-lg flex-col gap-2.5">
<Alert role={undefined}>
<AlertDescription>Exports older than 90 days are deleted on the first of each month.</AlertDescription>
</Alert>
<Alert role={undefined}>
<LockIcon aria-hidden="true" />
<AlertTitle>Read-only until Hiro Tanaka restores your access</AlertTitle>
</Alert>
<Alert role={undefined}>
<ReceiptTextIcon aria-hidden="true" />
<AlertTitle>Invoices now go out at 09:00 in each customer’s time zone</AlertTitle>
<AlertDescription>Until September, every invoice left at 09:00 UTC.</AlertDescription>
</Alert>
</div>
)
}Tone in the icon
The box and the words stay neutral and the icon carries success or warning, by its shape as much as its colour.
import { CircleCheckIcon, TriangleAlertIcon } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
// The box and the words stay neutral; the icon carries the tone, by its shape
// as much as its colour, so nothing rests on colour alone. The tint goes on
// through the alert's className because the recipe sets its icon to the text
// colour. For a notice washed in its tone, reach for Banner.
export default function AlertIconTone() {
return (
<div className="flex w-full max-w-lg flex-col gap-2.5">
<Alert className="*:[svg]:text-success">
<CircleCheckIcon aria-hidden="true" />
<AlertTitle>Payment received</AlertTitle>
<AlertDescription>$12,480.00 from Blue Harbor Logistics settled INV-2041 in full.</AlertDescription>
</Alert>
<Alert className="*:[svg]:text-warning">
<TriangleAlertIcon aria-hidden="true" />
<AlertTitle>84% of this month’s events are used</AlertTitle>
<AlertDescription>At this pace Northwind reaches 100,000 on September 27, four days early.</AlertDescription>
</Alert>
</div>
)
}With a link
The fix is elsewhere in the app, so the description links to it, underlined in the alert's own tone.
import { CreditCardIcon } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
// The fix is somewhere else in the app, so the description links to it. The
// recipe underlines a link inside the title or the description, in the alert's
// own tone, so it is found without relying on colour.
export default function AlertLink() {
return (
<Alert variant="destructive" className="max-w-lg">
<CreditCardIcon aria-hidden="true" />
<AlertTitle>The card ending 4242 was declined</AlertTitle>
<AlertDescription>
The Team plan renews on October 1.{" "}
<a href="/settings/billing#payment-method">Update the payment method</a> to keep all 24 seats.
</AlertDescription>
</Alert>
)
}With an id to copy
A failure someone else will look into: the id in the code face, and a copy button that keeps the corner.
import { WebhookIcon } from "lucide-react"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { CopyButton } from "@/components/ui/copy-button"
const EVENT = "evt_3PfK2tLx9QaN"
// A failure somebody else will have to look into: the id is set in the code
// face and one press copies it. An icon button keeps the alert's corner at any
// width, where a labelled action would move under the text.
export default function AlertReference() {
return (
<Alert variant="destructive" className="max-w-lg">
<WebhookIcon aria-hidden="true" />
<AlertTitle>Webhook delivery failed after 5 attempts</AlertTitle>
<AlertDescription>
hooks.northwind.example answered 401 Unauthorized. Quote <code className="font-mono text-xs">{EVENT}</code> when
you write to support.
</AlertDescription>
<AlertAction>
<CopyButton value={EVENT} size="icon-xs" label="Copy the event id" successLabel="Event id copied" />
</AlertAction>
</Alert>
)
}Checklist
What still stands in the way, each line saying its state in words as well as by its mark.
import { CircleCheckIcon, CircleDashedIcon, ListChecksIcon } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
const CHECKS = [
{ label: "Figures reconciled with the ledger", done: true },
{ label: "Two charts still need a source note", done: false },
{ label: "Legal review, waiting on Nadia Costa", done: false },
]
// What stands between the reader and the button they came for. Each line says
// its state in words as well as by its mark, and the open ones keep the ink:
// they are the reading that matters.
export default function AlertChecklist() {
const done = CHECKS.filter((check) => check.done).length
return (
<Alert className="max-w-md">
<ListChecksIcon aria-hidden="true" />
<AlertTitle>The Q3 board report can’t be published yet</AlertTitle>
<AlertDescription>
{/* A div, not a p: the description spaces its paragraphs a line apart. */}
<div>
{done} of {CHECKS.length} checks passed.
</div>
<ul className="mt-1.5 flex flex-col gap-1">
{CHECKS.map((check) => (
<li key={check.label} className="flex items-start gap-2">
{check.done ? (
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-3.5 shrink-0 text-success" />
) : (
<CircleDashedIcon aria-hidden="true" className="mt-0.5 size-3.5 shrink-0 text-foreground" />
)}
<span className={check.done ? undefined : "text-foreground"}>
<span className="sr-only">{check.done ? "Done: " : "To do: "}</span>
{check.label}
</span>
</li>
))}
</ul>
</AlertDescription>
</Alert>
)
}In progress
A spinner in the icon's place, the count in words and a named bar; aria-busy holds the updates back.
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Progress } from "@/components/ui/progress"
import { Spinner } from "@/components/ui/spinner"
// Work that is still going on: the spinner takes the icon's place (and stops
// for a reader who asked for less motion), the count is in words, and the bar
// is a progressbar with its own name. aria-busy holds the region's updates
// back until the import settles, so a screen reader is not read every tick.
export default function AlertProgress() {
return (
<Alert aria-busy="true" className="max-w-lg">
<Spinner aria-hidden="true" />
<AlertTitle>Importing customers from Harbour CRM</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>812 of 1,204 · about a minute left</span>
<Progress value={67} aria-label="Customer import" />
</AlertDescription>
</Alert>
)
}With an action
Undo, offered where the change is reported. The button stays, focused, once it has done its work.
"use client"
import * as React from "react"
import { ArchiveIcon, ArchiveRestoreIcon, Undo2Icon } from "lucide-react"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
// One way back, offered where the change is reported. Undoing rewrites the
// notice in place — it is a status region, so the new title is read out — and
// the button stays mounted, disabled but focusable, so the keyboard stays put.
export default function AlertUndo() {
const [undone, setUndone] = React.useState(false)
return (
<Alert className="max-w-lg">
{undone ? <ArchiveRestoreIcon aria-hidden="true" /> : <ArchiveIcon aria-hidden="true" />}
<AlertTitle>{undone ? "4 invoices are back in Overdue" : "Moved 4 invoices to Archive"}</AlertTitle>
<AlertDescription>INV-2038 to INV-2041, all from Blue Harbor Logistics.</AlertDescription>
<AlertAction>
<Button
variant="outline"
size="xs"
disabled={undone}
focusableWhenDisabled
onClick={() => setUndone(true)}
>
<Undo2Icon data-icon="inline-start" aria-hidden="true" />
{undone ? "Undone" : "Undo"}
</Button>
</AlertAction>
</Alert>
)
}Approve or decline
Two actions named for the person. The answer rewrites the notice, and the other button goes.
"use client"
import * as React from "react"
import { UserCheckIcon, UserPlusIcon, UserXIcon } from "lucide-react"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
const PERSON = "Saoirse Byrne"
const OUTCOME = {
approved: { icon: UserCheckIcon, title: `${PERSON} joined Northwind Finance`, detail: "She sees every Finance dashboard from now on.", done: "Approved" },
declined: { icon: UserXIcon, title: `You declined ${PERSON}’s request`, detail: "She can ask again in 30 days.", done: "Declined" },
}
// A request that needs an answer: two actions, each named for the person in
// words a screen reader hears. The answer rewrites the notice; the button that
// gave it stays, focused and spent, and the other one goes.
export default function AlertDecision() {
const [answer, setAnswer] = React.useState<keyof typeof OUTCOME | null>(null)
const outcome = answer ? OUTCOME[answer] : null
const Icon = outcome ? outcome.icon : UserPlusIcon
return (
<Alert className="max-w-lg">
<Icon aria-hidden="true" />
<AlertTitle>{outcome ? outcome.title : `${PERSON} asked to join Northwind Finance`}</AlertTitle>
<AlertDescription>{outcome ? outcome.detail : "saoirse@northwind.example\u00a0· asked 2 hours ago"}</AlertDescription>
<AlertAction>
{(["approved", "declined"] as const).map((choice) =>
answer && answer !== choice ? null : (
<Button
key={choice}
size="xs"
variant={choice === "approved" ? "default" : "outline"}
disabled={answer !== null}
focusableWhenDisabled
onClick={() => setAnswer(choice)}
>
{answer ? OUTCOME[choice].done : choice === "approved" ? "Approve" : "Decline"}{" "}
<span className="sr-only">{PERSON}</span>
</Button>
)
)}
</AlertAction>
</Alert>
)
}Dismissible
A close button named for what it closes. Focus moves to the way back, and back to the close button again.
"use client"
import * as React from "react"
import { ShieldCheckIcon, XIcon } from "lucide-react"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
// The close button names what it closes, and keeps its corner at any width.
// Closing hands the focus to the way back rather than dropping it on the page,
// and bringing the notice back hands it to the close button again.
export default function AlertDismissible() {
const [open, setOpen] = React.useState(true)
const moved = React.useRef(false)
const close = React.useRef<HTMLButtonElement>(null)
const reopen = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (!moved.current) return
const target = open ? close.current : reopen.current
target?.focus()
}, [open])
function toggle(next: boolean) {
moved.current = true
setOpen(next)
}
return (
<div className="flex w-full max-w-lg flex-col items-start gap-2">
{open ? (
<Alert role={undefined}>
<ShieldCheckIcon aria-hidden="true" />
<AlertTitle>Two-step verification is required for admins from November 1</AlertTitle>
<AlertDescription>Three admins still sign in with a password alone.</AlertDescription>
<AlertAction>
<Button
ref={close}
variant="ghost"
size="icon-xs"
aria-label="Dismiss the two-step verification notice"
onClick={() => toggle(false)}
>
<XIcon aria-hidden="true" />
</Button>
</AlertAction>
</Alert>
) : (
<Button ref={reopen} variant="link" size="sm" className="px-0" onClick={() => toggle(true)}>
Show the two-step verification notice
</Button>
)}
</div>
)
}Error summary
Counts a form's problems and links to each field. Sending with problems moves the focus to it.
"use client"
import * as React from "react"
import { CircleAlertIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const FIELDS = [
{ key: "email", label: "Billing email", type: "email", ok: isEmail, problem: "Enter the billing email in full" },
{ key: "po", label: "PO number", type: "text", ok: (v: string) => v.trim() !== "", problem: "Add the purchase-order number" },
] as const
type Values = Record<(typeof FIELDS)[number]["key"], string>
const failing = (values: Values) => FIELDS.filter((field) => !field.ok(values[field.key]))
// A summary above the form counts the problems and links to each field, and
// the message repeats under the field. Sending with problems moves focus to
// the summary, a link moves it to its field, and the summary is the one that
// announces — so the field messages drop FieldError's own alert role.
export default function AlertErrorSummary() {
const id = React.useId()
const summary = React.useRef<HTMLDivElement>(null)
const [values, setValues] = React.useState<Values>({ email: "billing@blueharbor", po: "" })
const [problems, setProblems] = React.useState(() => failing(values))
const [sent, setSent] = React.useState(false)
function send(event: React.FormEvent) {
event.preventDefault()
const found = failing(values)
setProblems(found)
setSent(found.length === 0)
if (found.length > 0) requestAnimationFrame(() => summary.current?.focus())
}
return (
<form noValidate onSubmit={send} className="flex w-full max-w-lg flex-col gap-3">
{problems.length > 0 ? (
<Alert ref={summary} tabIndex={-1} variant="destructive" className="focus-ring">
<CircleAlertIcon aria-hidden="true" />
<AlertTitle>{problems.length === 1 ? "1 problem" : `${problems.length} problems`} stopped INV-2042 from sending</AlertTitle>
<AlertDescription>
<ul className="flex flex-col gap-0.5">
{problems.map((field) => (
<li key={field.key}>
<a
href={`#${id}-${field.key}`}
onClick={(event) => {
event.preventDefault()
document.getElementById(`${id}-${field.key}`)?.focus()
}}
>
{field.problem}
</a>
</li>
))}
</ul>
</AlertDescription>
</Alert>
) : null}
<div className="grid grid-cols-2 gap-3">
{FIELDS.map((field) => {
const invalid = problems.includes(field)
return (
<Field key={field.key} data-invalid={invalid || undefined}>
<FieldLabel htmlFor={`${id}-${field.key}`}>{field.label}</FieldLabel>
<Input
id={`${id}-${field.key}`}
type={field.type}
value={values[field.key]}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? `${id}-${field.key}-error` : undefined}
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
/>
<FieldError id={`${id}-${field.key}-error`} role={undefined}>
{invalid ? field.problem : null}
</FieldError>
</Field>
)
})}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit">Send invoice</Button>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{sent ? "INV-2042 is on its way to Blue Harbor Logistics." : null}
</p>
</div>
</form>
)
}Right to left
The demo's alerts in Arabic: the icon at the inline start, the action at the inline end.
import { CircleAlertIcon, InfoIcon } from "lucide-react"
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
// The demo's two alerts in Arabic. Nothing in the recipe names a physical
// side: the icon takes the inline start, the text aligns to it, and the action
// keeps the inline end — on the left here. The ‏ keeps the figure after
// the Latin name in reading order, where the bidi algorithm would fold the two
// into one left-to-right run.
export default function AlertRtl() {
return (
<div dir="rtl" lang="ar" className="flex w-full max-w-lg flex-col gap-2.5">
<Alert role={undefined}>
<InfoIcon aria-hidden="true" />
<AlertTitle>يُعاد ضبط الاستخدام في 1 أكتوبر</AlertTitle>
<AlertDescription>استخدمت Northwind ‏84,120 من أصل 100,000 حدث في هذه الدورة.</AlertDescription>
</Alert>
<Alert variant="destructive">
<CircleAlertIcon aria-hidden="true" />
<AlertTitle>توقفت مزامنة المدفوعات</AlertTitle>
<AlertDescription>المدفوعات منذ الساعة 09:40 بالتوقيت العالمي غائبة عن الإيرادات.</AlertDescription>
<AlertAction>
<Button variant="outline" size="xs">
إعادة الربط
</Button>
</AlertAction>
</Alert>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "destructive" | "default" | destructive sets the text and icon in the danger tone. |
| role | React.AriaRole | undefined | "status", or "alert" when destructive | status is read at the next pause, alert at once. Pass role={undefined} for a notice that was on the page before the reader arrived. |
| AlertAction | React.ComponentProps<"div"> | — | A button or two at the inline end, beside the title. Under 24rem of alert a labelled action moves under the description; an icon button stays in the corner. |
Dependencies
Registry
Source
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
// An action takes a column of its own at the inline end, so a label of any
// length is laid out beside the text rather than over it; under 24rem of alert
// (a phone, a narrow card) that column would starve the text, so a labelled
// action drops under the description instead, while an icon button — a
// dismiss, a copy — keeps its corner. The alert is a size container only when
// it holds an action, so an alert sized by its content still has a width.
const alertVariants = cva(
"group/alert relative grid w-full grid-cols-[1fr] gap-0.5 rounded-lg border px-2.5 py-2 text-start text-sm has-data-[slot=alert-action]:@container/alert has-data-[slot=alert-action]:gap-x-2 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
data-variant={variant}
// A failure interrupts; a notice waits for a pause in the reading — the
// split Banner makes. A caller's own role wins, `undefined` included,
// for a notice that was on the page before the reader arrived.
role={variant === "destructive" ? "alert" : "status"}
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn(
// Beside the text: the column after it, from the title's line down.
"col-start-2 row-span-2 row-start-1 ms-1 flex flex-wrap items-center gap-1.5 self-start justify-self-end group-has-[>svg]/alert:col-start-3",
// Under it, in the text's own column, when the alert is narrow and
// the action has a label (Button writes its size to data-size).
"@max-sm/alert:not-has-[>[data-size^=icon]]:col-start-1 @max-sm/alert:not-has-[>[data-size^=icon]]:row-span-1 @max-sm/alert:not-has-[>[data-size^=icon]]:row-start-auto @max-sm/alert:not-has-[>[data-size^=icon]]:ms-0 @max-sm/alert:not-has-[>[data-size^=icon]]:mt-1.5 @max-sm/alert:not-has-[>[data-size^=icon]]:justify-self-start @max-sm/alert:not-has-[>[data-size^=icon]]:group-has-[>svg]/alert:col-start-2",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }