UIDex

Progress Bar vs Spinner vs Ring

مؤشّر التقدّم

role="progressbar"

also calledprogress bar، spinner، loader، progress ring، activity indicator، loading indicator، شريط التقدّم، بروجرس بار، سبينر، مؤشّر التحميل، حلقة التقدّم، لودر

A progress indicator tells the user that an operation is running, and how much of it is left when that is known. Three shapes cover two meanings. A determinate bar knows the fraction and shows it; a spinner does not know it and says only that something is happening; a progress ring is the determinate bar wrapped into a circle, a visual choice that changes nothing in the semantics or in ARIA. Which one to reach for is a question of time: show nothing for the first 400ms or so, because an indicator that flashes and vanishes reads as a glitch; a spinner is enough up to about four seconds; past that people want a determinate bar with a percentage; and when the work cannot be measured, name the current step instead of inventing a number. This is not a skeleton, which draws the shape of the content that is coming so the layout does not jump, while a progress indicator measures time. It is not a stepper either, because a stepper is navigation the user can move through and a progress indicator is a readout of work the user does not control. And it is not a slider: the moment the value can be dragged you have a slider whatever it looks like, since progressbar takes no focus and accepts no input.

If you called it…

"the spinning circle you stare at while it loads""the bar that fills up as the file uploads""the loading wheel""the thin line that crawls across the top of the page""the ring with a percentage in the middle"

Live specimen

Interact with the demo. Every part is real and numbered.

جارٍ رفع «العرض-النهائي.pdf»٥٪
aria-valuenow="5"يتحرّك كل ٥٪ لا مع كل إطار
٥٪
جارٍ الاتصال بالخادم
RTLالتعبئة تبدأ من اليمين، والدوّار لا ينعكس معها

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build a progress indicator in three shapes: a determinate bar, a ring carrying the same value, and an indeterminate spinner. Pick the shape by time rather than taste: render nothing for the first 400ms, let the spinner cover up to about four seconds, move to a determinate bar with a percentage past that, and when the fraction cannot be measured show named steps instead of an invented number. Put role="progressbar" on the track itself with aria-valuenow, aria-valuemin="0" and aria-valuemax="100", and tie it to a visible caption through aria-labelledby, since this role does not take its name from its contents. In the indeterminate state remove aria-valuenow rather than setting it to zero, and add role="status" with accompanying text so the rotation is not silent. Use aria-valuetext whenever the raw figure means nothing, such as "3 files of 12", and move the value on thresholds or every two seconds instead of every frame. Keep contrast between the fill and the track at 3:1 or better, and inside @media (prefers-reduced-motion: reduce) stop the rotation and put an opacity pulse or plain text in its place. Use logical properties throughout: pin the fill with inset-inline-start: 0 and animate inline-size, never left: 0 and never transform-origin: left. Write the indeterminate sweep as translateX(calc(var(--sweep) * var(--dir))) with --dir set to -1 under [dir="rtl"], since transform has no logical counterpart. Do not mirror the spinner or the ring geometry when the page flips, and generate every numeral on the page from one Intl.NumberFormat with font-variant-numeric: tabular-nums.

How it behaves right-to-left

This section is ours alone.

Which way the bar fills

mirrors

The bar fills from the logical start, which in Arabic is the right edge. The reliable build is a fill element pinned with inset-inline-start: 0 whose inline-size animates from 0% to the value: it mirrors by itself, because the edge it is anchored to is the one that moved. If you insist on scaleX, note that transform-origin has no logical keyword at all; its keywords are the physical left, right, top, bottom and center alongside lengths and percentages, so you end up writing transform-origin: right by hand under [dir="rtl"], or signing the scale with a direction variable.

The indeterminate sweep

mirrors

An indeterminate bar slides a short segment across the track, and setting off from the logical start is what matches the reading order. That motion lives in a @keyframes block written with translateX, and transform is physical with no logical counterpart anywhere in CSS, so fixed keyframes send the segment crawling against the reading direction in Arabic. Multiply the offset by a sign variable, translateX(calc(var(--sweep) * var(--dir))) with --dir set to -1 under [dir="rtl"], or ship a second @keyframes rule scoped to [dir="rtl"].

The spinner rotation

never mirrors

Rotation is not a reading direction, so the spinner stays exactly as it is when the page flips. A positive angle in rotate() turns clockwise under both directions, and a blanket [dir="rtl"] svg { transform: scaleX(-1) } reverses it to counter-clockwise while moving the rounded stroke-linecap to the other end of the arc, so the tail of the arc now leads. Scope any mirroring to a class you put on directional icons only, and keep the spinner and the clock glyph out of it.

Ring geometry against its label

never mirrors

The ring is drawn in the SVG coordinate system, where the direction property has no effect on geometry. The start point of stroke-dashoffset therefore stays put: the equivalent path of a <circle> begins at the rightmost point and runs clockwise, and the rotate(-90) that moves the start to the top is a fixed visual decision with no language in it. The number in the middle is the part that does react. Plain HTML text follows page direction, and if you draw it with SVG <text> instead, text-anchor start and end resolve against the inline direction of that text and swap in RTL, so use text-anchor: middle or overlay an HTML element on the ring.

Numerals and numbering system

never mirrors

Digits do not reverse: the sequence inside a number reads left to right even in Arabic text, and the bidi algorithm treats the run as one unit. The real bug is mixing the two systems on one screen, a bar reading 42% beside a ring reading ٤٢٪. Generate every numeral from one place: new Intl.NumberFormat("ar-EG", { style: "percent" }) returns Arabic-Indic digits with the Arabic percent sign U+066A, while ar-EG-u-nu-latn returns Latin 0 to 9 with an ASCII %, because the percent symbol follows the numbering system and not the language. Add font-variant-numeric: tabular-nums so the number holds its width as it counts and nothing beside it twitches.

In code

Each row is one framework's word for the same thing. Take the row your project speaks.

ARIArole="progressbar" + aria-valuenowThe pair that defines the widget; drop aria-valuenow for the indeterminate state.
HTML<progress value="42" max="100">The native element; drop the value attribute alone and it turns indeterminate, but styling its bar still goes through engine-specific pseudo-elements.
Radix UI<Progress.Root value> + <Progress.Indicator>Passing value={null} sets data-state="indeterminate", so both states style from one CSS rule.
Material UI<LinearProgress variant="determinate" value={n} />The default variant is indeterminate, and <CircularProgress /> is the same logic drawn as a ring.
Ant Design<Progress type="circle" percent={42} />One component swaps between line, circle and dashboard through the type prop alone.
SwiftUIProgressView(value:total:)The initialiser without a value gives you a spinner; with a value it gives you a determinate bar.
CSS@media (prefers-reduced-motion: reduce)The query that swaps continuous rotation for an opacity pulse or plain text.

See also

Updated · 2026-08-19