Components
Status
A persistent polite live region for non-urgent updates — Saved, 3 results, autosave timestamps. Render it once and swap text in; assistive tech announces the change when the user is idle, without interrupting them. The non-interruptive counterpart to Alert and Toast.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/status.json2. Use it
import { Status, StatusItem } from "@/components/ui/status"
{/* Single advisory message — render once, swap text in */}
<Status ariaLabel="Save status">Saved</Status>
{/* Append-only ordered sequence */}
<Status as="log" ariaLabel="Activity">
<StatusItem>Connected</StatusItem>
<StatusItem>Synced 3 files</StatusItem>
</Status>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Status — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A persistent POLITE live-region announcer for non-urgent updates:
// "Saved", "3 results", "Draft autosaved 14:02". The non-interruptive
// counterpart to Alert/Toast — the region lives on the page from first
// paint and you swap text INTO it (htmx innerHTML/textContent), so AT
// announces the change only when the user is idle. Never moves focus.
//
// Two structural roles, per APG / MDN:
// role="status" (default) — implicit aria-live="polite" + aria-atomic="true".
// A single advisory message that is replaced wholesale ("Saved").
// role="log" — implicit aria-live="polite" + aria-atomic="false".
// An append-only sequence read in arrival order (activity / chat log);
// only the newly-added entry is announced, not the whole list.
//
// Both are NAMED regions (aria-label) so AT users can find them. We set
// aria-live AND the role explicitly (some older AT honours only one) and
// pin aria-atomic to the role's correct default so swaps behave.
//
// Refs:
// repos/aria-practices/content/practices/structural-roles/structural-roles-practice.html
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/index.md
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/log_role/index.md
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-live/index.md
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-atomic/index.md
// repos/htmx/www/reference.md (hx-* forwarded via {...rest}; swap text in)
//
// Composition:
// <Status ariaLabel="Save status">Saved</Status>
// <Status as="log" ariaLabel="Activity">
// <StatusItem>Connected</StatusItem>
// <StatusItem>Synced 3 files</StatusItem>
// </Status>
export type StatusRole = "status" | "log"
export type StatusTone = "default" | "muted" | "success" | "destructive"
// role="status" reads the whole region on change (atomic). role="log" reads
// only the appended item (non-atomic) so a growing list isn't re-read in full.
const ROLE_ATOMIC: Record<StatusRole, "true" | "false"> = {
status: "true",
log: "false",
}
const base =
"block min-h-5 text-sm"
const tones: Record<StatusTone, string> = {
default: "text-foreground",
muted: "text-muted-foreground",
success: "text-emerald-700 dark:text-emerald-300",
destructive: "text-destructive",
}
export function statusClasses(opts?: {
tone?: StatusTone
class?: ClassValue
}): string {
const tone = opts?.tone ?? "muted"
return cn(base, tones[tone], opts?.class)
}
type StatusProps = PropsWithChildren<{
// Structural role. "status" = single advisory message (atomic). "log" =
// append-only ordered sequence (only new entries announced).
as?: StatusRole
// Text tone. Defaults to "muted" — status text is supporting, not primary.
tone?: StatusTone
// Override aria-atomic if you have an unusual case; otherwise it tracks
// the role's correct implicit default (status=true, log=false).
ariaAtomic?: boolean
// Required-by-spec accessible name for log; strongly recommended for
// status so AT can announce "Save status: Saved".
ariaLabel?: string
ariaLabelledby?: string
id?: string
class?: ClassValue
// hx-*, data-*, aria-* and anything else flow straight onto the region.
[key: string]: unknown
}>
export function Status(props: StatusProps) {
const {
children,
as = "status",
tone,
ariaAtomic,
ariaLabel,
ariaLabelledby,
id,
class: className,
...rest
} = props
const atomic =
ariaAtomic === undefined
? ROLE_ATOMIC[as]
: ariaAtomic
? "true"
: "false"
return (
<div
id={id}
data-slot="status"
data-role={as}
role={as}
aria-live="polite"
aria-atomic={atomic}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
class={statusClasses({ tone, class: className })}
{...rest}
>
{children}
</div>
)
}
// A single entry inside a role="log" region. Plain <div> so the log stays a
// simple ordered flow; the parent's aria-live announces each appended item.
export function StatusItem(
props: PropsWithChildren<{ id?: string; class?: ClassValue }>,
) {
return (
<div
id={props.id}
data-slot="status-item"
class={cn("py-0.5", props.class)}
>
{props.children}
</div>
)
}
1. Save the file
Copy status.html into templates/components/.
2. Use it
{% from "components/status.html" import status, status_item %}
{% call status(aria_label="Save status") %}Saved{% endcall %}
{% call status(as="log", aria_label="Activity") %}
{{ status_item("Connected") }}
{{ status_item("Synced 3 files") }}
{% endcall %}View source
{# Status macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/status.tsx.
Persistent POLITE live region for non-urgent updates ("Saved",
"3 results"). Swap text INTO it (hx-swap="innerHTML") — never move focus.
- role="status" (default) → aria-live="polite", aria-atomic="true"
single advisory message, read wholesale.
- role="log" → aria-live="polite", aria-atomic="false"
append-only ordered sequence; only the new
entry is announced.
Refs:
repos/aria-practices/.../practices/structural-roles/structural-roles-practice.html
repos/mdn/.../roles/status_role/ repos/mdn/.../roles/log_role/
Usage:
{% from "components/status.html" import status, status_item %}
{% call status(aria_label="Save status") %}Saved{% endcall %}
{% call status(as="log", aria_label="Activity") %}
{{ status_item("Connected") }}
{{ status_item("Synced 3 files") }}
{% endcall %} #}
{% macro status(
as="status",
tone="muted",
aria_atomic=none,
aria_label=none,
aria_labelledby=none,
id=none,
extra_class="",
attrs={}
) -%}
{%- set base = "block min-h-5 text-sm" -%}
{%- set tones = {
"default": "text-foreground",
"muted": "text-muted-foreground",
"success": "text-emerald-700 dark:text-emerald-300",
"destructive": "text-destructive"
} -%}
{%- set role_atomic = {"status": "true", "log": "false"} -%}
{%- set atomic = role_atomic[as] if aria_atomic is none else ("true" if aria_atomic else "false") -%}
<div
{%- if id %} id="{{ id }}"{% endif %}
data-slot="status"
data-role="{{ as }}"
role="{{ as }}"
aria-live="polite"
aria-atomic="{{ atomic }}"
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
class="{{ base }} {{ tones[tone] }} {{ extra_class }}">{{ caller() }}</div>
{%- endmacro %}
{% macro status_item(text, id=none, extra_class="") -%}
<div{% if id %} id="{{ id }}"{% endif %} data-slot="status-item" class="py-0.5 {{ extra_class }}">{{ text }}</div>
{%- endmacro %}
1. Save the file
Add status.tmpl alongside your templates.
2. Use it
{{template "status" (dict "AriaLabel" "Save status" "Body" (htmlSafe "Saved"))}}
{{template "status" (dict "As" "log" "AriaLabel" "Activity" "Body" (htmlSafe "
...status_item rows..."))}}View source
{{/*
Status template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/status.tsx.
Persistent POLITE live region for non-urgent updates ("Saved",
"3 results"). Swap text INTO it (hx-swap="innerHTML") — never move focus.
- role="status" (default) → aria-live="polite", aria-atomic="true"
- role="log" → aria-live="polite", aria-atomic="false"
only the appended entry is announced.
Refs:
repos/aria-practices/.../practices/structural-roles/structural-roles-practice.html
repos/mdn/.../roles/status_role/ repos/mdn/.../roles/log_role/
type StatusArgs struct {
As string // status (default) | log
Tone string // default | muted (default) | success | destructive
AriaAtomic *bool // nil = role default (status=true, log=false)
AriaLabel string
AriaLabelledby string
ID string
Body template.HTML // status text, or status_item rows for a log
}
Usage:
{{template "status" (dict "AriaLabel" "Save status" "Body" (htmlSafe "Saved"))}}
{{template "status" (dict "As" "log" "AriaLabel" "Activity" "Body" (htmlSafe "...status-item rows..."))}}
*/}}
{{define "status"}}
{{- $as := or .As "status" -}}
{{- $tone := or .Tone "muted" -}}
{{- $base := "block min-h-5 text-sm" -}}
{{- $tones := dict
"default" "text-foreground"
"muted" "text-muted-foreground"
"success" "text-emerald-700 dark:text-emerald-300"
"destructive" "text-destructive" -}}
{{- $roleAtomic := dict "status" "true" "log" "false" -}}
{{- $atomic := index $roleAtomic $as -}}
{{- if ne .AriaAtomic nil -}}{{- if deref .AriaAtomic -}}{{- $atomic = "true" -}}{{- else -}}{{- $atomic = "false" -}}{{- end -}}{{- end -}}
<div {{if .ID}}id="{{.ID}}"{{end}}
data-slot="status" data-role="{{$as}}"
role="{{$as}}" aria-live="polite" aria-atomic="{{$atomic}}"
{{if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="{{$base}} {{index $tones $tone}}">{{.Body}}</div>
{{end}}
{{define "status_item"}}
{{- $id := .ID -}}
<div {{if $id}}id="{{$id}}"{{end}} data-slot="status-item" class="py-0.5">{{.Body}}</div>
{{end}}
1. Save the file
Drop status.ex into lib/my_app_web/components/.
2. Use it
<.status aria_label="Save status">Saved</.status>
<.status as="log" aria_label="Activity">
<.status_item>Connected</.status_item>
<.status_item>Synced 3 files</.status_item>
</.status>View source
defmodule ShadcnHtmx.Components.Status do
@moduledoc """
Status — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/status.tsx. A persistent POLITE live region for
non-urgent updates ("Saved", "3 results"). Swap text INTO it
(hx-swap="innerHTML") — never move focus.
- role="status" (default) → aria-live="polite", aria-atomic="true"
- role="log" → aria-live="polite", aria-atomic="false"
only the appended entry is announced.
Refs:
repos/aria-practices/.../practices/structural-roles/structural-roles-practice.html
repos/mdn/.../roles/status_role/ repos/mdn/.../roles/log_role/
## Examples
<.status aria_label="Save status">Saved</.status>
<.status as="log" aria_label="Activity">
<.status_item>Connected</.status_item>
<.status_item>Synced 3 files</.status_item>
</.status>
"""
use Phoenix.Component
@base "block min-h-5 text-sm"
@tones %{
"default" => "text-foreground",
"muted" => "text-muted-foreground",
"success" => "text-emerald-700 dark:text-emerald-300",
"destructive" => "text-destructive"
}
@role_atomic %{"status" => "true", "log" => "false"}
attr :as, :string, default: "status", values: ~w(status log)
attr :tone, :string, default: "muted", values: ~w(default muted success destructive)
attr :aria_atomic, :boolean, default: nil
attr :aria_label, :string, default: nil
attr :aria_labelledby, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def status(assigns) do
atomic =
case assigns.aria_atomic do
nil -> Map.fetch!(@role_atomic, assigns.as)
true -> "true"
false -> "false"
end
assigns =
assigns
|> assign(:base, @base)
|> assign(:tone_class, Map.fetch!(@tones, assigns.tone))
|> assign(:atomic, atomic)
~H"""
<div
data-slot="status"
data-role={@as}
role={@as}
aria-live="polite"
aria-atomic={@atomic}
aria-label={@aria_label}
aria-labelledby={@aria_labelledby}
class={[@base, @tone_class, @class]}
{@rest}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def status_item(assigns) do
~H"""
<div data-slot="status-item" class={["py-0.5", @class]} {@rest}>
{render_slot(@inner_block)}
</div>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<div data-slot="status" data-role="status"
role="status" aria-live="polite" aria-atomic="true"
aria-label="Save status"
class="block min-h-5 text-sm text-muted-foreground">
Saved
</div>View source
<!--
shadcn-htmx — raw HTML status snippets. Mirrors registry/ui/status.tsx.
A persistent POLITE live region for non-urgent updates ("Saved",
"3 results"). Render it ONCE; then swap text INTO it (htmx innerHTML, or
set .textContent) so AT announces the change when the user is idle.
Never move focus to it.
role="status" + aria-live="polite" + aria-atomic="true"
→ single advisory message, re-read in full on change. ("Saved")
role="log" + aria-live="polite" + aria-atomic="false"
→ append-only ordered sequence; only the NEW entry is announced.
Relies only on theme tokens. No script.
BASE: block min-h-5 text-sm
TONES:
default text-foreground
muted text-muted-foreground (default — supporting text)
success text-emerald-700 dark:text-emerald-300
destructive text-destructive
-->
<!-- status — single advisory message (atomic). Swap text into it. -->
<div data-slot="status" data-role="status"
role="status" aria-live="polite" aria-atomic="true"
aria-label="Save status"
class="block min-h-5 text-sm text-muted-foreground">
Saved
</div>
<!-- log — append-only ordered sequence; only new entries are announced. -->
<div data-slot="status" data-role="log"
role="log" aria-live="polite" aria-atomic="false"
aria-label="Activity"
class="block min-h-5 text-sm text-muted-foreground">
<div data-slot="status-item" class="py-0.5">Connected</div>
<div data-slot="status-item" class="py-0.5">Synced 3 files</div>
</div>
Examples
Save status — single advisory message
role=status is implicitly aria-live=polite + aria-atomic=true. Render it once; replace its text to announce.
Per MDN, an element with role="status" has an implicit aria-live="polite" and aria-atomic="true", so the whole region is re-read when its content changes. Do not move focus to it — that would interrupt the user, which is exactly what status is meant to avoid. Give it an aria-label so AT can announce "Save status: Saved".
<Status ariaLabel="Save status" tone="success">
Saved just now
</Status>{% call status(aria_label="Save status", tone="success") %}Saved just now{% endcall %}{{template "status" (dict "AriaLabel" "Save status" "Tone" "success" "Body" (htmlSafe "Saved just now"))}}<.status aria_label="Save status" tone="success">Saved just now</.status><div data-slot="status" data-role="status" role="status" aria-live="polite" aria-atomic="true" aria-label="Save status" class="block min-h-5 text-sm text-emerald-700 dark:text-emerald-300">Saved just now</div>Further reading
Activity log — append-only ordered sequence
as="log" is aria-live=polite + aria-atomic=false, so only the newly-appended entry is announced — not the whole list.
MDN's role="log" is for content "added in a meaningful order" where "old information may disappear" — chat history, sync activity, an event feed. Its implicit aria-atomic="false" means each appended StatusItem is announced on its own. A log is required to have an accessible name, hence the aria-label.
<Status as="log" ariaLabel="Sync activity">
<StatusItem>Connected to server</StatusItem>
<StatusItem>Uploaded report.pdf</StatusItem>
<StatusItem>Synced 3 files</StatusItem>
</Status>{% call status(as="log", aria_label="Sync activity") %}
{{ status_item("Connected to server") }}
{{ status_item("Uploaded report.pdf") }}
{{ status_item("Synced 3 files") }}
{% endcall %}{{template "status" (dict "As" "log" "AriaLabel" "Sync activity" "Body" (htmlSafe "
...status_item rows..."))}}<.status as="log" aria_label="Sync activity">
<.status_item>Connected to server</.status_item>
<.status_item>Uploaded report.pdf</.status_item>
<.status_item>Synced 3 files</.status_item>
</.status><div data-slot="status" data-role="log" role="log" aria-live="polite" aria-atomic="false" aria-label="Sync activity" class="block min-h-5 text-sm text-muted-foreground space-y-0.5">
<div data-slot="status-item" class="py-0.5">Connected to server</div>
<div data-slot="status-item" class="py-0.5">Uploaded report.pdf</div>
<div data-slot="status-item" class="py-0.5">Synced 3 files</div>
</div>Further reading
htmx live count — swap text into the region
The status region is on the page from first paint. htmx swaps fresh text into it on each click; AT announces the new count politely.
This is the canonical htmx pattern: the live region exists before the request, and you target it with hx-target + hx-swap="innerHTML". Swapping into a persistent region (rather than swapping the region itself) keeps the live-region semantics intact so the change is actually announced.
<button
hx-post="/api/results"
hx-target="#live"
hx-swap="innerHTML"
>Add result</button>
<Status id="live" ariaLabel="Results">0 results</Status><button hx-post="/api/results" hx-target="#live" hx-swap="innerHTML">Add result</button>
{% call status(id="live", aria_label="Results") %}0 results{% endcall %}<button hx-post="/api/results" hx-target="#live" hx-swap="innerHTML">Add result</button>
{{template "status" (dict "ID" "live" "AriaLabel" "Results" "Body" (htmlSafe "0 results"))}}<button hx-post={~p"/api/results"} hx-target="#live" hx-swap="innerHTML">Add result</button>
<.status id="live" aria_label="Results">0 results</.status><div class="flex w-full max-w-md flex-col items-start gap-3">
<button type="button" 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" hx-post="/docs/status/announce" hx-target="#ex-status-live" hx-swap="innerHTML">Add result</button>
<div id="ex-status-live" data-slot="status" data-role="status" role="status" aria-live="polite" aria-atomic="true" aria-label="Results" class="block min-h-5 text-sm text-foreground">0 results</div>
</div>Further reading
API Reference
<Status>
| Prop | Type | Default | Description |
|---|---|---|---|
as | "status"|"log" | "status" | Structural role. status = single advisory message (atomic); log = append-only ordered sequence (only new entries announced).APGStructural Roles |
tone | "default"|"muted"|"success"|"destructive" | "muted" | Text tone. Status text is supporting by default. |
ariaAtomic | boolean | — | Override aria-atomic. Defaults to the role implicit value: status=true, log=false.MDNaria-atomic |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |