shshadcn-htmx

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

2. Use it

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

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

components/status.tmpl
{{template "status" (dict "AriaLabel" "Save status" "Body" (htmlSafe "Saved"))}}

{{template "status" (dict "As" "log" "AriaLabel" "Activity" "Body" (htmlSafe "
  ...status_item rows..."))}}
View source
components/status.tmpl
{{/*
  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

lib/my_app_web/components/status.ex
<.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
lib/my_app_web/components/status.ex
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

snippets/status.html
<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
snippets/status.html
<!--
  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".

Saved just now
<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>

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.

Connected to server
Uploaded report.pdf
Synced 3 files
<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>

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.

0 results
<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 [&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" 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>

API Reference

<Status>

PropTypeDefaultDescription
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.
ariaAtomicboolean
Override aria-atomic. Defaults to the role implicit value: status=true, log=false.MDNaria-atomic
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference