shshadcn-htmx

Components

Meter

The native <meter> element (ARIA role="meter") — a gauge of a value within a known range like battery, disk usage, or a score. Set low/high/optimum to color the fill. For task completion use Progress instead.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/meter.json

2. Use it

components/ui/meter.tsx
import { Meter } from "@/components/ui/meter"

<label for="battery">Battery</label>
<Meter id="battery" value={0.75} ariaLabel="Battery" />

// zoned gauge — low/high/optimum color the fill
<Meter value={0.62} low={0.25} high={0.85} optimum={0.1}
       valuetext="12.4 GB of 16 GB" ariaLabel="Disk usage" />
Or copy the source manually
components/ui/meter.tsx
/** @jsxImportSource hono/jsx */
import type { Child } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Meter — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Source of truth:
//   shadcn/ui ships no Meter component. This is a native-platform addition
//   distinct from Progress: a gauge of a value within a known range
//   (battery, disk usage, score) — NOT task completion.
//
// We render the real native <meter> element (implicit role="meter"), so the
// browser supplies the role + value semantics and AT announces the gauge.
//   repos/mdn/files/en-us/web/html/reference/elements/meter/index.md
//     - value/min/max/low/high/optimum attributes (lines 33-48)
//     - "Implicit ARIA role: meter" and "Permitted ARIA roles: No role
//       permitted" (lines 123-134) — so we DO NOT set role/aria-value*; the
//       element maps value/min/max onto aria-valuenow/min/max itself.
//
// APG: WAI-ARIA Meter pattern
//   repos/aria-practices/content/patterns/meter/meter-pattern.html
//     - Keyboard Interaction: "Not applicable" (line 46) — a meter is not
//       interactive; no JS keyboard contract.
//     - Accessible name via aria-labelledby (visible label) or aria-label
//       (lines 61-64). We pair with a native <label for> in the docs.
//     - aria-valuetext for human-readable values, e.g. "50% (6 hours)
//       remaining" (lines 56-59) — surfaced via the `valuetext` prop.
//
// Cross-browser styling note: WebKit/Blink expose ::-webkit-meter-* and
// Gecko exposes ::-moz-meter-bar. Both need `appearance: none` to accept
// custom styles. Those pseudo-elements can't take Tailwind classes, so the
// fill/track theming lives in app/styles/input.css scoped to
// [data-slot="meter"]. Tailwind utilities here only size the box.

const meterBase =
  "block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle"

export function meterClasses(opts?: { class?: ClassValue }): string {
  return cn(meterBase, opts?.class)
}

type MeterProps = {
  // Current value. Clamped by the browser to [min, max].
  value: number
  min?: number
  max?: number
  // Lower/upper bounds of the "low" and "high" zones. Combined with optimum
  // they drive the optimum / suboptimum / even-less-good fill color.
  low?: number
  high?: number
  optimum?: number
  id?: string
  // Free-form units hint. The WHATWG HTML spec notes <meter> has no units
  // attribute and that title is the only standard way to convey units
  // (e.g. title="gigabytes"). repos/whatwg-html/source meter section.
  title?: string
  // Accessible name — required when there's no linked <label>.
  ariaLabel?: string
  ariaLabelledby?: string
  ariaDescribedby?: string
  // Human-readable value for AT (e.g. "12.4 GB of 16 GB"). Falls back to the
  // child text content the element already carries.
  valuetext?: string
  // Fallback text content for legacy AT and as the visible value in browsers
  // without <meter> support. Defaults to a "value/max" string.
  children?: Child
  class?: ClassValue

  // htmx — refresh the gauge from the server.
  "hx-get"?: string
  "hx-post"?: string
  "hx-target"?: string
  "hx-swap"?: string
  "hx-trigger"?: string
}

export function Meter(props: MeterProps) {
  const {
    value,
    min = 0,
    max = 1,
    low,
    high,
    optimum,
    id,
    title,
    ariaLabel,
    ariaLabelledby,
    ariaDescribedby,
    valuetext,
    children,
    class: className,
    ...rest
  } = props
  const fallback = children ?? `${value} / ${max}`
  return (
    <meter
      id={id}
      data-slot="meter"
      title={title}
      value={value}
      min={min}
      max={max}
      low={low}
      high={high}
      optimum={optimum}
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-describedby={ariaDescribedby}
      aria-valuetext={valuetext}
      class={meterClasses({ class: className })}
      {...rest}
    >
      {fallback}
    </meter>
  )
}

1. Save the file

Copy meter.html into templates/components/.

2. Use it

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

<label for="battery">Battery</label>
{{ meter(id="battery", value=0.75, aria_label="Battery") }}

{# zoned gauge #}
{{ meter(value=0.62, low=0.25, high=0.85, optimum=0.1,
         value_text="12.4 GB of 16 GB", aria_label="Disk usage") }}
View source
templates/components/meter.html
{# Meter macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/meter.tsx. Renders the native <meter> element
   (implicit role="meter"); cross-browser fill/track theming lives in
   input.css scoped to [data-slot="meter"].

   Usage:
       {% from "components/meter.html" import meter %}
       <label for="disk" class="text-sm font-medium">Disk usage</label>
       {{ meter(id="disk", value=0.62, low=0.25, high=0.85, optimum=0.1,
                value_text="12.4 GB of 16 GB") }}
#}

{% macro meter(
    value=0,
    min=0,
    max=1,
    low=none,
    high=none,
    optimum=none,
    id=none,
    title=none,
    aria_label=none,
    aria_labelledby=none,
    aria_describedby=none,
    value_text=none,
    text=none,
    extra_class="",
    **attrs
) %}
{%- set base -%}
block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle
{%- endset -%}
<meter class="{{ base }} {{ extra_class }}"
       {%- if id %} id="{{ id }}"{% endif %}
       {#- title is the spec-sanctioned way to convey units, e.g. "gigabytes" #}
       {%- if title %} title="{{ title }}"{% endif %}
       value="{{ value }}" min="{{ min }}" max="{{ max }}"
       {%- if low is not none %} low="{{ low }}"{% endif %}
       {%- if high is not none %} high="{{ high }}"{% endif %}
       {%- if optimum is not none %} optimum="{{ optimum }}"{% endif %}
       {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
       {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
       {%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
       {%- if value_text %} aria-valuetext="{{ value_text }}"{% endif %}
       data-slot="meter"
       {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
  >{{ text if text is not none else (value ~ ' / ' ~ max) }}</meter>
{% endmacro %}

1. Save the file

Add meter.tmpl alongside your other templates.

2. Use it

components/meter.tmpl
{{template "meter" (dict "ID" "battery" "Value" 0.75 "AriaLabel" "Battery")}}

// zoned gauge
{{template "meter" (dict
  "Value" 0.62 "Low" 0.25 "High" 0.85 "Optimum" 0.1
  "ValueText" "12.4 GB of 16 GB" "AriaLabel" "Disk usage")}}
View source
components/meter.tmpl
{{/*
  Meter template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/meter.tsx. Renders the native <meter> element
  (implicit role="meter"); fill/track theming lives in input.css scoped
  to [data-slot="meter"].

  Usage:

      {{template "meter" (dict
          "ID" "disk" "Value" 0.62 "Low" 0.25 "High" 0.85 "Optimum" 0.1
          "ValueText" "12.4 GB of 16 GB")}}

  Low / High / Optimum are optional; pass them only when zoning the gauge.
*/}}

{{define "meter"}}
{{- $base := "block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle" -}}
{{- $value := or .Value 0 -}}
{{- $min := or .Min 0 -}}
{{- $max := or .Max 1 -}}
{{- $text := or .Text (printf "%v / %v" $value $max) -}}
<meter class="{{$base}}{{if .Class}} {{.Class}}{{end}}"
       {{- if .ID}} id="{{.ID}}"{{end}}
       {{- /* title: spec-sanctioned way to convey units, e.g. "gigabytes" */}}
       {{- if .Title}} title="{{.Title}}"{{end}}
       value="{{$value}}" min="{{$min}}" max="{{$max}}"
       {{- if .Low}} low="{{.Low}}"{{end}}
       {{- if .High}} high="{{.High}}"{{end}}
       {{- if .Optimum}} optimum="{{.Optimum}}"{{end}}
       {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
       {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
       {{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
       {{- if .ValueText}} aria-valuetext="{{.ValueText}}"{{end}}
       data-slot="meter"
       {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
  >{{$text}}</meter>
{{end}}

1. Save the file

Drop meter.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/meter.ex
<label for="battery">Battery</label>
<.meter id="battery" value={0.75} aria-label="Battery" />

<.meter value={0.62} low={0.25} high={0.85} optimum={0.1}
  value_text="12.4 GB of 16 GB" aria-label="Disk usage" />
View source
lib/my_app_web/components/meter.ex
defmodule ShadcnHtmx.Components.Meter do
  @moduledoc """
  Meter — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Renders the native `<meter>` element (implicit `role="meter"`) — a gauge
  of a value within a known range (battery, disk usage, score), NOT task
  progress. For progress use the Progress component instead.

  `low` / `high` / `optimum` drive the fill color via the cross-browser
  meter pseudo-elements styled in input.css ([data-slot="meter"]).

  ## Examples

      <label for="disk" class="text-sm font-medium">Disk usage</label>
      <.meter id="disk" value={0.62} low={0.25} high={0.85} optimum={0.1}
        value_text="12.4 GB of 16 GB" />

      <.meter value={6} min={0} max={10} aria-label="Score" />
  """

  use Phoenix.Component

  @base "block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle"

  attr :value, :float, default: 0.0
  attr :min, :float, default: 0.0
  attr :max, :float, default: 1.0
  attr :low, :float, default: nil
  attr :high, :float, default: nil
  attr :optimum, :float, default: nil
  attr :value_text, :string, default: nil
  attr :text, :string, default: nil
  attr :class, :string, default: nil

  # title is the spec-sanctioned way to convey units on a meter, e.g.
  # title="gigabytes" (WHATWG HTML: <meter> has no units attribute).
  attr :rest, :global,
    include:
      ~w(hx-get hx-post hx-put hx-patch hx-target hx-swap hx-trigger hx-vals hx-include
         id title aria-label aria-labelledby aria-describedby)

  def meter(assigns) do
    assigns =
      assigns
      |> assign(:base, @base)
      |> assign(:body, assigns.text || "#{assigns.value} / #{assigns.max}")

    ~H"""
    <meter
      class={[@base, @class]}
      value={@value}
      min={@min}
      max={@max}
      low={@low}
      high={@high}
      optimum={@optimum}
      aria-valuetext={@value_text}
      data-slot="meter"
      {@rest}
    >{@body}</meter>
    """
  end
end

1. Save the file

Paste the markup; fill/track theming lives in the [data-slot="meter"] rules in input.css.

2. Use it

snippets/meter.html
<label for="battery" class="text-sm font-medium">Battery</label>
<meter id="battery" data-slot="meter" value="0.75"
       class="block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle">75%</meter>
View source
snippets/meter.html
<!--
  shadcn-htmx — raw HTML meter snippets.

  Mirrors registry/ui/meter.tsx. The native <meter> element carries the
  implicit role="meter" (do NOT add role/aria-value*; the browser maps
  value/min/max onto aria-valuenow/min/max). Cross-browser fill + track
  theming lives in input.css scoped to [data-slot="meter"]; these snippets
  only size the box with Tailwind utilities.

  BASE: block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle

  Pair every <meter> with an accessible name — a linked <label for> (best)
  or aria-label.
-->

<!-- Basic — value within a 0..1 range, labelled by a visible <label> -->
<label for="battery" class="text-sm font-medium">Battery</label>
<meter id="battery" data-slot="meter" value="0.75"
       class="block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle">75%</meter>

<!-- Zoned — low/high/optimum color the fill (good / warning / danger).
     title carries free-form units: the only spec-sanctioned units channel
     for <meter> (WHATWG HTML — <meter> has no units attribute). -->
<label for="disk" class="text-sm font-medium">Disk usage</label>
<meter id="disk" data-slot="meter" value="0.62" min="0" max="1"
       low="0.25" high="0.85" optimum="0.1" aria-valuetext="12.4 GB of 16 GB"
       title="gigabytes"
       class="block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle">12.4 GB of 16 GB</meter>

<!-- Custom range + aria-label (no visible label) -->
<meter data-slot="meter" value="6" min="0" max="10" low="3" high="8" optimum="10"
       aria-label="Exam score"
       class="block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle">6 / 10</meter>

Examples

Basic — a labelled gauge

A value within a known range. Pair every meter with an accessible name — a linked <label for> is best.

The native <meter> carries role="meter" implicitly and maps value/min/max onto aria-valuenow/min/max. Default range is 0–1, so value=0.75 reads as 75%.

75%
6 / 10
<label for="m-battery">Battery</label>
<Meter id="m-battery" value={0.75} ariaLabel="Battery" />

<label for="m-score">Exam score</label>
<Meter id="m-score" value={6} min={0} max={10}
       ariaLabel="Exam score" />
<label for="m-battery">Battery</label>
{{ meter(id="m-battery", value=0.75, aria_label="Battery") }}

<label for="m-score">Exam score</label>
{{ meter(id="m-score", value=6, min=0, max=10,
         aria_label="Exam score") }}
{{template "meter" (dict "ID" "m-battery" "Value" 0.75 "AriaLabel" "Battery")}}
{{template "meter" (dict "ID" "m-score" "Value" 6 "Min" 0 "Max" 10 "AriaLabel" "Exam score")}}
<.meter id="m-battery" value={0.75} aria-label="Battery" />
<.meter id="m-score" value={6} min={0} max={10} aria-label="Exam score" />
<div class="grid w-full max-w-md gap-4">
  <div class="grid gap-1.5">
    <label for="m-battery" class="text-sm font-medium">Battery</label>
    <meter id="m-battery" data-slot="meter" value="0.75" min="0" max="1" aria-label="Battery" class="block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle">75%</meter>
  </div>
  <div class="grid gap-1.5">
    <label for="m-score" class="text-sm font-medium">Exam score</label>
    <meter id="m-score" data-slot="meter" value="6" min="0" max="10" aria-label="Exam score" class="block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle">6 / 10</meter>
  </div>
</div>

Zoned — low / high / optimum

low and high split the range into thirds; optimum tells the browser which end is preferable. The fill is green in the optimal zone, amber when suboptimal, red when far from optimum.

Here optimum=0.1 marks the low end as best (less disk used is better), so a value in the high zone renders red. Move optimum to flip which direction is "good". Set valuetext so AT announces a human-readable value, e.g. "12.4 GB of 16 GB".

18%
12.4 GB of 16 GB
15.0 GB of 16 GB
<Meter value={0.18} low={0.25} high={0.85} optimum={0.1}
       valuetext="18% load" ariaLabel="CPU idle" />     // green
<Meter value={0.62} low={0.25} high={0.85} optimum={0.1}
       valuetext="12.4 GB of 16 GB" ariaLabel="Disk" /> // amber
<Meter value={0.94} low={0.25} high={0.85} optimum={0.1}
       valuetext="15.0 GB of 16 GB" ariaLabel="Disk" /> // red
{{ meter(value=0.18, low=0.25, high=0.85, optimum=0.1,
         value_text="18% load", aria_label="CPU idle") }}
{{ meter(value=0.62, low=0.25, high=0.85, optimum=0.1,
         value_text="12.4 GB of 16 GB", aria_label="Disk") }}
{{ meter(value=0.94, low=0.25, high=0.85, optimum=0.1,
         value_text="15.0 GB of 16 GB", aria_label="Disk") }}
{{template "meter" (dict "Value" 0.18 "Low" 0.25 "High" 0.85 "Optimum" 0.1 "ValueText" "18% load" "AriaLabel" "CPU idle")}}
{{template "meter" (dict "Value" 0.62 "Low" 0.25 "High" 0.85 "Optimum" 0.1 "ValueText" "12.4 GB of 16 GB" "AriaLabel" "Disk")}}
<.meter value={0.18} low={0.25} high={0.85} optimum={0.1} value_text="18% load" aria-label="CPU idle" />
<.meter value={0.62} low={0.25} high={0.85} optimum={0.1} value_text="12.4 GB of 16 GB" aria-label="Disk" />
<div class="grid w-full max-w-md gap-4">
  <div class="grid gap-1.5">
    <label for="m-low" class="text-sm font-medium">CPU — idle (optimal)</label>
    <meter id="m-low" data-slot="meter" value="0.18" min="0" max="1" low="0.25" high="0.85" optimum="0.1" aria-label="CPU idle" aria-valuetext="18% load" class="block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle">18%</meter>
  </div>
  <div class="grid gap-1.5">
    <label for="m-mid" class="text-sm font-medium">Disk — getting full (suboptimal)</label>
    <meter id="m-mid" data-slot="meter" value="0.62" min="0" max="1" low="0.25" high="0.85" optimum="0.1" aria-label="Disk usage" aria-valuetext="12.4 GB of 16 GB" class="block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle">12.4 GB of 16 GB</meter>
  </div>
  <div class="grid gap-1.5">
    <label for="m-high" class="text-sm font-medium">Disk — nearly full (danger)</label>
    <meter id="m-high" data-slot="meter" value="0.94" min="0" max="1" low="0.25" high="0.85" optimum="0.1" aria-label="Disk nearly full" aria-valuetext="15.0 GB of 16 GB" class="block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle">15.0 GB of 16 GB</meter>
  </div>
</div>

htmx — server-driven live gauge

Poll the server every second; the response is a fresh Meter fragment with the latest reading. A meter (unlike progress) keeps polling — it tracks an ongoing measurement.

Wrap the meter in a container that hx-gets a fragment on hx-trigger="load, every 1s" and swaps outerHTML. Because a gauge measures a live quantity, polling never has to stop — here we just jitter a mock memory reading.

loading…
<div hx-get="/api/memory" hx-trigger="load, every 1s" hx-swap="outerHTML">
  <Meter value={0.4} low={0.5} high={0.85} optimum={0.1}
         ariaLabel="Memory usage" />
</div>

// Server returns a refreshed fragment each tick:
<Meter value={0.63} low={0.5} high={0.85} optimum={0.1}
       valuetext="10.1 GB of 16 GB" ariaLabel="Memory usage" />
<div hx-get="/api/memory" hx-trigger="load, every 1s" hx-swap="outerHTML">
  {{ meter(value=0.4, low=0.5, high=0.85, optimum=0.1, aria_label="Memory usage") }}
</div>
<div hx-get="/api/memory" hx-trigger="load, every 1s" hx-swap="outerHTML">
  {{template "meter" (dict "Value" 0.4 "Low" 0.5 "High" 0.85 "Optimum" 0.1 "AriaLabel" "Memory usage")}}
</div>
<div hx-get={~p"/api/memory"} hx-trigger="load, every 1s" hx-swap="outerHTML">
  <.meter value={0.4} low={0.5} high={0.85} optimum={0.1} aria-label="Memory usage" />
</div>
<div class="grid w-full max-w-md gap-1.5">
  <label for="m-mem" class="text-sm font-medium">Memory (live)</label>
  <div hx-get="/meter/tick" hx-trigger="load, every 1s" hx-swap="outerHTML">
    <meter id="m-mem" data-slot="meter" value="0.4" min="0" max="1" low="0.5" high="0.85" optimum="0.1" aria-label="Memory usage" class="block h-2 w-full overflow-hidden rounded-full bg-primary/20 align-middle">loading…</meter>
  </div>
</div>

API Reference

<Meter>

PropTypeDefaultDescription
titlestring
Free-form units hint, e.g. title="gigabytes". Per WHATWG HTML, <meter> has no units attribute and the global title is the only standard way to convey units. Forwarded to the native element.MDN<meter> global attributes
value*number
Current reading. The browser clamps it into [min, max].MDN<meter value>
minnumber0
Lower bound of the range.
maxnumber1
Upper bound of the range. Default 1, so value=0.75 reads as 75%.
lownumber
Upper bound of the low zone. Values below it render in the low colour band.MDN<meter low>
highnumber
Lower bound of the high zone. Values above it render in the high colour band.MDN<meter high>
optimumnumber
The preferred value. Tells the browser which zone is "good" (green), making the others suboptimal (amber) or far-off (red).MDN<meter optimum>
valuetextstring
Human-readable value for AT (e.g. "12.4 GB of 16 GB"). Sets aria-valuetext. Per APG, use it when a bare percentage isn't user-friendly.APGMeter pattern
childrenChild
Fallback text shown by legacy AT and browsers without <meter> support. Defaults to a "value / max" string.
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
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference
classstring
Extra Tailwind classes appended to the root element.

* required