Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions labs/gb/components/progress/_circular-progress-tokens.scss
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions labs/gb/components/progress/_linear-progress-tokens.scss
Original file line number Diff line number Diff line change
@@ -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);
}
156 changes: 156 additions & 0 deletions labs/gb/components/progress/circular-progress-element.ts
Original file line number Diff line number Diff line change
@@ -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`
<div
class=${circularProgress({indeterminate: this.indeterminate})}
role="progressbar"
aria-label="${ariaLabel || nothing}"
aria-valuemin="0"
aria-valuemax=${this.max}
aria-valuenow=${this.indeterminate ? nothing : this.value}
>${this.renderIndicator()}</div
>
`;
}

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`
<svg viewBox="0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}">
<path
class="circular-progress-track"
d=${trackPath}
pathLength="100"
style=${styleMap(trackStyles)}></path>
<path
class="circular-progress-active"
d=${activePath}
pathLength="100"
stroke-dashoffset=${activeOffset}></path>
</svg>
`;
}

private renderIndeterminate(
trackPath: string,
activePath: string,
): TemplateResult {
return html`
<svg viewBox="0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}">
<path
class="circular-progress-track"
d=${trackPath}
pathLength="100"></path>
<path
class="circular-progress-active"
d=${activePath}
pathLength="100"></path>
</svg>
`;
}
}
94 changes: 94 additions & 0 deletions labs/gb/components/progress/circular-progress.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
119 changes: 119 additions & 0 deletions labs/gb/components/progress/circular-progress.ts
Original file line number Diff line number Diff line change
@@ -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`<div class="${circularProgress({indeterminate: true})}"></div>`;
* ```
*/
export const circularProgress =
createClassMapDirective<CircularProgressClassesState>({
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`;
}
Loading
Loading