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
19 changes: 19 additions & 0 deletions labs/gb/components/circularprogress/_circular-progress-tokens.scss
Original file line number Diff line number Diff line change
@@ -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
);
}
104 changes: 104 additions & 0 deletions labs/gb/components/circularprogress/circular-progress-element.ts
Original file line number Diff line number Diff line change
@@ -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`
<svg viewBox="0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}">
<path
class="track"
d=${path}
pathLength="100"
style=${styleMap(trackStyles)}></path>
<path
class="active"
d=${path}
pathLength="100"
stroke-dashoffset=${activeOffset}></path>
</svg>
`;
}

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