shshadcn-htmx

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

2. Use it

components/ui/progress.tsx
import { Progress } from "@/components/ui/progress"

<Progress value={42} ariaLabel="Upload" />        // determinate
<Progress ariaLabel="Loading" />                   // indeterminate (no value)
Or copy the source manually
components/ui/progress.tsx
/** @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

templates/components/progress.html
{% from "components/progress.html" import progress %}

{{ progress(value=42, aria_label="Upload") }}
{{ progress(aria_label="Loading") }}    {# indeterminate #}
View source
templates/components/progress.html
{# 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

templates/components/progress.tmpl
// determinate
{{template "progress" (dict "Value" (ptr 42) "AriaLabel" "Upload")}}
// indeterminate
{{template "progress" (dict "AriaLabel" "Loading")}}
View source
templates/components/progress.tmpl
{{/* 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

lib/my_app_web/components/progress.ex
<.progress value={42} aria-label="Upload" />
<.progress aria-label="Loading" />
View source
lib/my_app_web/components/progress.ex
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

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

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>

API Reference

<Progress>

PropTypeDefaultDescription
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.
valuenumber
Current value 0-max. Omit for indeterminate.
minnumber0
Minimum value.
maxnumber100
Maximum value.
ariaValuetextstring
Human-readable value (e.g. "42 of 100 MB").
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
ariaDescribedbystring
Id of an element describing this control (announced after the name).MDNaria-describedby
classstring
Extra Tailwind classes appended to the root element.