shshadcn-htmx

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.json

2. Use it

components/ui/alert.tsx
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
components/ui/alert.tsx
/** @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

templates/components/alert.html
{% 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
templates/components/alert.html
{# 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

templates/components/alert.tmpl
{{template "alert" (dict
  "Variant" "success"
  "Title" "Saved"
  "Body"  (htmlSafe "Your changes have been recorded.")
)}}
View source
templates/components/alert.tmpl
{{/*
  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

lib/my_app_web/components/alert.ex
<.alert variant="success">
  <.alert_title>Saved</.alert_title>
  <.alert_description>Your changes have been recorded.</.alert_description>
</.alert>
View source
lib/my_app_web/components/alert.ex
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

index.html
<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
index.html
<!--
  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.

Heads up
You can add components to your app using the CLI.
Saved
Your changes have been recorded.
Action needed
Your trial ends in 3 days.
New feature
Try the new keyboard-driven palette: Cmd-K.
Error
We couldn't save your changes. Try again in a moment.
<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-[&gt;svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[&gt;svg]:gap-x-3 [&amp;&gt;svg]:size-4 [&amp;&gt;svg]:translate-y-0.5 [&amp;&gt;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 [&amp;_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-[&gt;svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[&gt;svg]:gap-x-3 [&amp;&gt;svg]:size-4 [&amp;&gt;svg]:translate-y-0.5 [&amp;&gt;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 [&amp;&gt;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 [&amp;_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-[&gt;svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[&gt;svg]:gap-x-3 [&amp;&gt;svg]:size-4 [&amp;&gt;svg]:translate-y-0.5 [&amp;&gt;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 [&amp;&gt;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 [&amp;_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-[&gt;svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[&gt;svg]:gap-x-3 [&amp;&gt;svg]:size-4 [&amp;&gt;svg]:translate-y-0.5 [&amp;&gt;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 [&amp;&gt;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 [&amp;_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-[&gt;svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[&gt;svg]:gap-x-3 [&amp;&gt;svg]:size-4 [&amp;&gt;svg]:translate-y-0.5 [&amp;&gt;svg]:text-current border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=alert-description]:text-destructive/90 [&amp;&gt;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 [&amp;_p]:leading-relaxed">We couldn&#39;t save your changes. Try again in a moment.</div>
  </div>
</div>

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 announcement
Page-load tip
Cards stack vertically on mobile.
live="polite" (default) — role="status"
Saved
Filter applied. Showing 12 results.
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=&quot;off&quot; — 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-[&gt;svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[&gt;svg]:gap-x-3 [&amp;&gt;svg]:size-4 [&amp;&gt;svg]:translate-y-0.5 [&amp;&gt;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 [&amp;&gt;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 [&amp;_p]:leading-relaxed">Cards stack vertically on mobile.</div>
  </div>
  <code class="text-muted-foreground">live=&quot;polite&quot; (default) — role=&quot;status&quot;</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-[&gt;svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[&gt;svg]:gap-x-3 [&amp;&gt;svg]:size-4 [&amp;&gt;svg]:translate-y-0.5 [&amp;&gt;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 [&amp;&gt;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 [&amp;_p]:leading-relaxed">Filter applied. Showing 12 results.</div>
  </div>
  <code class="text-muted-foreground">live=&quot;assertive&quot; — role=&quot;alert&quot;, 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-[&gt;svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[&gt;svg]:gap-x-3 [&amp;&gt;svg]:size-4 [&amp;&gt;svg]:translate-y-0.5 [&amp;&gt;svg]:text-current border-destructive/30 bg-destructive/5 text-destructive *:data-[slot=alert-description]:text-destructive/90 [&amp;&gt;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 [&amp;_p]:leading-relaxed">Trying to reconnect…</div>
  </div>
</div>

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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;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>

API Reference

<Alert>

PropTypeDefaultDescription
ariaLabelledbystring
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.
ariaLabelstring
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.
ariaAtomicbooleantrue
Read the entire alert on update (vs only changed parts).
classstring
Extra Tailwind classes appended to the root element.