diff --git a/labs/gb/components/circularprogress/_circular-progress-tokens.scss b/labs/gb/components/circularprogress/_circular-progress-tokens.scss
new file mode 100644
index 0000000000..7318186a96
--- /dev/null
+++ b/labs/gb/components/circularprogress/_circular-progress-tokens.scss
@@ -0,0 +1,19 @@
+//
+// 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;
+ --four-color-active-indicator-one-color: var(--md-sys-color-primary);
+ --four-color-active-indicator-two-color: var(
+ --md-sys-color-primary-container
+ );
+ --four-color-active-indicator-three-color: var(--md-sys-color-tertiary);
+ --four-color-active-indicator-four-color: var(
+ --md-sys-color-tertiary-container
+ );
+}
diff --git a/labs/gb/components/circularprogress/circular-progress-element.ts b/labs/gb/components/circularprogress/circular-progress-element.ts
new file mode 100644
index 0000000000..e10eebe4a2
--- /dev/null
+++ b/labs/gb/components/circularprogress/circular-progress-element.ts
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {CSSResultOrNative, html, TemplateResult} from 'lit';
+import {property} from 'lit/decorators.js';
+import {styleMap, type StyleInfo} from 'lit/directives/style-map.js';
+
+import {ProgressElement} from '../shared/progress-base.js';
+import {circularWavePath} from '../shared/wave-path.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 = 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 circular progress component.
+ *
+ * @cssprop --active-indicator-color
+ * @cssprop --active-indicator-thickness
+ * @cssprop --track-color
+ * @cssprop --size
+ * @cssprop --four-color-active-indicator-one-color
+ * @cssprop --four-color-active-indicator-two-color
+ * @cssprop --four-color-active-indicator-three-color
+ * @cssprop --four-color-active-indicator-four-color
+ */
+export class CircularProgressElement extends ProgressElement {
+ static override styles: CSSResultOrNative[] = [circularProgressStyles];
+
+ /**
+ * The amplitude of the wavy active indicator, in viewBox units. A value of 0
+ * renders a smooth (non-wavy) indicator.
+ */
+ @property({type: Number}) amplitude = 2;
+
+ /**
+ * The wavelength of the wavy active indicator, in viewBox units.
+ */
+ @property({type: Number}) wavelength = 15;
+
+ protected override renderIndicator(): TemplateResult {
+ const path = circularWavePath({
+ size: VIEWBOX_SIZE,
+ strokeWidth: STROKE_INSET,
+ amplitude: this.amplitude,
+ wavelength: this.wavelength,
+ });
+
+ if (this.indeterminate) {
+ return this.renderIndeterminate(path);
+ }
+ return this.renderDeterminate(path);
+ }
+
+ private renderDeterminate(path: 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(path: string): TemplateResult {
+ return html`
+
+ `;
+ }
+}
diff --git a/labs/gb/components/circularprogress/circular-progress.scss b/labs/gb/components/circularprogress/circular-progress.scss
new file mode 100644
index 0000000000..ce3d1d612c
--- /dev/null
+++ b/labs/gb/components/circularprogress/circular-progress.scss
@@ -0,0 +1,128 @@
+/*!
+ * 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;
+$four-color-duration: 4s;
+
+@layer md.sys;
+@layer md.comp.circular-progress {
+ :host {
+ @include circular-progress-tokens.root;
+
+ display: inline-flex;
+ vertical-align: middle;
+ width: var(--size);
+ height: var(--size);
+ position: relative;
+ align-items: center;
+ justify-content: center;
+ contain: strict;
+ content-visibility: auto;
+ }
+
+ .progress {
+ position: absolute;
+ inset: 0;
+ }
+
+ svg {
+ width: 100%;
+ height: 100%;
+ transform: rotate(-90deg);
+ overflow: visible;
+ }
+
+ path {
+ fill: none;
+ stroke-linecap: round;
+ stroke-width: var(--active-indicator-thickness);
+ }
+
+ .track {
+ stroke: var(--track-color);
+ }
+
+ .active {
+ stroke: var(--active-indicator-color);
+ stroke-dasharray: 100;
+ transition: stroke-dashoffset 500ms $determinate-easing;
+ }
+
+ .progress.indeterminate svg {
+ animation: circular-rotate $rotate-duration linear infinite;
+ }
+
+ .progress.indeterminate .active {
+ transition: none;
+ animation: circular-dash $dash-duration ease-in-out infinite;
+ }
+
+ .progress.indeterminate.four-color .active {
+ animation:
+ circular-dash $dash-duration ease-in-out infinite,
+ circular-four-color $four-color-duration ease-in-out infinite;
+ }
+
+ @media (forced-colors: active) {
+ .active {
+ stroke: CanvasText;
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .progress.indeterminate svg,
+ .progress.indeterminate .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;
+ }
+ }
+
+ @keyframes circular-four-color {
+ 0% {
+ stroke: var(--four-color-active-indicator-one-color);
+ }
+ 25% {
+ stroke: var(--four-color-active-indicator-two-color);
+ }
+ 50% {
+ stroke: var(--four-color-active-indicator-three-color);
+ }
+ 75% {
+ stroke: var(--four-color-active-indicator-four-color);
+ }
+ 100% {
+ stroke: var(--four-color-active-indicator-one-color);
+ }
+ }
+}
diff --git a/labs/gb/components/circularprogress/circular-progress.ts b/labs/gb/components/circularprogress/circular-progress.ts
new file mode 100644
index 0000000000..e97cebbe6e
--- /dev/null
+++ b/labs/gb/components/circularprogress/circular-progress.ts
@@ -0,0 +1,53 @@
+/**
+ * @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 = {
+ progress: 'progress',
+ indeterminate: 'indeterminate',
+ fourColor: 'four-color',
+} as const;
+
+/** The state provided to the `circularProgressClasses()` function. */
+export interface CircularProgressClassesState {
+ /** Whether the progress is indeterminate. */
+ indeterminate?: boolean;
+ /** Whether indeterminate mode cycles through 4 colors. */
+ fourColor?: 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,
+ fourColor = false,
+}: CircularProgressClassesState = {}): ClassInfo {
+ return {
+ [CIRCULAR_PROGRESS_CLASSES.progress]: true,
+ [CIRCULAR_PROGRESS_CLASSES.indeterminate]: indeterminate,
+ [CIRCULAR_PROGRESS_CLASSES.fourColor]: fourColor,
+ };
+}
+
+/**
+ * A Lit directive that adds circular progress root styling to its element.
+ *
+ * @example
+ * ```ts
+ * html`
`;
+ * ```
+ */
+export const circularProgress =
+ createClassMapDirective({
+ getClasses: circularProgressClasses,
+ });
diff --git a/labs/gb/components/circularprogress/circular-progress_test.ts b/labs/gb/components/circularprogress/circular-progress_test.ts
new file mode 100644
index 0000000000..6fc272d731
--- /dev/null
+++ b/labs/gb/components/circularprogress/circular-progress_test.ts
@@ -0,0 +1,107 @@
+/**
+ * @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 {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('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.active');
+ const track = element.shadowRoot!.querySelector('svg path.track');
+ expect(active).not.toBeNull();
+ expect(track).not.toBeNull();
+ // (1 - 0.25 / 1) * 100 = 75
+ expect(active!.getAttribute('stroke-dashoffset')).toBe('75');
+ });
+
+ it('renders only the active path and no track when indeterminate', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(element.shadowRoot!.querySelector('svg path.active')).not.toBeNull();
+ expect(element.shadowRoot!.querySelector('svg path.track')).toBeNull();
+ });
+
+ it('draws a wavy path by default and a smoother path when amplitude is 0', async () => {
+ const {element: wavy} = await setupTest(
+ html``,
+ );
+ const {element: smooth} = await setupTest(
+ html``,
+ );
+ const wavyPath = wavy
+ .shadowRoot!.querySelector('svg path.active')!
+ .getAttribute('d')!;
+ const smoothPath = smooth
+ .shadowRoot!.querySelector('svg path.active')!
+ .getAttribute('d')!;
+ expect(wavyPath.length).toBeGreaterThan(0);
+ expect(smoothPath.length).toBeGreaterThan(0);
+ expect(wavyPath).not.toEqual(smoothPath);
+ });
+});
diff --git a/labs/gb/components/circularprogress/demo/demo.ts b/labs/gb/components/circularprogress/demo/demo.ts
new file mode 100644
index 0000000000..1b9480bc36
--- /dev/null
+++ b/labs/gb/components/circularprogress/demo/demo.ts
@@ -0,0 +1,34 @@
+/**
+ * @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>(
+ 'Circular Progress (gBreeze)',
+ [
+ new Knob('value', {ui: numberInput({step: 0.1}), defaultValue: 0.5}),
+ new Knob('max', {ui: numberInput(), defaultValue: 1}),
+ new Knob('amplitude', {ui: numberInput({step: 0.5}), defaultValue: 2}),
+ new Knob('wavelength', {ui: numberInput({step: 1}), defaultValue: 15}),
+ new Knob('indeterminate', {ui: boolInput(), defaultValue: false}),
+ new Knob('fourColor', {ui: boolInput(), defaultValue: false}),
+ ],
+);
+
+collection.addStories(...materialInitsToStoryInits(stories));
+
+setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'});
diff --git a/labs/gb/components/circularprogress/demo/stories.ts b/labs/gb/components/circularprogress/demo/stories.ts
new file mode 100644
index 0000000000..204e77da77
--- /dev/null
+++ b/labs/gb/components/circularprogress/demo/stories.ts
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '@material/web/labs/gb/components/circularprogress/md-gb-circular-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 circular progress stories. */
+export interface StoryKnobs {
+ value: number;
+ max: number;
+ amplitude: number;
+ wavelength: number;
+ indeterminate: boolean;
+ fourColor: boolean;
+}
+
+const playground: MaterialStoryInit = {
+ name: 'Playground',
+ render(knobs) {
+ return html`
+
+ `;
+ },
+};
+
+/** Circular progress stories. */
+export const stories = [playground];
diff --git a/labs/gb/components/circularprogress/md-gb-circular-progress.ts b/labs/gb/components/circularprogress/md-gb-circular-progress.ts
new file mode 100644
index 0000000000..19f7d39764
--- /dev/null
+++ b/labs/gb/components/circularprogress/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/linearprogress/_linear-progress-tokens.scss b/labs/gb/components/linearprogress/_linear-progress-tokens.scss
new file mode 100644
index 0000000000..4d9ddb41c6
--- /dev/null
+++ b/labs/gb/components/linearprogress/_linear-progress-tokens.scss
@@ -0,0 +1,20 @@
+//
+// 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);
+ --four-color-active-indicator-one-color: var(--md-sys-color-primary);
+ --four-color-active-indicator-two-color: var(
+ --md-sys-color-primary-container
+ );
+ --four-color-active-indicator-three-color: var(--md-sys-color-tertiary);
+ --four-color-active-indicator-four-color: var(
+ --md-sys-color-tertiary-container
+ );
+}
diff --git a/labs/gb/components/linearprogress/demo/demo.ts b/labs/gb/components/linearprogress/demo/demo.ts
new file mode 100644
index 0000000000..7a59fb6602
--- /dev/null
+++ b/labs/gb/components/linearprogress/demo/demo.ts
@@ -0,0 +1,35 @@
+/**
+ * @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>(
+ 'Linear 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('amplitude', {ui: numberInput({step: 0.5}), defaultValue: 3}),
+ new Knob('wavelength', {ui: numberInput({step: 1}), defaultValue: 40}),
+ new Knob('indeterminate', {ui: boolInput(), defaultValue: false}),
+ new Knob('fourColor', {ui: boolInput(), defaultValue: false}),
+ ],
+);
+
+collection.addStories(...materialInitsToStoryInits(stories));
+
+setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'});
diff --git a/labs/gb/components/linearprogress/demo/stories.ts b/labs/gb/components/linearprogress/demo/stories.ts
new file mode 100644
index 0000000000..3de7decc7b
--- /dev/null
+++ b/labs/gb/components/linearprogress/demo/stories.ts
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '@material/web/labs/gb/components/linearprogress/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 linear progress stories. */
+export interface StoryKnobs {
+ value: number;
+ max: number;
+ buffer: number;
+ amplitude: number;
+ wavelength: number;
+ indeterminate: boolean;
+ fourColor: boolean;
+}
+
+const playground: MaterialStoryInit = {
+ name: 'Playground',
+ render(knobs) {
+ return html`
+
+
+
+ `;
+ },
+};
+
+/** Linear progress stories. */
+export const stories = [playground];
diff --git a/labs/gb/components/linearprogress/linear-progress-element.ts b/labs/gb/components/linearprogress/linear-progress-element.ts
new file mode 100644
index 0000000000..902316a3ef
--- /dev/null
+++ b/labs/gb/components/linearprogress/linear-progress-element.ts
@@ -0,0 +1,151 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {CSSResultOrNative, html, TemplateResult} from 'lit';
+import {property, state} from 'lit/decorators.js';
+import {styleMap, type StyleInfo} from 'lit/directives/style-map.js';
+
+import {ProgressElement} from '../shared/progress-base.js';
+import {linearWavePath} from '../shared/wave-path.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;
+
+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 --four-color-active-indicator-one-color
+ * @cssprop --four-color-active-indicator-two-color
+ * @cssprop --four-color-active-indicator-three-color
+ * @cssprop --four-color-active-indicator-four-color
+ */
+export class LinearProgressElement extends ProgressElement {
+ static override styles: CSSResultOrNative[] = [linearProgressStyles];
+
+ /**
+ * 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;
+
+ /**
+ * The amplitude of the wavy active indicator, in pixels. A value of 0 renders
+ * a smooth (non-wavy) indicator.
+ */
+ @property({type: Number}) amplitude = 3;
+
+ /**
+ * The wavelength of the wavy active indicator, in pixels.
+ */
+ @property({type: Number}) wavelength = 40;
+
+ @state() private measuredWidth = 0;
+ @state() private measuredHeight = DEFAULT_HEIGHT;
+
+ private resizeObserver?: ResizeObserver;
+
+ override connectedCallback() {
+ super.connectedCallback();
+ if (typeof ResizeObserver !== 'undefined') {
+ this.resizeObserver = new ResizeObserver(() => {
+ this.measure();
+ });
+ this.resizeObserver.observe(this);
+ }
+ this.measure();
+ }
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ this.resizeObserver?.disconnect();
+ this.resizeObserver = undefined;
+ }
+
+ 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,
+ });
+ }
+
+ 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 hasBuffer = bufferValue > 0;
+ const dotSize =
+ this.indeterminate || !hasBuffer
+ ? 1
+ : clamp(bufferValue / this.max, 0, 1);
+ const dotStyles: StyleInfo = {
+ transform: `scaleX(${dotSize * 100}%)`,
+ };
+
+ const hideDots =
+ !hasBuffer || bufferValue >= this.max || this.value >= this.max;
+
+ return html`
+
+
+
+
+ `;
+ }
+
+ private renderIndeterminate(): TemplateResult {
+ return html`
+
+ `;
+ }
+}
diff --git a/labs/gb/components/linearprogress/linear-progress.scss b/labs/gb/components/linearprogress/linear-progress.scss
new file mode 100644
index 0000000000..f0b3114295
--- /dev/null
+++ b/labs/gb/components/linearprogress/linear-progress.scss
@@ -0,0 +1,168 @@
+/*!
+ * 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;
+$four-color-duration: 4s;
+
+@layer md.sys;
+@layer md.comp.linear-progress {
+ :host {
+ @include linear-progress-tokens.root;
+
+ border-radius: var(--track-shape);
+ display: block;
+ position: relative;
+ min-width: 80px;
+ height: var(--container-height);
+ content-visibility: auto;
+ contain: strict;
+ }
+
+ .progress {
+ position: absolute;
+ inset: 0;
+ direction: ltr;
+ border-radius: inherit;
+ }
+
+ .dots,
+ .inactive-track,
+ .stop-indicator,
+ .indicator {
+ position: absolute;
+ }
+
+ .inactive-track,
+ .dots {
+ inset-inline: 0;
+ top: calc(50% - var(--active-indicator-thickness) / 2);
+ height: var(--active-indicator-thickness);
+ border-radius: var(--active-indicator-thickness);
+ }
+
+ .inactive-track {
+ background: var(--track-color);
+ transform-origin: left center;
+ transition: transform $determinate-duration $determinate-easing;
+ }
+
+ .dots {
+ animation: linear infinite $determinate-duration;
+ animation-name: buffering;
+ background-color: var(--track-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);
+ }
+
+ .dots[hidden] {
+ display: none;
+ }
+
+ .indicator {
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ }
+
+ .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;
+ }
+
+ .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);
+ }
+
+ .progress.indeterminate .dots,
+ .progress.indeterminate .inactive-track,
+ .progress.indeterminate .stop-indicator {
+ display: none;
+ }
+
+ .progress.indeterminate .active {
+ transition: none;
+ stroke-dasharray: 30 100;
+ animation: linear infinite $indeterminate-duration;
+ animation-name: linear-indeterminate;
+ }
+
+ .progress.indeterminate.four-color .active {
+ animation-name: linear-indeterminate, linear-four-color;
+ animation-duration: $indeterminate-duration, $four-color-duration;
+ }
+
+ :host(:dir(rtl)) {
+ transform: scale(-1);
+ }
+
+ @media (forced-colors: active) {
+ :host {
+ outline: 1px solid CanvasText;
+ }
+
+ .active {
+ stroke: CanvasText;
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .progress.indeterminate .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});
+ }
+ }
+
+ @keyframes linear-four-color {
+ 0% {
+ stroke: var(--four-color-active-indicator-one-color);
+ }
+ 25% {
+ stroke: var(--four-color-active-indicator-two-color);
+ }
+ 50% {
+ stroke: var(--four-color-active-indicator-three-color);
+ }
+ 75% {
+ stroke: var(--four-color-active-indicator-four-color);
+ }
+ 100% {
+ stroke: var(--four-color-active-indicator-one-color);
+ }
+ }
+}
diff --git a/labs/gb/components/linearprogress/linear-progress.ts b/labs/gb/components/linearprogress/linear-progress.ts
new file mode 100644
index 0000000000..2adf8c4cee
--- /dev/null
+++ b/labs/gb/components/linearprogress/linear-progress.ts
@@ -0,0 +1,53 @@
+/**
+ * @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 = {
+ progress: 'progress',
+ indeterminate: 'indeterminate',
+ fourColor: 'four-color',
+} as const;
+
+/** The state provided to the `linearProgressClasses()` function. */
+export interface LinearProgressClassesState {
+ /** Whether the progress is indeterminate. */
+ indeterminate?: boolean;
+ /** Whether indeterminate mode cycles through 4 colors. */
+ fourColor?: 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,
+ fourColor = false,
+}: LinearProgressClassesState = {}): ClassInfo {
+ return {
+ [LINEAR_PROGRESS_CLASSES.progress]: true,
+ [LINEAR_PROGRESS_CLASSES.indeterminate]: indeterminate,
+ [LINEAR_PROGRESS_CLASSES.fourColor]: fourColor,
+ };
+}
+
+/**
+ * A Lit directive that adds linear progress root styling to its element.
+ *
+ * @example
+ * ```ts
+ * html``;
+ * ```
+ */
+export const linearProgress =
+ createClassMapDirective({
+ getClasses: linearProgressClasses,
+ });
diff --git a/labs/gb/components/linearprogress/linear-progress_test.ts b/labs/gb/components/linearprogress/linear-progress_test.ts
new file mode 100644
index 0000000000..6f897fd373
--- /dev/null
+++ b/labs/gb/components/linearprogress/linear-progress_test.ts
@@ -0,0 +1,142 @@
+/**
+ * @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 {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('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.active')!;
+ // (1 - 0.5 / 1) * 100 = 50
+ expect(active.getAttribute('stroke-dashoffset')).toBe('50');
+ });
+
+ it('renders a stop indicator when determinate', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(element.shadowRoot!.querySelector('.stop-indicator')).not.toBeNull();
+ });
+
+ it('hides buffer dots when there is no buffer', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(
+ element.shadowRoot!.querySelector('.dots')!.hasAttribute('hidden'),
+ ).toBeTrue();
+ });
+
+ it('shows dots and scales inactive-track when buffer is set', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ const dots = element.shadowRoot!.querySelector('.dots')!;
+ const inactiveTrack =
+ element.shadowRoot!.querySelector('.inactive-track')!;
+ expect(dots.hasAttribute('hidden')).toBeFalse();
+ expect(inactiveTrack.style.transform).toContain('scaleX(0.6)');
+ });
+
+ it('renders only the active path and no track chrome when indeterminate', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(element.shadowRoot!.querySelector('svg path.active')).not.toBeNull();
+ expect(element.shadowRoot!.querySelector('.dots')).toBeNull();
+ expect(element.shadowRoot!.querySelector('.inactive-track')).toBeNull();
+ expect(element.shadowRoot!.querySelector('.stop-indicator')).toBeNull();
+ });
+
+ it('hides dots when the buffer reaches max', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(
+ element.shadowRoot!.querySelector('.dots')!.hasAttribute('hidden'),
+ ).toBeTrue();
+ });
+
+ it('hides dots when the value reaches max', async () => {
+ const {element} = await setupTest(
+ html``,
+ );
+ expect(
+ element.shadowRoot!.querySelector('.dots')!.hasAttribute('hidden'),
+ ).toBeTrue();
+ });
+});
diff --git a/labs/gb/components/linearprogress/md-gb-linear-progress.ts b/labs/gb/components/linearprogress/md-gb-linear-progress.ts
new file mode 100644
index 0000000000..f10fced462
--- /dev/null
+++ b/labs/gb/components/linearprogress/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);
diff --git a/labs/gb/components/shared/progress-base.ts b/labs/gb/components/shared/progress-base.ts
new file mode 100644
index 0000000000..9ec6c59aeb
--- /dev/null
+++ b/labs/gb/components/shared/progress-base.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {html, LitElement, nothing, TemplateResult} from 'lit';
+import {property} from 'lit/decorators.js';
+import {classMap, type ClassInfo} from 'lit/directives/class-map.js';
+
+import {ARIAMixinStrict} from '../../../../internal/aria/aria.js';
+import {mixinDelegatesAria} from '../../../../internal/aria/delegate.js';
+
+// Separate variable needed for closure.
+const progressBaseClass = mixinDelegatesAria(LitElement);
+
+/**
+ * A shared base class for progress indicators (circular and linear).
+ *
+ * Owns the common reactive state (`value`, `max`, `indeterminate`,
+ * `fourColor`), the `role="progressbar"` container and the ARIA wiring.
+ * Subclasses implement `renderIndicator()` to render the actual indicator DOM.
+ */
+export abstract class ProgressElement extends progressBaseClass {
+ /**
+ * Progress to display, a fraction between 0 and `max`.
+ */
+ @property({type: Number}) value = 0;
+
+ /**
+ * Maximum progress to display, defaults to 1.
+ */
+ @property({type: Number}) max = 1;
+
+ /**
+ * Whether or not to display indeterminate progress, which gives no indication
+ * to how long an activity will take.
+ */
+ @property({type: Boolean}) indeterminate = false;
+
+ /**
+ * Whether or not to render indeterminate mode using 4 colors instead of one.
+ */
+ @property({type: Boolean, attribute: 'four-color'}) fourColor = false;
+
+ protected override render() {
+ // Needed for closure conformance
+ const {ariaLabel} = this as ARIAMixinStrict;
+ return html`
+ ${this.renderIndicator()}
+ `;
+ }
+
+ protected getRenderClasses(): ClassInfo {
+ return {
+ 'indeterminate': this.indeterminate,
+ 'four-color': this.fourColor,
+ };
+ }
+
+ protected abstract renderIndicator(): TemplateResult;
+}
diff --git a/labs/gb/components/shared/wave-path.ts b/labs/gb/components/shared/wave-path.ts
new file mode 100644
index 0000000000..2fa6867453
--- /dev/null
+++ b/labs/gb/components/shared/wave-path.ts
@@ -0,0 +1,96 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const DEG_STEP = (2 * Math.PI) / 360;
+
+function round(value: number): number {
+ return Math.round(value * 100) / 100;
+}
+
+/**
+ * Options for {@link linearWavePath}.
+ */
+export interface LinearWaveOptions {
+ width: number;
+ height: number;
+ amplitude: number;
+ wavelength: number;
+ phase?: number;
+ resolution?: number;
+}
+
+/**
+ * Builds the SVG path `d` for a horizontal sine wave centered vertically.
+ *
+ * When `amplitude` or `wavelength` is not positive a straight line is returned.
+ */
+export function linearWavePath(options: LinearWaveOptions): string {
+ const {width, height, amplitude, wavelength, phase = 0} = options;
+ const resolution =
+ options.resolution && options.resolution > 0 ? options.resolution : 1;
+ const center = height / 2;
+
+ if (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 += resolution) {
+ 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(' ')}`;
+}
+
+/**
+ * Options for {@link circularWavePath}.
+ */
+export interface CircularWaveOptions {
+ size: number;
+ strokeWidth: number;
+ amplitude: number;
+ wavelength: number;
+ phase?: number;
+}
+
+/**
+ * Builds the SVG path `d` for a closed wavy 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 `amplitude` or `wavelength` is not
+ * positive a plain circle (sampled as a closed polygon) is returned.
+ */
+export function circularWavePath(options: CircularWaveOptions): string {
+ const {size, strokeWidth, amplitude, wavelength, phase = 0} = options;
+ const center = size / 2;
+ const safeAmplitude = amplitude > 0 ? amplitude : 0;
+ const radius = center - strokeWidth / 2 - safeAmplitude;
+
+ if (radius <= 0) {
+ return '';
+ }
+
+ const circumference = 2 * Math.PI * radius;
+ const waves =
+ safeAmplitude > 0 && wavelength > 0
+ ? Math.max(1, Math.round(circumference / wavelength))
+ : 0;
+
+ const points: string[] = [];
+ for (let angle = 0; angle < 2 * Math.PI; angle += DEG_STEP) {
+ const r = radius + safeAmplitude * 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/shared/wave-path_test.ts b/labs/gb/components/shared/wave-path_test.ts
new file mode 100644
index 0000000000..5fa409dc5b
--- /dev/null
+++ b/labs/gb/components/shared/wave-path_test.ts
@@ -0,0 +1,136 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// import 'jasmine'; (google3-only)
+
+import {circularWavePath, linearWavePath} from './wave-path.js';
+
+interface Point {
+ x: number;
+ y: number;
+}
+
+function parsePoints(path: string): Point[] {
+ return path
+ .replace(/[MLZ]/g, ' ')
+ .trim()
+ .split(/\s+/)
+ .filter((pair) => pair.includes(','))
+ .map((pair) => {
+ const [x, y] = pair.split(',').map(Number);
+ return {x, y};
+ });
+}
+
+describe('linearWavePath', () => {
+ it('returns a straight centered line when amplitude is 0', () => {
+ const path = linearWavePath({
+ width: 100,
+ height: 8,
+ amplitude: 0,
+ wavelength: 40,
+ });
+ expect(path).toBe('M0,4 L100,4');
+ });
+
+ it('returns a straight line when wavelength is not positive', () => {
+ const path = linearWavePath({
+ width: 100,
+ height: 8,
+ amplitude: 3,
+ wavelength: 0,
+ });
+ expect(path).toBe('M0,4 L100,4');
+ });
+
+ it('starts at x=0 and ends at x=width', () => {
+ const points = parsePoints(
+ linearWavePath({width: 120, height: 10, amplitude: 3, wavelength: 40}),
+ );
+ expect(points[0].x).toBe(0);
+ expect(points[points.length - 1].x).toBe(120);
+ });
+
+ it('keeps all x values monotonically non-decreasing within bounds', () => {
+ const points = parsePoints(
+ linearWavePath({width: 120, height: 10, amplitude: 3, wavelength: 40}),
+ );
+ for (let i = 1; i < points.length; i++) {
+ expect(points[i].x).toBeGreaterThanOrEqual(points[i - 1].x);
+ expect(points[i].x).toBeLessThanOrEqual(120);
+ }
+ });
+
+ it('oscillates within +/- amplitude of the vertical center', () => {
+ const amplitude = 3;
+ const center = 5;
+ const points = parsePoints(
+ linearWavePath({width: 200, height: 10, amplitude, wavelength: 40}),
+ );
+ let maxDeviation = 0;
+ for (const {y} of points) {
+ maxDeviation = Math.max(maxDeviation, Math.abs(y - center));
+ expect(Math.abs(y - center)).toBeLessThanOrEqual(amplitude + 0.01);
+ }
+ expect(maxDeviation).toBeGreaterThan(amplitude - 0.5);
+ });
+});
+
+describe('circularWavePath', () => {
+ it('returns a closed path', () => {
+ const path = circularWavePath({
+ size: 48,
+ strokeWidth: 4,
+ amplitude: 2,
+ wavelength: 15,
+ });
+ expect(path.startsWith('M')).toBeTrue();
+ expect(path.endsWith('Z')).toBeTrue();
+ });
+
+ it('returns an empty string when the radius collapses', () => {
+ const path = circularWavePath({
+ size: 4,
+ strokeWidth: 8,
+ amplitude: 2,
+ wavelength: 15,
+ });
+ expect(path).toBe('');
+ });
+
+ it('samples a constant radius when amplitude is 0', () => {
+ const size = 48;
+ const strokeWidth = 4;
+ const center = size / 2;
+ const expectedRadius = center - strokeWidth / 2;
+ const points = parsePoints(
+ circularWavePath({size, strokeWidth, amplitude: 0, wavelength: 15}),
+ );
+ for (const {x, y} of points) {
+ const r = Math.hypot(x - center, y - center);
+ expect(Math.abs(r - expectedRadius)).toBeLessThan(0.05);
+ }
+ });
+
+ it('keeps every point within +/- amplitude of the midline radius', () => {
+ const size = 48;
+ const strokeWidth = 4;
+ const amplitude = 2;
+ const center = size / 2;
+ const midlineRadius = center - strokeWidth / 2 - amplitude;
+ const points = parsePoints(
+ circularWavePath({size, strokeWidth, amplitude, wavelength: 15}),
+ );
+ let maxRadius = 0;
+ for (const {x, y} of points) {
+ const r = Math.hypot(x - center, y - center);
+ expect(r).toBeGreaterThanOrEqual(midlineRadius - amplitude - 0.05);
+ expect(r).toBeLessThanOrEqual(midlineRadius + amplitude + 0.05);
+ maxRadius = Math.max(maxRadius, r);
+ }
+ expect(maxRadius).toBeLessThanOrEqual(center - strokeWidth / 2 + 0.05);
+ });
+});