macbetter

Status

Progress

Show how far a task has come, or that it's under way when you can't tell.

Exporting “Q3 Review.mov”62%
import { Progress, ProgressLabel, ProgressValue } from "macbetter/progress"

export default function Example() {
  return (
    <div style={{ width: "100%", maxWidth: 360 }}>
      <Progress value={62}>
        <ProgressLabel>Exporting “Q3 Review.mov”</ProgressLabel>
        <ProgressValue />
      </Progress>
    </div>
  )
}

When to use it

Use a progress bar for a task that will finish: an export, a download, an upload, a backup. Show the real fraction whenever you know it, because a bar that moves tells people the app is working and how long to wait. When you can't tell how far along it is, use an indeterminate bar, and switch to a determinate one as soon as you can.

For a short wait with nothing to measure, use an activity indicator instead. For a reading that doesn't finish, like storage used, use a gauge.

Say what's happening in the label, and keep the bar in one place while it runs. Don't restart a bar from zero for each step of one task.

Usage

import { Progress, ProgressCircle, ProgressLabel, ProgressValue } from "macbetter/progress"

Put a ProgressLabel and a ProgressValue inside a Progress. The track and its fill are drawn for you, below them, and the fill eases to each new value.

Indeterminate

Pass value={null}. A short segment glides across the track and comes back around.

Connecting to server…
import { Progress, ProgressLabel } from "macbetter/progress"

export default function Example() {
  return (
    <div style={{ width: "100%", maxWidth: 360 }}>
      <Progress value={null}>
        <ProgressLabel>Connecting to server…</ProgressLabel>
      </Progress>
    </div>
  )
}

Following a task

Pass the latest value as the work advances.

Uploading photos0 of 24
"use client"

import { Button } from "macbetter/button"
import { Progress, ProgressLabel, ProgressValue } from "macbetter/progress"
import { useEffect, useState } from "react"

export default function Example() {
  const [uploaded, setUploaded] = useState(0)
  const [running, setRunning] = useState(false)

  useEffect(() => {
    if (!running) return
    const timer = setInterval(() => {
      setUploaded((count) => {
        if (count >= 24) {
          setRunning(false)
          return count
        }
        return count + 1
      })
    }, 160)
    return () => clearInterval(timer)
  }, [running])

  return (
    <div style={{ display: "grid", gap: 16, width: "100%", maxWidth: 360, justifyItems: "start" }}>
      <Progress value={uploaded} max={24} format={(count) => `${count} of 24`}>
        <ProgressLabel>Uploading photos</ProgressLabel>
        <ProgressValue />
      </Progress>
      <Button
        onClick={() => {
          if (uploaded >= 24) setUploaded(0)
          setRunning(!running)
        }}
      >
        {running ? "Pause" : uploaded >= 24 ? "Start Over" : "Upload"}
      </Button>
    </div>
  )
}

Sizes and tones

md is the standard bar. Use sm inside a row or a table cell and lg for the one task a view is about. neutral is for background work that shouldn't draw the eye.

import { Progress } from "macbetter/progress"

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 20, width: "100%", maxWidth: 360 }}>
      <Progress size="sm" value={30} aria-label="Syncing Photos" />
      <Progress size="md" value={55} aria-label="Downloading update" />
      <Progress size="lg" value={80} aria-label="Backing up" />
      <Progress size="md" tone="neutral" value={45} aria-label="Indexing" />
    </div>
  )
}

Formatting the value

format takes Intl.NumberFormat options or a function, for units and time remaining.

macOS Tahoe 26.11.2 GB
Time Machine backupAbout 15 minutes left
"use client"

import { Progress, ProgressLabel, ProgressValue } from "macbetter/progress"

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 20, width: "100%", maxWidth: 360 }}>
      <Progress value={1.2} max={4.8} format={{ style: "unit", unit: "gigabyte", maximumFractionDigits: 1 }}>
        <ProgressLabel>macOS Tahoe 26.1</ProgressLabel>
        <ProgressValue />
      </Progress>
      <Progress value={45} max={60} format={(minutes) => `About ${60 - minutes} minutes left`}>
        <ProgressLabel>Time Machine backup</ProgressLabel>
        <ProgressValue />
      </Progress>
    </div>
  )
}

Circular

ProgressCircle is the ring from the App Store and Safari downloads. Anything you put inside sits in the middle, like a stop square. Without a value, it spins.

import { ProgressCircle } from "macbetter/progress"
import { SquareIcon } from "lucide-react"

export default function Example() {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 28 }}>
      <ProgressCircle size="sm" value={25} aria-label="Uploading attachment" />
      <ProgressCircle value={60} aria-label="Downloading Keynote" />
      <ProgressCircle size="lg" value={72} aria-label="Downloading Final Cut Pro">
        <SquareIcon aria-hidden="true" fill="currentColor" strokeWidth={0} />
      </ProgressCircle>
      <ProgressCircle size="lg" value={null} aria-label="Waiting to download" />
    </div>
  )
}

Platform behavior

On macOS the bar is 5 points thick, on iOS and iPadOS 4 points, like UIProgressView. Rings are 24 points on macOS and 28 on iOS, where they're usually tap targets. Neither changes behavior between platforms.

In a right-to-left layout the bar fills from the right and the indeterminate segment travels right to left, as Apple's do. A ring keeps turning clockwise, like a clock face, which Apple doesn't mirror either.

With Reduce Motion on, the indeterminate segment stops sliding and fills the track, dimmed, so it still reads as busy without moving. A spinning ring holds still the same way.

Accessibility

  • Progress is a progressbar, named by ProgressLabel or aria-label. The value is part of its description, so the visible ProgressValue is hidden from screen readers to avoid reading it twice.
  • An indeterminate bar has no aria-valuenow.
  • Use getValueLabel when the number needs words, like "3 of 12 files".
  • A ring has no visible label, so always give it an aria-label.

API

Progress

PropTypeDefaultDescription
valuenumber | null—How far the task has come. `null` is indeterminate.
minnumber0The start of the range.
maxnumber100The end of the range.
size"sm" | "md" | "lg""md"The bar's thickness.
tone"accent" | "neutral" | "destructive""accent"The fill color.
formatIntl.NumberFormatOptions | (value) => string—How the value reads.
getValueLabel(value, details) => string—What screen readers announce for the value.

ProgressLabel and ProgressValue take ui-sdk's props.

ProgressCircle

PropTypeDefaultDescription
valuenumber | null—How far the task has come. `null` spins.
size"sm" | "md" | "lg""md"16, 24 or 36 points on macOS; 20, 28 or 44 on iOS.
tone"accent" | "neutral" | "destructive""accent"The ring's color.
childrenReactNode—What sits in the middle.

Data attributes and variables

Attribute or variableOnDescription
data-indeterminate, data-progressing, data-completeevery partThe task's status, from ui-sdk.
data-size, data-tone.mb-progress, .mb-progress-circleThe props.
--progress-value.mb-progressThe value as a percentage, from ui-sdk.
--mb-progress-tone.mb-progress, .mb-progress-circleThe fill color.
--mb-progress-track-color.mb-progress, .mb-progress-circleThe track color.