Feedback

Progress Meter

One progress contract, two geometries — a linear bar or a compact ring, both with determinate and indeterminate modes, four tones and four sizes.

Preview in your theme

Loading preview…

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * Keyframes ship inside the component via a React 19 hoisted <style> — no
 * Tailwind config edits, and duplicates dedupe by href.
 */
const KEYFRAMES = `@keyframes zpm-sweep{from{transform:translateX(0)}to{transform:translateX(100%)}}
@keyframes zpm-spin{to{transform:rotate(360deg)}}`

/** Stripe texture rides the host background token, so it reads on any fill color in both schemes. */
const STRIPES =
  "repeating-linear-gradient(45deg, color-mix(in oklab, var(--background) 34%, transparent) 0 6px, transparent 6px 12px)"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/progress-meter.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ProgressMeter" component (no
dependencies beyond React; pure markup + CSS, no state and no timers, so it
works inside a server component).

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props:
  - shape = "bar" | "ring" (default "bar") — the only thing that changes is how
    the value is drawn; the accessibility contract is identical.
  - value?: number — omit for an indeterminate meter; out-of-range values are
    clamped into 0..max.
  - max = 100 (guard against max <= 0 by falling back to 100).
  - size = "xs" | "sm" | "md" | "lg" (default "md"). Bar: track height
    (h-1 / h-2 / h-3 / h-4). Ring: outer diameter (48 / 64 / 96 / 128 px) with
    a default stroke that scales with it (5 / 6 / 8 / 10) so a small ring never
    reads as a donut. Document this dual meaning in the prop's JSDoc.
  - tone = "default" | "success" | "warning" | "destructive" (default
    "default") — bg-primary / bg-chart-2 / bg-chart-4 / bg-destructive for the
    bar fill, and the stroke-* equivalents for the ring arc.
  - label?: string — bar: the title on the left of the row above the track;
    ring: custom center content that wins over the percentage (e.g. "3/4").
  - showValue?: boolean — the rounded percentage. Defaults to true for rings
    and false for bars (a ring's center would otherwise be empty).
  - striped?: boolean — diagonal texture on the bar fill; the ring ignores it.
  - strokeWidth?: number — ring only, overriding the size-derived default.
  - className merges onto the root for BOTH shapes; the fill and the arc carry
    data-slot="fill" / data-slot="arc" so a consumer can target them in CSS.
- Both shapes render role="progressbar" with aria-valuemin={0},
  aria-valuemax={max}, aria-valuenow (omitted while indeterminate),
  aria-busy while indeterminate, and aria-label falling back to
  label ?? "Progress". Build this attribute set once and spread it into
  whichever shape renders, so the two branches can never drift.

Behavior
- Determinate bar: the fill's width is the clamped percentage, transitioned
  (width, 300ms ease-out).
- Indeterminate bar: a half-width fill sweeps left↔right on an alternating
  keyframe.
- Determinate ring: an SVG with a muted track circle plus an arc circle whose
  stroke-dasharray is the circumference and whose stroke-dashoffset is
  circumference * (1 - percent/100), transitioned (stroke-dashoffset, 500ms).
  Rotate the svg -90deg so 0% starts at twelve o'clock; strokeLinecap="round".
- Indeterminate ring: keep a fixed quarter-circumference dash
  (`${c*0.25} ${c}`) at offset 0 and rotate the whole svg 360deg on a loop.
- Reduced motion: never remove the meter. Drop the sweep/spin animations and
  the transitions (motion-reduce:[animation:none] /
  motion-reduce:transition-none) — the value still shows, the aria contract is
  unchanged, only the movement stops.
- Ship both keyframes (bar sweep, ring spin) in ONE React 19 hoisted
  <style href precedence="medium"> tag; several meters on a page dedupe to a
  single style tag.

Rendering & styling
- Semantic tokens only: bg-muted / stroke-muted for the track, the tone map
  above for the fill and arc, text-muted-foreground for the percentage,
  tabular-nums so the number never jitters as it counts.
- The stripe texture is a repeating-linear-gradient built from
  color-mix(in oklab, var(--background) 34%, transparent), so it reads on any
  fill color in both light and dark schemes without a hardcoded value.
- Bar root is w-full (it fills its column); ring root is
  relative inline-flex shrink-0 with explicit width/height, so it behaves like
  an icon in a flex row.

Customization levers
- Shape is the headline lever: the same value/tone/label props render either
  geometry, so a dashboard can show a bar in a list row and a ring in a KPI
  tile without a second component.
- Thresholds: pick `tone` from the value on the consumer side (e.g. >90%
  destructive, >75% warning) — the component deliberately does not encode
  policy, so quota rules stay in your app.
- Density: `size` covers the common range; for a bespoke ring pass
  strokeWidth, or set width/height via className and let the svg follow.
- Ring center: `label` accepts any short string — steps ("3/4"), remaining
  budget ("12 GB"), or a letter grade; pass showValue={false} to leave it bare.
- Bar row: omit both `label` and showValue to get a naked track for use inside
  a table cell or a card footer.

Concepts

  • One contract, two geometries — a bar and a ring answer the same question ("how far along?") and report the identical role="progressbar" value set; only the drawing differs, so switching shape is a layout decision rather than a rewrite.
  • Determinate vs indeterminate — omitting value is the switch: the meter stops claiming a number, drops aria-valuenow, and raises aria-busy so assistive tech hears "busy" instead of a fake percentage.
  • Size means edge or diameter — the same enum sets a bar's track height and a ring's outer diameter (with the stroke scaled from it), which keeps one prop for "how big" without pretending the two shapes measure the same thing.
  • Clamped value, honest label — out-of-range values are clamped and the printed percentage is derived from the clamp, so a buggy 140 can never render a 140%-wide fill or a label that disagrees with the arc.
  • Tone carries policy, not the component — quota thresholds ("red past 90%") live in the consumer's tone choice; the meter ships no built-in policy, which is what lets it serve uploads, quotas and checklists alike.
  • Reduced-motion honesty — under prefers-reduced-motion the sweep, the spin and the width/offset transitions are dropped, but the value and the ARIA contract stay exactly the same: information is never carried by motion alone.

On This Page