diff --git a/config/vale/styles/config/vocabularies/Elements/accept.txt b/config/vale/styles/config/vocabularies/Elements/accept.txt
index 9685582ed..b641b6b43 100644
--- a/config/vale/styles/config/vocabularies/Elements/accept.txt
+++ b/config/vale/styles/config/vocabularies/Elements/accept.txt
@@ -358,3 +358,5 @@ spark line
spark lines
Alternatively
misprefixed
+indicate
+utilization
diff --git a/projects/core/.visual/gauge.dark.png b/projects/core/.visual/gauge.dark.png
new file mode 100644
index 000000000..5aed72c29
--- /dev/null
+++ b/projects/core/.visual/gauge.dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:66f1eb01a717e40850561ea71b9bf7e62cdd4abca9bc1e9456fda3e3ae428800
+size 87863
diff --git a/projects/core/.visual/gauge.png b/projects/core/.visual/gauge.png
new file mode 100644
index 000000000..c59565bd3
--- /dev/null
+++ b/projects/core/.visual/gauge.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:19c30d2ef9960e5862cda3532d9b92724d5a5f720bcaad8578058279286914b6
+size 85829
diff --git a/projects/core/package.json b/projects/core/package.json
index d1e78788b..e70899183 100644
--- a/projects/core/package.json
+++ b/projects/core/package.json
@@ -421,6 +421,18 @@
"types": "./dist/forms/define.d.ts",
"default": "./dist/forms/define.js"
},
+ "./gauge": {
+ "types": "./dist/gauge/index.d.ts",
+ "default": "./dist/gauge/index.js"
+ },
+ "./gauge/index.js": {
+ "types": "./dist/gauge/index.d.ts",
+ "default": "./dist/gauge/index.js"
+ },
+ "./gauge/define.js": {
+ "types": "./dist/gauge/define.d.ts",
+ "default": "./dist/gauge/define.js"
+ },
"./grid": {
"types": "./dist/grid/index.d.ts",
"default": "./dist/grid/index.js"
diff --git a/projects/core/src/bundle.ts b/projects/core/src/bundle.ts
index b94181262..8d15c1e5e 100644
--- a/projects/core/src/bundle.ts
+++ b/projects/core/src/bundle.ts
@@ -31,6 +31,7 @@ import '@nvidia-elements/core/format-datetime/define.js';
import '@nvidia-elements/core/format-number/define.js';
import '@nvidia-elements/core/format-relative-time/define.js';
import '@nvidia-elements/core/forms/define.js';
+import '@nvidia-elements/core/gauge/define.js';
import '@nvidia-elements/core/grid/define.js';
import '@nvidia-elements/core/icon/define.js';
import '@nvidia-elements/core/icon-button/define.js';
@@ -99,6 +100,7 @@ export * from '@nvidia-elements/core/format-datetime';
export * from '@nvidia-elements/core/format-number';
export * from '@nvidia-elements/core/format-relative-time';
export * from '@nvidia-elements/core/forms';
+export * from '@nvidia-elements/core/gauge';
export * from '@nvidia-elements/core/grid';
export * from '@nvidia-elements/core/icon';
export * from '@nvidia-elements/core/icon-button';
diff --git a/projects/core/src/gauge/define.ts b/projects/core/src/gauge/define.ts
new file mode 100644
index 000000000..88bd9cba8
--- /dev/null
+++ b/projects/core/src/gauge/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 { Gauge } from '@nvidia-elements/core/gauge';
+
+define(Gauge);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'nve-gauge': Gauge;
+ }
+}
diff --git a/projects/core/src/gauge/gauge.css b/projects/core/src/gauge/gauge.css
new file mode 100644
index 000000000..5cea5d31a
--- /dev/null
+++ b/projects/core/src/gauge/gauge.css
@@ -0,0 +1,223 @@
+/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
+/* SPDX-License-Identifier: Apache-2.0 */
+
+:host {
+ --color: var(--nve-sys-text-emphasis-color);
+ --background: var(--nve-sys-interaction-background);
+ --needle-background: var(--nve-sys-interaction-color);
+ --accent-color: var(--nve-sys-interaction-color);
+ --thumb-background: var(--accent-color);
+ --track-background: var(--accent-color);
+ --track-width: 8px;
+ --font-size: var(--nve-ref-font-size-400);
+ --gap: var(--nve-ref-space-xs);
+ --width: 128px;
+ --height: calc(var(--width) * 0.8627);
+ --_animation-duration: var(--nve-ref-animation-duration-250);
+
+ display: inline-block;
+ position: relative;
+ width: var(--width);
+ height: var(--height);
+ container-type: inline-size;
+ contain: layout;
+ text-box: trim-both cap alphabetic;
+}
+
+.fill-dot-start,
+.fill-dot-end {
+ r: calc(var(--track-width) / 2.4);
+}
+
+:host([size='sm']) {
+ --font-size: var(--nve-ref-font-size-300);
+ --width: 96px;
+
+ .fill-dot-start,
+ .fill-dot-end {
+ r: calc(var(--track-width) / 1.8);
+ }
+}
+
+:host([size='lg']) {
+ --font-size: var(--nve-ref-font-size-500);
+ --width: 160px;
+
+ .fill-dot-start,
+ .fill-dot-end {
+ r: calc(var(--track-width) / 3);
+ }
+}
+
+[internal-host] {
+ display: grid;
+ place-items: center;
+ position: relative;
+ height: 100%;
+}
+
+:host([shape='half']) {
+ --height: calc(var(--width) * 0.5313);
+}
+
+:host([shape='half']) [internal-host] {
+ place-items: end center;
+}
+
+svg {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+}
+
+path {
+ fill: none;
+ stroke: white;
+ stroke-width: var(--track-width);
+ vector-effect: non-scaling-stroke;
+}
+
+path.background {
+ stroke-linecap: round;
+}
+
+path.gauge {
+ animation: gauge-progress-in var(--_animation-duration) var(--nve-ref-animation-easing-100);
+ stroke-linecap: butt;
+ stroke-dasharray: var(--_dash-progress) var(--_dash-gap);
+ transition: stroke-dasharray var(--_animation-duration) var(--nve-ref-animation-easing-100);
+ will-change: stroke-dasharray;
+}
+
+.background-surface,
+.fill-surface {
+ inline-size: 100%;
+ block-size: 100%;
+}
+
+.background-surface {
+ background: var(--background);
+}
+
+.fill-surface {
+ background: var(--track-background);
+}
+
+.fill-dot-start {
+ fill: var(--track-background);
+}
+
+.fill-dot-end {
+ animation: gauge-dot-in var(--_animation-duration) var(--nve-ref-animation-easing-100);
+ fill: var(--thumb-background);
+ transform: rotate(var(--_dot-angle));
+ transform-box: view-box;
+ transform-origin: var(--_dot-origin);
+ transition: transform var(--_animation-duration) var(--nve-ref-animation-easing-100);
+ will-change: transform;
+}
+
+.fill-layer[hidden],
+.fill-dot-start[hidden],
+.fill-dot-end[hidden],
+.needle[hidden] {
+ display: none;
+}
+
+.needle {
+ transform: rotate(var(--_needle-angle));
+ transform-box: view-box;
+ transform-origin: var(--_needle-origin);
+ will-change: transform;
+}
+
+.needle-line {
+ stroke: var(--needle-background);
+ stroke-linecap: round;
+ stroke-width: calc(var(--track-width) / 3);
+}
+
+.needle-hub {
+ fill: var(--needle-background);
+ r: calc(var(--track-width) / 3);
+}
+
+slot {
+ display: flex;
+ flex-direction: column;
+ place-items: center;
+ justify-content: center;
+ height: 100%;
+ gap: var(--gap);
+ color: var(--color);
+ font-size: var(--font-size);
+ font-weight: var(--nve-ref-font-weight-medium);
+ padding-block-start: calc(var(--track-width) * 2);
+ text-box: trim-both cap alphabetic;
+ line-height: 1;
+}
+
+:host([shape='half']) slot {
+ transform: translateY(1cqh);
+}
+
+:host([thumb='needle']) slot {
+ transform: translateY(1cqh);
+ justify-content: end;
+}
+
+:host([shape='half'][thumb='needle']) slot {
+ transform: translateY(2cqh);
+}
+
+::slotted(*) {
+ color: var(--color);
+ font-size: var(--font-size);
+}
+
+:host([status='success']) {
+ --accent-color: var(--nve-sys-support-success-emphasis-color);
+ --background: var(--nve-sys-support-success-muted-color);
+}
+
+:host([status='warning']) {
+ --accent-color: var(--nve-sys-support-warning-emphasis-color);
+ --background: var(--nve-sys-support-warning-muted-color);
+}
+
+:host([status='danger']) {
+ --accent-color: var(--nve-sys-support-danger-emphasis-color);
+ --background: var(--nve-sys-support-danger-muted-color);
+}
+
+:host([status='accent']) {
+ --accent-color: var(--nve-sys-accent-secondary-background);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ :host {
+ --_animation-duration: 0s;
+ }
+}
+
+@keyframes gauge-progress-in {
+ from {
+ stroke-dasharray: 0 var(--_dash-gap);
+ }
+
+ to {
+ stroke-dasharray: var(--_dash-progress) var(--_dash-gap);
+ }
+}
+
+@keyframes gauge-dot-in {
+ from {
+ transform: rotate(var(--_dot-start-angle));
+ }
+
+ to {
+ transform: rotate(var(--_dot-angle));
+ }
+}
diff --git a/projects/core/src/gauge/gauge.examples.ts b/projects/core/src/gauge/gauge.examples.ts
new file mode 100644
index 000000000..a93c88a3b
--- /dev/null
+++ b/projects/core/src/gauge/gauge.examples.ts
@@ -0,0 +1,268 @@
+// 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/gauge/define.js';
+
+export default {
+ title: 'Elements/Gauge',
+ component: 'nve-gauge',
+};
+
+/**
+ * @summary 270-degree gauges for displaying system resource usage.
+ */
+export const Default = {
+ render: () => html`
+
+ 12%
+ 50%
+ 66%
+
+`};
+
+/**
+ * @summary Shape variants compare the default 270-degree gauge with the compact half gauge for telemetry layouts with tighter vertical space.
+ * @tags test-case
+ */
+export const Shape = {
+ render: () => html`
+
+ 66%
+ 66%
+
+`};
+
+/**
+ * @summary Half shape variant for telemetry layouts with tighter vertical space.
+ * @tags test-case
+ */
+export const ShapeHalf = {
+ render: () => html`
+
+ 66%
+ 66%
+ 66%
+
+`};
+
+/**
+ * @summary Half shape with needle thumb variant for rapidly changing values in confined spaces.
+ * @tags test-case
+ */
+export const ShapeHalfNeedle = {
+ render: () => html`
+
+ 66%
+ 66%
+ 66%
+
+`};
+
+/**
+ * @summary Thumb variants compare filled, dot, and needle indicators for dashboards that need different levels of visual emphasis.
+ * @tags test-case
+ */
+export const Thumb = {
+ render: () => html`
+
+ fill
+ dot
+ needle
+
+`};
+
+/**
+ * @summary Gauges with values from 0% to 100% for displaying system resource usage.
+ * @tags test-case
+ */
+export const Values = {
+ render: () => html`
+
+ 0%
+ 33%
+ 66%
+ 100%
+
+`};
+
+/**
+ * @summary Gauges with custom max values for mission checkpoints, validation clips, and map tile processing.
+ * @tags test-case
+ */
+export const Max = {
+ render: () => html`
+
+ 5/20
+ 10/20
+ 15/20
+
+`};
+
+/**
+ * @summary Gauges with accent, success, warning, and danger colors for autonomous system health and readiness signals.
+ * @tags test-case
+ */
+export const Status = {
+ render: () => html`
+
+ 50%
+ 75%
+ 75%
+ 2.1m
+ 0Hz
+
+`};
+
+/**
+ * @summary Use gradient colors for gauges with scaled non-segmented values such as temperature and bandwidth.
+ * @tags pattern
+ */
+export const Gradient = {
+ render: () => html`
+
+
+
+
+ 82°C
+ TEMP
+
+
+
+ 980Mbps
+ Download
+
+
+`};
+
+/**
+ * @summary Small gauge paired with route-solve text for compact autonomous vehicle task rows.
+ * @tags test-case
+ */
+export const WithText = {
+ render: () => html`
+
+ 2.4s
+ Route solve
+
+`};
+
+/**
+ * @summary Gauges in small, medium, and large sizes for dense robotics and autonomous vehicle dashboards.
+ * @tags test-case
+ */
+export const Size = {
+ render: () => html`
+
+ 30Hz
+ 12Hz
+ 84%
+
+`};
+
+/**
+ * @summary Use for displaying real-time system usage and performance metrics.
+ */
+export const Dynamic = {
+ render: () => html`
+
+
+ 0%
+ GPU
+
+
+
+`};
+
+/**
+ * @summary Autonomous vehicle taxi gauges combine battery, perception, compute, and link status with realistic drift. Use for live dispatch views that need changing system health.
+ * @tags pattern
+ */
+export const MultiGauge = {
+ render: () => html`
+
+
+ 74%
+ BATTERY
+
+
+ 96%
+ PERCEPTION
+
+
+ 62%
+ GPU
+
+
+ 42Mbps
+ LINK
+
+
+
+`};
diff --git a/projects/core/src/gauge/gauge.test.axe.ts b/projects/core/src/gauge/gauge.test.axe.ts
new file mode 100644
index 000000000..b72096d4d
--- /dev/null
+++ b/projects/core/src/gauge/gauge.test.axe.ts
@@ -0,0 +1,35 @@
+// 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, beforeEach, afterEach } from 'vitest';
+import { createFixture, removeFixture, elementIsStable } from '@internals/testing';
+import { runAxe } from '@internals/testing/axe';
+import { Gauge } from '@nvidia-elements/core/gauge';
+import '@nvidia-elements/core/gauge/define.js';
+
+describe(Gauge.metadata.tag, () => {
+ let fixture: HTMLElement;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html`
+
+
+
+
+
+
+ `);
+ const elements = Array.from(fixture.querySelectorAll(Gauge.metadata.tag)) as Gauge[];
+ await Promise.all(elements.map(gauge => elementIsStable(gauge)));
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ });
+
+ it('should pass axe check', async () => {
+ const results = await runAxe([Gauge.metadata.tag]);
+ expect(results.violations.length).toBe(0);
+ });
+});
diff --git a/projects/core/src/gauge/gauge.test.lighthouse.ts b/projects/core/src/gauge/gauge.test.lighthouse.ts
new file mode 100644
index 000000000..abaf4402d
--- /dev/null
+++ b/projects/core/src/gauge/gauge.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 { expect, test, describe } from 'vitest';
+import { lighthouseRunner } from '@internals/vite';
+
+describe('gauge lighthouse report', () => {
+ test('gauge should meet lighthouse benchmarks', async () => {
+ const report = await lighthouseRunner.getReport('nve-gauge', /* 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(15);
+ });
+});
diff --git a/projects/core/src/gauge/gauge.test.ssr.ts b/projects/core/src/gauge/gauge.test.ssr.ts
new file mode 100644
index 000000000..b0a7f851c
--- /dev/null
+++ b/projects/core/src/gauge/gauge.test.ssr.ts
@@ -0,0 +1,17 @@
+// 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 { Gauge } from '@nvidia-elements/core/gauge';
+import '@nvidia-elements/core/gauge/define.js';
+
+describe(Gauge.metadata.tag, () => {
+ it('should pass baseline ssr check', async () => {
+ const result = await ssrRunner.render(html` `);
+ expect(result.includes('shadowroot="open"')).toBe(true);
+ expect(result.includes('nve-gauge')).toBe(true);
+ expect(result.includes('thumb="needle"')).toBe(true);
+ });
+});
diff --git a/projects/core/src/gauge/gauge.test.ts b/projects/core/src/gauge/gauge.test.ts
new file mode 100644
index 000000000..e998185c8
--- /dev/null
+++ b/projects/core/src/gauge/gauge.test.ts
@@ -0,0 +1,391 @@
+// 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, beforeEach, afterEach } from 'vitest';
+import { createFixture, removeFixture, elementIsStable } from '@internals/testing';
+import { Gauge } from '@nvidia-elements/core/gauge';
+import '@nvidia-elements/core/gauge/define.js';
+
+describe(Gauge.metadata.tag, () => {
+ let fixture: HTMLElement;
+ let element: Gauge;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html`
+
+ `);
+ element = fixture.querySelector(Gauge.metadata.tag);
+ await elementIsStable(element);
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ });
+
+ it('should define element', () => {
+ expect(customElements.get(Gauge.metadata.tag)).toBeDefined();
+ });
+
+ it('should set aria attributes', async () => {
+ element.value = 50;
+ element.max = 80;
+ await elementIsStable(element);
+
+ expect(element._internals.role).toBe('progressbar');
+ expect(element._internals.ariaValueNow).toBe('50');
+ expect(element._internals.ariaValueMax).toBe('80');
+ expect(element._internals.ariaLabel).toBe('information');
+
+ element.status = 'success';
+ await elementIsStable(element);
+ expect(element._internals.ariaLabel).toBe('success');
+ });
+
+ it('should default to neutral status', () => {
+ expect(element.status).toBe('neutral');
+ });
+
+ it('should default to the 270-degree shape', () => {
+ expect(element.shape).toBeUndefined();
+ });
+
+ it('should default to the fill thumb', () => {
+ expect(element.thumb).toBe('fill');
+ });
+
+ it('should default max to 100', () => {
+ expect(element.max).toBe(100);
+ });
+
+ it('should default value to 0', () => {
+ expect(element.value).toBe(0);
+ });
+
+ it('should default track width to 8px', () => {
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+
+ expect(getComputedStyle(gauge).strokeWidth).toBe('8px');
+ });
+
+ it('should support css backgrounds for the track and thumb', async () => {
+ element.value = 50;
+ element.style.setProperty('--background', 'linear-gradient(90deg, red, blue)');
+ element.style.setProperty('--track-background', 'transparent');
+ element.style.setProperty('--thumb-background', 'rgb(1, 2, 3)');
+ await elementIsStable(element);
+
+ const background = element.shadowRoot.querySelector('.background-surface') as HTMLElement;
+ const fill = element.shadowRoot.querySelector('.fill-surface') as HTMLElement;
+ const dot = element.shadowRoot.querySelector('.fill-dot-end:not([hidden])') as SVGCircleElement;
+
+ expect(getComputedStyle(background).backgroundImage).toContain('linear-gradient');
+ expect(getComputedStyle(fill).backgroundColor).toBe('rgba(0, 0, 0, 0)');
+ expect(getComputedStyle(dot).fill).toBe('rgb(1, 2, 3)');
+ });
+
+ it.each([
+ ['default', undefined, '128px'],
+ ['sm', 'sm', '96px'],
+ ['md', 'md', '128px'],
+ ['lg', 'lg', '160px']
+ ] as const)('should set %s width', async (_, size, width) => {
+ if (size) {
+ element.size = size;
+ }
+
+ await elementIsStable(element);
+
+ expect(getComputedStyle(element).width).toBe(width);
+ });
+
+ it('should scale slotted text with size', async () => {
+ removeFixture(fixture);
+ fixture = await createFixture(html`
+
+ 50%
+ 50%
+ 50%
+
+ `);
+
+ const gauges = Array.from(fixture.querySelectorAll(Gauge.metadata.tag)) as Gauge[];
+ await Promise.all(gauges.map(gauge => elementIsStable(gauge)));
+
+ const fontSizes = ['sm', 'md', 'lg'].map(size =>
+ parseFloat(getComputedStyle(fixture.querySelector(`[data-size="${size}"]`) as HTMLElement).fontSize)
+ );
+
+ expect(fontSizes[0]).toBeLessThan(fontSizes[1]);
+ expect(fontSizes[1]).toBeLessThan(fontSizes[2]);
+ });
+
+ it('should not apply the status color to slotted text', async () => {
+ removeFixture(fixture);
+ fixture = await createFixture(html`
+
+ 50%
+
+ `);
+ element = fixture.querySelector(Gauge.metadata.tag);
+ await elementIsStable(element);
+
+ const dot = element.shadowRoot.querySelector('.fill-dot-end:not([hidden])') as SVGCircleElement;
+ const text = fixture.querySelector('span') as HTMLSpanElement;
+
+ expect(getComputedStyle(text).color).not.toBe(getComputedStyle(dot).fill);
+ });
+
+ it.each(['warning', 'success', 'danger'] as const)(
+ 'should leave the default slot empty for %s status',
+ async status => {
+ element.status = status;
+ await elementIsStable(element);
+
+ const defaultSlot = element.shadowRoot.querySelector('slot:not([name])') as HTMLSlotElement;
+ expect(defaultSlot.childElementCount).toBe(0);
+ expect(defaultSlot.textContent.trim()).toBe('');
+ }
+ );
+
+ it('should render default state as determinate 0 progress', async () => {
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+
+ expect(gauge.getAttribute('stroke-dasharray')).toBe('0 200');
+ });
+
+ it('should hide fill dots when progress is 0', async () => {
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+ const dots = Array.from(element.shadowRoot.querySelectorAll('.fill-dot-start, .fill-dot-end')) as SVGElement[];
+
+ expect(gauge.hasAttribute('empty')).toBe(true);
+ expect(getComputedStyle(gauge).strokeLinecap).toBe('butt');
+ expect(dots.every(dot => dot.hasAttribute('hidden'))).toBe(true);
+
+ element.value = 1;
+ await elementIsStable(element);
+
+ expect(gauge.hasAttribute('empty')).toBe(false);
+ expect(getComputedStyle(gauge).strokeLinecap).toBe('butt');
+ expect(dots.every(dot => dot.hasAttribute('hidden'))).toBe(false);
+ });
+
+ it('should render the fill caps as circles', async () => {
+ element.value = 50;
+ await elementIsStable(element);
+
+ const startDot = element.shadowRoot.querySelector('.fill-dot-start') as SVGCircleElement;
+ const endDot = element.shadowRoot.querySelector('.fill-dot-end') as SVGCircleElement;
+
+ expect(startDot.tagName).toBe('circle');
+ expect(startDot.getAttribute('cx')).toBe('27.23');
+ expect(startDot.getAttribute('cy')).toBe('100.77');
+ expect(endDot.tagName).toBe('circle');
+ expect(endDot.getAttribute('cx')).toBe('116');
+ expect(endDot.getAttribute('cy')).toBe('64');
+ });
+
+ it('should animate progress on initial render and value changes', async () => {
+ removeFixture(fixture);
+ fixture = await createFixture(html`
+
+ `);
+ element = fixture.querySelector(Gauge.metadata.tag);
+ await elementIsStable(element);
+
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+ const dot = element.shadowRoot.querySelector('.fill-dot-end') as SVGCircleElement;
+
+ expect(gauge.style.getPropertyValue('--_progress')).toBe('50');
+ expect(dot.style.getPropertyValue('--_dot-angle')).toBe('270deg');
+ expect(getComputedStyle(gauge).animationName).toBe('gauge-progress-in');
+ expect(getComputedStyle(gauge).transitionProperty).toBe('stroke-dasharray');
+ expect(getComputedStyle(dot).animationName).toBe('gauge-dot-in');
+ expect(getComputedStyle(dot).transitionProperty).toBe('transform');
+
+ element.value = 75;
+ await elementIsStable(element);
+
+ expect(gauge.style.getPropertyValue('--_progress')).toBe('75');
+ expect(gauge.getAttribute('stroke-dasharray')).toBe('75 200');
+ expect(dot.style.getPropertyValue('--_dot-angle')).toBe('337.5deg');
+ });
+
+ it('should position the end dot at low values without wrapping', async () => {
+ element.value = 12;
+ await elementIsStable(element);
+
+ const dot = element.shadowRoot.querySelector('.fill-dot-end') as SVGCircleElement;
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+ gauge.style.animation = 'none';
+ const pathLength = gauge.getTotalLength();
+ const paintedPoint = gauge.getPointAtLength(pathLength * 0.11);
+ const wrappedPoint = gauge.getPointAtLength(pathLength * 0.99);
+
+ expect(dot.tagName).toBe('circle');
+ expect(dot.style.getPropertyValue('--_dot-angle')).toBe('167.4deg');
+ expect(parseFloat(gauge.style.getPropertyValue('--_dash-progress'))).toBeCloseTo((12 * 100) / 110.93, 1);
+ expect(gauge.isPointInStroke(new DOMPoint(paintedPoint.x, paintedPoint.y))).toBe(true);
+ expect(gauge.isPointInStroke(new DOMPoint(wrappedPoint.x, wrappedPoint.y))).toBe(false);
+ });
+
+ it.each([
+ { thumb: 'fill', fillHidden: false, startDotHidden: false, endDotHidden: false, needleHidden: true },
+ { thumb: 'dot', fillHidden: true, startDotHidden: true, endDotHidden: false, needleHidden: true },
+ { thumb: 'needle', fillHidden: true, startDotHidden: true, endDotHidden: true, needleHidden: false }
+ ] as const)(
+ 'should render the $thumb thumb',
+ async ({ thumb, fillHidden, startDotHidden, endDotHidden, needleHidden }) => {
+ element.value = 50;
+ element.thumb = thumb;
+ await elementIsStable(element);
+
+ const fill = element.shadowRoot.querySelector('.fill-layer') as SVGElement;
+ const startDot = element.shadowRoot.querySelector('.fill-dot-start') as SVGElement;
+ const endDot = element.shadowRoot.querySelector('.fill-dot-end') as SVGElement;
+ const needle = element.shadowRoot.querySelector('.needle') as SVGElement;
+
+ expect(element.getAttribute('thumb')).toBe(thumb);
+ expect(fill.hasAttribute('hidden')).toBe(fillHidden);
+ expect(startDot.hasAttribute('hidden')).toBe(startDotHidden);
+ expect(endDot.hasAttribute('hidden')).toBe(endDotHidden);
+ expect(needle.hasAttribute('hidden')).toBe(needleHidden);
+ }
+ );
+
+ it('should rotate the needle thumb to the current value', async () => {
+ element.thumb = 'needle';
+ element.value = 50;
+ await elementIsStable(element);
+
+ const needle = element.shadowRoot.querySelector('.needle') as SVGElement;
+ const line = element.shadowRoot.querySelector('.needle-line') as SVGLineElement;
+
+ expect(needle.style.getPropertyValue('--_needle-angle')).toBe('270deg');
+ expect(line.getAttribute('x1')).toBe('64');
+ expect(line.getAttribute('x2')).toBe('104');
+
+ element.value = 75;
+ await elementIsStable(element);
+
+ expect(needle.style.getPropertyValue('--_needle-angle')).toBe('337.5deg');
+ });
+
+ it('should fall back to the fill thumb for invalid values', async () => {
+ element.setAttribute('thumb', 'invalid');
+ element.value = 50;
+ await elementIsStable(element);
+
+ const fill = element.shadowRoot.querySelector('.fill-layer') as SVGElement;
+ const endDot = element.shadowRoot.querySelector('.fill-dot-end') as SVGElement;
+ const needle = element.shadowRoot.querySelector('.needle') as SVGElement;
+
+ expect(fill.hasAttribute('hidden')).toBe(false);
+ expect(endDot.hasAttribute('hidden')).toBe(false);
+ expect(needle.hasAttribute('hidden')).toBe(true);
+ });
+
+ it('should render a 270-degree inset arc with rounded ends by default', async () => {
+ element.value = 50;
+ await elementIsStable(element);
+
+ const svg = element.shadowRoot.querySelector('svg') as SVGElement;
+ const background = element.shadowRoot.querySelector('.background') as SVGPathElement;
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+
+ expect(svg.getAttribute('viewBox')).toBe('8.53 8.53 110.93 95.7');
+ expect(gauge.getAttribute('d')).toBe('M 27.23 100.77 A 52 52 0 1 1 100.77 100.77');
+ expect(background.getAttribute('d')).toBe('M 27.23 100.77 A 52 52 0 1 1 100.77 100.77');
+ expect(getComputedStyle(background).strokeLinecap).toBe('round');
+ expect(getComputedStyle(gauge).strokeLinecap).toBe('butt');
+ expect(parseFloat(getComputedStyle(element).height)).toBeCloseTo(110.4, 1);
+ });
+
+ it('should render the half shape with the semi-circular arc', async () => {
+ element.shape = 'half';
+ element.value = 50;
+ await elementIsStable(element);
+
+ const svg = element.shadowRoot.querySelector('svg') as SVGElement;
+ const background = element.shadowRoot.querySelector('.background') as SVGPathElement;
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+
+ expect(element.getAttribute('shape')).toBe('half');
+ expect(svg.getAttribute('viewBox')).toBe('8.53 8.53 110.93 58.93');
+ expect(gauge.getAttribute('d')).toBe('M 12 64 A 52 52 0 0 1 116 64');
+ expect(background.getAttribute('d')).toBe('M 12 64 A 52 52 0 0 1 116 64');
+ expect(getComputedStyle(background).strokeLinecap).toBe('round');
+ expect(getComputedStyle(gauge).strokeLinecap).toBe('butt');
+ expect(parseFloat(getComputedStyle(element).height)).toBeCloseTo(68, 1);
+ });
+
+ it('should assign slotted content to the default slot', async () => {
+ removeFixture(fixture);
+ fixture = await createFixture(html`
+
+ 50%
+
+ `);
+ element = fixture.querySelector(Gauge.metadata.tag);
+ await elementIsStable(element);
+
+ const defaultSlot = element.shadowRoot.querySelector('slot:not([name])') as HTMLSlotElement;
+ const assigned = defaultSlot.assignedElements();
+ expect(assigned).toHaveLength(1);
+ expect(assigned[0]).toBe(fixture.querySelector('span'));
+ });
+
+ it('should set stroke-dasharray to 0 200 when value is 0', async () => {
+ element.value = 0;
+ await elementIsStable(element);
+
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+ expect(gauge.getAttribute('stroke-dasharray')).toBe('0 200');
+ });
+
+ it('should default stroke-dasharray scaling when max is omitted', async () => {
+ element.value = 50;
+ element.max = undefined;
+ await elementIsStable(element);
+
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+ expect(gauge.getAttribute('stroke-dasharray')).toBe('50 200');
+ });
+
+ it('should scale stroke-dasharray with a custom max', async () => {
+ element.value = 5;
+ element.max = 20;
+ await elementIsStable(element);
+
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+ expect(gauge.getAttribute('stroke-dasharray')).toBe('25 200');
+ });
+
+ it('should clamp over-max values', async () => {
+ element.value = 150;
+ element.max = 100;
+ await elementIsStable(element);
+
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+ expect(gauge.getAttribute('stroke-dasharray')).toBe('100 200');
+ expect(element._internals.ariaValueNow).toBe('100');
+ expect(element._internals.ariaValueMax).toBe('100');
+ });
+
+ it.each([
+ { name: 'NaN value', value: Number.NaN, max: 100, dasharray: '0 200', ariaValueNow: '0', ariaValueMax: '100' },
+ { name: 'negative value', value: -1, max: 100, dasharray: '0 200', ariaValueNow: '0', ariaValueMax: '100' },
+ { name: 'NaN max', value: 50, max: Number.NaN, dasharray: '50 200', ariaValueNow: '50', ariaValueMax: '100' },
+ { name: 'zero max', value: 50, max: 0, dasharray: '50 200', ariaValueNow: '50', ariaValueMax: '100' },
+ { name: 'negative max', value: 50, max: -1, dasharray: '50 200', ariaValueNow: '50', ariaValueMax: '100' }
+ ])('should normalize $name', async ({ value, max, dasharray, ariaValueNow, ariaValueMax }) => {
+ element.value = value;
+ element.max = max;
+ await elementIsStable(element);
+
+ const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement;
+ expect(gauge.getAttribute('stroke-dasharray')).toBe(dasharray);
+ expect(element._internals.ariaValueNow).toBe(ariaValueNow);
+ expect(element._internals.ariaValueMax).toBe(ariaValueMax);
+ });
+});
diff --git a/projects/core/src/gauge/gauge.test.visual.ts b/projects/core/src/gauge/gauge.test.visual.ts
new file mode 100644
index 000000000..20b990655
--- /dev/null
+++ b/projects/core/src/gauge/gauge.test.visual.ts
@@ -0,0 +1,97 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { expect, test, describe } from 'vitest';
+import { visualRunner } from '@internals/vite';
+
+describe('gauge visual', () => {
+ test('gauge should match visual baseline', async () => {
+ const report = await visualRunner.render('gauge', template());
+ expect(report.maxDiffPercentage).toBeLessThan(1);
+ });
+
+ test('gauge should match visual baseline dark theme', async () => {
+ const report = await visualRunner.render('gauge.dark', template('dark'));
+ expect(report.maxDiffPercentage).toBeLessThan(1);
+ });
+});
+
+function template(theme: '' | 'dark' = '') {
+ return /* html */ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 82°C
+
+
+
+
+
+ fill
+
+
+ dot
+
+
+ needle
+
+
+
+
+
+
+
+
+
+
+
+ 30Hz
+
+
+ 12Hz
+
+
+ 84%
+
+
+
+
+
+
+
+ 75%
+
+
+ 100%
+
+
+ `;
+}
diff --git a/projects/core/src/gauge/gauge.ts b/projects/core/src/gauge/gauge.ts
new file mode 100644
index 000000000..5990081d1
--- /dev/null
+++ b/projects/core/src/gauge/gauge.ts
@@ -0,0 +1,174 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { PropertyValues } from 'lit';
+import { html, LitElement } from 'lit';
+import { property } from 'lit/decorators/property.js';
+import type { Size, SupportStatus } from '@nvidia-elements/core/internal';
+import { attachInternals, I18nController, useStyles } from '@nvidia-elements/core/internal';
+import styles from './gauge.css?inline';
+
+const GAUGE_DASH_GAP = 200;
+const GAUGE_DASH_SCALE = 100 / 110.93;
+
+const GAUGE_GEOMETRY = {
+ default: {
+ center: 64,
+ path: 'M 27.23 100.77 A 52 52 0 1 1 100.77 100.77',
+ radius: 52,
+ start: { x: 27.23, y: 100.77 },
+ startAngle: 135,
+ sweepAngle: 270,
+ surfaceHeight: 128,
+ viewBox: '8.53 8.53 110.93 95.7'
+ },
+ half: {
+ center: 64,
+ path: 'M 12 64 A 52 52 0 0 1 116 64',
+ radius: 52,
+ start: { x: 12, y: 64 },
+ startAngle: 180,
+ sweepAngle: 180,
+ surfaceHeight: 64,
+ viewBox: '8.53 8.53 110.93 58.93'
+ }
+} as const;
+
+/**
+ * @element nve-gauge
+ * @description Use a gauge to show system resource usage.
+ * @since 2.0.2
+ * @entrypoint \@nvidia-elements/core/gauge
+ * @slot - Content to display in the gauge center.
+ * @cssprop --track-width
+ * @cssprop --accent-color
+ * @cssprop --background
+ * @cssprop --needle-background
+ * @cssprop --track-background
+ * @cssprop --thumb-background
+ * @cssprop --color
+ * @cssprop --width
+ * @cssprop --height
+ * @cssprop --font-size
+ * @cssprop --gap
+ * @aria https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/progressbar_role
+ * @stable false
+ */
+export class Gauge extends LitElement {
+ static styles = useStyles([styles]);
+
+ static readonly metadata = {
+ tag: 'nve-gauge',
+ version: '0.0.0'
+ };
+
+ /** @private */
+ declare _internals: ElementInternals;
+
+ /** The current `value` of the gauge. */
+ @property({ type: Number }) value = 0;
+
+ /** The `max` value of the gauge that the `value` is proportionally scaled to. */
+ @property({ type: Number }) max? = 100;
+
+ /** Four visual treatments represent the `status` of tasks. */
+ @property({ type: String, reflect: true }) status?: SupportStatus | 'neutral' = 'neutral';
+
+ /** Determines the gauge shape. Set `half` for a compact semi-circular arc. */
+ @property({ type: String, reflect: true }) shape?: 'half';
+
+ /** Controls the value indicator. Set `dot` for only the end dot or `needle` for a pointer. */
+ @property({ type: String, reflect: true }) thumb: 'fill' | 'dot' | 'needle' = 'fill';
+
+ /** T-shirt `size` of the gauge. */
+ @property({ type: String, reflect: true }) size?: Size;
+
+ #i18nController: I18nController = new I18nController(this);
+
+ /** Enables updating internal string values for internationalization. */
+ @property({ type: Object }) i18n = this.#i18nController.i18n;
+
+ #normalizedValues() {
+ const sourceMax = this.max;
+ const max = sourceMax !== undefined && Number.isFinite(sourceMax) && sourceMax > 0 ? sourceMax : 100;
+ const value = Number.isFinite(this.value) ? Math.min(Math.max(this.value, 0), max) : 0;
+ return { value, max };
+ }
+
+ render() {
+ const geometry = this.#geometry();
+ const { value, max } = this.#normalizedValues();
+ const progress = (value / max) * 100;
+ const thumb = this.#normalizedThumb();
+ const progressAngle = this.#angleAtProgress(geometry, progress);
+ const showFill = thumb === 'fill';
+ const showDot = progress > 0 && (thumb === 'fill' || thumb === 'dot');
+
+ return html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0)}>
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ attachInternals(this);
+ this._internals.role = 'progressbar';
+ }
+
+ updated(props: PropertyValues) {
+ super.updated(props);
+ const { value, max } = this.#normalizedValues();
+ this._internals.ariaValueNow = `${value}`;
+ this._internals.ariaValueMax = `${max}`;
+ const i18nRecord = this.i18n as Record;
+ this._internals.ariaLabel =
+ (this.status && i18nRecord[this.status] && i18nRecord[this.status] !== 'neutral'
+ ? i18nRecord[this.status]!
+ : this.i18n.information) ?? null;
+ }
+
+ #angleAtProgress(geometry: (typeof GAUGE_GEOMETRY)[keyof typeof GAUGE_GEOMETRY], progress: number) {
+ return geometry.startAngle + geometry.sweepAngle * (progress / 100);
+ }
+
+ #geometry() {
+ return this.shape === 'half' ? GAUGE_GEOMETRY.half : GAUGE_GEOMETRY.default;
+ }
+
+ #normalizedThumb() {
+ return this.thumb === 'dot' || this.thumb === 'needle' ? this.thumb : 'fill';
+ }
+}
diff --git a/projects/core/src/gauge/index.ts b/projects/core/src/gauge/index.ts
new file mode 100644
index 000000000..62a8ac82d
--- /dev/null
+++ b/projects/core/src/gauge/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 './gauge.js';
diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts
index a493fa164..b98474efa 100644
--- a/projects/core/src/index.test.lighthouse.ts
+++ b/projects/core/src/index.test.lighthouse.ts
@@ -15,7 +15,7 @@ describe('lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(130.5);
+ expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(132);
// if sudden drop in size, check vite bundle config and bundle demo to ensure side effects are properly preserved
expect(report.payload.javascript.requests['index.js'].kb).toBeGreaterThan(120);
@@ -46,6 +46,7 @@ describe('lighthouse report', () => {
import '@nvidia-elements/core/dropdown/define.js';
import '@nvidia-elements/core/file/define.js';
import '@nvidia-elements/core/forms/define.js';
+ import '@nvidia-elements/core/gauge/define.js';
import '@nvidia-elements/core/grid/define.js';
import '@nvidia-elements/core/icon/define.js';
import '@nvidia-elements/core/icon-button/define.js';
diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js
index 580b90cc9..3f09a61d9 100644
--- a/projects/site/src/_11ty/layouts/common.js
+++ b/projects/site/src/_11ty/layouts/common.js
@@ -270,6 +270,7 @@ export const renderDocsNav = data => /* html */ `
Actions
Control
+ Gauge
Icon
Icon Button
Input
diff --git a/projects/site/src/docs/elements/gauge.md b/projects/site/src/docs/elements/gauge.md
new file mode 100644
index 000000000..21e9ef504
--- /dev/null
+++ b/projects/site/src/docs/elements/gauge.md
@@ -0,0 +1,71 @@
+---
+{
+ title: 'Gauge',
+ layout: 'docs.11ty.js',
+ tag: 'nve-gauge'
+}
+---
+
+## Installation
+
+{% install 'nve-gauge' %}
+
+## Indicating Status
+
+{% api 'nve-gauge', 'property', 'status' %}
+
+{% example '@nvidia-elements/core/gauge/gauge.examples.json' 'Status' %}
+
+## Shape
+
+{% api 'nve-gauge', 'property', 'shape' %}
+
+{% example '@nvidia-elements/core/gauge/gauge.examples.json' 'Shape' %}
+
+## Thumb
+
+{% api 'nve-gauge', 'property', 'thumb' %}
+
+{% example '@nvidia-elements/core/gauge/gauge.examples.json' 'Thumb' %}
+
+## Size
+
+{% api 'nve-gauge', 'property', 'size' %}
+
+{% example '@nvidia-elements/core/gauge/gauge.examples.json' 'Size' %}
+
+## Value
+
+{% api 'nve-gauge', 'property', 'value' %}
+
+{% example '@nvidia-elements/core/gauge/gauge.examples.json' 'Values' %}
+
+## Gradient
+
+{% example '@nvidia-elements/core/gauge/gauge.examples.json' 'Gradient' %}
+
+## Half Shape
+
+{% api 'nve-gauge', 'property', 'shape' %}
+
+{% example '@nvidia-elements/core/gauge/gauge.examples.json' 'ShapeHalf' %}
+
+## Half Shape Needle
+
+{% api 'nve-gauge', 'property', 'shape' %}
+
+{% example '@nvidia-elements/core/gauge/gauge.examples.json' 'ShapeHalfNeedle' %}
+
+## Dynamic
+
+{% example '@nvidia-elements/core/gauge/gauge.examples.json' 'Dynamic' %}
+
+## Multi Gauge
+
+{% example '@nvidia-elements/core/gauge/gauge.examples.json' 'MultiGauge' %}
+
+## Max Value
+
+{% api 'nve-gauge', 'property', 'max' %}
+
+{% example '@nvidia-elements/core/gauge/gauge.examples.json' 'Max' %}