Components
Alert
A boxed informational, success, warning, or error message. Five variants; the live prop maps to the right ARIA live-region politeness so assistive tech announces (or doesn't) at the right urgency.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/alert.json2. Use it
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"
<Alert variant="success">
<CheckIcon />
<AlertTitle>Saved</AlertTitle>
<AlertDescription>Your changes have been recorded.</AlertDescription>
</Alert>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Alert — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Source of truth (visual layout):
// repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/alert.tsx
//
// Spec divergence from upstream — important: shadcn hardcodes role="alert"
// on every instance. That role is implicit aria-live="assertive" and
// interrupts the user's current screen-reader output. APG and WCAG advice
// is to reserve "assertive" announcements for genuinely time-sensitive
// content (errors after submit, lost connection). For typical
// informational messages ("Saved", "Filter updated") "polite" is correct;
// for static page content that's there on load, no role at all is
// correct. So we expose `live` and default to "polite" (role="status").
//
// Refs:
// repos/aria-practices/content/patterns/alert/ ("alert" role guidance)
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/alert_role/
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-live/
//
// Composition (matches shadcn):
// <Alert>
// <SomeIcon />
// <AlertTitle>Heads up!</AlertTitle>
// <AlertDescription>Body of the alert…</AlertDescription>
// </Alert>
export type AlertVariant = "default" | "destructive" | "success" | "warning" | "info"
// "off" — static informational content; no aria-live region.
// "polite" — implicit role="status". AT waits until idle to announce.
// "assertive"— implicit role="alert". AT interrupts current speech. Use sparingly.
export type AlertLive = "off" | "polite" | "assertive"
const base =
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm " +
"has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 " +
"[&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current"
const variants: Record<AlertVariant, string> = {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
success:
"border-emerald-500/30 bg-emerald-500/5 text-emerald-700 *:data-[slot=alert-description]:text-emerald-700/90 dark:text-emerald-300 dark:*:data-[slot=alert-description]:text-emerald-300/90 [&>svg]:text-current",
warning:
"border-amber-500/30 bg-amber-500/10 text-amber-800 *:data-[slot=alert-description]:text-amber-800/90 dark:text-amber-200 dark:*:data-[slot=alert-description]:text-amber-200/90 [&>svg]:text-current",
info:
"border-sky-500/30 bg-sky-500/5 text-sky-800 *:data-[slot=alert-description]:text-sky-800/90 dark:text-sky-200 dark:*:data-[slot=alert-description]:text-sky-200/90 [&>svg]:text-current",
}
export function alertClasses(opts?: {
variant?: AlertVariant
class?: ClassValue
}): string {
const variant = opts?.variant ?? "default"
return cn(base, variants[variant], opts?.class)
}
type AlertProps = PropsWithChildren<{
variant?: AlertVariant
// ARIA live-region politeness. "polite" (default) sets role="status".
// "assertive" sets role="alert". "off" omits both — use for static info.
live?: AlertLive
// Override the role directly if you need something unusual; takes
// precedence over `live`.
role?: "alert" | "status" | "log" | "none"
// Most alerts contain the full message at render time, so aria-atomic
// defaults to true (read the whole alert, not just changed bits).
ariaAtomic?: boolean
// Name the live region for AT. Point ariaLabelledby at the id of the
// AlertTitle inside, or pass ariaLabel for a literal name.
// status_role lists aria-label / aria-labelledby as associated properties:
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/index.md:28-29
ariaLabelledby?: string
ariaLabel?: string
id?: string
class?: ClassValue
}>
export function Alert(props: AlertProps) {
const {
children,
variant,
live = "polite",
role: roleOverride,
ariaAtomic = true,
ariaLabelledby,
ariaLabel,
id,
class: className,
} = props
// Map live → role + aria-live. Both attributes communicate the same
// thing; some older AT pays attention to one and not the other, so we
// set both for resilience.
const role =
roleOverride ??
(live === "assertive" ? "alert" : live === "polite" ? "status" : undefined)
const ariaLive = live === "off" ? undefined : live
return (
<div
id={id}
data-slot="alert"
data-variant={variant ?? "default"}
role={role}
aria-live={ariaLive}
aria-atomic={ariaAtomic ? "true" : undefined}
aria-labelledby={ariaLabelledby}
aria-label={ariaLabel}
class={alertClasses({ variant, class: className })}
>
{children}
</div>
)
}
export function AlertTitle(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<div
data-slot="alert-title"
class={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
props.class,
)}
>
{props.children}
</div>
)
}
export function AlertDescription(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<div
data-slot="alert-description"
class={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
props.class,
)}
>
{props.children}
</div>
)
}
1. Save the file
Copy alert.html into templates/components/.
2. Use it
{% from "components/alert.html" import alert_open, alert_close, alert_title, alert_description %}
{{ alert_open(variant="success") }}
<svg …>…</svg>
{{ alert_title("Saved") }}
{{ alert_description("Your changes have been recorded.") }}
{{ alert_close() }}View source
{# Alert macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/alert.tsx.
- live="polite" (default) → role="status", aria-live="polite"
- live="assertive" → role="alert", aria-live="assertive"
- live="off" → no role; static informational content
Usage:
{% from "components/alert.html" import alert_open, alert_close, alert_title, alert_description %}
{{ alert_open(variant="success") }}
{{ alert_title("Saved") }}
{{ alert_description("Your changes have been recorded.") }}
{{ alert_close() }} #}
{# aria_labelledby / aria_label name the live region for AT — point
aria_labelledby at the id of an alert_title, or pass aria_label.
status_role associated properties:
repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/index.md:28-29 #}
{% macro alert_open(
variant="default",
live="polite",
role=none,
aria_atomic=true,
aria_labelledby=none,
aria_label=none,
id=none,
extra_class=""
) -%}
{%- set base -%}
relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current
{%- endset -%}
{%- set variants = {
"default": "bg-card text-card-foreground",
"destructive": "border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
"success": "border-emerald-500/30 bg-emerald-500/5 text-emerald-700 *:data-[slot=alert-description]:text-emerald-700/90 dark:text-emerald-300 dark:*:data-[slot=alert-description]:text-emerald-300/90 [&>svg]:text-current",
"warning": "border-amber-500/30 bg-amber-500/10 text-amber-800 *:data-[slot=alert-description]:text-amber-800/90 dark:text-amber-200 dark:*:data-[slot=alert-description]:text-amber-200/90 [&>svg]:text-current",
"info": "border-sky-500/30 bg-sky-500/5 text-sky-800 *:data-[slot=alert-description]:text-sky-800/90 dark:text-sky-200 dark:*:data-[slot=alert-description]:text-sky-200/90 [&>svg]:text-current"
} -%}
{%- set computed_role = role or ({"assertive":"alert","polite":"status","off":none}[live]) -%}
<div
{%- if id %} id="{{ id }}"{% endif %}
data-slot="alert"
data-variant="{{ variant }}"
{%- if computed_role %} role="{{ computed_role }}"{% endif %}
{%- if live != "off" %} aria-live="{{ live }}"{% endif %}
{%- if aria_atomic %} aria-atomic="true"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
class="{{ base }} {{ variants[variant] }} {{ extra_class }}">
{%- endmacro %}
{% macro alert_close() %}</div>{% endmacro %}
{% macro alert_title(text, extra_class="") -%}
<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight {{ extra_class }}">{{ text }}</div>
{%- endmacro %}
{% macro alert_description(text, extra_class="") -%}
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed {{ extra_class }}">{{ text }}</div>
{%- endmacro %}
1. Save the file
Add alert.tmpl alongside button.tmpl.
2. Use it
{{template "alert" (dict
"Variant" "success"
"Title" "Saved"
"Body" (htmlSafe "Your changes have been recorded.")
)}}View source
{{/*
Alert template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/alert.tsx.
type AlertArgs struct {
Variant string // default | destructive | success | warning | info
Live string // off | polite (default) | assertive
Role string // override; one of alert | status | log | none
Title string
Body template.HTML
AriaAtomic *bool // nil = default true (read full alert); set to &false to omit
// Name the live region for AT: AriaLabelledby points at an
// alert-title id; AriaLabel is a literal name. status_role
// associated properties:
// repos/mdn/.../roles/status_role/index.md:28-29
AriaLabelledby string
AriaLabel string
ID string
}
*/}}
{{define "alert"}}
{{- $variant := or .Variant "default" -}}
{{- $live := or .Live "polite" -}}
{{- $ariaAtomic := true -}}{{- if ne .AriaAtomic nil -}}{{- $ariaAtomic = deref .AriaAtomic -}}{{- end -}}
{{- $base := "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current" -}}
{{- $variants := dict
"default" "bg-card text-card-foreground"
"destructive" "border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current"
"success" "border-emerald-500/30 bg-emerald-500/5 text-emerald-700 *:data-[slot=alert-description]:text-emerald-700/90 dark:text-emerald-300 dark:*:data-[slot=alert-description]:text-emerald-300/90 [&>svg]:text-current"
"warning" "border-amber-500/30 bg-amber-500/10 text-amber-800 *:data-[slot=alert-description]:text-amber-800/90 dark:text-amber-200 dark:*:data-[slot=alert-description]:text-amber-200/90 [&>svg]:text-current"
"info" "border-sky-500/30 bg-sky-500/5 text-sky-800 *:data-[slot=alert-description]:text-sky-800/90 dark:text-sky-200 dark:*:data-[slot=alert-description]:text-sky-200/90 [&>svg]:text-current" -}}
{{- $role := .Role -}}
{{- if not $role -}}
{{- if eq $live "assertive" -}}{{- $role = "alert" -}}
{{- else if eq $live "polite" -}}{{- $role = "status" -}}
{{- end -}}
{{- end -}}
<div {{if .ID}}id="{{.ID}}"{{end}}
data-slot="alert" data-variant="{{$variant}}"
{{if $role}}role="{{$role}}"{{end}}
{{if ne $live "off"}}aria-live="{{$live}}"{{end}}
{{if $ariaAtomic}}aria-atomic="true"{{end}}
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
class="{{$base}} {{index $variants $variant}}">
{{- if .Title}}<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">{{.Title}}</div>{{end}}
{{- if .Body}}<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed">{{.Body}}</div>{{end}}
</div>
{{end}}
1. Save the file
Drop alert.ex into lib/my_app_web/components/.
2. Use it
<.alert variant="success">
<.alert_title>Saved</.alert_title>
<.alert_description>Your changes have been recorded.</.alert_description>
</.alert>View source
defmodule ShadcnHtmx.Components.Alert do
@moduledoc """
Alert — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/alert.tsx. Spec-divergence note: shadcn upstream
hardcodes role="alert" (assertive). We expose `live` so polite/static
cases use role="status" or no role, per APG.
## Examples
<.alert variant="success">
<.alert_title>Saved</.alert_title>
<.alert_description>Your changes have been recorded.</.alert_description>
</.alert>
# Truly time-critical — interrupts AT
<.alert variant="destructive" live="assertive">
<.alert_title>Connection lost</.alert_title>
<.alert_description>Trying to reconnect…</.alert_description>
</.alert>
"""
use Phoenix.Component
@base "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm " <>
"has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 " <>
"[&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current"
@variants %{
"default" => "bg-card text-card-foreground",
"destructive" =>
"border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
"success" =>
"border-emerald-500/30 bg-emerald-500/5 text-emerald-700 *:data-[slot=alert-description]:text-emerald-700/90 dark:text-emerald-300 dark:*:data-[slot=alert-description]:text-emerald-300/90 [&>svg]:text-current",
"warning" =>
"border-amber-500/30 bg-amber-500/10 text-amber-800 *:data-[slot=alert-description]:text-amber-800/90 dark:text-amber-200 dark:*:data-[slot=alert-description]:text-amber-200/90 [&>svg]:text-current",
"info" =>
"border-sky-500/30 bg-sky-500/5 text-sky-800 *:data-[slot=alert-description]:text-sky-800/90 dark:text-sky-200 dark:*:data-[slot=alert-description]:text-sky-200/90 [&>svg]:text-current"
}
attr :variant, :string, default: "default", values: ~w(default destructive success warning info)
attr :live, :string, default: "polite", values: ~w(off polite assertive)
attr :role, :string, default: nil
attr :aria_atomic, :boolean, default: true
# Name the live region for AT: aria_labelledby points at an alert_title id,
# aria_label is a literal name. status_role associated properties —
# repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/index.md:28-29
attr :aria_labelledby, :string, default: nil
attr :aria_label, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def alert(assigns) do
role =
assigns.role ||
case assigns.live do
"assertive" -> "alert"
"polite" -> "status"
_ -> nil
end
assigns =
assigns
|> assign(:variant_class, Map.fetch!(@variants, assigns.variant))
|> assign(:base, @base)
|> assign(:computed_role, role)
~H"""
<div
data-slot="alert"
data-variant={@variant}
role={@computed_role}
aria-live={if @live != "off", do: @live}
aria-atomic={if @aria_atomic, do: "true"}
aria-labelledby={@aria_labelledby}
aria-label={@aria_label}
class={[@base, @variant_class, @class]}
{@rest}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :class, :string, default: nil
slot :inner_block, required: true
def alert_title(assigns) do
~H"""
<div
data-slot="alert-title"
class={["col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", @class]}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :class, :string, default: nil
slot :inner_block, required: true
def alert_description(assigns) do
~H"""
<div
data-slot="alert-description"
class={[
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
@class
]}
>
{render_slot(@inner_block)}
</div>
"""
end
end
1. Save the file
Tailwind v4 utilities only; no script.
2. Use it
<div data-slot="alert" role="status" aria-live="polite" aria-atomic="true"
class="relative grid w-full grid-cols-[0_1fr] items-start … rounded-lg border …">
<svg …>…</svg>
<div data-slot="alert-title">Saved</div>
<div data-slot="alert-description">Your changes have been recorded.</div>
</div>View source
<!--
shadcn-htmx — raw HTML alert snippets.
Spec note (important): shadcn upstream hardcodes role="alert" (assertive,
interrupts screen reader). APG prefers role="status" (polite) for most
informational messages, and no role at all for static content. Pick:
- role="status" + aria-live="polite" — "Saved", "Filter applied"
- role="alert" + aria-live="assertive" — "Connection lost", critical errors
- (no role) — static page-load content
BASE:
relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5
rounded-lg border px-4 py-3 text-sm
has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3
[&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current
-->
<!-- Default (polite) -->
<div data-slot="alert" data-variant="default" role="status" aria-live="polite" aria-atomic="true"
class="relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border bg-card px-4 py-3 text-sm text-card-foreground has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">Heads up</div>
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed">
You can add components to your app using the CLI.
</div>
</div>
<!-- Destructive (assertive — critical error) -->
<div data-slot="alert" data-variant="destructive" role="alert" aria-live="assertive" aria-atomic="true"
class="relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">Connection lost</div>
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-destructive/90">
Trying to reconnect…
</div>
</div>
<!-- Named live region: aria-labelledby points at the title so AT prefaces
the announcement with it. status_role associated properties —
repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/index.md:28-29 -->
<div data-slot="alert" data-variant="info" role="status" aria-live="polite" aria-atomic="true" aria-labelledby="alert-deploy-title"
class="relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border border-sky-500/30 bg-sky-500/5 px-4 py-3 text-sm text-sky-800 dark:text-sky-200 has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current">
<div id="alert-deploy-title" data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">Deploy finished</div>
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed">
Your site is live.
</div>
</div>
Examples
Variants — pick the right colour for the message
Five visual variants for five common situations. Pair the visual with the right live-region politeness (next example).
Don't lean on colour alone — every alert keeps a textual title and description so colour-blind users get the same information. Icons reinforce the meaning further. WCAG 1.4.1 (Use of Colour) forbids using colour as the sole signal.
<Alert variant="success">
<CheckIcon />
<AlertTitle>Saved</AlertTitle>
<AlertDescription>Your changes have been recorded.</AlertDescription>
</Alert>{{ alert_open(variant="success") }}
<svg …>…</svg>
{{ alert_title("Saved") }}
{{ alert_description("Your changes have been recorded.") }}
{{ alert_close() }}{{template "alert" (dict
"Variant" "success"
"Title" "Saved"
"Body" (htmlSafe "Your changes have been recorded.")
)}}<.alert variant="success">
<.alert_title>Saved</.alert_title>
<.alert_description>Your changes have been recorded.</.alert_description>
</.alert><div class="flex w-full max-w-lg flex-col gap-3">
<div data-slot="alert" data-variant="default" role="status" aria-live="polite" aria-atomic="true" class="relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current bg-card text-card-foreground">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 16v-4">
</path>
<path d="M12 8h.01">
</path>
<path d="M12 2a10 10 0 100 20 10 10 0 000-20z">
</path>
</svg>
<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">Heads up</div>
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed">You can add components to your app using the CLI.</div>
</div>
<div data-slot="alert" data-variant="success" role="status" aria-live="polite" aria-atomic="true" class="relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current border-emerald-500/30 bg-emerald-500/5 text-emerald-700 *:data-[slot=alert-description]:text-emerald-700/90 dark:text-emerald-300 dark:*:data-[slot=alert-description]:text-emerald-300/90 [&>svg]:text-current">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6L9 17l-5-5">
</path>
</svg>
<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">Saved</div>
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed">Your changes have been recorded.</div>
</div>
<div data-slot="alert" data-variant="warning" role="status" aria-live="polite" aria-atomic="true" class="relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current border-amber-500/30 bg-amber-500/10 text-amber-800 *:data-[slot=alert-description]:text-amber-800/90 dark:text-amber-200 dark:*:data-[slot=alert-description]:text-amber-200/90 [&>svg]:text-current">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z">
</path>
<path d="M12 9v4">
</path>
<path d="M12 17h.01">
</path>
</svg>
<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">Action needed</div>
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed">Your trial ends in 3 days.</div>
</div>
<div data-slot="alert" data-variant="info" role="status" aria-live="polite" aria-atomic="true" class="relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current border-sky-500/30 bg-sky-500/5 text-sky-800 *:data-[slot=alert-description]:text-sky-800/90 dark:text-sky-200 dark:*:data-[slot=alert-description]:text-sky-200/90 [&>svg]:text-current">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 16v-4">
</path>
<path d="M12 8h.01">
</path>
<path d="M12 2a10 10 0 100 20 10 10 0 000-20z">
</path>
</svg>
<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">New feature</div>
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed">Try the new keyboard-driven palette: Cmd-K.</div>
</div>
<div data-slot="alert" data-variant="destructive" role="status" aria-live="polite" aria-atomic="true" class="relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 9l-6 6">
</path>
<path d="M9 9l6 6">
</path>
<path d="M12 2a10 10 0 100 20 10 10 0 000-20z">
</path>
</svg>
<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">Error</div>
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed">We couldn't save your changes. Try again in a moment.</div>
</div>
</div>Further reading
Live-region politeness — when to interrupt
Three modes — off (static), polite (status), assertive (alert). The default is polite. Reach for assertive only when the user MUST hear it immediately.
shadcn's upstream Alert hardcodes role="alert", which interrupts screen-reader output. We default to role="status" (polite) because most alerts in real apps are informational. Use live="assertive" only for the rare urgent cases: connection lost, unsaved data warning before navigation, server errors after submit.
live="off" — static, no announcementlive="polite" (default) — role="status"live="assertive" — role="alert", interrupts<Alert live="off">…</Alert> // static
<Alert live="polite">…</Alert> // default (role=status)
<Alert live="assertive">…</Alert> // role=alert; AT interrupts{{ alert_open(live="off") }}…{{ alert_close() }}
{{ alert_open(live="polite") }}…{{ alert_close() }}
{{ alert_open(live="assertive") }}…{{ alert_close() }}{{template "alert" (dict "Live" "off" "Body" (htmlSafe …))}}
{{template "alert" (dict "Live" "polite" "Body" (htmlSafe …))}}
{{template "alert" (dict "Live" "assertive" "Body" (htmlSafe …))}}<.alert live="off">…</.alert>
<.alert live="polite">…</.alert>
<.alert live="assertive">…</.alert><div class="flex w-full max-w-lg flex-col gap-3 text-xs">
<code class="text-muted-foreground">live="off" — static, no announcement</code>
<div data-slot="alert" data-variant="info" aria-atomic="true" class="relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current border-sky-500/30 bg-sky-500/5 text-sky-800 *:data-[slot=alert-description]:text-sky-800/90 dark:text-sky-200 dark:*:data-[slot=alert-description]:text-sky-200/90 [&>svg]:text-current">
<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">Page-load tip</div>
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed">Cards stack vertically on mobile.</div>
</div>
<code class="text-muted-foreground">live="polite" (default) — role="status"</code>
<div data-slot="alert" data-variant="success" role="status" aria-live="polite" aria-atomic="true" class="relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current border-emerald-500/30 bg-emerald-500/5 text-emerald-700 *:data-[slot=alert-description]:text-emerald-700/90 dark:text-emerald-300 dark:*:data-[slot=alert-description]:text-emerald-300/90 [&>svg]:text-current">
<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">Saved</div>
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed">Filter applied. Showing 12 results.</div>
</div>
<code class="text-muted-foreground">live="assertive" — role="alert", interrupts</code>
<div data-slot="alert" data-variant="destructive" role="alert" aria-live="assertive" aria-atomic="true" class="relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current">
<div data-slot="alert-title" class="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">Connection lost</div>
<div data-slot="alert-description" class="col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed">Trying to reconnect…</div>
</div>
</div>Further reading
htmx — server-sent alert into a live region
The page has an empty polite live-region; the server returns an <Alert> fragment on submit which htmx swaps in. AT announces the message as soon as it appears.
This is the canonical htmx pattern for flash messages. The host element is rendered once at page load with aria-live="polite"; the server returns an Alert fragment whose own role + aria-live get inherited by the host because they're children of the live region. (Polite-on-polite is fine.) Returning nothing on the empty case keeps the live region silent.
<form hx-post="/api/save" hx-target="#flash" hx-swap="innerHTML">
<Button type="submit">Save</Button>
</form>
<div id="flash" aria-live="polite" aria-atomic="true" />
// Server returns either an <Alert variant="success"> or
// <Alert variant="destructive" live="assertive"> fragment.<form hx-post="/api/save" hx-target="#flash" hx-swap="innerHTML">
{{ button("Save", type="submit") }}
</form>
<div id="flash" aria-live="polite" aria-atomic="true"></div><form hx-post="/api/save" hx-target="#flash" hx-swap="innerHTML">
{{template "button" (dict "Label" "Save" "Type" "submit")}}
</form>
<div id="flash" aria-live="polite" aria-atomic="true"></div><form hx-post={~p"/api/save"} hx-target="#flash" hx-swap="innerHTML">
<.button type="submit">Save</.button>
</form>
<div id="flash" aria-live="polite" aria-atomic="true"></div><div class="flex w-full max-w-md flex-col gap-3">
<form hx-post="/alert/save" hx-target="#ex-alert-flash" hx-swap="innerHTML" class="flex items-center gap-2">
<button type="submit" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="default" data-size="default">Submit</button>
<button type="submit" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="outline" data-size="default" name="fail" value="1">Submit (fails)</button>
</form>
<div id="ex-alert-flash" aria-live="polite" aria-atomic="true">
</div>
</div>Further reading
API Reference
<Alert>
| Prop | Type | Default | Description |
|---|---|---|---|
ariaLabelledby | string | — | id of the AlertTitle (or other visible text) that names the live region. Some screen readers announce a status region's name before its contents; aria-labelledby wires that up. Forwarded as aria-labelledby on the alert div. Omitted when unset. |
ariaLabel | string | — | Literal accessible name for the live region when no visible label element exists. Forwarded as aria-label on the alert div. Prefer ariaLabelledby when an AlertTitle is present. Omitted when unset. |
variant | "default"|"destructive"|"success"|"warning"|"info" | "default" | Colour variant. |
live | "off"|"polite"|"assertive" | "polite" | Live-region politeness. polite = role=status, assertive = role=alert.MDNaria-live |
role | "alert"|"status"|"log"|"none" | — | Direct override; takes precedence over live. |
ariaAtomic | boolean | true | Read the entire alert on update (vs only changed parts). |
class | string | — | Extra Tailwind classes appended to the root element. |