diff --git a/labs/gb/components/progress/_circular-progress-tokens.scss b/labs/gb/components/progress/_circular-progress-tokens.scss
new file mode 100644
index 0000000000..6982b09f02
--- /dev/null
+++ b/labs/gb/components/progress/_circular-progress-tokens.scss
@@ -0,0 +1,11 @@
+//
+// Copyright 2026 Google LLC
+// SPDX-License-Identifier: Apache-2.0
+//
+
+@mixin root {
+ --active-indicator-color: var(--md-sys-color-primary);
+ --active-indicator-thickness: 4px;
+ --track-color: var(--md-sys-color-secondary-container);
+ --size: 48px;
+}
diff --git a/labs/gb/components/progress/_linear-progress-tokens.scss b/labs/gb/components/progress/_linear-progress-tokens.scss
new file mode 100644
index 0000000000..7a7ded100d
--- /dev/null
+++ b/labs/gb/components/progress/_linear-progress-tokens.scss
@@ -0,0 +1,12 @@
+//
+// Copyright 2026 Google LLC
+// SPDX-License-Identifier: Apache-2.0
+//
+
+@mixin root {
+ --active-indicator-color: var(--md-sys-color-primary);
+ --active-indicator-thickness: 4px;
+ --container-height: 16px;
+ --track-color: var(--md-sys-color-secondary-container);
+ --track-shape: var(--md-sys-shape-corner-full);
+}
diff --git a/labs/gb/components/progress/circular-progress-element.ts b/labs/gb/components/progress/circular-progress-element.ts
new file mode 100644
index 0000000000..ba47c34bbe
--- /dev/null
+++ b/labs/gb/components/progress/circular-progress-element.ts
@@ -0,0 +1,156 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {css, CSSResultOrNative, html, nothing, TemplateResult} from 'lit';
+import {property} from 'lit/decorators.js';
+import {styleMap, type StyleInfo} from 'lit/directives/style-map.js';
+
+import {ARIAMixinStrict} from '../../../../internal/aria/aria.js';
+import {Progress} from '../../../../progress/internal/progress.js';
+
+import {circularProgress, circularWavePath} from './circular-progress.js';
+
+import circularProgressStyles from './circular-progress.css' with {type: 'css'}; // github-only
+// import {styles as circularProgressStyles} from './circular-progress.cssresult.js'; // google3-only
+
+const VIEWBOX_SIZE = 48;
+const STROKE_INSET = 6;
+const GAP_PERCENT = 6;
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+function round(value: number): number {
+ return Math.round(value * 100) / 100;
+}
+
+/**
+ * A Material Design circular progress component.
+ *
+ * @cssprop --active-indicator-color
+ * @cssprop --active-indicator-thickness
+ * @cssprop --track-color
+ * @cssprop --size
+ */
+export class CircularProgressElement extends Progress {
+ static override styles: CSSResultOrNative[] = [
+ circularProgressStyles,
+ css`
+ :host {
+ display: inline-flex;
+ vertical-align: middle;
+ }
+ `,
+ ];
+
+ /**
+ * Whether or not to render a wavy (expressive) active indicator instead of a
+ * standard smooth one.
+ */
+ @property({type: Boolean}) wavy = false;
+
+ /**
+ * The amplitude of the wavy active indicator, in viewBox units. Only applies
+ * when `wavy` is true.
+ */
+ @property({type: Number}) amplitude = 1.6;
+
+ /**
+ * The wavelength of the wavy active indicator, in viewBox units. Only applies
+ * when `wavy` is true.
+ */
+ @property({type: Number}) wavelength = 15;
+
+ protected override render() {
+ // Needed for closure conformance
+ const {ariaLabel} = this as ARIAMixinStrict;
+ return html`
+
${this.renderIndicator()}
+ `;
+ }
+
+ protected override renderIndicator(): TemplateResult {
+ const amplitude = this.wavy ? this.amplitude : 0;
+ const activePath = circularWavePath({
+ size: VIEWBOX_SIZE,
+ strokeWidth: STROKE_INSET,
+ amplitude,
+ wavelength: this.wavelength,
+ wavy: this.wavy,
+ });
+ // The track shares the active indicator's mean radius but is always smooth.
+ const trackPath = circularWavePath({
+ size: VIEWBOX_SIZE,
+ strokeWidth: STROKE_INSET,
+ amplitude,
+ wavelength: this.wavelength,
+ wavy: false,
+ });
+
+ if (this.indeterminate) {
+ return this.renderIndeterminate(trackPath, activePath);
+ }
+ return this.renderDeterminate(trackPath, activePath);
+ }
+
+ private renderDeterminate(
+ trackPath: string,
+ activePath: string,
+ ): TemplateResult {
+ const progress = this.max > 0 ? clamp(this.value / this.max, 0, 1) : 0;
+ const activeOffset = round((1 - progress) * 100);
+ const trackStart = progress * 100 + GAP_PERCENT;
+ const trackLength = Math.max(0, 100 - progress * 100 - 2 * GAP_PERCENT);
+ const trackTail = Math.max(0, 100 - trackStart - trackLength);
+ const trackStyles: StyleInfo = {
+ 'stroke-dasharray': `0 ${round(trackStart)} ${round(trackLength)} ${round(
+ trackTail,
+ )}`,
+ };
+
+ return html`
+
+ `;
+ }
+
+ private renderIndeterminate(
+ trackPath: string,
+ activePath: string,
+ ): TemplateResult {
+ return html`
+
+ `;
+ }
+}
diff --git a/labs/gb/components/progress/circular-progress.scss b/labs/gb/components/progress/circular-progress.scss
new file mode 100644
index 0000000000..e21ff96529
--- /dev/null
+++ b/labs/gb/components/progress/circular-progress.scss
@@ -0,0 +1,94 @@
+/*!
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// go/keep-sorted start by_regex='(.+) prefix_order=sass:
+@use 'circular-progress-tokens';
+// go/keep-sorted end
+
+$determinate-easing: cubic-bezier(0, 0, 0.2, 1);
+$rotate-duration: 1568ms;
+$dash-duration: 1333ms;
+
+@layer md.sys;
+@layer md.comp.circular-progress {
+ .circular-progress {
+ @include circular-progress-tokens.root;
+
+ position: relative;
+ width: var(--size);
+ height: var(--size);
+ contain: strict;
+ content-visibility: auto;
+ }
+
+ .circular-progress svg {
+ width: 100%;
+ height: 100%;
+ transform: rotate(-90deg);
+ overflow: visible;
+ }
+
+ .circular-progress path {
+ fill: none;
+ stroke-linecap: round;
+ stroke-width: var(--active-indicator-thickness);
+ }
+
+ .circular-progress-track {
+ stroke: var(--track-color);
+ }
+
+ .circular-progress-active {
+ stroke: var(--active-indicator-color);
+ stroke-dasharray: 100;
+ transition: stroke-dashoffset 500ms $determinate-easing;
+ }
+
+ .circular-progress.indeterminate svg {
+ animation: circular-rotate $rotate-duration linear infinite;
+ }
+
+ .circular-progress.indeterminate .circular-progress-active {
+ transition: none;
+ animation: circular-dash $dash-duration ease-in-out infinite;
+ }
+
+ @media (forced-colors: active) {
+ .circular-progress-active {
+ stroke: CanvasText;
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .circular-progress.indeterminate svg,
+ .circular-progress.indeterminate .circular-progress-active {
+ animation-duration: 6s;
+ }
+ }
+
+ @keyframes circular-rotate {
+ from {
+ transform: rotate(-90deg);
+ }
+ to {
+ transform: rotate(270deg);
+ }
+ }
+
+ @keyframes circular-dash {
+ 0% {
+ stroke-dasharray: 2 100;
+ stroke-dashoffset: 0;
+ }
+ 50% {
+ stroke-dasharray: 55 100;
+ stroke-dashoffset: -20;
+ }
+ 100% {
+ stroke-dasharray: 2 100;
+ stroke-dashoffset: -100;
+ }
+ }
+}
diff --git a/labs/gb/components/progress/circular-progress.ts b/labs/gb/components/progress/circular-progress.ts
new file mode 100644
index 0000000000..f9ec92d2ab
--- /dev/null
+++ b/labs/gb/components/progress/circular-progress.ts
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {type ClassInfo} from 'lit/directives/class-map.js';
+import {createClassMapDirective} from '../shared/directives.js';
+
+/** Circular progress classes. */
+export const CIRCULAR_PROGRESS_CLASSES = {
+ circularProgress: 'circular-progress',
+ indeterminate: 'indeterminate',
+} as const;
+
+/** The state provided to the `circularProgressClasses()` function. */
+export interface CircularProgressClassesState {
+ /** Whether the progress is indeterminate. */
+ indeterminate?: boolean;
+}
+
+/**
+ * Returns the circular progress root classes to apply to an element.
+ *
+ * @param state The state of the circular progress.
+ * @return An object of class names and truthy values if they apply.
+ */
+export function circularProgressClasses({
+ indeterminate = false,
+}: CircularProgressClassesState = {}): ClassInfo {
+ return {
+ [CIRCULAR_PROGRESS_CLASSES.circularProgress]: true,
+ [CIRCULAR_PROGRESS_CLASSES.indeterminate]: indeterminate,
+ };
+}
+
+/**
+ * A Lit directive that adds circular progress root styling to its element.
+ *
+ * @example
+ * ```ts
+ * html``;
+ * ```
+ */
+export const circularProgress =
+ createClassMapDirective({
+ getClasses: circularProgressClasses,
+ });
+
+const DEG_STEP = (2 * Math.PI) / 360;
+
+function round(value: number): number {
+ return Math.round(value * 100) / 100;
+}
+
+/**
+ * Options for {@link circularWavePath}.
+ */
+export interface CircularWaveOptions {
+ /** The size of the square viewBox the ring is inscribed in. */
+ size: number;
+ /** The stroke width of the ring, used to inset the ring from the edge. */
+ strokeWidth: number;
+ /** The wave amplitude, in viewBox units. */
+ amplitude: number;
+ /** The wave wavelength, in viewBox units. */
+ wavelength: number;
+ /** The phase offset of the wave, in radians. */
+ phase?: number;
+ /** Whether to render a wave. When false, a plain ring is returned. */
+ wavy?: boolean;
+}
+
+/**
+ * Builds the SVG path `d` for a closed ring inscribed in a square viewBox of
+ * `size` units.
+ *
+ * The number of wave cycles is rounded to an integer so the wave closes on
+ * itself without a visible seam. When `wavy` is false (or `amplitude`/
+ * `wavelength` is not positive) a plain circle (sampled as a closed polygon) is
+ * returned at the same mean radius, so a wavy active indicator and a smooth
+ * track stay concentric.
+ */
+export function circularWavePath(options: CircularWaveOptions): string {
+ const {
+ size,
+ strokeWidth,
+ amplitude,
+ wavelength,
+ phase = 0,
+ wavy = true,
+ } = options;
+ const center = size / 2;
+ const safeAmplitude = amplitude > 0 ? amplitude : 0;
+ // Reserve room for the wave peaks so the smooth track and the wavy active
+ // indicator share the same mean radius.
+ const radius = center - strokeWidth / 2 - safeAmplitude;
+
+ if (radius <= 0) {
+ return '';
+ }
+
+ const circumference = 2 * Math.PI * radius;
+ const waves =
+ wavy && safeAmplitude > 0 && wavelength > 0
+ ? Math.max(1, Math.round(circumference / wavelength))
+ : 0;
+ const oscillation = waves > 0 ? safeAmplitude : 0;
+
+ const points: string[] = [];
+ for (let angle = 0; angle < 2 * Math.PI; angle += DEG_STEP) {
+ const r = radius + oscillation * Math.sin(waves * angle + phase);
+ const x = center + r * Math.cos(angle);
+ const y = center + r * Math.sin(angle);
+ points.push(`${round(x)},${round(y)}`);
+ }
+
+ return `M${points[0]} L${points.slice(1).join(' ')} Z`;
+}
diff --git a/labs/gb/components/progress/circular-progress_test.ts b/labs/gb/components/progress/circular-progress_test.ts
new file mode 100644
index 0000000000..d70a26c8ef
--- /dev/null
+++ b/labs/gb/components/progress/circular-progress_test.ts
@@ -0,0 +1,169 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// import 'jasmine'; (google3-only)
+import './md-gb-circular-progress.js';
+
+import {html} from 'lit';
+
+import {Environment} from '../../../../testing/environment.js';
+
+import {circularWavePath} from './circular-progress.js';
+import {CircularProgressElement} from './circular-progress-element.js';
+
+describe('', () => {
+ const env = new Environment();
+
+ async function setupTest(
+ template = html``,
+ ) {
+ const root = env.render(template);
+ const element = root.querySelector('md-gb-circular-progress');
+ if (!(element instanceof CircularProgressElement)) {
+ throw new Error('Could not find CircularProgressElement');
+ }
+ await env.waitForStability();
+ const progressbar = element.shadowRoot!.querySelector(
+ '[role="progressbar"]',
+ )!;
+ return {element, progressbar};
+ }
+
+ it('renders a progressbar with default ARIA values', async () => {
+ const {progressbar} = await setupTest();
+ expect(progressbar.getAttribute('aria-valuemin')).toBe('0');
+ expect(progressbar.getAttribute('aria-valuemax')).toBe('1');
+ expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
+ });
+
+ it('renders the circular-progress root class', async () => {
+ const {progressbar} = await setupTest();
+ expect(progressbar.classList.contains('circular-progress')).toBeTrue();
+ });
+
+ it('reflects value and max to ARIA', async () => {
+ const {progressbar} = await setupTest(
+ html``,
+ );
+ expect(progressbar.getAttribute('aria-valuemax')).toBe('2');
+ expect(progressbar.getAttribute('aria-valuenow')).toBe('0.5');
+ });
+
+ it('removes aria-valuenow when indeterminate', async () => {
+ const {progressbar} = await setupTest(
+ html``,
+ );
+ expect(progressbar.hasAttribute('aria-valuenow')).toBeFalse();
+ });
+
+ it('delegates host aria-label to the progressbar', async () => {
+ const {progressbar} = await setupTest(
+ html``,
+ );
+ expect(progressbar.getAttribute('aria-label')).toBe('Loading');
+ });
+
+ it('reveals the active path via stroke-dashoffset when determinate', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ const active = element.shadowRoot!.querySelector(
+ 'svg path.circular-progress-active',
+ );
+ const track = element.shadowRoot!.querySelector(
+ 'svg path.circular-progress-track',
+ );
+ expect(active).not.toBeNull();
+ expect(track).not.toBeNull();
+ // (1 - 0.25 / 1) * 100 = 75
+ expect(active!.getAttribute('stroke-dashoffset')).toBe('75');
+ });
+
+ it('renders both the track and active paths when indeterminate', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(
+ element.shadowRoot!.querySelector('svg path.circular-progress-active'),
+ ).not.toBeNull();
+ expect(
+ element.shadowRoot!.querySelector('svg path.circular-progress-track'),
+ ).not.toBeNull();
+ });
+
+ it('draws a smooth active by default and a wavy active when wavy', async () => {
+ const {element: standard} = await setupTest(
+ html``,
+ );
+ const {element: wavy} = await setupTest(
+ html``,
+ );
+ const standardPath = standard
+ .shadowRoot!.querySelector('svg path.circular-progress-active')!
+ .getAttribute('d')!;
+ const wavyPath = wavy
+ .shadowRoot!.querySelector('svg path.circular-progress-active')!
+ .getAttribute('d')!;
+ expect(standardPath.length).toBeGreaterThan(0);
+ expect(wavyPath.length).toBeGreaterThan(0);
+ expect(standardPath).not.toEqual(wavyPath);
+ });
+
+ describe('circularWavePath', () => {
+ const base = {size: 48, strokeWidth: 6, wavelength: 15};
+
+ it('returns a closed path that starts with a move command', () => {
+ const path = circularWavePath({...base, amplitude: 1.6, wavy: true});
+ expect(path.startsWith('M')).toBeTrue();
+ expect(path.endsWith('Z')).toBeTrue();
+ });
+
+ it('returns an empty string when the radius is not positive', () => {
+ expect(
+ circularWavePath({
+ size: 4,
+ strokeWidth: 20,
+ amplitude: 0,
+ wavelength: 15,
+ }),
+ ).toBe('');
+ });
+
+ it('renders a wave only when wavy, amplitude and wavelength are positive', () => {
+ const wavy = circularWavePath({...base, amplitude: 1.6, wavy: true});
+ const flatByAmplitude = circularWavePath({
+ ...base,
+ amplitude: 0,
+ wavy: true,
+ });
+ const flatByWavy = circularWavePath({
+ ...base,
+ amplitude: 1.6,
+ wavy: false,
+ });
+ const flatByWavelength = circularWavePath({
+ ...base,
+ amplitude: 1.6,
+ wavelength: 0,
+ wavy: true,
+ });
+
+ expect(wavy).not.toEqual(flatByAmplitude);
+ expect(wavy).not.toEqual(flatByWavy);
+ expect(wavy).not.toEqual(flatByWavelength);
+ // Zeroing the wavelength or disabling wavy both flatten to the same ring
+ // (radius still reserves the amplitude).
+ expect(flatByWavelength).toEqual(flatByWavy);
+ });
+ });
+});
diff --git a/labs/gb/components/progress/demo/demo.ts b/labs/gb/components/progress/demo/demo.ts
new file mode 100644
index 0000000000..14c0a45a3e
--- /dev/null
+++ b/labs/gb/components/progress/demo/demo.ts
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import './material-collection.js';
+import './index.js';
+
+import {
+ KnobTypesToKnobs,
+ MaterialCollection,
+ materialInitsToStoryInits,
+ setUpDemo,
+} from './material-collection.js';
+import {boolInput, Knob, numberInput} from './index.js';
+
+import {stories, StoryKnobs} from './stories.js';
+
+const collection = new MaterialCollection>(
+ 'Progress (gBreeze)',
+ [
+ new Knob('value', {ui: numberInput({step: 0.1}), defaultValue: 0.5}),
+ new Knob('max', {ui: numberInput(), defaultValue: 1}),
+ new Knob('buffer', {ui: numberInput({step: 0.1}), defaultValue: 0.8}),
+ new Knob('indeterminate', {ui: boolInput(), defaultValue: false}),
+ ],
+);
+
+collection.addStories(...materialInitsToStoryInits(stories));
+
+setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'});
diff --git a/labs/gb/components/progress/demo/stories.ts b/labs/gb/components/progress/demo/stories.ts
new file mode 100644
index 0000000000..e2e5d1f913
--- /dev/null
+++ b/labs/gb/components/progress/demo/stories.ts
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '@material/web/labs/gb/components/progress/md-gb-circular-progress.js';
+import '@material/web/labs/gb/components/progress/md-gb-linear-progress.js';
+
+import {MaterialStoryInit} from './material-collection.js';
+import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js';
+import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js';
+import {html} from 'lit';
+
+adoptStyles(document, [m3Styles]);
+
+/** Knob types for progress stories. */
+export interface StoryKnobs {
+ value: number;
+ max: number;
+ buffer: number;
+ indeterminate: boolean;
+}
+
+const circular: MaterialStoryInit = {
+ name: 'Circular progress',
+ render(knobs) {
+ return html`
+
+
+ Standard
+
+
+
+ Wavy
+
+
+
+ `;
+ },
+};
+
+const linear: MaterialStoryInit = {
+ name: 'Linear progress',
+ render(knobs) {
+ return html`
+
+
+ Standard
+
+
+
+ Wavy
+
+
+
+ `;
+ },
+};
+
+/** Progress stories. */
+export const stories = [circular, linear];
diff --git a/labs/gb/components/progress/linear-progress-element.ts b/labs/gb/components/progress/linear-progress-element.ts
new file mode 100644
index 0000000000..b51110488a
--- /dev/null
+++ b/labs/gb/components/progress/linear-progress-element.ts
@@ -0,0 +1,204 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {css, CSSResultOrNative, html, nothing, TemplateResult} from 'lit';
+import {property, state} from 'lit/decorators.js';
+import {styleMap, type StyleInfo} from 'lit/directives/style-map.js';
+
+import {ARIAMixinStrict} from '../../../../internal/aria/aria.js';
+import {Progress} from '../../../../progress/internal/progress.js';
+
+import {linearProgress, linearWavePath} from './linear-progress.js';
+
+import linearProgressStyles from './linear-progress.css' with {type: 'css'}; // github-only
+// import {styles as linearProgressStyles} from './linear-progress.cssresult.js'; // google3-only
+
+const DEFAULT_HEIGHT = 16;
+// The gap between the active indicator and the inactive track, in pixels.
+const TRACK_GAP = 4;
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+function round(value: number): number {
+ return Math.round(value * 100) / 100;
+}
+
+/**
+ * A Material Design linear progress component.
+ *
+ * @cssprop --active-indicator-color
+ * @cssprop --active-indicator-thickness
+ * @cssprop --track-color
+ * @cssprop --track-shape
+ * @cssprop --container-height
+ */
+export class LinearProgressElement extends Progress {
+ static override styles: CSSResultOrNative[] = [
+ linearProgressStyles,
+ css`
+ :host {
+ display: block;
+ }
+ :host(:dir(rtl)) {
+ transform: scale(-1);
+ }
+ @media (forced-colors: active) {
+ :host {
+ outline: 1px solid CanvasText;
+ }
+ }
+ `,
+ ];
+
+ /**
+ * Buffer amount to display, a fraction between 0 and `max`.
+ * If the value is 0 or negative, the buffer is not displayed.
+ */
+ @property({type: Number}) buffer = 0;
+
+ /**
+ * Whether or not to render a wavy (expressive) active indicator instead of a
+ * standard smooth one.
+ */
+ @property({type: Boolean}) wavy = false;
+
+ /**
+ * The amplitude of the wavy active indicator, in pixels. Only applies when
+ * `wavy` is true.
+ */
+ @property({type: Number}) amplitude = 3;
+
+ /**
+ * The wavelength of the wavy active indicator, in pixels. Only applies when
+ * `wavy` is true.
+ */
+ @property({type: Number}) wavelength = 40;
+
+ @state() private measuredWidth = 0;
+ @state() private measuredHeight = DEFAULT_HEIGHT;
+
+ private readonly resizeObserver = new ResizeObserver(() => {
+ this.measure();
+ });
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this.resizeObserver.observe(this);
+ this.measure();
+ }
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ this.resizeObserver.disconnect();
+ }
+
+ private measure() {
+ const rect = this.getBoundingClientRect();
+ if (rect.width > 0) {
+ this.measuredWidth = rect.width;
+ }
+ if (rect.height > 0) {
+ this.measuredHeight = rect.height;
+ }
+ }
+
+ private wavePath(): string {
+ return linearWavePath({
+ width: this.measuredWidth,
+ height: this.measuredHeight,
+ amplitude: this.amplitude,
+ wavelength: this.wavelength,
+ wavy: this.wavy,
+ });
+ }
+
+ protected override render() {
+ // Needed for closure conformance
+ const {ariaLabel} = this as ARIAMixinStrict;
+ return html`
+ ${this.renderIndicator()}
+ `;
+ }
+
+ protected override renderIndicator(): TemplateResult {
+ if (this.indeterminate) {
+ return this.renderIndeterminate();
+ }
+ return this.renderDeterminate();
+ }
+
+ private renderDeterminate(): TemplateResult {
+ const progress = this.max > 0 ? clamp(this.value / this.max, 0, 1) : 0;
+ const activeOffset = round((1 - progress) * 100);
+
+ const bufferValue = this.buffer ?? 0;
+ const buffer = this.max > 0 ? clamp(bufferValue / this.max, 0, 1) : 0;
+ const hasBuffer = bufferValue > 0 && buffer > progress;
+
+ // The inactive track starts after the active indicator (plus a gap) and
+ // ends at the buffer (if any) or the end of the track.
+ const trackStyles: StyleInfo = {
+ 'inset-inline-start': `calc(${round(progress * 100)}% + ${TRACK_GAP}px)`,
+ 'inset-inline-end': `${round((1 - (hasBuffer ? buffer : 1)) * 100)}%`,
+ };
+
+ // Buffer dots fill the region between the buffer and the end of the track.
+ const dotsStyles: StyleInfo = {
+ 'inset-inline-start': `${round(buffer * 100)}%`,
+ };
+
+ const hideDots = !hasBuffer || buffer >= 1 || progress >= 1;
+ const hideStopIndicator = progress >= 1;
+
+ return html`
+
+
+
+
+ `;
+ }
+
+ private renderIndeterminate(): TemplateResult {
+ return html`
+
+
+ `;
+ }
+}
diff --git a/labs/gb/components/progress/linear-progress.scss b/labs/gb/components/progress/linear-progress.scss
new file mode 100644
index 0000000000..552d3d004e
--- /dev/null
+++ b/labs/gb/components/progress/linear-progress.scss
@@ -0,0 +1,131 @@
+/*!
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// go/keep-sorted start by_regex='(.+) prefix_order=sass:
+@use 'linear-progress-tokens';
+// go/keep-sorted end
+
+$determinate-duration: 400ms;
+$determinate-easing: cubic-bezier(0, 0, 0.2, 1);
+$indeterminate-duration: 2s;
+
+@layer md.sys;
+@layer md.comp.linear-progress {
+ .linear-progress {
+ @include linear-progress-tokens.root;
+
+ position: relative;
+ direction: ltr;
+ min-width: 80px;
+ height: var(--container-height);
+ border-radius: var(--track-shape);
+ contain: strict;
+ content-visibility: auto;
+ }
+
+ .linear-progress-inactive-track,
+ .linear-progress-dots,
+ .linear-progress-stop-indicator,
+ .linear-progress-indicator {
+ position: absolute;
+ }
+
+ .linear-progress-inactive-track,
+ .linear-progress-dots {
+ top: calc(50% - var(--active-indicator-thickness) / 2);
+ height: var(--active-indicator-thickness);
+ border-radius: var(--active-indicator-thickness);
+ }
+
+ .linear-progress-inactive-track {
+ inset-inline: 0;
+ background: var(--track-color);
+ transition:
+ inset-inline-start $determinate-duration $determinate-easing,
+ inset-inline-end $determinate-duration $determinate-easing;
+ }
+
+ .linear-progress-dots {
+ inset-inline-end: 0;
+ animation: linear infinite $determinate-duration;
+ animation-name: buffering;
+ background-color: var(--active-indicator-color);
+ background-repeat: repeat-x;
+ $svg: "data:image/svg+xml,%3Csvg version='1.1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 5 2' preserveAspectRatio='xMinYMin slice'%3E%3Ccircle cx='1' cy='1' r='1'/%3E%3C/svg%3E";
+ -webkit-mask-image: url($svg);
+ mask-image: url($svg);
+ }
+
+ .linear-progress-dots[hidden],
+ .linear-progress-stop-indicator[hidden] {
+ display: none;
+ }
+
+ .linear-progress-indicator {
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ }
+
+ .linear-progress-active {
+ fill: none;
+ stroke: var(--active-indicator-color);
+ stroke-linecap: round;
+ stroke-width: var(--active-indicator-thickness);
+ stroke-dasharray: 100;
+ transition: stroke-dashoffset $determinate-duration $determinate-easing;
+ }
+
+ .linear-progress-stop-indicator {
+ inset-inline-end: 0;
+ top: calc(50% - var(--active-indicator-thickness) / 2);
+ width: var(--active-indicator-thickness);
+ height: var(--active-indicator-thickness);
+ border-radius: 50%;
+ background: var(--active-indicator-color);
+ }
+
+ .linear-progress.indeterminate .linear-progress-inactive-track {
+ inset-inline: 0;
+ transition: none;
+ }
+
+ .linear-progress.indeterminate .linear-progress-active {
+ transition: none;
+ stroke-dasharray: 30 100;
+ animation: linear infinite $indeterminate-duration;
+ animation-name: linear-indeterminate;
+ }
+
+ @media (forced-colors: active) {
+ .linear-progress-active {
+ stroke: CanvasText;
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .linear-progress.indeterminate .linear-progress-active {
+ animation-duration: 4s;
+ }
+ }
+
+ @keyframes linear-indeterminate {
+ 0% {
+ stroke-dashoffset: 40;
+ }
+ 100% {
+ stroke-dashoffset: -130;
+ }
+ }
+
+ @keyframes buffering {
+ 0% {
+ $_dot-size: calc(var(--active-indicator-thickness));
+ $_dot-background-width: calc($_dot-size * 2.5);
+ transform: translateX(#{$_dot-background-width});
+ }
+ }
+}
diff --git a/labs/gb/components/progress/linear-progress.ts b/labs/gb/components/progress/linear-progress.ts
new file mode 100644
index 0000000000..fd3c3d8d4a
--- /dev/null
+++ b/labs/gb/components/progress/linear-progress.ts
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {type ClassInfo} from 'lit/directives/class-map.js';
+import {createClassMapDirective} from '../shared/directives.js';
+
+/** Linear progress classes. */
+export const LINEAR_PROGRESS_CLASSES = {
+ linearProgress: 'linear-progress',
+ indeterminate: 'indeterminate',
+} as const;
+
+/** The state provided to the `linearProgressClasses()` function. */
+export interface LinearProgressClassesState {
+ /** Whether the progress is indeterminate. */
+ indeterminate?: boolean;
+}
+
+/**
+ * Returns the linear progress root classes to apply to an element.
+ *
+ * @param state The state of the linear progress.
+ * @return An object of class names and truthy values if they apply.
+ */
+export function linearProgressClasses({
+ indeterminate = false,
+}: LinearProgressClassesState = {}): ClassInfo {
+ return {
+ [LINEAR_PROGRESS_CLASSES.linearProgress]: true,
+ [LINEAR_PROGRESS_CLASSES.indeterminate]: indeterminate,
+ };
+}
+
+/**
+ * A Lit directive that adds linear progress root styling to its element.
+ *
+ * @example
+ * ```ts
+ * html``;
+ * ```
+ */
+export const linearProgress =
+ createClassMapDirective({
+ getClasses: linearProgressClasses,
+ });
+
+function round(value: number): number {
+ return Math.round(value * 100) / 100;
+}
+
+/**
+ * Options for {@link linearWavePath}.
+ */
+export interface LinearWaveOptions {
+ /** The width of the wave, in pixels. */
+ width: number;
+ /** The height of the wave's viewBox, in pixels. */
+ height: number;
+ /** The wave amplitude, in pixels. */
+ amplitude: number;
+ /** The wave wavelength, in pixels. */
+ wavelength: number;
+ /** The phase offset of the wave, in radians. */
+ phase?: number;
+ /** Whether to render a wave. When false, a straight line is returned. */
+ wavy?: boolean;
+}
+
+/**
+ * Builds the SVG path `d` for a horizontal sine wave centered vertically.
+ *
+ * When `wavy` is false (or `amplitude`/`wavelength`/`width` is not positive) a
+ * straight line is returned.
+ */
+export function linearWavePath(options: LinearWaveOptions): string {
+ const {
+ width,
+ height,
+ amplitude,
+ wavelength,
+ phase = 0,
+ wavy = true,
+ } = options;
+ const center = height / 2;
+
+ if (!wavy || amplitude <= 0 || wavelength <= 0 || width <= 0) {
+ return `M0,${round(center)} L${round(Math.max(width, 0))},${round(center)}`;
+ }
+
+ const k = (2 * Math.PI) / wavelength;
+ const points: string[] = [];
+ for (let x = 0; x < width; x += 1) {
+ const y = center + amplitude * Math.sin(k * x + phase);
+ points.push(`${round(x)},${round(y)}`);
+ }
+ const endY = center + amplitude * Math.sin(k * width + phase);
+ points.push(`${round(width)},${round(endY)}`);
+
+ return `M${points[0]} L${points.slice(1).join(' ')}`;
+}
diff --git a/labs/gb/components/progress/linear-progress_test.ts b/labs/gb/components/progress/linear-progress_test.ts
new file mode 100644
index 0000000000..0d98c9034e
--- /dev/null
+++ b/labs/gb/components/progress/linear-progress_test.ts
@@ -0,0 +1,272 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// import 'jasmine'; (google3-only)
+import './md-gb-linear-progress.js';
+
+import {html} from 'lit';
+
+import {Environment} from '../../../../testing/environment.js';
+
+import {linearWavePath} from './linear-progress.js';
+import {LinearProgressElement} from './linear-progress-element.js';
+
+describe('', () => {
+ const env = new Environment();
+
+ async function setupTest(
+ template = html``,
+ ) {
+ const root = env.render(template);
+ const element = root.querySelector('md-gb-linear-progress');
+ if (!(element instanceof LinearProgressElement)) {
+ throw new Error('Could not find LinearProgressElement');
+ }
+ await env.waitForStability();
+ const progressbar = element.shadowRoot!.querySelector(
+ '[role="progressbar"]',
+ )!;
+ return {element, progressbar};
+ }
+
+ it('renders a progressbar with default ARIA values', async () => {
+ const {progressbar} = await setupTest();
+ expect(progressbar.getAttribute('aria-valuemin')).toBe('0');
+ expect(progressbar.getAttribute('aria-valuemax')).toBe('1');
+ expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
+ });
+
+ it('renders the linear-progress root class', async () => {
+ const {progressbar} = await setupTest();
+ expect(progressbar.classList.contains('linear-progress')).toBeTrue();
+ });
+
+ it('reflects value and max to ARIA', async () => {
+ const {progressbar} = await setupTest(
+ html``,
+ );
+ expect(progressbar.getAttribute('aria-valuemax')).toBe('2');
+ expect(progressbar.getAttribute('aria-valuenow')).toBe('0.5');
+ });
+
+ it('removes aria-valuenow when indeterminate', async () => {
+ const {progressbar} = await setupTest(
+ html``,
+ );
+ expect(progressbar.hasAttribute('aria-valuenow')).toBeFalse();
+ });
+
+ it('delegates host aria-label to the progressbar', async () => {
+ const {progressbar} = await setupTest(
+ html``,
+ );
+ expect(progressbar.getAttribute('aria-label')).toBe('Loading');
+ });
+
+ it('reveals the active path via stroke-dashoffset when determinate', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ const active = element.shadowRoot!.querySelector(
+ 'svg path.linear-progress-active',
+ )!;
+ // (1 - 0.5 / 1) * 100 = 50
+ expect(active.getAttribute('stroke-dashoffset')).toBe('50');
+ });
+
+ it('measures its width and renders a sized indicator viewBox', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ const svg = element.shadowRoot!.querySelector(
+ 'svg.linear-progress-indicator',
+ )!;
+ const viewBoxWidth = Number(svg.getAttribute('viewBox')!.split(' ')[2]);
+ expect(viewBoxWidth).toBeGreaterThan(0);
+ });
+
+ it('renders a stop indicator when determinate', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(
+ element.shadowRoot!.querySelector('.linear-progress-stop-indicator'),
+ ).not.toBeNull();
+ });
+
+ it('hides buffer dots when there is no buffer', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(
+ element
+ .shadowRoot!.querySelector('.linear-progress-dots')!
+ .hasAttribute('hidden'),
+ ).toBeTrue();
+ });
+
+ it('shows dots and an inactive track when the buffer is set', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ const dots = element.shadowRoot!.querySelector('.linear-progress-dots')!;
+ const inactiveTrack = element.shadowRoot!.querySelector(
+ '.linear-progress-inactive-track',
+ );
+ expect(dots.hasAttribute('hidden')).toBeFalse();
+ expect(inactiveTrack).not.toBeNull();
+ });
+
+ it('renders an inactive track but no dots or stop indicator when indeterminate', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(
+ element.shadowRoot!.querySelector('svg path.linear-progress-active'),
+ ).not.toBeNull();
+ expect(
+ element.shadowRoot!.querySelector('.linear-progress-inactive-track'),
+ ).not.toBeNull();
+ expect(
+ element.shadowRoot!.querySelector('.linear-progress-dots'),
+ ).toBeNull();
+ expect(
+ element.shadowRoot!.querySelector('.linear-progress-stop-indicator'),
+ ).toBeNull();
+ });
+
+ it('hides dots when the buffer reaches max', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(
+ element
+ .shadowRoot!.querySelector('.linear-progress-dots')!
+ .hasAttribute('hidden'),
+ ).toBeTrue();
+ });
+
+ it('hides the dots and stop indicator when the value reaches max', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(
+ element
+ .shadowRoot!.querySelector('.linear-progress-dots')!
+ .hasAttribute('hidden'),
+ ).toBeTrue();
+ expect(
+ element
+ .shadowRoot!.querySelector('.linear-progress-stop-indicator')!
+ .hasAttribute('hidden'),
+ ).toBeTrue();
+ });
+
+ it('draws a smooth active by default and a wavy active when wavy', async () => {
+ const {element: standard} = await setupTest(
+ html``,
+ );
+ const {element: wavy} = await setupTest(
+ html``,
+ );
+ const standardPath = standard
+ .shadowRoot!.querySelector('svg path.linear-progress-active')!
+ .getAttribute('d')!;
+ const wavyPath = wavy
+ .shadowRoot!.querySelector('svg path.linear-progress-active')!
+ .getAttribute('d')!;
+ expect(standardPath.length).toBeGreaterThan(0);
+ expect(wavyPath.length).toBeGreaterThan(0);
+ expect(standardPath).not.toEqual(wavyPath);
+ });
+
+ describe('linearWavePath', () => {
+ const straight = 'M0,8 L100,8';
+
+ it('returns a straight line when not wavy', () => {
+ expect(
+ linearWavePath({
+ width: 100,
+ height: 16,
+ amplitude: 3,
+ wavelength: 40,
+ wavy: false,
+ }),
+ ).toBe(straight);
+ });
+
+ it('returns a straight line when amplitude is not positive', () => {
+ expect(
+ linearWavePath({
+ width: 100,
+ height: 16,
+ amplitude: 0,
+ wavelength: 40,
+ wavy: true,
+ }),
+ ).toBe(straight);
+ });
+
+ it('returns a straight line when wavelength is not positive', () => {
+ expect(
+ linearWavePath({
+ width: 100,
+ height: 16,
+ amplitude: 3,
+ wavelength: 0,
+ wavy: true,
+ }),
+ ).toBe(straight);
+ });
+
+ it('returns a zero-length line when width is not positive', () => {
+ expect(
+ linearWavePath({
+ width: 0,
+ height: 16,
+ amplitude: 3,
+ wavelength: 40,
+ wavy: true,
+ }),
+ ).toBe('M0,8 L0,8');
+ });
+
+ it('renders a wave that differs from a straight line', () => {
+ const wavy = linearWavePath({
+ width: 100,
+ height: 16,
+ amplitude: 3,
+ wavelength: 40,
+ wavy: true,
+ });
+ expect(wavy.startsWith('M')).toBeTrue();
+ expect(wavy).not.toEqual(straight);
+ });
+ });
+});
diff --git a/labs/gb/components/progress/md-gb-circular-progress.ts b/labs/gb/components/progress/md-gb-circular-progress.ts
new file mode 100644
index 0000000000..19f7d39764
--- /dev/null
+++ b/labs/gb/components/progress/md-gb-circular-progress.ts
@@ -0,0 +1,16 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {CircularProgressElement} from './circular-progress-element.js';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ /** A Material Design circular progress component. */
+ 'md-gb-circular-progress': CircularProgressElement;
+ }
+}
+
+customElements.define('md-gb-circular-progress', CircularProgressElement);
diff --git a/labs/gb/components/progress/md-gb-linear-progress.ts b/labs/gb/components/progress/md-gb-linear-progress.ts
new file mode 100644
index 0000000000..f10fced462
--- /dev/null
+++ b/labs/gb/components/progress/md-gb-linear-progress.ts
@@ -0,0 +1,16 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {LinearProgressElement} from './linear-progress-element.js';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ /** A Material Design linear progress component. */
+ 'md-gb-linear-progress': LinearProgressElement;
+ }
+}
+
+customElements.define('md-gb-linear-progress', LinearProgressElement);