diff --git a/projects/core/.visual/progress-dot.dark.png b/projects/core/.visual/progress-dot.dark.png
new file mode 100644
index 000000000..196cd1c5a
--- /dev/null
+++ b/projects/core/.visual/progress-dot.dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1da5934dee0d82c7b5995ed6e2311eb1001bb2c43f307f95905d9aec7b0dde60
+size 888
diff --git a/projects/core/.visual/progress-dot.png b/projects/core/.visual/progress-dot.png
new file mode 100644
index 000000000..1474c9d30
--- /dev/null
+++ b/projects/core/.visual/progress-dot.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:35795c878ec87f08b16a00cda467f0df2755ed4c8f651a1e0f1c9532a1f9dcc0
+size 855
diff --git a/projects/core/package.json b/projects/core/package.json
index 8b450d894..391c01c4a 100644
--- a/projects/core/package.json
+++ b/projects/core/package.json
@@ -663,6 +663,18 @@
"types": "./dist/progress-bar/define.d.ts",
"default": "./dist/progress-bar/define.js"
},
+ "./progress-dot": {
+ "types": "./dist/progress-dot/index.d.ts",
+ "default": "./dist/progress-dot/index.js"
+ },
+ "./progress-dot/index.js": {
+ "types": "./dist/progress-dot/index.d.ts",
+ "default": "./dist/progress-dot/index.js"
+ },
+ "./progress-dot/define.js": {
+ "types": "./dist/progress-dot/define.d.ts",
+ "default": "./dist/progress-dot/define.js"
+ },
"./progress-ring": {
"types": "./dist/progress-ring/index.d.ts",
"default": "./dist/progress-ring/index.js"
diff --git a/projects/core/src/bundle.ts b/projects/core/src/bundle.ts
index fb8c672ed..11fdec6f0 100644
--- a/projects/core/src/bundle.ts
+++ b/projects/core/src/bundle.ts
@@ -51,6 +51,7 @@ import '@nvidia-elements/core/panel/define.js';
import '@nvidia-elements/core/password/define.js';
import '@nvidia-elements/core/preferences-input/define.js';
import '@nvidia-elements/core/progress-bar/define.js';
+import '@nvidia-elements/core/progress-dot/define.js';
import '@nvidia-elements/core/progress-ring/define.js';
import '@nvidia-elements/core/progressive-filter-chip/define.js';
import '@nvidia-elements/core/pulse/define.js';
@@ -123,6 +124,7 @@ export * from '@nvidia-elements/core/panel';
export * from '@nvidia-elements/core/password';
export * from '@nvidia-elements/core/preferences-input';
export * from '@nvidia-elements/core/progress-bar';
+export * from '@nvidia-elements/core/progress-dot';
export * from '@nvidia-elements/core/progress-ring';
export * from '@nvidia-elements/core/progressive-filter-chip';
export * from '@nvidia-elements/core/pulse';
diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts
index 3ed158ce1..04ce6c67a 100644
--- a/projects/core/src/index.test.lighthouse.ts
+++ b/projects/core/src/index.test.lighthouse.ts
@@ -76,6 +76,7 @@ describe('lighthouse report', () => {
import '@nvidia-elements/core/password/define.js';
import '@nvidia-elements/core/preferences-input/define.js';
import '@nvidia-elements/core/progress-bar/define.js';
+ import '@nvidia-elements/core/progress-dot/define.js';
import '@nvidia-elements/core/progress-ring/define.js';
import '@nvidia-elements/core/progressive-filter-chip/define.js';
import '@nvidia-elements/core/pulse/define.js';
diff --git a/projects/core/src/progress-dot/define.ts b/projects/core/src/progress-dot/define.ts
new file mode 100644
index 000000000..61cef7cb3
--- /dev/null
+++ b/projects/core/src/progress-dot/define.ts
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { define } from '@nvidia-elements/core/internal';
+import { ProgressDot } from '@nvidia-elements/core/progress-dot';
+
+define(ProgressDot);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'nve-progress-dot': ProgressDot;
+ }
+}
diff --git a/projects/core/src/progress-dot/index.ts b/projects/core/src/progress-dot/index.ts
new file mode 100644
index 000000000..c59967153
--- /dev/null
+++ b/projects/core/src/progress-dot/index.ts
@@ -0,0 +1,4 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export * from './progress-dot.js';
diff --git a/projects/core/src/progress-dot/progress-dot.css b/projects/core/src/progress-dot/progress-dot.css
new file mode 100644
index 000000000..4b2300198
--- /dev/null
+++ b/projects/core/src/progress-dot/progress-dot.css
@@ -0,0 +1,72 @@
+/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
+/* SPDX-License-Identifier: Apache-2.0 */
+
+:host {
+ --color: currentColor;
+ --width: var(--nve-ref-size-500);
+ --height: var(--nve-ref-size-500);
+ --animation-duration: calc(var(--nve-ref-animation-duration-400) * 1.5);
+ display: inline-flex;
+ color: inherit;
+}
+
+:host([size='sm']) {
+ --width: var(--nve-ref-size-400);
+ --height: var(--nve-ref-size-400);
+}
+
+:host([size='lg']) {
+ --width: var(--nve-ref-size-600);
+ --height: var(--nve-ref-size-600);
+}
+
+[internal-host] {
+ width: var(--width);
+ height: var(--height);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+svg {
+ width: 100%;
+ height: 100%;
+ fill: var(--color);
+}
+
+circle {
+ opacity: 1;
+ animation: fade var(--animation-duration) ease-in-out infinite;
+}
+
+circle:nth-child(2) {
+ animation-delay: calc(var(--animation-duration) / 3);
+}
+
+circle:nth-child(3) {
+ animation-delay: calc(var(--animation-duration) * 2 / 3);
+}
+
+@keyframes fade {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: var(--nve-ref-opacity-200);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ circle {
+ animation: none;
+ }
+}
+
+/* A zero short-duration token means the active theme suppresses motion. */
+@container style(--nve-ref-animation-duration-150: 0) {
+ circle {
+ animation: none;
+ }
+}
diff --git a/projects/core/src/progress-dot/progress-dot.examples.ts b/projects/core/src/progress-dot/progress-dot.examples.ts
new file mode 100644
index 000000000..467388aca
--- /dev/null
+++ b/projects/core/src/progress-dot/progress-dot.examples.ts
@@ -0,0 +1,83 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import '@nvidia-elements/core/progress-dot/define.js';
+
+export default {
+ title: 'Elements/Progress Dot',
+ component: 'nve-progress-dot'
+};
+
+/**
+ * @summary Indeterminate dots communicate ongoing work when a completion percentage is unavailable.
+ */
+export const Default = {
+ render: () => html``
+};
+
+/**
+ * @summary Three indicator sizes fit loading feedback into compact rows or spacious panels.
+ */
+export const Size = {
+ render: () => html`
+
+
+
+
+
+ `
+};
+
+/**
+ * @summary An adjacent label describes the pending task and supplies the indicator's accessible name.
+ */
+export const WithText = {
+ render: () => html`
+
+
+ Generating response…
+
+ `
+};
+
+/**
+ * @summary Button indicators inherit text color to keep loading feedback visible across button treatments.
+ */
+export const WithButton = {
+ render: () => html`
+
+
+
+ Saving…
+
+
+
+ Saving…
+
+
+ `
+};
+
+/**
+ * @summary Custom color, dimensions, and timing adapt loading feedback to its surrounding interface.
+ * @tags theme
+ */
+export const CustomStyles = {
+ render: () => html`
+
+ `
+};
+
+/**
+ * @summary Static dots keep loading feedback visible when animation is unnecessary or distracting.
+ * @tags test-case
+ */
+export const Static = {
+ render: () => html`
+
+ `
+};
diff --git a/projects/core/src/progress-dot/progress-dot.test.axe.ts b/projects/core/src/progress-dot/progress-dot.test.axe.ts
new file mode 100644
index 000000000..416db3838
--- /dev/null
+++ b/projects/core/src/progress-dot/progress-dot.test.axe.ts
@@ -0,0 +1,32 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { createFixture, elementIsStable, removeFixture } from '@internals/testing';
+import { runAxe } from '@internals/testing/axe';
+import { ProgressDot } from '@nvidia-elements/core/progress-dot';
+import '@nvidia-elements/core/progress-dot/define.js';
+
+describe(ProgressDot.metadata.tag, () => {
+ let fixture: HTMLElement;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html`
+
+
+ Generating response
+
+
+
+ `);
+ await Promise.all([...fixture.querySelectorAll('nve-progress-dot')].map(element => elementIsStable(element)));
+ });
+
+ afterEach(() => removeFixture(fixture));
+
+ it('should pass axe for all sizes and label sources', async () => {
+ const results = await runAxe([ProgressDot.metadata.tag]);
+ expect(results.violations.length).toBe(0);
+ });
+});
diff --git a/projects/core/src/progress-dot/progress-dot.test.lighthouse.ts b/projects/core/src/progress-dot/progress-dot.test.lighthouse.ts
new file mode 100644
index 000000000..9acb04ff2
--- /dev/null
+++ b/projects/core/src/progress-dot/progress-dot.test.lighthouse.ts
@@ -0,0 +1,21 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, test } from 'vitest';
+import { lighthouseRunner } from '@internals/vite';
+
+describe('progress-dot lighthouse report', () => {
+ test('should meet lighthouse benchmarks', async () => {
+ const report = await lighthouseRunner.getReport('nve-progress-dot', /* html */ `
+
+
+ `);
+
+ expect(report.scores.performance).toBe(100);
+ expect(report.scores.accessibility).toBe(100);
+ expect(report.scores.bestPractices).toBe(100);
+ expect(report.payload.javascript.kb).toBeLessThan(12);
+ });
+});
diff --git a/projects/core/src/progress-dot/progress-dot.test.ssr.ts b/projects/core/src/progress-dot/progress-dot.test.ssr.ts
new file mode 100644
index 000000000..43db0b156
--- /dev/null
+++ b/projects/core/src/progress-dot/progress-dot.test.ssr.ts
@@ -0,0 +1,23 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+import { describe, expect, it } from 'vitest';
+import { ssrRunner } from '@internals/vite';
+import { ProgressDot } from '@nvidia-elements/core/progress-dot';
+import '@nvidia-elements/core/progress-dot/define.js';
+
+describe(ProgressDot.metadata.tag, () => {
+ it('should render three decorative dots and preserve author configuration', async () => {
+ const result = await ssrRunner.render(html`
+
+ `);
+ expect(result).toContain('shadowroot="open"');
+ expect(result).toContain('nve-progress-dot');
+ expect(result).toContain('size="sm"');
+ expect(result).toContain('aria-label="Loading results"');
+ expect(result).toContain('aria-hidden="true"');
+ expect(result.match(/ {
+ let fixture: HTMLElement;
+ let element: ProgressDot;
+ let loadingLabel: string;
+ let themeStyles: HTMLStyleElement;
+
+ beforeEach(async () => {
+ loadingLabel = I18nService.i18n.loading;
+ themeStyles = document.createElement('style');
+ themeStyles.textContent = reducedMotionStyles;
+ document.head.append(themeStyles);
+ fixture = await createFixture(html``);
+ element = fixture.querySelector('nve-progress-dot');
+ await elementIsStable(element);
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ themeStyles.remove();
+ I18nService.update({ loading: loadingLabel });
+ });
+
+ it('should register an indeterminate progress indicator with a default label', () => {
+ expect(customElements.get(ProgressDot.metadata.tag)).toBe(ProgressDot);
+ expect(element.size).toBe('md');
+ expect(element.getAttribute('size')).toBe('md');
+ expect(element._internals.role).toBe('progressbar');
+ expect(element._internals.ariaLabel).toBe(loadingLabel);
+ expect(element._internals.ariaValueNow).toBeNull();
+ expect(element.hasAttribute('aria-valuenow')).toBe(false);
+ expect(element.hasAttribute('tabindex')).toBe(false);
+ expect(element.shadowRoot.querySelector('svg').getAttribute('aria-hidden')).toBe('true');
+ expect(element.shadowRoot.querySelectorAll('circle')).toHaveLength(3);
+ });
+
+ it.each([
+ ['sm', 16],
+ ['md', 20],
+ ['lg', 24]
+ ] as const)('should reflect and render the %s size', async (size, dimension) => {
+ element.size = size;
+ await elementIsStable(element);
+
+ expect(element.getAttribute('size')).toBe(size);
+ expect(element.getBoundingClientRect().width).toBe(dimension);
+ expect(element.getBoundingClientRect().height).toBe(dimension);
+
+ element.setAttribute('size', 'sm');
+ await elementIsStable(element);
+ expect(element.size).toBe('sm');
+ });
+
+ it('should inherit surrounding text color', () => {
+ fixture.style.color = 'rgb(12, 34, 56)';
+ expect(getComputedStyle(element.shadowRoot.querySelector('svg')).fill).toBe('rgb(12, 34, 56)');
+ });
+
+ it('should allow CSS overrides across sizes', async () => {
+ element.style.cssText = '--width: 60px; --height: 40px; --color: rgb(56, 34, 12)';
+ element.size = 'lg';
+ await elementIsStable(element);
+
+ expect(element.getBoundingClientRect().width).toBe(60);
+ expect(element.getBoundingClientRect().height).toBe(40);
+ expect(getComputedStyle(element.shadowRoot.querySelector('svg')).fill).toBe('rgb(56, 34, 12)');
+ });
+
+ it('should scale animation delays with the cycle duration', () => {
+ element.style.setProperty('--animation-duration', '2.4s');
+ const dots = [...element.shadowRoot.querySelectorAll('circle')];
+ expect(dots.map(dot => getComputedStyle(dot).animationDuration)).toEqual(['2.4s', '2.4s', '2.4s']);
+ expect(dots.map(dot => getComputedStyle(dot).animationDelay)).toEqual(['0s', '0.8s', '1.6s']);
+ });
+
+ it('should visibly fade the dots during the default animation', () => {
+ // Loading a theme registers its availability without selecting that theme.
+ expect(getComputedStyle(element).getPropertyValue('--nve-config-reduced-motion')).toBe('true');
+ const dot = element.shadowRoot.querySelector('circle');
+ const animations = dot.getAnimations();
+ expect(animations).toHaveLength(1);
+ const animation = animations[0];
+ expect(animation.playState).toBe('running');
+ const duration = Number(animation.effect.getComputedTiming().duration);
+ expect(duration).toBeGreaterThan(0);
+ animation.pause();
+ animation.currentTime = 0;
+ expect(getComputedStyle(dot).opacity).toBe('1');
+ animation.currentTime = duration / 2;
+ expect(Number(getComputedStyle(dot).opacity)).toBeLessThan(0.3);
+ });
+
+ it('should show static visible dots when the duration is zero', () => {
+ element.style.setProperty('--animation-duration', '0s');
+ for (const dot of element.shadowRoot.querySelectorAll('circle')) {
+ expect(getComputedStyle(dot).animationDuration).toBe('0s');
+ expect(getComputedStyle(dot).opacity).toBe('1');
+ }
+ });
+
+ it('should stop animation for the reduced-motion theme', () => {
+ fixture.setAttribute('nve-theme', 'reduced-motion');
+ element.style.setProperty('--animation-duration', '2s');
+ for (const dot of element.shadowRoot.querySelectorAll('circle')) {
+ expect(getComputedStyle(dot).animationName).toBe('none');
+ expect(getComputedStyle(dot).opacity).toBe('1');
+ }
+
+ fixture.removeAttribute('nve-theme');
+ for (const dot of element.shadowRoot.querySelectorAll('circle')) {
+ expect(getComputedStyle(dot).animationName).toBe('fade');
+ expect(getComputedStyle(dot).animationDuration).toBe('2s');
+ }
+ });
+
+ it('should honor native hidden without changing loading semantics', () => {
+ element.hidden = true;
+ expect(getComputedStyle(element).display).toBe('none');
+ element.hidden = false;
+ expect(getComputedStyle(element).display).toBe('inline-flex');
+ expect(element._internals.role).toBe('progressbar');
+ });
+
+ it('should update the fallback label from global translations', async () => {
+ I18nService.update({ loading: 'chargement' });
+ await elementIsStable(element);
+ expect(element._internals.ariaLabel).toBe('chargement');
+ });
+
+ it('should preserve instance translations across global updates', async () => {
+ element.i18n = { loading: 'Generating response' };
+ await elementIsStable(element);
+ I18nService.update({ loading: 'chargement' });
+ await elementIsStable(element);
+
+ expect(element._internals.ariaLabel).toBe('Generating response');
+ expect(element.hasAttribute('i18n')).toBe(false);
+ });
+
+ it('should preserve author labels when translations change', async () => {
+ element.setAttribute('aria-label', 'Loading results');
+ element.setAttribute('aria-labelledby', 'results-label');
+ element.i18n = { loading: 'chargement' };
+ await elementIsStable(element);
+
+ expect(element.getAttribute('aria-label')).toBe('Loading results');
+ expect(element.getAttribute('aria-labelledby')).toBe('results-label');
+ expect(element._internals.ariaLabel).toBe('chargement');
+ });
+});
diff --git a/projects/core/src/progress-dot/progress-dot.test.visual.ts b/projects/core/src/progress-dot/progress-dot.test.visual.ts
new file mode 100644
index 000000000..f2c5c7fc2
--- /dev/null
+++ b/projects/core/src/progress-dot/progress-dot.test.visual.ts
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, test } from 'vitest';
+import { visualRunner } from '@internals/vite';
+
+describe('progress-dot visual', () => {
+ test.each(['', 'dark'] as const)('should match the %s theme baseline', async theme => {
+ const report = await visualRunner.render(theme ? 'progress-dot.dark' : 'progress-dot', template(theme));
+ expect(report.maxDiffPercentage).toBeLessThan(1);
+ });
+});
+
+function template(theme: '' | 'dark' = '') {
+ return /* html */ `
+
+
+
+
+
+
+
+ `;
+}
diff --git a/projects/core/src/progress-dot/progress-dot.ts b/projects/core/src/progress-dot/progress-dot.ts
new file mode 100644
index 000000000..89d3e3a3a
--- /dev/null
+++ b/projects/core/src/progress-dot/progress-dot.ts
@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html, LitElement, type PropertyValues } from 'lit';
+import { property } from 'lit/decorators/property.js';
+import type { Size } from '@nvidia-elements/core/internal';
+import { attachInternals, I18nController, useStyles } from '@nvidia-elements/core/internal';
+import styles from './progress-dot.css?inline';
+
+/**
+ * @element nve-progress-dot
+ * @description An inline visual indicator that shows the progress of an ongoing task with an unknown completion percentage.
+ * @documentation https://nvidia.github.io/elements/docs/elements/progress-dot/
+ * @since 0.0.0
+ * @entrypoint \@nvidia-elements/core/progress-dot
+ * @cssprop --color - Dot color. Defaults to the inherited text color.
+ * @cssprop --width - Indicator width.
+ * @cssprop --height - Indicator height.
+ * @cssprop --animation-duration - Duration of one animation cycle. Set to 0s for static dots.
+ * @aria https://www.w3.org/TR/wai-aria-1.2/#progressbar
+ */
+export class ProgressDot extends LitElement {
+ static styles = useStyles([styles]);
+
+ static readonly metadata = {
+ tag: 'nve-progress-dot',
+ version: '0.0.0'
+ };
+
+ /** @private */
+ declare _internals: ElementInternals;
+
+ /** Controls the indicator size. */
+ @property({ type: String, reflect: true }) size: Size = 'md';
+
+ #i18nController: I18nController = new I18nController(this);
+
+ /** Enables updating the default loading label for internationalization. */
+ @property({ type: Object }) i18n = this.#i18nController.i18n;
+
+ render() {
+ return html`
+
+
+
+ `;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ attachInternals(this);
+ this._internals.role = 'progressbar';
+ }
+
+ updated(props: PropertyValues) {
+ super.updated(props);
+ this._internals.ariaLabel = this.i18n.loading ?? null;
+ }
+}
diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js
index f6f41e3a1..54f67381b 100644
--- a/projects/site/src/_11ty/layouts/common.js
+++ b/projects/site/src/_11ty/layouts/common.js
@@ -347,6 +347,7 @@ export const renderDocsNav = data => /* html */ `
Pagination
Progressive Filter Chip
Progress Bar
+ Progress Dot
Progress Ring
Password
Preferences Input
diff --git a/projects/site/src/docs/elements/progress-dot.md b/projects/site/src/docs/elements/progress-dot.md
new file mode 100644
index 000000000..dad4ac62f
--- /dev/null
+++ b/projects/site/src/docs/elements/progress-dot.md
@@ -0,0 +1,25 @@
+---
+{
+ title: 'Progress Dot',
+ layout: 'docs.11ty.js',
+ tag: 'nve-progress-dot'
+}
+---
+
+## Installation
+
+{% install 'nve-progress-dot' %}
+
+## Sizing
+
+{% api 'nve-progress-dot' 'property' 'size' %}
+
+{% example '@nvidia-elements/core/progress-dot/progress-dot.examples.json' 'Size' %}
+
+## Accessible Labels
+
+The indicator provides a localized loading label by default. Set `aria-label` to describe the task, or use `aria-labelledby` to reference a visible label. Set `aria-busy="true"` on the region that loads, then remove or hide the indicator when the task finishes.
+
+{% example '@nvidia-elements/core/progress-dot/progress-dot.examples.json' 'WithText' %}
+
+{% api 'nve-progress-dot' 'property' 'i18n' %}