diff --git a/projects/core/.visual/format-truncate.dark.png b/projects/core/.visual/format-truncate.dark.png
new file mode 100644
index 000000000..cd20c77b9
--- /dev/null
+++ b/projects/core/.visual/format-truncate.dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6745ff0971fff8dd2ab6326c7f19e36994b481cc7f48064c872d70b3bcf2995f
+size 28870
diff --git a/projects/core/.visual/format-truncate.png b/projects/core/.visual/format-truncate.png
new file mode 100644
index 000000000..5ead374ca
--- /dev/null
+++ b/projects/core/.visual/format-truncate.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3c936fd8a189fc69bf9ccb409d6435e567ed4abefc07dd75c7dda8f9119d794b
+size 29407
diff --git a/projects/core/package.json b/projects/core/package.json
index 84c50a87c..7d2f3dba8 100644
--- a/projects/core/package.json
+++ b/projects/core/package.json
@@ -426,6 +426,18 @@
"types": "./dist/format-relative-time/define.d.ts",
"default": "./dist/format-relative-time/define.js"
},
+ "./format-truncate": {
+ "types": "./dist/format-truncate/index.d.ts",
+ "default": "./dist/format-truncate/index.js"
+ },
+ "./format-truncate/index.js": {
+ "types": "./dist/format-truncate/index.d.ts",
+ "default": "./dist/format-truncate/index.js"
+ },
+ "./format-truncate/define.js": {
+ "types": "./dist/format-truncate/define.d.ts",
+ "default": "./dist/format-truncate/define.js"
+ },
"./forms": {
"types": "./dist/forms/index.d.ts",
"default": "./dist/forms/index.js"
diff --git a/projects/core/src/bundle.ts b/projects/core/src/bundle.ts
index de28bb4e1..d9d59eb8f 100644
--- a/projects/core/src/bundle.ts
+++ b/projects/core/src/bundle.ts
@@ -31,6 +31,7 @@ import '@nvidia-elements/core/format-bytes/define.js';
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/format-truncate/define.js';
import '@nvidia-elements/core/forms/define.js';
import '@nvidia-elements/core/gauge/define.js';
import '@nvidia-elements/core/grid/define.js';
@@ -101,6 +102,7 @@ export * from '@nvidia-elements/core/format-bytes';
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/format-truncate';
export * from '@nvidia-elements/core/forms';
export * from '@nvidia-elements/core/gauge';
export * from '@nvidia-elements/core/grid';
diff --git a/projects/core/src/format-truncate/define.ts b/projects/core/src/format-truncate/define.ts
new file mode 100644
index 000000000..4ba0054a4
--- /dev/null
+++ b/projects/core/src/format-truncate/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 { FormatTruncate } from '@nvidia-elements/core/format-truncate';
+
+define(FormatTruncate);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'nve-format-truncate': FormatTruncate;
+ }
+}
diff --git a/projects/core/src/format-truncate/format-truncate.css b/projects/core/src/format-truncate/format-truncate.css
new file mode 100644
index 000000000..469ba8f0f
--- /dev/null
+++ b/projects/core/src/format-truncate/format-truncate.css
@@ -0,0 +1,21 @@
+/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
+/* SPDX-License-Identifier: Apache-2.0 */
+
+:host {
+ display: contents;
+}
+
+span {
+ text-wrap: nowrap;
+}
+
+slot {
+ display: initial;
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
diff --git a/projects/core/src/format-truncate/format-truncate.examples.ts b/projects/core/src/format-truncate/format-truncate.examples.ts
new file mode 100644
index 000000000..538f1dc72
--- /dev/null
+++ b/projects/core/src/format-truncate/format-truncate.examples.ts
@@ -0,0 +1,64 @@
+// 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/format-truncate/define.js';
+
+export default {
+ title: 'Elements/FormatTruncate',
+ component: 'nve-format-truncate'
+};
+
+/**
+ * @summary Start, center, and end positions keep the most useful part of constrained text. Choose the position that matches how users distinguish each value.
+ */
+export const Default = {
+ render: () => html`
+
+ training-pipeline-2026-08-05-production
+ training-pipeline-2026-08-05-production
+ training-pipeline-2026-08-05-production
+
+ `
+};
+
+/**
+ * @summary Inherited heading, body, and code styles keep truncated text aligned with surrounding typography. Place the component inside semantic text elements to preserve their visual hierarchy.
+ * @tags test-case
+ */
+export const Typography = {
+ render: () => html`
+
+
training-pipeline-2026-08-05-production
+
training-pipeline-2026-08-05-production
+
training-pipeline-2026-08-05-production
+
+ `
+};
+
+/**
+ * @summary Center truncation bias keeps units from either the start or end. Use end bias for shared suffixes and start bias for identifiers with meaningful endings.
+ * @tags test-case
+ */
+export const Bias = {
+ render: () => html`
+
+ sha256:1234567890abcdef1234567890abcdef
+ sha256:1234567890abcdef1234567890abcdef
+
+ `
+};
+
+/**
+ * @summary Character, word, and path strategies preserve complete graphemes or meaningful text units. Match the strategy to identifiers, labels, or file paths.
+ * @tags test-case
+ */
+export const Strategy = {
+ render: () => html`
+
+ experiment-👩🏽‍💻-0123456789abcdef
+ NVIDIA autonomous vehicle training pipeline production run
+ /models/checkpoints/production/model.bin
+
+ `
+};
diff --git a/projects/core/src/format-truncate/format-truncate.test.axe.ts b/projects/core/src/format-truncate/format-truncate.test.axe.ts
new file mode 100644
index 000000000..69312cf03
--- /dev/null
+++ b/projects/core/src/format-truncate/format-truncate.test.axe.ts
@@ -0,0 +1,29 @@
+// 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 { createFixture, elementIsStable, removeFixture } from '@internals/testing';
+import { runAxe } from '@internals/testing/axe';
+import { FormatTruncate } from '@nvidia-elements/core/format-truncate';
+import '@nvidia-elements/core/format-truncate/define.js';
+
+describe(FormatTruncate.metadata.tag, () => {
+ it('should pass axe checks for every truncation position and strategy', async () => {
+ const fixture = await createFixture(html`
+ training-pipeline-2026-08-05-production
+ training-pipeline-2026-08-05-production
+ experiment-👩🏽‍💻-0123456789abcdef
+ NVIDIA autonomous vehicle training pipeline
+ /models/checkpoints/production/model.bin
+ `);
+
+ try {
+ await elementIsStable(fixture.querySelector(FormatTruncate.metadata.tag));
+ const results = await runAxe([FormatTruncate.metadata.tag]);
+ expect(results.violations.length).toBe(0);
+ } finally {
+ removeFixture(fixture);
+ }
+ });
+});
diff --git a/projects/core/src/format-truncate/format-truncate.test.lighthouse.ts b/projects/core/src/format-truncate/format-truncate.test.lighthouse.ts
new file mode 100644
index 000000000..f8bdcea5e
--- /dev/null
+++ b/projects/core/src/format-truncate/format-truncate.test.lighthouse.ts
@@ -0,0 +1,24 @@
+// 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('format-truncate lighthouse report', () => {
+ test('format-truncate should meet lighthouse benchmarks', async () => {
+ const report = await lighthouseRunner.getReport(
+ 'nve-format-truncate',
+ /* html */ `
+ /models/checkpoints/production/model.bin
+
+ `
+ );
+
+ expect(report.scores.performance).toBe(100);
+ expect(report.scores.accessibility).toBe(100);
+ expect(report.scores.bestPractices).toBe(100);
+ expect(report.payload.javascript.kb).toBeLessThan(16);
+ });
+});
diff --git a/projects/core/src/format-truncate/format-truncate.test.ssr.ts b/projects/core/src/format-truncate/format-truncate.test.ssr.ts
new file mode 100644
index 000000000..fd72e9f0f
--- /dev/null
+++ b/projects/core/src/format-truncate/format-truncate.test.ssr.ts
@@ -0,0 +1,20 @@
+// 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 { FormatTruncate } from '@nvidia-elements/core/format-truncate';
+import '@nvidia-elements/core/format-truncate/define.js';
+
+describe(FormatTruncate.metadata.tag, () => {
+ it('should pass baseline ssr check', async () => {
+ const result = await ssrRunner.render(
+ html`abcdefghij`
+ );
+
+ expect(result.includes('shadowroot="open"')).toBe(true);
+ expect(result.includes('nve-format-truncate')).toBe(true);
+ expect(result.includes('abcdefghij')).toBe(true);
+ });
+});
diff --git a/projects/core/src/format-truncate/format-truncate.test.ts b/projects/core/src/format-truncate/format-truncate.test.ts
new file mode 100644
index 000000000..4c68da46a
--- /dev/null
+++ b/projects/core/src/format-truncate/format-truncate.test.ts
@@ -0,0 +1,241 @@
+// 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 { FormatTruncate } from '@nvidia-elements/core/format-truncate';
+import '@nvidia-elements/core/format-truncate/define.js';
+import { truncateText } from './utils.js';
+
+describe(FormatTruncate.metadata.tag, () => {
+ let fixture: HTMLElement;
+ let element: FormatTruncate;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html`short text`);
+ element = getTruncate(fixture);
+ await elementIsStable(element);
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ });
+
+ it('should define element', () => {
+ expect(customElements.get(FormatTruncate.metadata.tag)).toBeDefined();
+ });
+
+ it('should expose reflected defaults', () => {
+ expect(element.position).toBe('start');
+ expect(element.getAttribute('position')).toBe('start');
+ expect(element.bias).toBe('end');
+ expect(element.getAttribute('bias')).toBe('end');
+ expect(element.strategy).toBe('character');
+ expect(element.getAttribute('strategy')).toBe('character');
+ expect(element.preserve).toBe(6);
+ expect(element.getAttribute('preserve')).toBe('6');
+ });
+
+ it('should render computed content in an aria-hidden internal host', () => {
+ expect(element.hasAttribute('title')).toBe(false);
+ expect(element.shadowRoot?.textContent).toBe('short text');
+ const internalHost = element.shadowRoot?.querySelector('[internal-host]');
+ expect(internalHost).toBeInstanceOf(HTMLSpanElement);
+ expect(internalHost?.getAttribute('aria-hidden')).toBe('true');
+ expect(internalHost?.textContent).toBe('short text');
+ });
+
+ it('should inherit font size from its container', () => {
+ fixture.style.font = '700 32px/1.2 serif';
+ fixture.style.color = 'rgb(12, 34, 56)';
+
+ const internalHost = element.shadowRoot?.querySelector('[internal-host]');
+ expect(internalHost).toBeInstanceOf(HTMLSpanElement);
+
+ const styles = getComputedStyle(internalHost!);
+ expect(styles.fontSize).toBe('32px');
+ });
+
+ it('should normalize mixed slotted content', async () => {
+ element.innerHTML = 'alphabetagamma';
+ getDefaultSlot(element).dispatchEvent(new Event('slotchange'));
+ await elementIsStable(element);
+
+ expect(element.shadowRoot?.textContent).toBe('alpha beta gamma');
+ expect(element.hasAttribute('title')).toBe(false);
+ });
+
+ it('should remove text alternatives for empty content', async () => {
+ element.textContent = '';
+ getDefaultSlot(element).dispatchEvent(new Event('slotchange'));
+ await elementIsStable(element);
+
+ expect(element.shadowRoot?.textContent).toBe('');
+ expect(element.hasAttribute('title')).toBe(false);
+ });
+
+ it('should visually hide the source slot with the component stylesheet', () => {
+ const slot = getDefaultSlot(element);
+ expect(slot.hidden).toBe(false);
+ expect(getComputedStyle(slot).position).toBe('absolute');
+ expect(getComputedStyle(slot).width).toBe('1px');
+ });
+
+ it('should recompute the plain text for a constrained container', async () => {
+ fixture.style.width = '40px';
+ element.position = 'end';
+ element.textContent = 'abcdefghij';
+ getDefaultSlot(element).dispatchEvent(new Event('slotchange'));
+ await elementIsStable(element);
+
+ expect(element.shadowRoot?.textContent).toContain('…');
+ expect(element.title).toBe('abcdefghij');
+ expect(element.shadowRoot?.querySelector('[internal-host]')).toBeInstanceOf(HTMLSpanElement);
+ });
+
+ it('should recompute text after moving between containers', async () => {
+ const narrowContainer = document.createElement('div');
+ narrowContainer.style.width = '40px';
+ const wideContainer = document.createElement('div');
+ wideContainer.style.width = '400px';
+ fixture.append(narrowContainer, wideContainer);
+
+ element.textContent = 'abcdefghij';
+ narrowContainer.append(element);
+ await elementIsStable(element);
+
+ expect(element.shadowRoot?.textContent).toContain('…');
+ expect(element.title).toBe('abcdefghij');
+
+ wideContainer.append(element);
+ await elementIsStable(element);
+
+ expect(element.shadowRoot?.textContent).toBe('abcdefghij');
+ expect(element.hasAttribute('title')).toBe(false);
+ });
+});
+
+describe('truncateText', () => {
+ it('should leave text unchanged when it fits', () => {
+ expect(renderTruncatedText('short', { availableWidth: 5 })).toBe('short');
+ });
+
+ it('should truncate at the start or end by grapheme', () => {
+ expect(renderTruncatedText('abcdefghij', { position: 'start', availableWidth: 6 })).toBe('…fghij');
+ expect(renderTruncatedText('abcdefghij', { position: 'end', availableWidth: 6 })).toBe('abcde…');
+ });
+
+ it('should keep the configured center bias', () => {
+ expect(
+ renderTruncatedText('abcdefghij', { position: 'center', bias: 'start', preserve: 3, availableWidth: 8 })
+ ).toBe('abc…ghij');
+ expect(renderTruncatedText('abcdefghij', { position: 'center', bias: 'end', preserve: 3, availableWidth: 8 })).toBe(
+ 'abcd…hij'
+ );
+ });
+
+ it('should preserve complete words', () => {
+ expect(
+ renderTruncatedText('alpha beta gamma delta', {
+ position: 'center',
+ strategy: 'word',
+ bias: 'end',
+ preserve: 2,
+ availableWidth: 17
+ })
+ ).toBe('alpha…gamma delta');
+ expect(
+ renderTruncatedText('alpha beta gamma delta', {
+ position: 'center',
+ strategy: 'word',
+ bias: 'start',
+ preserve: 2,
+ availableWidth: 16
+ })
+ ).toBe('alpha beta…delta');
+ });
+
+ it('should preserve complete path segments', () => {
+ expect(
+ renderTruncatedText('/models/checkpoints/run/model.bin', {
+ position: 'center',
+ strategy: 'path',
+ bias: 'end',
+ preserve: 2,
+ availableWidth: 22
+ })
+ ).toBe('/models…/run/model.bin');
+ expect(
+ renderTruncatedText('/models/checkpoints/run/model.bin', {
+ position: 'center',
+ strategy: 'path',
+ bias: 'start',
+ preserve: 2,
+ availableWidth: 30
+ })
+ ).toBe('/models/checkpoints/…model.bin');
+ });
+
+ it('should not split Unicode grapheme clusters', () => {
+ expect(
+ renderTruncatedText('A👩🏽‍💻BCD', {
+ position: 'end',
+ availableWidth: 4,
+ measureText: measureGraphemes
+ })
+ ).toBe('A👩🏽‍💻B…');
+ });
+
+ it('should clamp preserve to one whole unit', () => {
+ expect(
+ renderTruncatedText('abcdefghij', {
+ position: 'center',
+ bias: 'end',
+ preserve: 0.5,
+ availableWidth: 6
+ })
+ ).toBe('abcd…j');
+ });
+
+ it('should keep the biased edge when preserved content does not fit', () => {
+ expect(
+ renderTruncatedText('abcdefghij', {
+ position: 'center',
+ bias: 'end',
+ preserve: 6,
+ availableWidth: 4
+ })
+ ).toBe('…hij');
+ });
+});
+
+type TruncateOptions = Parameters[1];
+
+function renderTruncatedText(text: string, options: Partial): string {
+ return truncateText(text, {
+ position: 'end',
+ strategy: 'character',
+ bias: 'end',
+ preserve: 6,
+ availableWidth: Number.POSITIVE_INFINITY,
+ measureText: value => Array.from(value).length,
+ ...options
+ });
+}
+
+function measureGraphemes(text: string): number {
+ return Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)).length;
+}
+
+function getTruncate(container: ParentNode): FormatTruncate {
+ const element = container.querySelector(FormatTruncate.metadata.tag);
+ if (!element) throw new Error('Expected format truncate element');
+ return element;
+}
+
+function getDefaultSlot(element: FormatTruncate): HTMLSlotElement {
+ const slot = element.shadowRoot?.querySelector('slot:not([name])');
+ if (!slot) throw new Error('Expected default slot');
+ return slot;
+}
diff --git a/projects/core/src/format-truncate/format-truncate.test.visual.ts b/projects/core/src/format-truncate/format-truncate.test.visual.ts
new file mode 100644
index 000000000..e80682f0d
--- /dev/null
+++ b/projects/core/src/format-truncate/format-truncate.test.visual.ts
@@ -0,0 +1,35 @@
+// 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('format-truncate visual', () => {
+ test('format-truncate should match visual baseline', async () => {
+ const report = await visualRunner.render('format-truncate', template());
+ expect(report.maxDiffPercentage).toBeLessThan(1);
+ });
+
+ test('format-truncate should match visual baseline in dark theme', async () => {
+ const report = await visualRunner.render('format-truncate.dark', template('dark'));
+ expect(report.maxDiffPercentage).toBeLessThan(2);
+ });
+});
+
+function template(theme: '' | 'dark' = ''): string {
+ return /* html */ `
+
+
+ training-pipeline-2026-08-05-production
+
training-pipeline-2026-08-05-production
+ training-pipeline-2026-08-05-production
+ NVIDIA autonomous vehicle training pipeline
+ /models/checkpoints/production/model.bin
+ training-pipeline-2026-08-05-production
+ short text
+
+ `;
+}
diff --git a/projects/core/src/format-truncate/format-truncate.ts b/projects/core/src/format-truncate/format-truncate.ts
new file mode 100644
index 000000000..aa9d1dfd8
--- /dev/null
+++ b/projects/core/src/format-truncate/format-truncate.ts
@@ -0,0 +1,210 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { TemplateResult } from 'lit';
+import { html, LitElement } from 'lit';
+import { property } from 'lit/decorators/property.js';
+import { attachInternals, useStyles, typeSSR } from '@nvidia-elements/core/internal';
+import { normalizeTruncateString, normalizeTruncateText, truncateText } from './utils.js';
+import styles from './format-truncate.css?inline';
+
+/**
+ * @element nve-format-truncate
+ * @description Truncates text at its start, center, or end while preserving the full text for assistive technology.
+ * @documentation https://nvidia.github.io/elements/docs/elements/format-truncate/
+ * @since 0.0.0
+ * @entrypoint \@nvidia-elements/core/format-truncate
+ * @slot - Text to truncate. The component flattens, trims, and normalizes mixed text and element nodes.
+ * @beta
+ */
+@typeSSR()
+export class FormatTruncate extends LitElement {
+ static styles = useStyles([styles]);
+
+ static readonly metadata = {
+ tag: 'nve-format-truncate',
+ version: '0.0.0'
+ };
+
+ /**
+ * Location of the ellipsis. Use start or end to preserve one edge, or center to preserve both edges.
+ */
+ @property({ type: String, reflect: true }) position: 'start' | 'center' | 'end' = 'start';
+
+ /**
+ * Text unit used for center truncation. Character preserves graphemes, word preserves words, and path preserves path segments.
+ */
+ @property({ type: String, reflect: true }) strategy: 'character' | 'word' | 'path' = 'character';
+
+ /**
+ * Edge favored by center truncation. The favored edge keeps at least the configured number of units.
+ */
+ @property({ type: String, reflect: true }) bias: 'start' | 'end' = 'end';
+
+ /**
+ * Number of strategy units retained at the biased edge during center truncation.
+ */
+ @property({ type: Number, reflect: true }) preserve = 6;
+
+ /** @private */
+ declare _internals: ElementInternals;
+
+ #measurementContext?: CanvasRenderingContext2D | null;
+ #resizeObserver?: ResizeObserver;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ attachInternals(this);
+ this.#observeAvailableWidth();
+ this.requestUpdate();
+ }
+
+ override disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this.#resizeObserver?.disconnect();
+ }
+
+ connectedMoveCallback(): void {
+ this.#observeAvailableWidth();
+ this.requestUpdate();
+ }
+
+ override render(): TemplateResult {
+ const text = this.#slottedText;
+ const truncatedText = this.#renderText(text);
+
+ if (truncatedText !== text) this.title = text;
+ else this.removeAttribute('title');
+
+ return html`${truncatedText}`;
+ }
+
+ get #slottedText(): string {
+ const slot = this.shadowRoot?.querySelector('slot:not([name])');
+ if (slot) return normalizeTruncateText(slot.assignedNodes({ flatten: true }));
+
+ const nodes = Array.from(this.childNodes ?? []).filter(isDefaultSlotNode);
+ return nodes.length ? normalizeTruncateText(nodes) : normalizeTruncateString(this.textContent ?? '');
+ }
+
+ #renderText(text: string): string {
+ const measureText = this.#createTextMeasurer();
+
+ return truncateText(text, {
+ position: this.position,
+ strategy: this.strategy,
+ bias: this.bias,
+ preserve: this.preserve,
+ availableWidth: this.#availableWidth,
+ measureText
+ });
+ }
+
+ get #availableWidth(): number {
+ const view = this.ownerDocument?.defaultView;
+ if (!view) return Number.POSITIVE_INFINITY;
+
+ for (let container = getComposedParent(this); container; container = getComposedParent(container)) {
+ const containerWidth = getContentWidth(container, view);
+ if (containerWidth > 0) return containerWidth;
+ }
+
+ return Number.POSITIVE_INFINITY;
+ }
+
+ #createTextMeasurer(): (text: string) => number {
+ const view = this.ownerDocument?.defaultView;
+ const context = this.#textMeasurementContext;
+ if (!view || !context) return () => 0;
+
+ const computedStyles = view.getComputedStyle(this);
+ context.font = computedStyles.font;
+
+ const letterSpacing = cssPixelValue(computedStyles.letterSpacing);
+ const wordSpacing = cssPixelValue(computedStyles.wordSpacing);
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
+
+ return text => this.#measureText(text, { context, letterSpacing, wordSpacing, graphemeSegmenter });
+ }
+
+ #measureText(
+ text: string,
+ {
+ context,
+ letterSpacing,
+ wordSpacing,
+ graphemeSegmenter
+ }: {
+ context: CanvasRenderingContext2D;
+ letterSpacing: number;
+ wordSpacing: number;
+ graphemeSegmenter: Intl.Segmenter;
+ }
+ ): number {
+ const graphemeSegments = graphemeSegmenter.segment(text);
+ const whitespaceCount = Array.from(text.matchAll(/\s/gu)).length;
+
+ return (
+ context.measureText(text).width +
+ Math.max(0, Array.from(graphemeSegments).length - 1) * letterSpacing +
+ whitespaceCount * wordSpacing
+ );
+ }
+
+ get #textMeasurementContext(): CanvasRenderingContext2D | null {
+ if (this.#measurementContext === undefined) {
+ this.#measurementContext = this.ownerDocument?.createElement('canvas').getContext('2d') ?? null;
+ }
+
+ return this.#measurementContext;
+ }
+
+ #observeAvailableWidth(): void {
+ if (typeof ResizeObserver === 'undefined') return;
+
+ this.#resizeObserver ??= new ResizeObserver(() => {
+ if (this.isConnected) this.requestUpdate();
+ });
+ this.#resizeObserver.disconnect();
+ this.#resizeObserver.observe(this);
+
+ for (let container = getComposedParent(this); container; container = getComposedParent(container)) {
+ this.#resizeObserver.observe(container);
+ if (container.clientWidth > 0) break;
+ }
+ }
+
+ #onSlotChange = (): void => {
+ this.requestUpdate();
+ };
+}
+
+function getContentWidth(element: HTMLElement, view: Window): number {
+ const computedStyles = view.getComputedStyle(element);
+ return Math.max(
+ 0,
+ element.clientWidth -
+ cssPixelValue(computedStyles.paddingInlineStart) -
+ cssPixelValue(computedStyles.paddingInlineEnd)
+ );
+}
+
+function getComposedParent(element: HTMLElement): HTMLElement | null {
+ if (element.parentElement) return element.parentElement;
+
+ const root = element.getRootNode();
+ return root instanceof ShadowRoot && root.host instanceof HTMLElement ? root.host : null;
+}
+
+function cssPixelValue(value: string): number {
+ const parsed = Number.parseFloat(value);
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+function isDefaultSlotNode(node: Node): boolean {
+ return !isElementNode(node) || !node.getAttribute('slot');
+}
+
+function isElementNode(node: Node): node is Element {
+ return node.nodeType === 1;
+}
diff --git a/projects/core/src/format-truncate/index.ts b/projects/core/src/format-truncate/index.ts
new file mode 100644
index 000000000..f0417514d
--- /dev/null
+++ b/projects/core/src/format-truncate/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 './format-truncate.js';
diff --git a/projects/core/src/format-truncate/utils.ts b/projects/core/src/format-truncate/utils.ts
new file mode 100644
index 000000000..a7a41e1e9
--- /dev/null
+++ b/projects/core/src/format-truncate/utils.ts
@@ -0,0 +1,220 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+const ELLIPSIS = '…';
+
+type TruncatePosition = 'start' | 'center' | 'end';
+type TruncateStrategy = 'character' | 'word' | 'path';
+type TruncateBias = 'start' | 'end';
+
+export function normalizeTruncateText(nodes: Iterable): string {
+ return normalizeTruncateString(Array.from(nodes, node => node.textContent ?? '').join(' '));
+}
+
+export function normalizeTruncateString(text: string): string {
+ return text.replace(/\s+/gu, ' ').trim();
+}
+
+export function truncateText(
+ text: string,
+ {
+ position,
+ strategy,
+ bias,
+ preserve,
+ availableWidth,
+ measureText
+ }: {
+ position: TruncatePosition;
+ strategy: TruncateStrategy;
+ bias: TruncateBias;
+ preserve: number;
+ availableWidth: number;
+ measureText: (value: string) => number;
+ }
+): string {
+ if (!text || !Number.isFinite(availableWidth)) return text;
+ if (availableWidth <= 0) return '';
+ if (measureText(text) <= availableWidth) return text;
+ if (measureText(ELLIPSIS) > availableWidth) return ELLIPSIS;
+
+ switch (position) {
+ case 'start':
+ return truncateStart(segmentText(text, 'grapheme'), availableWidth, measureText);
+ case 'center':
+ return truncateMiddle(text, { strategy, bias, preserve, availableWidth, measureText });
+ case 'end':
+ default:
+ return truncateEnd(segmentText(text, 'grapheme'), availableWidth, measureText);
+ }
+}
+
+function truncateMiddle(
+ text: string,
+ {
+ strategy,
+ bias,
+ preserve,
+ availableWidth,
+ measureText
+ }: {
+ strategy: TruncateStrategy;
+ bias: TruncateBias;
+ preserve: number;
+ availableWidth: number;
+ measureText: (value: string) => number;
+ }
+): string {
+ const units = getUnits(text, strategy, bias);
+ const preserveCount = Math.min(units.length, normalizePreserve(preserve));
+
+ if (bias === 'start') {
+ const preservedUnits = units.slice(0, preserveCount);
+ const truncatableUnits = units.slice(preserveCount);
+ const candidate = (count: number) =>
+ joinWithEllipsis(preservedUnits.join(''), truncatableUnits.slice(-count || truncatableUnits.length).join(''));
+
+ if (measureText(candidate(0)) > availableWidth) {
+ return truncateEnd(preservedUnits, availableWidth, measureText);
+ }
+
+ return candidate(findLargestFittingCount(truncatableUnits.length, candidate, { availableWidth, measureText }));
+ }
+
+ const truncatableUnits = units.slice(0, units.length - preserveCount);
+ const preservedUnits = units.slice(units.length - preserveCount);
+ const candidate = (count: number) =>
+ joinWithEllipsis(truncatableUnits.slice(0, count).join(''), preservedUnits.join(''));
+
+ if (measureText(candidate(0)) > availableWidth) {
+ return truncateStart(preservedUnits, availableWidth, measureText);
+ }
+
+ return candidate(findLargestFittingCount(truncatableUnits.length, candidate, { availableWidth, measureText }));
+}
+
+function truncateStart(units: string[], availableWidth: number, measureText: (value: string) => number): string {
+ const candidate = (count: number) => `${ELLIPSIS}${units.slice(-count || units.length).join('')}`;
+ return candidate(findLargestFittingCount(units.length - 1, candidate, { availableWidth, measureText }));
+}
+
+function truncateEnd(units: string[], availableWidth: number, measureText: (value: string) => number): string {
+ const candidate = (count: number) => `${units.slice(0, count).join('')}${ELLIPSIS}`;
+ return candidate(findLargestFittingCount(units.length - 1, candidate, { availableWidth, measureText }));
+}
+
+function findLargestFittingCount(
+ maximum: number,
+ candidate: (count: number) => string,
+ {
+ availableWidth,
+ measureText
+ }: {
+ availableWidth: number;
+ measureText: (value: string) => number;
+ }
+): number {
+ let lower = 0;
+ let upper = maximum;
+ let fitting = 0;
+
+ while (lower <= upper) {
+ const count = Math.floor((lower + upper) / 2);
+
+ if (measureText(candidate(count)) <= availableWidth) {
+ fitting = count;
+ lower = count + 1;
+ } else {
+ upper = count - 1;
+ }
+ }
+
+ return fitting;
+}
+
+function joinWithEllipsis(start: string, end: string): string {
+ return `${start.trimEnd()}${ELLIPSIS}${end.trimStart()}`;
+}
+
+function normalizePreserve(value: number): number {
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
+}
+
+function segmentText(text: string, granularity: 'grapheme' | 'word'): string[] {
+ const segments = new Intl.Segmenter(undefined, { granularity }).segment(text);
+ return Array.from(segments, segment => segment.segment);
+}
+
+function mergeWhitespace(segments: string[], bias: TruncateBias): string[] {
+ return bias === 'start' ? mergeFollowingWhitespace(segments) : mergePrecedingWhitespace(segments);
+}
+
+function mergeFollowingWhitespace(segments: string[]): string[] {
+ const units: string[] = [];
+
+ for (const segment of segments) {
+ if (segment.trim()) units.push(segment);
+ else appendToLastUnit(units, segment);
+ }
+
+ return units;
+}
+
+function mergePrecedingWhitespace(segments: string[]): string[] {
+ const units: string[] = [];
+ let whitespace = '';
+
+ for (const segment of segments) {
+ if (segment.trim()) {
+ units.push(whitespace + segment);
+ whitespace = '';
+ } else {
+ whitespace += segment;
+ }
+ }
+
+ appendToLastUnit(units, whitespace);
+
+ return units;
+}
+
+function appendToLastUnit(units: string[], text: string): void {
+ const previous = units.at(-1);
+ if (previous !== undefined) units[units.length - 1] = previous + text;
+}
+
+function splitPathUnits(text: string, bias: TruncateBias): string[] {
+ const pathSegments = Array.from(text.matchAll(/[^/\\]+/gu));
+ if (pathSegments.length === 0) return [text];
+
+ if (bias === 'start') {
+ return pathSegments.map((segment, index) => {
+ const start = index === 0 ? 0 : segment.index;
+ const end = pathSegments[index + 1]?.index ?? text.length;
+ return text.slice(start, end);
+ });
+ }
+
+ return pathSegments.map((segment, index) => {
+ const previous = pathSegments[index - 1];
+ const start = previous ? previous.index + previous[0].length : 0;
+ const end = index === pathSegments.length - 1 ? text.length : segment.index + segment[0].length;
+ return text.slice(start, end);
+ });
+}
+
+function getUnits(text: string, strategy: TruncateStrategy, bias: TruncateBias): string[] {
+ switch (strategy) {
+ case 'word': {
+ const units = mergeWhitespace(segmentText(text, 'word'), bias);
+ return units.length ? units : [text];
+ }
+ case 'path': {
+ const units = splitPathUnits(text, bias);
+ return units.length ? units : [text];
+ }
+ case 'character':
+ default:
+ return segmentText(text, 'grapheme');
+ }
+}
diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts
index 9d6b92790..58fd17e20 100644
--- a/projects/core/src/index.test.lighthouse.ts
+++ b/projects/core/src/index.test.lighthouse.ts
@@ -6,23 +6,28 @@ import { lighthouseRunner } from '@internals/vite';
describe('lighthouse report', () => {
test('JS Bundles should remain within compressed bundle limits', async () => {
- const report = await lighthouseRunner.getReport(`bundles/index.js`, /* html */`
+ const report = await lighthouseRunner.getReport(
+ `bundles/index.js`,
+ /* html */ `
- `);
+ `
+ );
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(134.5);
+ expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(136);
// 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(100);
});
test('JS imports should remain within compressed bundle limits', async () => {
- const report = await lighthouseRunner.getReport('js-modules', /* html */`
+ const report = await lighthouseRunner.getReport(
+ 'js-modules',
+ /* html */ `
- `);
+ `
+ );
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.requests[Object.keys(report.payload.javascript.requests)[0]].kb).toBeLessThan(86.3);
+ expect(report.payload.javascript.requests[Object.keys(report.payload.javascript.requests)[0]].kb).toBeLessThan(88);
});
});
diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js
index c46a66e07..1cb233d6a 100644
--- a/projects/site/src/_11ty/layouts/common.js
+++ b/projects/site/src/_11ty/layouts/common.js
@@ -277,6 +277,7 @@ export const renderDocsNav = data => /* html */ `
Format Datetime
Format Number
Format Relative Time
+ Format Truncate
Forms
Getting Started
diff --git a/projects/site/src/docs/elements/format-truncate.md b/projects/site/src/docs/elements/format-truncate.md
new file mode 100644
index 000000000..6c7546fc1
--- /dev/null
+++ b/projects/site/src/docs/elements/format-truncate.md
@@ -0,0 +1,27 @@
+---
+{
+ title: 'Format Truncate',
+ layout: 'docs.11ty.js',
+ tag: 'nve-format-truncate'
+}
+---
+
+## Installation
+
+{% install 'nve-format-truncate' %}
+
+## Default
+
+{% example '@nvidia-elements/core/format-truncate/format-truncate.examples.json', 'Default' %}
+
+## Typography
+
+{% example '@nvidia-elements/core/format-truncate/format-truncate.examples.json', 'Typography' %}
+
+## Bias
+
+{% example '@nvidia-elements/core/format-truncate/format-truncate.examples.json', 'Bias' %}
+
+## Strategy
+
+{% example '@nvidia-elements/core/format-truncate/format-truncate.examples.json', 'Strategy' %}