Progress indicators

Progress indicators show the status of an ongoing process. Choose the shape that fits your layout. A linear indicator spans a width, a circular one fits a fixed square. Then choose the mode that matches what you know:

  • Determinate when you know how far along the process is. Pass a value.
  • Indeterminate when you do not. Pass indeterminate and no value.

Determinate

value is a fraction between 0 and max, which defaults to 1. Drag the slider below to see how the active indicator, the gap and the stop indicator change. Any progress above 0 shows the active indicator as at least a dot.

<script lang="ts">
	import { CircularProgress, LinearProgress, Slider } from 'noph-ui'

	let value = $state(0.6)
</script>

<div class="controls">
	<Slider
		min={0}
		max={1}
		labeled
		step={0.01}
		bind:value
		aria-label="Progress value"
		format={(v) => `${Math.round(v * 100)}%`}
	/>
	<div class="stack">
		<LinearProgress aria-label="Download progress" {value} />
		<LinearProgress aria-label="Download progress, wavy" wavy {value} />
	</div>
	<div class="row">
		<CircularProgress aria-label="Download progress" {value} />
		<CircularProgress aria-label="Download progress, wavy" wavy {value} />
	</div>
</div>

<style>
	.controls {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
		min-width: min(22rem, 70vw);
	}
	.stack {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
	}
	.row {
		display: flex;
		align-items: center;
		gap: 1.5rem;
	}
</style>

Indeterminate

Use this when you cannot predict how long something takes. The indicator loops until you remove it or give it a value.

<script lang="ts">
	import { CircularProgress, LinearProgress } from 'noph-ui'
</script>

<div class="controls">
	<div class="stack">
		<LinearProgress aria-label="Loading" indeterminate />
	</div>
	<CircularProgress aria-label="Loading" indeterminate />
</div>

<style>
	.controls {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
		min-width: min(22rem, 70vw);
	}
	.stack {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
	}
</style>

Wavy

The Material 3 expressive variant. Only the active indicator waves. The track stays smooth. The wave moves one wavelength per second. A determinate LinearProgress flattens the wave at 10% and below and at 95% and above. Drag the slider above past those points to see it.

<script lang="ts">
	import { CircularProgress, LinearProgress } from 'noph-ui'
</script>

<div class="controls">
	<div class="stack">
		<LinearProgress aria-label="Uploading, wavy" wavy value={0.6} />
		<LinearProgress aria-label="Loading, wavy" wavy indeterminate />
	</div>
	<div class="row">
		<CircularProgress aria-label="Uploading, wavy" wavy value={0.6} />
		<CircularProgress aria-label="Loading, wavy" wavy indeterminate />
	</div>
</div>

<style>
	.controls {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
		min-width: min(22rem, 70vw);
	}
	.stack {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
	}
	.row {
		display: flex;
		align-items: center;
		gap: 1.5rem;
	}
</style>

Buffer

Linear only. buffer shows how much has loaded ahead of the current position, like in a streaming video. Everything after the buffer is drawn as dots.

<script lang="ts">
	import { LinearProgress, Slider } from 'noph-ui'

	let buffered = $state(0.85)
</script>

<div class="controls">
	<Slider
		labeled
		min={0}
		max={1}
		step={0.01}
		format={(v) => `${Math.round(v * 100)}%`}
		bind:value={buffered}
		aria-label="Buffer amount"
	/>
	<div class="stack">
		<LinearProgress aria-label="Playback progress" value={0.4} buffer={buffered} />
	</div>
</div>

<style>
	.controls {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
		min-width: min(22rem, 70vw);
	}
	.stack {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
	}
</style>

Without a track

Set track=false when the indicator sits on top of another component that already provides a surface, such as a button's loading state. Button and IconButton do this when you pass loading.

<script lang="ts">
	import { CircularProgress, LinearProgress } from 'noph-ui'
</script>

<CircularProgress aria-label="Loading, no track" indeterminate track={false} />
<div class="stack">
	<LinearProgress aria-label="Loading, no track" indeterminate track={false} />
</div>

<style>
	.stack {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
	}
</style>

The 4dp stop indicator marks the end of a linear track. It stays when the track is hidden. Material 3 allows removing it only where the indicator has at least 3:1 contrast with the surfaces around it. In that case pass stopIndicator=false.

Four colors

fourColor cycles the indicator through primary and tertiary tones instead of a single colour. Use it on indeterminate indicators.

<script lang="ts">
	import { CircularProgress, LinearProgress } from 'noph-ui'
</script>

<CircularProgress aria-label="Loading, four colors" fourColor indeterminate />
<CircularProgress aria-label="Loading, four colors, wavy" fourColor wavy indeterminate />
<div class="stack">
	<LinearProgress aria-label="Loading, four colors" fourColor indeterminate />
</div>

<style>
	.stack {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
	}
</style>

Accessibility

Both components render role="progressbar" and keep aria-valuenow and aria-valuemax in sync with value and max. Neither has text of its own, so always pass an aria-label that says what is loading. Indeterminate indicators leave out aria-valuenow. This tells assistive technology that the duration is unknown.

<CircularProgress
	indeterminate
	aria-label="Loading search results"
/>

wavy respects prefers-reduced-motion. With the preference set, the indicator shows its plain shape without the wave. The spinner and the bar keep animating.

Right-to-left

LinearProgress follows the inherited dir. In a right-to-left context the indicator grows from the right and the stop indicator moves to the left edge.

<script lang="ts">
	import { LinearProgress } from 'noph-ui'
</script>

<div class="controls" dir="rtl">
	<div class="stack">
		<LinearProgress aria-label="Download progress, right to left" value={0.6} />
		<LinearProgress aria-label="Loading, right to left" wavy indeterminate />
	</div>
</div>

<style>
	.controls {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
		min-width: min(22rem, 70vw);
	}
	.stack {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
	}
</style>

Theming

Circular progress tokens

TokenDefault value
--np-circular-progress-color--np-color-primary
--np-circular-progress-track-color--np-color-secondary-container
--np-circular-progress-active-indicator-width8.3333
--np-circular-progress-size3rem

--np-circular-progress-active-indicator-width is a unitless percentage of the indicator size, so the stroke scales with --np-circular-progress-size. The gap between indicator and track stays the same at any stroke width: 4.8dp, and 4dp for the wavy variant.

Circular progress example

<script lang="ts">
	import { CircularProgress } from 'noph-ui'
</script>

<CircularProgress
	--np-circular-progress-color="var(--np-color-error)"
	--np-circular-progress-track-color="var(--np-color-error-container)"
	--np-circular-progress-active-indicator-width="20"
	--np-circular-progress-size="5rem"
	aria-label="Circular progress custom"
	indeterminate
/>

Linear progress tokens

TokenDefault value
--np-linear-progress-track-shape--np-shape-corner-full
--np-linear-progress-track-height0.25rem
--np-linear-progress-active-indicator-height0.25rem
--np-linear-progress-active-indicator-color--np-color-primary
--np-linear-progress-track-color--np-color-secondary-container
--np-linear-progress-track-gap0.25rem
--np-linear-progress-wave-height--np-linear-progress-active-indicator-height + 0.375rem

--np-linear-progress-wave-height only applies to the wavy variant. It needs a taller container to fit the wave. The wave keeps the Material 3 amplitude of 3dp and wavelength of 40dp at any thickness. A thicker indicator makes the container taller, so the wave is not clipped.

Linear progress example

<script lang="ts">
	import { LinearProgress } from 'noph-ui'
</script>

<div class="stack">
	<LinearProgress
		--np-linear-progress-active-indicator-color="var(--np-color-error)"
		--np-linear-progress-track-color="var(--np-color-error-container)"
		--np-linear-progress-track-shape="0.5rem"
		--np-linear-progress-track-height="0.5rem"
		--np-linear-progress-active-indicator-height="0.5rem"
		aria-label="Linear progress custom"
		indeterminate
	/>
</div>

<style>
	.stack {
		display: flex;
		flex-direction: column;
		gap: 1.75rem;
	}
</style>

API

Both components take the same attributes, apart from buffer, which is linear only.

Circular progress attributes

AttributeTypeDefaultDescription
valuenumber0Progress to display, a fraction between 0 and max
maxnumber1Maximum progress.
indeterminatebooleanfalseWhether to show indeterminate progress, for tasks of unknown length.
fourColorbooleanfalseWhether to use 4 colors in indeterminate mode instead of one.
trackbooleantrueWhether to show the inactive track. Set to false for indicators on top of another component, such as a button's loading state.
wavybooleanfalseWhether to draw the active indicator as a moving wave (M3 expressive).

Linear progress attributes

AttributeTypeDefaultDescription
valuenumber0Progress to display, a fraction between 0 and max
maxnumber1Maximum progress.
indeterminatebooleanfalseWhether to show indeterminate progress, for tasks of unknown length.
fourColorbooleanfalseWhether to use 4 colors in indeterminate mode instead of one.
trackbooleantrueWhether to show the inactive track. Set to false for indicators on top of another component, such as a button's loading state.
wavybooleanfalseWhether to draw the active indicator as a moving wave (M3 expressive).
buffernumber0Buffer amount, a fraction between 0 and max. A value of 0 or less hides the buffer.
stopIndicatorbooleantrueShows the 4dp dot at the end of a determinate track. Only turn it off where the indicator has 3:1 contrast with its surroundings.