Status
Progress
Show how far a task has come, or that it's under way when you can't tell.
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.
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.
"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.
"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
Progressis aprogressbar, named byProgressLabeloraria-label. The value is part of its description, so the visibleProgressValueis hidden from screen readers to avoid reading it twice.- An indeterminate bar has no
aria-valuenow. - Use
getValueLabelwhen the number needs words, like "3 of 12 files". - A ring has no visible label, so always give it an
aria-label.
API
Progress
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | null | — | How far the task has come. `null` is indeterminate. |
min | number | 0 | The start of the range. |
max | number | 100 | The end of the range. |
size | "sm" | "md" | "lg" | "md" | The bar's thickness. |
tone | "accent" | "neutral" | "destructive" | "accent" | The fill color. |
format | Intl.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
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | 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. |
children | ReactNode | — | What sits in the middle. |
Data attributes and variables
| Attribute or variable | On | Description |
|---|---|---|
data-indeterminate, data-progressing, data-complete | every part | The task's status, from ui-sdk. |
data-size, data-tone | .mb-progress, .mb-progress-circle | The props. |
--progress-value | .mb-progress | The value as a percentage, from ui-sdk. |
--mb-progress-tone | .mb-progress, .mb-progress-circle | The fill color. |
--mb-progress-track-color | .mb-progress, .mb-progress-circle | The track color. |