HextaUI

Progress

A bar or ring that shows how far a task has come, eases between updates and slides while the total is unknown.

Preparing…
x
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Progress,
  ProgressLabel,
  ProgressValue,
} from "@/components/ui/progress"

export function ProgressDemo() {
  const [value, setValue] = React.useState<number | null>(null)
  const [run, setRun] = React.useState(0)

  React.useEffect(() => {
    let current = 0
    let tick: ReturnType<typeof setInterval> | undefined
    const start = setTimeout(() => {
      setValue(0)
      tick = setInterval(() => {
        current = Math.min(100, current + Math.round(Math.random() * 12 + 3))
        setValue(current)
        if (current === 100) {
          clearInterval(tick)
        }
      }, 400)
    }, 1200)
    return () => {
      clearTimeout(start)
      clearInterval(tick)
    }
  }, [run])

  const label =
    value === null ? "Preparing…" : value === 100 ? "Uploaded" : "Uploading"

  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-6">
      <Progress value={value}>
        <ProgressLabel>{label}</ProgressLabel>
        <ProgressValue />
      </Progress>
      <Button
        variant="outline"
        size="sm"
        onClick={() => {
          setValue(null)
          setRun(run + 1)
        }}
      >
        Restart
      </Button>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/progress.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

import {
  Progress,
  ProgressCircle,
  ProgressLabel,
  ProgressValue,
} from "@/components/ui/progress"
<Progress value={40}>
  <ProgressLabel>Uploading</ProgressLabel>
  <ProgressValue />
</Progress>

<ProgressCircle value={40} aria-label="Uploading" />

<Progress /> draws its own track and indicator after its children, so a label and value sit on one line above the bar. Each update eases the fill from where it is, so rapid updates read as one smooth motion instead of steps.

Progress
├── ProgressLabel
└── ProgressValue

ProgressCircle
└── ProgressValue

Sizes

xs, sm, default and lg change the bar's thickness. xs is the hairline Attachment draws along its bottom edge.

Extra small
x
Small
x
Default
x
Large
x
import {
  Progress,
  ProgressLabel,
  ProgressValue,
} from "@/components/ui/progress"

export function ProgressSizes() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-6">
      <Progress value={15} size="xs">
        <ProgressLabel>Extra small</ProgressLabel>
        <ProgressValue />
      </Progress>
      <Progress value={30} size="sm">
        <ProgressLabel>Small</ProgressLabel>
        <ProgressValue />
      </Progress>
      <Progress value={55}>
        <ProgressLabel>Default</ProgressLabel>
        <ProgressValue />
      </Progress>
      <Progress value={80} size="lg">
        <ProgressLabel>Large</ProgressLabel>
        <ProgressValue />
      </Progress>
    </div>
  )
}

Status

variant colors only the fill or ring, so the track, label and value stay neutral.

Backup complete
x
Storage almost full
x
Upload failed
x
x
x
x
import {
  Progress,
  ProgressCircle,
  ProgressLabel,
  ProgressValue,
} from "@/components/ui/progress"

export function ProgressVariants() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-6">
      <Progress value={100} variant="success">
        <ProgressLabel>Backup complete</ProgressLabel>
        <ProgressValue />
      </Progress>
      <Progress value={86} variant="warning">
        <ProgressLabel>Storage almost full</ProgressLabel>
        <ProgressValue />
      </Progress>
      <Progress value={47} variant="destructive">
        <ProgressLabel>Upload failed</ProgressLabel>
        <ProgressValue />
      </Progress>
      <div className="flex items-center gap-4">
        <ProgressCircle value={100} variant="success" aria-label="Synced" />
        <ProgressCircle value={86} variant="warning" aria-label="Almost full" />
        <ProgressCircle value={47} variant="destructive" aria-label="Failed" />
      </div>
    </div>
  )
}

Indeterminate

Pass value={null} while the total is unknown. A segment slides across the track, and once a number arrives the fill grows from the start.

Connecting to server…
x
import { Progress, ProgressLabel } from "@/components/ui/progress"

export function ProgressIndeterminate() {
  return (
    <div className="w-full max-w-sm">
      <Progress value={null}>
        <ProgressLabel>Connecting to server…</ProgressLabel>
      </Progress>
    </div>
  )
}

Circle

<ProgressCircle /> draws the same value as a ring, starting at the top. Children sit in the middle, which fits <ProgressValue /> at lg and xl.

x
x
x
x
import { ProgressCircle, ProgressValue } from "@/components/ui/progress"

export function ProgressCircleDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-8">
      <ProgressCircle value={25} size="sm" aria-label="Small" />
      <ProgressCircle value={50} aria-label="Default" />
      <ProgressCircle value={75} size="lg" aria-label="Large">
        <ProgressValue />
      </ProgressCircle>
      <ProgressCircle value={100} size="xl" aria-label="Extra large">
        <ProgressValue />
      </ProgressCircle>
    </div>
  )
}

Indeterminate circle

An arc spins around the ring until a value arrives.

x
x
x
x
import { ProgressCircle } from "@/components/ui/progress"

export function ProgressCircleIndeterminate() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-8">
      <ProgressCircle value={null} size="sm" aria-label="Syncing" />
      <ProgressCircle value={null} aria-label="Syncing" />
      <ProgressCircle value={null} size="lg" aria-label="Syncing" />
      <ProgressCircle value={null} size="xl" aria-label="Syncing" />
    </div>
  )
}

Custom range and format

Set min and max for any range, format for the number, and a function child on <ProgressValue /> for the text. Give screen readers the same words with getAriaValueText.

Storage
x
"use client"

import {
  Progress,
  ProgressLabel,
  ProgressValue,
} from "@/components/ui/progress"

export function ProgressFormat() {
  return (
    <div className="w-full max-w-sm">
      <Progress
        value={37.5}
        max={50}
        format={{ maximumFractionDigits: 1 }}
        getAriaValueText={(formatted) => `${formatted} of 50 GB used`}
      >
        <ProgressLabel>Storage</ProgressLabel>
        <ProgressValue>{(formatted) => `${formatted} of 50 GB`}</ProgressValue>
      </Progress>
    </div>
  )
}

Animated value

Render <NumberFlow /> inside <ProgressValue /> so only the digits that change spin, in step with the fill.

Course completed
x
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { NumberFlow } from "@/components/ui/number-flow"
import {
  Progress,
  ProgressLabel,
  ProgressValue,
} from "@/components/ui/progress"

export function ProgressNumberFlow() {
  const [value, setValue] = React.useState(42)

  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-6">
      <Progress value={value}>
        <ProgressLabel>Course completed</ProgressLabel>
        <ProgressValue>
          {(_, current) => <NumberFlow value={current ?? 0} suffix="%" />}
        </ProgressValue>
      </Progress>
      <div className="flex gap-2">
        <Button
          variant="outline"
          size="sm"
          onClick={() => setValue(Math.max(0, value - 13))}
        >
          −13
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => setValue(Math.min(100, value + 13))}
        >
          +13
        </Button>
      </div>
    </div>
  )
}

Long labels

Long names wrap onto their own lines and the value stays on the end. Rings work as compact status beside each row.

  • x
    quarterly-report-final-v3-approved-by-legal-and-finance.pdf
    x
  • x
    IMG_20260914_183022_HDR_edited_export.jpg
    x
  • x
    brand-assets.zip
    x
import {
  Progress,
  ProgressCircle,
  ProgressLabel,
  ProgressValue,
} from "@/components/ui/progress"

const files = [
  {
    name: "quarterly-report-final-v3-approved-by-legal-and-finance.pdf",
    value: 64,
  },
  { name: "IMG_20260914_183022_HDR_edited_export.jpg", value: 100 },
  { name: "brand-assets.zip", value: 12 },
]

export function ProgressFiles() {
  return (
    <ul className="flex w-full max-w-sm flex-col gap-5">
      {files.map((file) => (
        <li key={file.name} className="flex items-start gap-3">
          <ProgressCircle value={file.value} aria-label={file.name} />
          <div className="min-w-0 flex-1">
            <Progress value={file.value} size="sm">
              <ProgressLabel>{file.name}</ProgressLabel>
              <ProgressValue />
            </Progress>
          </div>
        </li>
      ))}
    </ul>
  )
}

Without a visible label

Name the bar with aria-label when the context already says what is loading.

x
import { Progress } from "@/components/ui/progress"

export function ProgressUnlabeled() {
  return (
    <div className="w-full max-w-sm">
      <Progress value={45} aria-label="Profile setup" />
    </div>
  )
}

Right to left

The fill and the indeterminate slide start from the right. Pass locale to format the value in the reader's digits.

جارٍ التحميل
x
جارٍ الاتصال…
x
x
import {
  Progress,
  ProgressCircle,
  ProgressLabel,
  ProgressValue,
} from "@/components/ui/progress"

export function ProgressRtl() {
  return (
    <div dir="rtl" className="flex w-full max-w-sm flex-col gap-6">
      <Progress value={65} locale="ar-EG">
        <ProgressLabel>جارٍ التحميل</ProgressLabel>
        <ProgressValue />
      </Progress>
      <Progress value={null}>
        <ProgressLabel>جارٍ الاتصال…</ProgressLabel>
      </Progress>
      <ProgressCircle value={65} size="xl" locale="ar-EG" aria-label="التقدم">
        <ProgressValue />
      </ProgressCircle>
    </div>
  )
}
  • The root is a progressbar with aria-valuenow, aria-valuemin, aria-valuemax and a formatted aria-valuetext. While indeterminate it has no current value.
  • <ProgressLabel /> names the bar. Without one, pass aria-label.
  • <ProgressValue /> is hidden from screen readers, since the progressbar already announces the value.
  • With reduced motion, the fill jumps to each new value, and the indeterminate bar and ring pulse in place instead of moving.
  • Values are formatted in en-US unless you pass locale, so the server and browser render the same text.

Built on the Base UI progress. Every part accepts the props of the primitive it wraps.

PropTypeDefault
valuenull makes the bar indeterminate.
number | null–
min
number0
max
number100
size
"xs" | "sm" | "default" | "lg""default"
variant
"default" | "success" | "warning" | "destructive""default"
formatFormats the value. Without it, the value shows as a percentage.
Intl.NumberFormatOptions–
locale
Intl.LocalesArgument"en-US"
getAriaValueText
(formattedValue: string, value: number | null) => string–
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="progress"The root.
data-sizeThe size: xs, sm, default or lg.
data-variantThe status variant.
data-progressingPresent while the value is below max.
data-completePresent when the value reaches max.
data-indeterminatePresent when the value is null or not a finite number.

Names the progressbar. Renders a <span> and takes the same state attributes as the root.

PropTypeDefault
render
ReactElement | (props, state) => ReactElement<span>
AttributeDescription
data-slot="progress-label"The label.
PropTypeDefault
childrenCustom text. Without it, the formatted value shows, or nothing while indeterminate.
(formattedValue: string | null, value: number | null) => ReactNode–
render
ReactElement | (props, state) => ReactElement<span>
AttributeDescription
data-slot="progress-value"The value.

Rendered by <Progress /> and sized by its size. Exported for custom compositions.

AttributeDescription
data-slot="progress-track"The track.
--progress-dir1, or -1 in right-to-left, so the indeterminate slide follows the reading direction.

The fill. Its width is set inline from the value and eases between updates.

AttributeDescription
data-slot="progress-indicator"The fill.
PropTypeDefault
valuenull spins an arc.
number | null–
min
number0
max
number100
size
"sm" | "default" | "lg" | "xl""default"
variant
"default" | "success" | "warning" | "destructive""default"
locale
Intl.LocalesArgument"en-US"
childrenShown in the middle of the ring.
ReactNode–
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="progress-circle"The root.
data-sizeThe size: sm, default, lg or xl.
data-variantThe status variant.
data-progressingPresent while the value is below max.
data-completePresent when the value reaches max.
data-indeterminatePresent when the value is null or not a finite number.
--progress-circle-sizeThe ring's width and height.
--progress-strokeThe ring's stroke width.