Components
Progress
An ARIA role="progressbar" with valuemin / valuemax / valuenow. Omit value to render the indeterminate state — perfect for "we don't know yet" operations.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/progress.json2. Use it
import { Progress } from "@/components/ui/progress"
<Progress value={42} ariaLabel="Upload" /> // determinate
<Progress ariaLabel="Loading" /> // indeterminate (no value)Or copy the source manually
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"
// Progress — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Source of truth (track visual):
// repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/progress.tsx
//
// Upstream uses Radix Progress.Root + Indicator. For SSR we render a real
// progressbar element: role="progressbar" + aria-valuemin/max/now/text.
// Pass value=undefined to render the indeterminate state (per ARIA spec).
//
// Refs:
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/progressbar_role/
// repos/mdn/files/en-us/web/html/reference/elements/progress/ (native; we don't use this — limited styling)
type ProgressProps = {
// 0–max (or max prop). Pass undefined for indeterminate ("we don't
// know how long this will take").
value?: number
max?: number
min?: number
// Accessible name — required when there's no visible label.
ariaLabel?: string
ariaLabelledby?: string
// Optional human-readable label, e.g. "Uploading… 42 of 100 MB".
ariaValuetext?: string
class?: ClassValue
id?: string
// Forwarded to the root <div role="progressbar">. Progress is the textbook
// htmx polling target — re-render the bar from the server on a recurring
// trigger (hx-get + hx-trigger="every 2s", repos/htmx htmx-guidance.md Polling).
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
}
export function Progress(props: ProgressProps) {
const {
value,
max = 100,
min = 0,
ariaLabel,
ariaLabelledby,
ariaValuetext,
class: className,
id,
...rest
} = props
const determinate = value !== undefined
const pct = determinate ? Math.min(100, Math.max(0, ((value! - min) / (max - min)) * 100)) : 0
return (
<div
id={id}
role="progressbar"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={determinate ? value : undefined}
aria-valuetext={ariaValuetext}
data-slot="progress"
data-state={determinate ? "determinate" : "indeterminate"}
class={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className,
)}
{...rest}
>
<div
data-slot="progress-indicator"
class={cn(
"h-full bg-primary transition-all",
// Indeterminate state animates a 30%-width bar across the track.
!determinate && "absolute inset-y-0 -left-1/3 w-1/3 animate-[scn-progress-indeterminate_1.2s_ease-in-out_infinite]",
)}
style={determinate ? `width: ${pct}%` : undefined}
/>
</div>
)
}
1. Save the file
Copy progress.html into templates/components/.
2. Use it
{% from "components/progress.html" import progress %}
{{ progress(value=42, aria_label="Upload") }}
{{ progress(aria_label="Loading") }} {# indeterminate #}View source
{# Progress macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/progress.tsx. Pass value=none for indeterminate. #}
{# **attrs forwards arbitrary attributes (e.g. hx-* polling) onto the
progressbar root — repos/htmx htmx-guidance.md Polling (hx-get + every 2s). #}
{% macro progress(value=none, min=0, max=100, aria_label=none, aria_labelledby=none, aria_valuetext=none, id=none, extra_class="", **attrs) %}
{%- set determinate = value is not none -%}
{%- set pct = ((value - min) / (max - min) * 100) if determinate else 0 -%}
<div {% if id %}id="{{ id }}"{% endif %}
role="progressbar"
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
{% if aria_labelledby %}aria-labelledby="{{ aria_labelledby }}"{% endif %}
aria-valuemin="{{ min }}" aria-valuemax="{{ max }}"
{% if determinate %}aria-valuenow="{{ value }}"{% endif %}
{% if aria_valuetext %}aria-valuetext="{{ aria_valuetext }}"{% endif %}
data-slot="progress" data-state="{{ 'determinate' if determinate else 'indeterminate' }}"
class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20 {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>
<div data-slot="progress-indicator"
class="h-full bg-primary transition-all {% if not determinate %}absolute inset-y-0 -left-1/3 w-1/3 animate-[scn-progress-indeterminate_1.2s_ease-in-out_infinite]{% endif %}"
{% if determinate %}style="width: {{ pct }}%"{% endif %}></div>
</div>
{% endmacro %}
1. Save the file
Add progress.tmpl alongside button.tmpl.
2. Use it
// determinate
{{template "progress" (dict "Value" (ptr 42) "AriaLabel" "Upload")}}
// indeterminate
{{template "progress" (dict "AriaLabel" "Loading")}}View source
{{/* Progress template — shadcn-htmx, htmx v4 + Tailwind v4.
Pass Value as nil for indeterminate.
.Attrs (map[string]string) forwards arbitrary attributes (e.g. hx-*
polling) onto the progressbar root — repos/htmx htmx-guidance.md Polling. */}}
{{define "progress"}}
{{- $min := or .Min 0 -}}
{{- $max := or .Max 100 -}}
{{- $determinate := false -}}{{- if .Value -}}{{- $determinate = true -}}{{- end -}}
<div {{if .ID}}id="{{.ID}}"{{end}}
role="progressbar"
{{if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{end}}
aria-valuemin="{{$min}}" aria-valuemax="{{$max}}"
{{if $determinate}}aria-valuenow="{{deref .Value}}"{{end}}
{{if .AriaValuetext}}aria-valuetext="{{.AriaValuetext}}"{{end}}
data-slot="progress" data-state="{{if $determinate}}determinate{{else}}indeterminate{{end}}"
class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}>
<div data-slot="progress-indicator"
class="h-full bg-primary transition-all{{if not $determinate}} absolute inset-y-0 -left-1/3 w-1/3 animate-[scn-progress-indeterminate_1.2s_ease-in-out_infinite]{{end}}"
{{if $determinate}}style="width: {{deref .Value}}%"{{end}}></div>
</div>
{{end}}
1. Save the file
Drop progress.ex into lib/my_app_web/components/.
2. Use it
<.progress value={42} aria-label="Upload" />
<.progress aria-label="Loading" />View source
defmodule ShadcnHtmx.Components.Progress do
@moduledoc """
Progress — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
ARIA role="progressbar" with valuemin/max/now. Pass `value={nil}` for
the indeterminate state (animated stripe).
## Examples
<.progress value={42} aria-label="Upload progress" />
<.progress aria-label="Loading" /> # indeterminate
"""
use Phoenix.Component
attr :value, :integer, default: nil
attr :min, :integer, default: 0
attr :max, :integer, default: 100
attr :"aria-label", :string, default: nil
attr :"aria-labelledby", :string, default: nil
attr :"aria-valuetext", :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
def progress(assigns) do
determinate = !is_nil(assigns.value)
pct =
if determinate,
do: (assigns.value - assigns.min) / (assigns.max - assigns.min) * 100,
else: 0
assigns =
assigns
|> assign(:determinate, determinate)
|> assign(:pct, pct)
~H"""
<div
role="progressbar"
aria-label={assigns[:"aria-label"]}
aria-labelledby={assigns[:"aria-labelledby"]}
aria-valuemin={@min}
aria-valuemax={@max}
aria-valuenow={if @determinate, do: @value}
aria-valuetext={assigns[:"aria-valuetext"]}
data-slot="progress"
data-state={if @determinate, do: "determinate", else: "indeterminate"}
class={[
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
@class
]}
{@rest}
>
<div
data-slot="progress-indicator"
class={[
"h-full bg-primary transition-all",
!@determinate &&
"absolute inset-y-0 -left-1/3 w-1/3 animate-[scn-progress-indeterminate_1.2s_ease-in-out_infinite]"
]}
style={if @determinate, do: "width: #{@pct}%"}
/>
</div>
"""
end
end
1. Save the file
Tailwind utilities only; keyframes in input.css for indeterminate stripe.
2. Use it
<div role="progressbar" aria-valuenow="42" aria-valuemin="0" aria-valuemax="100"
class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
<div class="h-full bg-primary" style="width: 42%"></div>
</div>View source
<!--
shadcn-htmx — raw HTML progress snippets.
role="progressbar" + aria-valuemin/max/now. Omit aria-valuenow for the
indeterminate state and add the animated stripe via Tailwind keyframes
in input.css.
-->
<!-- Determinate (42%) -->
<div role="progressbar" aria-label="Upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="42"
data-slot="progress" data-state="determinate"
class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
<div data-slot="progress-indicator" class="h-full bg-primary transition-all" style="width: 42%"></div>
</div>
<!-- Indeterminate -->
<div role="progressbar" aria-label="Loading" aria-valuemin="0" aria-valuemax="100"
data-slot="progress" data-state="indeterminate"
class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
<div data-slot="progress-indicator"
class="absolute inset-y-0 -left-1/3 h-full w-1/3 bg-primary animate-[scn-progress-indeterminate_1.2s_ease-in-out_infinite]"></div>
</div>
Examples
Determinate — known percentage
Pass value 0–100 (or scale via min/max). The bar fills from 0 to value width.
Always pair with an accessible name (ariaLabel or ariaLabelledby). For exact units (MB, items), set ariaValuetext="42 of 100 MB" so AT can announce a human-readable value alongside the number.
<Progress value={20} ariaLabel="Step 1 of 5" />
<Progress value={60} ariaLabel="Upload"
ariaValuetext="60 of 100 MB" />{{ progress(value=20, aria_label="Step 1 of 5") }}
{{ progress(value=60, aria_label="Upload",
aria_valuetext="60 of 100 MB") }}{{template "progress" (dict "Value" (ptr 20) "AriaLabel" "Step 1 of 5")}}
{{template "progress" (dict "Value" (ptr 60) "AriaLabel" "Upload")}}<.progress value={20} aria-label="Step 1 of 5" />
<.progress value={60} aria-label="Upload" /><div class="grid w-full max-w-md gap-3">
<div role="progressbar" aria-label="Step 1 of 5" aria-valuemin="0" aria-valuemax="100" aria-valuenow="20" data-slot="progress" data-state="determinate" class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
<div data-slot="progress-indicator" class="h-full bg-primary transition-all" style="width: 20%">
</div>
</div>
<div role="progressbar" aria-label="Upload" aria-valuemin="0" aria-valuemax="100" aria-valuenow="60" aria-valuetext="60 of 100 MB" data-slot="progress" data-state="determinate" class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
<div data-slot="progress-indicator" class="h-full bg-primary transition-all" style="width: 60%">
</div>
</div>
<div role="progressbar" aria-label="Almost done" aria-valuemin="0" aria-valuemax="100" aria-valuenow="95" data-slot="progress" data-state="determinate" class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
<div data-slot="progress-indicator" class="h-full bg-primary transition-all" style="width: 95%">
</div>
</div>
</div>Further reading
Indeterminate — unknown duration
Omit value. A stripe animates across the bar while the task is in flight.
Use when you can't compute a percentage — long server requests, "still thinking" states. Per ARIA spec, omitting aria-valuenow signals "indeterminate"; AT announces it as such. Switch to determinate as soon as you have a real value.
<Progress ariaLabel="Loading…" />{{ progress(aria_label="Loading…") }}{{template "progress" (dict "AriaLabel" "Loading…")}}<.progress aria-label="Loading…" /><div class="grid w-full max-w-md gap-3">
<div role="progressbar" aria-label="Loading…" aria-valuemin="0" aria-valuemax="100" data-slot="progress" data-state="indeterminate" class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
<div data-slot="progress-indicator" class="h-full bg-primary transition-all absolute inset-y-0 -left-1/3 w-1/3 animate-[scn-progress-indeterminate_1.2s_ease-in-out_infinite]">
</div>
</div>
</div>Further reading
htmx — server-driven progress
Poll the server every 800ms; the response is a fresh Progress fragment with the latest value. Stop when value=100.
Pair hx-trigger="every 800ms" with hx-swap="outerHTML" to refresh the whole progress bar each tick. When the server returns a fragment without hx-get, the polling stops automatically — a tidy way to end the cycle when value reaches 100.
<div hx-get="/api/progress?value=0" hx-trigger="load" hx-swap="outerHTML">
<Progress value={0} ariaLabel="Upload" />
</div>
// Server returns a refreshed fragment, e.g.:
<Progress value={42} ariaLabel="Upload"
hx-get="/api/progress?value=42"
hx-trigger="every 800ms"
hx-swap="outerHTML" /><div hx-get="/api/progress?value=0" hx-trigger="load" hx-swap="outerHTML">
{{ progress(value=0, aria_label="Upload") }}
</div><div hx-get="/api/progress?value=0" hx-trigger="load" hx-swap="outerHTML">
{{template "progress" (dict "Value" (ptr 0) "AriaLabel" "Upload")}}
</div><div hx-get={~p"/api/progress?value=0"} hx-trigger="load" hx-swap="outerHTML">
<.progress value={0} aria-label="Upload" />
</div><div class="grid w-full max-w-md gap-3">
<div hx-get="/progress/tick?value=0" hx-trigger="load" hx-swap="outerHTML">
<div role="progressbar" aria-label="Mock upload — click reload to restart" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-slot="progress" data-state="determinate" class="relative h-2 w-full overflow-hidden rounded-full bg-primary/20">
<div data-slot="progress-indicator" class="h-full bg-primary transition-all" style="width: 0%">
</div>
</div>
</div>
</div>Further reading
API Reference
<Progress>
| Prop | Type | Default | Description |
|---|---|---|---|
hx-* | any | — | Any htmx attribute, forwarded onto the root progressbar element. Progress is the textbook htmx polling target: re-render the bar from the server on a recurring trigger, e.g. hx-get="/upload/status" hx-trigger="every 2s" hx-swap="outerHTML".htmxAttribute reference |
data-* | any | — | Any data-* attribute, forwarded onto the root progressbar element. |
value | number | — | Current value 0-max. Omit for indeterminate. |
min | number | 0 | Minimum value. |
max | number | 100 | Maximum value. |
ariaValuetext | string | — | Human-readable value (e.g. "42 of 100 MB"). |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |