Progress

Displays an indicator showing the completion progress of a task.

x
"use client"

import { useEffect, useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Progress } from "@/components/ui/progress"

const styles = stylex.create({
  progress: { width: "100%", maxWidth: "24rem" },
})

export default function ProgressDemo() {
  const [value, setValue] = useState(13)

  useEffect(() => {
    const timer = setTimeout(() => setValue(66), 500)
    return () => clearTimeout(timer)
  }, [])

  return <Progress value={value} xstyle={styles.progress} />
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste state-styles.ts from the same page. The indicator reads Base UI's state through it.

Copy and paste the following code into your project.

components/ui/progress.tsx
"use client"

import type { CSSProperties } from "react"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { stateStyles } from "@/lib/state-styles"
import {
  color,
  duration,
  ease,
  leading,
  radius,
  shadow,
  space,
  text,
} from "@/theme/tokens.stylex"

const sweep = stylex.keyframes({
  from: { transform: "translateX(-100%)" },
  to: { transform: "translateX(400%)" },
})

const pulse = stylex.keyframes({
  "0%": { opacity: 0.35 },
  "50%": { opacity: 1 },
  "100%": { opacity: 0.35 },
})

const styles = stylex.create({
  root: {
    display: "flex",
    flexWrap: "wrap",
    gap: space.s3,
  },
  track: {
    position: "relative",
    display: "flex",
    alignItems: "center",
    width: "100%",
    height: space.s1,
    borderRadius: radius.full,
    backgroundColor: color.muted,
    boxShadow: shadow.groove,
    overflowX: "hidden",
  },
  indicator: {
    height: "100%",
    borderRadius: radius.full,
    backgroundColor: color.primary,
    backgroundImage: `linear-gradient(to bottom, ${color.buttonPrimarySheen}, transparent)`,
    transitionProperty: "width",
    transitionDuration: {
      default: duration.panel,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.spring,
  },
  indeterminate: {
    width: { default: "25%", "@media (prefers-reduced-motion: reduce)": "100%" },
    animationName: { default: sweep, "@media (prefers-reduced-motion: reduce)": pulse },
    animationDuration: { default: "1.2s", "@media (prefers-reduced-motion: reduce)": "2s" },
    animationTimingFunction: {
      default: "linear",
      "@media (prefers-reduced-motion: reduce)": ease.standard,
    },
    animationIterationCount: "infinite",
  },
  label: {
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontWeight: 500,
  },
  value: {
    marginInlineStart: "auto",
    color: color.mutedForeground,
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontVariantNumeric: "tabular-nums",
  },
})

const progressStyles = {
  root: styles.root,
  track: styles.track,
  indicator: styles.indicator,
  indeterminate: styles.indeterminate,
  label: styles.label,
  value: styles.value,
}

type StyleProps = {
  xstyle?: StyleXStyles
  className?: string
}

type StateOverrides = StyleProps & { style?: CSSProperties }

type ProgressProps = Omit<ProgressPrimitive.Root.Props, "className"> & StyleProps

function Progress({ xstyle, className, children, ...props }: ProgressProps) {
  const sx = stylex.props(styles.root, xstyle)

  return (
    <ProgressPrimitive.Root
      data-slot="progress"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    >
      {children}
      <ProgressTrack>
        <ProgressIndicator />
      </ProgressTrack>
    </ProgressPrimitive.Root>
  )
}

function ProgressTrack({
  xstyle,
  className,
  ...props
}: Omit<ProgressPrimitive.Track.Props, "className"> & StyleProps) {
  const sx = stylex.props(styles.track, xstyle)

  return (
    <ProgressPrimitive.Track
      data-slot="progress-track"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

function ProgressIndicator({
  xstyle,
  style,
  className,
  ...props
}: Omit<ProgressPrimitive.Indicator.Props, "className" | "style"> & StateOverrides) {
  return (
    <ProgressPrimitive.Indicator
      data-slot="progress-indicator"
      {...props}
      {...stateStyles(
        (state) => [
          styles.indicator,
          state.status === "indeterminate" && styles.indeterminate,
          xstyle,
        ],
        { className, style },
      )}
    />
  )
}

function ProgressLabel({
  xstyle,
  className,
  ...props
}: Omit<ProgressPrimitive.Label.Props, "className"> & StyleProps) {
  const sx = stylex.props(styles.label, xstyle)

  return (
    <ProgressPrimitive.Label
      data-slot="progress-label"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

function ProgressValue({
  xstyle,
  className,
  ...props
}: Omit<ProgressPrimitive.Value.Props, "className"> & StyleProps) {
  const sx = stylex.props(styles.value, xstyle)

  return (
    <ProgressPrimitive.Value
      data-slot="progress-value"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

export { Progress, ProgressIndicator, ProgressLabel, ProgressTrack, ProgressValue, progressStyles }

Update the import paths to match your project setup.

Usage

import { Progress } from "@/components/ui/progress"
<Progress value={33} />

value is required and runs from min to max, which default to 0 and 100. Progress renders the track and the indicator itself, so children sit above the bar.

Progress
├── ProgressLabel
├── ProgressValue
└── ProgressTrack
    └── ProgressIndicator

Label

Use ProgressLabel to name the task and ProgressValue to print the number. The value is pushed to the end of the row and the track wraps onto its own line below them.

Upload progress
x
import * as stylex from "@stylexjs/stylex"
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"

const styles = stylex.create({
  progress: { width: "100%", maxWidth: "24rem" },
})

export default function ProgressLabelDemo() {
  return (
    <Progress value={56} xstyle={styles.progress}>
      <ProgressLabel>Upload progress</ProgressLabel>
      <ProgressValue />
    </Progress>
  )
}

With no format, ProgressValue divides the value by 100 and prints it as a percentage. Give it a function child to render something else from the same numbers.

<ProgressValue>{(formatted, value) => `${value} of 100 files`}</ProgressValue>

Controlled

Pass a new value to move the bar. The indicator transitions its width over 300ms, so a jump of any size animates instead of snapping.

Transfer
x
"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({
  panel: { width: "100%", maxWidth: "24rem" },
  progress: { width: "100%" },
})

const clamp = (value: number) => Math.min(100, Math.max(0, value))

export default function ProgressControlled() {
  const [value, setValue] = useState(40)

  return (
    <VStack gap={4} align="start" xstyle={styles.panel}>
      <Progress value={value} xstyle={styles.progress}>
        <ProgressLabel>Transfer</ProgressLabel>
        <ProgressValue />
      </Progress>
      <ButtonGroup>
        <Button variant="outline" onClick={() => setValue(clamp(value - 10))}>
          -10
        </Button>
        <Button variant="outline" onClick={() => setValue(clamp(value + 10))}>
          +10
        </Button>
        <Button variant="outline" onClick={() => setValue(0)}>
          Reset
        </Button>
      </ButtonGroup>
    </VStack>
  )
}

Indeterminate

Pass value={null} for a task with no measurable progress. The indicator becomes a quarter-width bar sweeping the track, ProgressValue renders nothing, and Base UI writes data-indeterminate on every part.

Preparing export
x
import * as stylex from "@stylexjs/stylex"
import { Progress, ProgressLabel } from "@/components/ui/progress"

const styles = stylex.create({
  progress: { width: "100%", maxWidth: "24rem" },
})

export default function ProgressIndeterminate() {
  return (
    <Progress value={null} xstyle={styles.progress}>
      <ProgressLabel>Preparing export</ProgressLabel>
    </Progress>
  )
}

Under prefers-reduced-motion: reduce the sweep becomes a full-width bar that fades in and out. Motion is the only way an indeterminate bar shows that work is happening, so the component replaces it rather than dropping it.

Formatting

Use format to pass Intl.NumberFormat options, which replaces the default percentage, and locale to pick the locale, which defaults to the runtime's.

<Progress value={3400} max={5000} format={{ style: "currency", currency: "USD" }}>
  <ProgressLabel>Raised</ProgressLabel>
  <ProgressValue />
</Progress>

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI Progress part of the same name and accepts its props. See the Base UI reference. Each part also takes xstyle and className. ProgressIndicator styles itself from Base UI's state, so it takes style as well. It is real inline CSS, merged over the compiled style alongside the width Base UI writes.

Progress

PropTypeDefault
valuenumber | null—
minnumber0
maxnumber100
formatIntl.NumberFormatOptions—
localeIntl.LocalesArgument—
getAriaValueText(formattedValue: string | null, value) => string—
xstyleStyleXStyles—
classNamestring—

The root is a <div role="progressbar">. It renders ProgressTrack and ProgressIndicator after its children, so a bar with no label needs no children at all.

ProgressValue

PropTypeDefault
children(formattedValue: string | null, value: number | null) => Node—

The function child receives "indeterminate" as its first argument while value is null.

progressStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
rootThe wrapping row: flex, wrap, gap
trackThe groove: height, fill, inset shadow, clipping
indicatorThe lit bar and its width transition
indeterminateThe sweep, and its reduced-motion replacement
labelLabel type
valueMuted, tabular type pushed to the end

Data attributes

AttributeOnEffect
data-slot="progress"the rootTargets the row from a parent
data-slot="progress-track"the trackTargets the groove
data-slot="progress-indicator"the barTargets the fill
data-slot="progress-label", data-slot="progress-value"the textTargets the label and the number
data-indeterminate, data-progressing, data-completeevery partBase UI writes them from the value