Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 32 additions & 29 deletions README.md

Large diffs are not rendered by default.

189 changes: 189 additions & 0 deletions guides/ui-components/progress-ring/demo.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions guides/ui-components/progress-ring/expectations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Expectations
Comment thread
LeaVerou marked this conversation as resolved.

- The component contains a native HTML `<progress>` element.
- The component is visible on the page.
- The visual progress ring is implemented using a CSS `conic-gradient`.
- The center of the progress ring is transparent or "hollowed out" using `mask-image`.
- The `--value` CSS custom property is used to drive the visual progress of the ring.
- Methods that change the `value` of the `<progress>` element also updates the visual ring via the `--value` property.
- The component supports displaying text content (e.g., the percentage) in the center of the ring.
- The ring smoothly transitions between values when the `--value` property is updated (requiring `@property` support in the browser).
- The `<progress>` element includes an `aria-label` for accessibility.
- The `<progress>` element is visually hidden using a standard utility class (e.g., `.visually-hidden`).
- The component's fill color changes to a success color (e.g., green) when the progress value reaches 100%.
- The component has a distinct visual "track" (background) behind the progress fill.
- The spinner respects `prefers-reduced-motion` by changing to a new value immediately, rather than having a smooth transition.
172 changes: 171 additions & 1 deletion guides/ui-components/progress-ring/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,176 @@ description: Build a progress ring component that visually represents the comple
web-feature-ids:
- progress
- conic-gradients
- masks
- registered-custom-properties
---

<!-- Link to spinner for indeterminate state -->
## Overview

A progress ring (or circular progress bar) provides visual feedback on the status of a task. Unlike a linear progress bar, its circular shape is ideal for dashboards, card components, or anywhere space is constrained.

This guide implements a progress ring by:

- Using the native `<progress>` element as the semantic foundation to ensure the component is accessible to screen readers and keyboard users out-of-the-box.
- Styling the component with `conic-gradient()` and `mask-image`, allowing for a fully responsive and themeable ring without the complexity of SVG path manipulation.
- Leveraging CSS Custom Properties and `@property` to enable smooth, GPU-accelerated transitions of the progress fill.

This approach is preferred over SVG-only solutions because it uses the semantic `<progress>` element rather than ARIA, and more easily integrates with existing layout, design systems and typography.

See the {{ GUIDE_REF("spinner") }} for handling indeterminate loading states.

## Implementation

### 1. Markup

Use a wrapper to hold both the visual ring and the optional center content. The `<progress>` element remains the semantic source of truth. Use a utility class to visually hide the native progress bar while keeping it accessible.

```html
<div class="progress-ring-wrapper" style="--value: 75;">
<div class="progress-ring">
<progress value="75" max="100" aria-label="Task progress" class="visually-hidden"></progress>
</div>
<!-- Optional: Content to display in the center -->
<div class="progress-ring-content">
75%
</div>
</div>
```

### 2. Styles

#### Container and Ring

The wrapper provides the positioning context. The `progress-ring` element handles the visual gradient and mask.

```css
.progress-ring-wrapper {
--size: 120px;
--thickness: 12px;
--track-color: #eee;
--fill-color: #3b82f6;

position: relative;
display: grid;
place-items: center;
width: var(--size);
height: var(--size);
}

.progress-ring {
width: 100%;
height: 100%;
border-radius: 50%;

/* The progress fill is a conic gradient mapped to the --value */
background: conic-gradient(
var(--fill-color) calc(var(--value) * 1%),
var(--track-color) 0
);

/* Create the "ring" by masking out the center */
mask-image: radial-gradient(
transparent calc(50% - var(--thickness)),
black calc(50% - var(--thickness) + 0.5px)
);
}

/* Standard utility to visually hide elements while keeping them accessible */
.visually-hidden:where(:not(:focus-within, :active)) {
position: absolute !important;
clip-path: inset(50%) !important;
overflow: hidden !important;
width: 1px !important;
height: 1px !important;
margin: -1px !important;
padding: 0 !important;
border: 0 !important;
white-space: nowrap !important;
}

.progress-ring-content {
/* Positioned in the center of the wrapper */
position: absolute;
}
```

#### Enable smooth transitions with `@property`

To animate the progress ring smoothly when the value changes, register `--value` as a numeric custom property.

```css
@property --value {
syntax: '<number>';
inherits: true;
initial-value: 0;
}

.progress-ring-wrapper {
transition: --value 0.3s ease-in-out;
}

```

### 3. Progress Updates

Update the `--value` custom property on the wrapper whenever the `<progress>` value changes.

```html
<input type="range" min="0" max="100" value="75" id="range">

<script>
const range = document.getElementById('range');
const wrapper = document.querySelector('.progress-ring-wrapper');
const progress = wrapper.querySelector('progress');
const content = wrapper.querySelector('.progress-ring-content');

range.addEventListener('input', (e) => {
const newValue = e.target.value;

// Update semantic value
progress.value = newValue;

// Update visual value
wrapper.style.setProperty('--value', newValue);

// Update optional center content
content.textContent = `${newValue}%`;
});
</script>
```

### 4. Optional Success State

You can use the CSS `:has()` pseudo-class to automatically update the ring's appearance (e.g., changing the color to green) when the task reaches completion.

```css
/* Change the fill color to green when the progress reaches 100% */
.progress-ring-wrapper:has(progress[value="100"]) {
--fill-color: #10b981;
}
```
Comment thread
LeaVerou marked this conversation as resolved.

### 5. Respecting Motion Preferences

Users with motion sensitivities may find the transition between values disorienting. Respect the `prefers-reduced-motion` media query by transitioning immediately.


```css
@media (prefers-reduced-motion: reduce) {
.progress-ring-wrapper {
transition-duration: 0s;
}
}
```
Comment thread
LeaVerou marked this conversation as resolved.
Outdated

## Fallback strategies

The core components of this implementation — `<progress>` and `conic-gradient()` and `mask-image` with `radial-gradient()` — are Baseline Widely available. The registered `@property` for animation is the only modern addition.

Do not add a fallback value inside the `<progress>` element. It is not used by assistive technology and ignored by all modern browsers.

#### Animation fallback

{{ FEATURE_FALLBACKS("registered-custom-properties") }}

If `@property` is not supported, the ring will jump to the new value instantly instead of transitioning smoothly. This does not break the functionality. For browsers without `@property`, you can achieve transitions using a JavaScript `requestAnimationFrame` loop to interpolate the `--value`, though the native CSS transition is preferred for performance.
Comment thread
jamesnw marked this conversation as resolved.
Outdated
Loading
Loading