diff --git a/projects/core/.visual/format-bytes.dark.png b/projects/core/.visual/format-bytes.dark.png
new file mode 100644
index 000000000..7765296bb
--- /dev/null
+++ b/projects/core/.visual/format-bytes.dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:839759336d5205be122856fcd784e71a1cc0890bd60acd3b93596160764388e1
+size 7217
diff --git a/projects/core/.visual/format-bytes.png b/projects/core/.visual/format-bytes.png
new file mode 100644
index 000000000..c39431495
--- /dev/null
+++ b/projects/core/.visual/format-bytes.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c5335be8f45db8e866ef98449d92077ed4d8755a0d819c7bfb2fc216f501a28b
+size 7183
diff --git a/projects/core/package.json b/projects/core/package.json
index ba28fcaf2..4eb8ff15a 100644
--- a/projects/core/package.json
+++ b/projects/core/package.json
@@ -378,6 +378,18 @@
"types": "./dist/file/define.d.ts",
"default": "./dist/file/define.js"
},
+ "./format-bytes": {
+ "types": "./dist/format-bytes/index.d.ts",
+ "default": "./dist/format-bytes/index.js"
+ },
+ "./format-bytes/index.js": {
+ "types": "./dist/format-bytes/index.d.ts",
+ "default": "./dist/format-bytes/index.js"
+ },
+ "./format-bytes/define.js": {
+ "types": "./dist/format-bytes/define.d.ts",
+ "default": "./dist/format-bytes/define.js"
+ },
"./format-datetime": {
"types": "./dist/format-datetime/index.d.ts",
"default": "./dist/format-datetime/index.js"
diff --git a/projects/core/src/bundle.ts b/projects/core/src/bundle.ts
index 8d15c1e5e..de28bb4e1 100644
--- a/projects/core/src/bundle.ts
+++ b/projects/core/src/bundle.ts
@@ -27,6 +27,7 @@ import '@nvidia-elements/core/dropdown/define.js';
import '@nvidia-elements/core/dropdown-group/define.js';
import '@nvidia-elements/core/dropzone/define.js';
import '@nvidia-elements/core/file/define.js';
+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';
@@ -96,6 +97,7 @@ export * from '@nvidia-elements/core/dropdown';
export * from '@nvidia-elements/core/dropdown-group';
export * from '@nvidia-elements/core/dropzone';
export * from '@nvidia-elements/core/file';
+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';
diff --git a/projects/core/src/format-bytes/define.ts b/projects/core/src/format-bytes/define.ts
new file mode 100644
index 000000000..74b3aa856
--- /dev/null
+++ b/projects/core/src/format-bytes/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 { FormatBytes } from '@nvidia-elements/core/format-bytes';
+
+define(FormatBytes);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'nve-format-bytes': FormatBytes;
+ }
+}
diff --git a/projects/core/src/format-bytes/format-bytes.css b/projects/core/src/format-bytes/format-bytes.css
new file mode 100644
index 000000000..5843acc02
--- /dev/null
+++ b/projects/core/src/format-bytes/format-bytes.css
@@ -0,0 +1,14 @@
+/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
+/* SPDX-License-Identifier: Apache-2.0 */
+
+:host {
+ display: inline;
+}
+
+[internal-host] {
+ color: var(--nve-sys-text-color, inherit);
+}
+
+slot {
+ display: none;
+}
diff --git a/projects/core/src/format-bytes/format-bytes.examples.ts b/projects/core/src/format-bytes/format-bytes.examples.ts
new file mode 100644
index 000000000..d71c4cb1c
--- /dev/null
+++ b/projects/core/src/format-bytes/format-bytes.examples.ts
@@ -0,0 +1,94 @@
+// 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-bytes/define.js';
+
+export default {
+ title: 'Elements/FormatBytes',
+ component: 'nve-format-bytes'
+};
+
+/**
+ * @summary Automatic decimal conversion for concise file sizes and storage metrics. The component selects the appropriate unit from the byte count.
+ */
+export const Default = {
+ render: () => html`
+
+ 1024
+ 1048576
+ 1073741824
+
+ `
+};
+
+/**
+ * @summary Forced unit magnitudes for comparing byte counts on a consistent scale. Use when values need the same unit across a table or chart.
+ */
+export const Unit = {
+ render: () => html`
+
+ 1048576
+ 1048576
+ 1048576
+
+ `
+};
+
+/**
+ * @summary Short and long unit labels for compact metrics or explanatory text. Long labels improve clarity when space allows.
+ */
+export const UnitDisplay = {
+ render: () => html`
+
+ 1048576
+ 1048576
+ 1048576
+
+ `
+};
+
+/**
+ * @summary Fraction digit controls for matching the precision of storage measurements. Use fixed digits when values must align visually.
+ */
+export const Precision = {
+ render: () => html`
+
+ 1234567
+ 1234567
+ 1234567
+
+ `
+};
+
+/**
+ * @summary Explicit locale formatting for audiences whose numeric separators differ from the document language. Unit labels remain lowercase English.
+ */
+export const Locale = {
+ render: () => html`
+
+ 1048576
+ 1048576
+ 1048576
+
+ `
+};
+
+/**
+ * @summary Value attribute input for JavaScript or bound data. It takes precedence over text content while the text remains an SSR fallback.
+ */
+export const Value = {
+ render: () => html`1024`
+};
+
+/**
+ * @summary Decimal and binary conversion for matching SI or IEC storage conventions. Use the convention expected by the surrounding product.
+ */
+export const Display = {
+ render: () => html`
+
+ 1024
+ 1024
+
+ `
+};
diff --git a/projects/core/src/format-bytes/format-bytes.test.axe.ts b/projects/core/src/format-bytes/format-bytes.test.axe.ts
new file mode 100644
index 000000000..36b9ffcc0
--- /dev/null
+++ b/projects/core/src/format-bytes/format-bytes.test.axe.ts
@@ -0,0 +1,27 @@
+// 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 { FormatBytes } from '@nvidia-elements/core/format-bytes';
+import '@nvidia-elements/core/format-bytes/define.js';
+
+describe(FormatBytes.metadata.tag, () => {
+ it('should pass axe check', async () => {
+ const fixture = await createFixture(html`
+ 1048576
+ 1048576
+ 1048576
+ `);
+
+ try {
+ await elementIsStable(fixture.querySelector(FormatBytes.metadata.tag));
+ const results = await runAxe([FormatBytes.metadata.tag]);
+ expect(results.violations.length).toBe(0);
+ } finally {
+ removeFixture(fixture);
+ }
+ });
+});
diff --git a/projects/core/src/format-bytes/format-bytes.test.lighthouse.ts b/projects/core/src/format-bytes/format-bytes.test.lighthouse.ts
new file mode 100644
index 000000000..0005f478f
--- /dev/null
+++ b/projects/core/src/format-bytes/format-bytes.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('format-bytes lighthouse report', () => {
+ test('format-bytes should meet lighthouse benchmarks', async () => {
+ const report = await lighthouseRunner.getReport('nve-format-bytes', /* html */ `
+ 1048576
+
+ `);
+
+ expect(report.scores.performance).toBe(100);
+ expect(report.scores.accessibility).toBe(100);
+ expect(report.scores.bestPractices).toBe(100);
+ expect(report.payload.javascript.kb).toBeLessThan(11);
+ });
+});
diff --git a/projects/core/src/format-bytes/format-bytes.test.ssr.ts b/projects/core/src/format-bytes/format-bytes.test.ssr.ts
new file mode 100644
index 000000000..6db4ac007
--- /dev/null
+++ b/projects/core/src/format-bytes/format-bytes.test.ssr.ts
@@ -0,0 +1,18 @@
+// 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 { FormatBytes } from '@nvidia-elements/core/format-bytes';
+import '@nvidia-elements/core/format-bytes/define.js';
+
+describe(FormatBytes.metadata.tag, () => {
+ it('should render formatted semantic output during ssr', async () => {
+ const result = await ssrRunner.render(html``);
+ expect(result.includes('shadowroot="open"')).toBe(true);
+ expect(result.includes('')).toBe(true);
+ expect(result.includes('1.05 mb')).toBe(true);
+ expect(result.includes('nve-format-bytes')).toBe(true);
+ });
+});
diff --git a/projects/core/src/format-bytes/format-bytes.test.ts b/projects/core/src/format-bytes/format-bytes.test.ts
new file mode 100644
index 000000000..aa31379a3
--- /dev/null
+++ b/projects/core/src/format-bytes/format-bytes.test.ts
@@ -0,0 +1,215 @@
+// 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, vi } from 'vitest';
+import { createFixture, elementIsStable, removeFixture } from '@internals/testing';
+import { FormatBytes, type FormatBytesUnit } from '@nvidia-elements/core/format-bytes';
+import { LogService } from '@nvidia-elements/core/internal';
+import '@nvidia-elements/core/format-bytes/define.js';
+
+function renderedData(element: FormatBytes): HTMLDataElement | null {
+ return element.shadowRoot?.querySelector('data') ?? null;
+}
+
+function renderedText(element: FormatBytes): string {
+ return renderedData(element)?.textContent?.trim() ?? '';
+}
+
+describe(FormatBytes.metadata.tag, () => {
+ let fixture: HTMLElement;
+ let element: FormatBytes;
+ let originalDocumentLang: string;
+
+ beforeEach(async () => {
+ originalDocumentLang = document.documentElement.lang;
+ fixture = await createFixture(html`1048576`);
+ element = fixture.querySelector(FormatBytes.metadata.tag);
+ await elementIsStable(element);
+ });
+
+ afterEach(() => {
+ document.documentElement.lang = originalDocumentLang;
+ removeFixture(fixture);
+ vi.restoreAllMocks();
+ });
+
+ it('should define element', () => {
+ expect(customElements.get(FormatBytes.metadata.tag)).toBeDefined();
+ });
+
+ it('should render semantic data with the raw byte count', () => {
+ expect(renderedData(element)?.getAttribute('value')).toBe('1048576');
+ expect(renderedText(element)).toBe('1.05 mb');
+ });
+
+ it('should use value over slot content', async () => {
+ element.value = 1024;
+ await elementIsStable(element);
+
+ expect(renderedData(element)?.getAttribute('value')).toBe('1024');
+ expect(renderedText(element)).toBe('1.02 kb');
+ });
+
+ it('should re-render when slot content changes', async () => {
+ element.textContent = '1073741824';
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('1.07 gb');
+ });
+
+ it.each([
+ ['999', '999 b'],
+ ['1000', '1 kb'],
+ ['1024', '1.02 kb'],
+ ['1048576', '1.05 mb'],
+ ['1073741824', '1.07 gb']
+ ])('should automatically format decimal bytes %s as %s', async (value, expected) => {
+ element.textContent = value;
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe(expected);
+ });
+
+ it.each([
+ ['1023', '1,023 b'],
+ ['1024', '1 kib'],
+ ['1048576', '1 mib'],
+ ['1073741824', '1 gib']
+ ])('should automatically format binary bytes %s as %s', async (value, expected) => {
+ element.display = 'binary';
+ element.textContent = value;
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe(expected);
+ });
+
+ it.each<[FormatBytesUnit, string]>([
+ ['kb', '1,048.58 kb'],
+ ['mb', '1.05 mb'],
+ ['gb', '0 gb']
+ ])('should force the %s unit', async (unit, expected) => {
+ element.unit = unit;
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe(expected);
+ });
+
+ it('should use the forced magnitude with binary labels', async () => {
+ element.display = 'binary';
+ element.unit = 'kb';
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('1,024 kib');
+ });
+
+ it.each([
+ [1000000, '1 megabyte'],
+ [1500000, '1.5 megabytes'],
+ [-1000000, '-1 megabyte']
+ ])('should format decimal long labels for %s bytes', async (value, expected) => {
+ element.value = value;
+ element.unitDisplay = 'long';
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe(expected);
+ });
+
+ it('should format binary long labels', async () => {
+ element.display = 'binary';
+ element.unitDisplay = 'long';
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('1 mebibyte');
+ });
+
+ it('should format with maximum fraction digits', async () => {
+ element.textContent = '1234567';
+ element.maximumFractionDigits = 0;
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('1 mb');
+ });
+
+ it('should format with fixed fraction digits', async () => {
+ element.textContent = '1234567';
+ element.minimumFractionDigits = 3;
+ element.maximumFractionDigits = 3;
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('1.235 mb');
+ });
+
+ it('should expand the effective default maximum for minimum fraction digits', async () => {
+ element.textContent = '1234567';
+ element.minimumFractionDigits = 3;
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('1.235 mb');
+ });
+
+ it('should use the configured locale', async () => {
+ element.locale = 'de-DE';
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('1,05 mb');
+ });
+
+ it('should use the document locale by default', async () => {
+ element.locale = undefined;
+ document.documentElement.lang = 'de-DE';
+ element.requestUpdate();
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('1,05 mb');
+ });
+
+ it('should preserve zero and negative values', async () => {
+ element.value = 0;
+ await elementIsStable(element);
+ expect(renderedText(element)).toBe('0 b');
+
+ element.value = -1000;
+ await elementIsStable(element);
+ expect(renderedText(element)).toBe('-1 kb');
+ });
+
+ it('should cap automatic conversion at petabytes', async () => {
+ element.value = 1e18;
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('1,000 pb');
+ });
+
+ it.each([
+ ['display', 'invalid'],
+ ['unit', 'invalid'],
+ ['unit-display', 'invalid']
+ ])('should preserve input for an invalid %s option', async (attribute, value) => {
+ const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined);
+ element.setAttribute(attribute, value);
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('1048576');
+ expect(warn).toHaveBeenCalledOnce();
+ });
+
+ it('should preserve and warn for invalid numeric input', async () => {
+ const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined);
+ element.textContent = 'not-a-number';
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('not-a-number');
+ expect(warn).toHaveBeenCalledWith('format-bytes: invalid numeric value "not-a-number"');
+ });
+
+ it('should preserve input for invalid fraction digit options', async () => {
+ const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined);
+ element.minimumFractionDigits = 3;
+ element.maximumFractionDigits = 2;
+ await elementIsStable(element);
+
+ expect(renderedText(element)).toBe('1048576');
+ expect(warn).toHaveBeenCalledOnce();
+ });
+});
diff --git a/projects/core/src/format-bytes/format-bytes.test.visual.ts b/projects/core/src/format-bytes/format-bytes.test.visual.ts
new file mode 100644
index 000000000..2507788d7
--- /dev/null
+++ b/projects/core/src/format-bytes/format-bytes.test.visual.ts
@@ -0,0 +1,33 @@
+// 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-bytes visual', () => {
+ test('format-bytes should match visual baseline', async () => {
+ const report = await visualRunner.render('format-bytes', template());
+ expect(report.maxDiffPercentage).toBeLessThan(1);
+ });
+
+ test('format-bytes should match visual baseline dark theme', async () => {
+ const report = await visualRunner.render('format-bytes.dark', template('dark'));
+ expect(report.maxDiffPercentage).toBeLessThan(1);
+ });
+});
+
+function template(theme: '' | 'dark' = '') {
+ return /* html */ `
+
+
+ 1024
+ 1048576
+ 1048576
+ 1048576
+ 1048576
+
+ `;
+}
diff --git a/projects/core/src/format-bytes/format-bytes.ts b/projects/core/src/format-bytes/format-bytes.ts
new file mode 100644
index 000000000..6c2045378
--- /dev/null
+++ b/projects/core/src/format-bytes/format-bytes.ts
@@ -0,0 +1,206 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html, LitElement } from 'lit';
+import { property } from 'lit/decorators/property.js';
+import { LogService, typeSSR, useStyles } from '@nvidia-elements/core/internal';
+import styles from './format-bytes.css?inline';
+
+export type FormatBytesDisplay = 'decimal' | 'binary';
+export type FormatBytesUnit = 'b' | 'kb' | 'mb' | 'gb' | 'tb' | 'pb';
+export type FormatBytesUnitDisplay = 'short' | 'long';
+
+interface UnitLabels {
+ short: string;
+ singular: string;
+ plural: string;
+}
+
+const UNITS: readonly FormatBytesUnit[] = ['b', 'kb', 'mb', 'gb', 'tb', 'pb'];
+const DISPLAYS: readonly FormatBytesDisplay[] = ['decimal', 'binary'];
+const UNIT_DISPLAYS: readonly FormatBytesUnitDisplay[] = ['short', 'long'];
+
+const DECIMAL_LABELS: Record = {
+ b: { short: 'b', singular: 'byte', plural: 'bytes' },
+ kb: { short: 'kb', singular: 'kilobyte', plural: 'kilobytes' },
+ mb: { short: 'mb', singular: 'megabyte', plural: 'megabytes' },
+ gb: { short: 'gb', singular: 'gigabyte', plural: 'gigabytes' },
+ tb: { short: 'tb', singular: 'terabyte', plural: 'terabytes' },
+ pb: { short: 'pb', singular: 'petabyte', plural: 'petabytes' }
+};
+
+const BINARY_LABELS: Record = {
+ b: { short: 'b', singular: 'byte', plural: 'bytes' },
+ kb: { short: 'kib', singular: 'kibibyte', plural: 'kibibytes' },
+ mb: { short: 'mib', singular: 'mebibyte', plural: 'mebibytes' },
+ gb: { short: 'gib', singular: 'gibibyte', plural: 'gibibytes' },
+ tb: { short: 'tib', singular: 'tebibyte', plural: 'tebibytes' },
+ pb: { short: 'pib', singular: 'pebibyte', plural: 'pebibytes' }
+};
+
+function isDisplay(value: unknown): value is FormatBytesDisplay {
+ return DISPLAYS.some(display => display === value);
+}
+
+function isUnit(value: unknown): value is FormatBytesUnit {
+ return UNITS.some(unit => unit === value);
+}
+
+function isUnitDisplay(value: unknown): value is FormatBytesUnitDisplay {
+ return UNIT_DISPLAYS.some(unitDisplay => unitDisplay === value);
+}
+
+/**
+ * @element nve-format-bytes
+ * @description Formats a byte count as localized, human-readable decimal or binary units.
+ * @documentation https://nvidia.github.io/elements/docs/elements/format-bytes/
+ * @since 0.0.0
+ * @entrypoint \@nvidia-elements/core/format-bytes
+ * @slot - Numeric byte count to format (such as 1048576). Serves as fallback before hydration.
+ * @aria https://developer.mozilla.org/en-US/docs/Web/HTML/Element/data
+ */
+@typeSSR()
+export class FormatBytes extends LitElement {
+ static styles = useStyles([styles]);
+
+ static readonly metadata = {
+ tag: 'nve-format-bytes',
+ version: '0.0.0'
+ };
+
+ /**
+ * Optional byte count for values supplied by JavaScript or bound data.
+ * By default, the component formats the element's text content, which also serves as the SSR fallback.
+ * When both are present, this property takes precedence.
+ */
+ @property({ type: Number }) value?: number;
+
+ /**
+ * Unit system: 'decimal' uses powers of 1000 and 'binary' uses powers of 1024.
+ */
+ @property({ type: String }) display: FormatBytesDisplay = 'decimal';
+
+ /**
+ * Optional unit magnitude. When omitted, the component selects a unit from the byte count.
+ */
+ @property({ type: String }) unit?: FormatBytesUnit;
+
+ /**
+ * Unit label length: 'short' renders labels such as 'mb'; 'long' renders labels such as 'megabytes'.
+ */
+ @property({ type: String, attribute: 'unit-display' }) unitDisplay: FormatBytesUnitDisplay = 'short';
+
+ /**
+ * Language tag (such as en-US or de-DE) used to format the number.
+ * Defaults to document.documentElement.lang or the runtime default.
+ */
+ @property({ type: String }) locale?: string;
+
+ /**
+ * Pad fraction output to at least this many digits.
+ */
+ @property({ type: Number, attribute: 'minimum-fraction-digits' }) minimumFractionDigits?: number;
+
+ /**
+ * Round fraction output to at most this many digits. Defaults to two effective digits after the decimal point.
+ */
+ @property({ type: Number, attribute: 'maximum-fraction-digits' }) maximumFractionDigits?: number;
+
+ get #rawValue(): string {
+ if (this.value === undefined) return this.textContent?.trim() ?? '';
+ if (Number.isNaN(this.value)) return this.getAttribute('value') ?? String(this.value);
+ return String(this.value);
+ }
+
+ get #resolvedLocale(): string | undefined {
+ return this.locale ?? (globalThis.document?.documentElement?.lang || undefined);
+ }
+
+ get #parsedValue(): number | null {
+ const rawValue = this.#rawValue;
+ if (!rawValue) return null;
+
+ const numericValue = Number(rawValue);
+ if (Number.isFinite(numericValue)) return numericValue;
+
+ LogService.warn(`format-bytes: invalid numeric value "${rawValue}"`);
+ return null;
+ }
+
+ #resolveAutoUnit(value: number, display: FormatBytesDisplay): FormatBytesUnit {
+ const base = display === 'binary' ? 1024 : 1000;
+ const absoluteValue = Math.abs(value);
+
+ for (let index = UNITS.length - 1; index > 0; index--) {
+ const unit = UNITS[index];
+ if (unit && absoluteValue >= base ** index) return unit;
+ }
+
+ return 'b';
+ }
+
+ #formatLabel(unit: FormatBytesUnit, convertedValue: number): string {
+ const labels = this.display === 'binary' ? BINARY_LABELS[unit] : DECIMAL_LABELS[unit];
+ if (this.unitDisplay === 'short') return labels.short;
+ return Math.abs(convertedValue) === 1 ? labels.singular : labels.plural;
+ }
+
+ #warnInvalidOption(name: string, value: unknown, rawValue: string): string {
+ LogService.warn(`format-bytes: invalid ${name} value "${String(value)}"`);
+ return rawValue;
+ }
+
+ #hasValidConfiguration(rawValue: string): boolean {
+ if (!isDisplay(this.display)) {
+ this.#warnInvalidOption('display', this.display, rawValue);
+ return false;
+ }
+ if (!isUnitDisplay(this.unitDisplay)) {
+ this.#warnInvalidOption('unit-display', this.unitDisplay, rawValue);
+ return false;
+ }
+ if (this.unit !== undefined && !isUnit(this.unit)) {
+ this.#warnInvalidOption('unit', this.unit, rawValue);
+ return false;
+ }
+ return true;
+ }
+
+ #formatNumber(value: number): string {
+ const effectiveMaximumFractionDigits = this.maximumFractionDigits ?? Math.max(this.minimumFractionDigits ?? 0, 2);
+ return new Intl.NumberFormat(this.#resolvedLocale, {
+ minimumFractionDigits: this.minimumFractionDigits,
+ maximumFractionDigits: effectiveMaximumFractionDigits
+ }).format(value);
+ }
+
+ get #formattedBytes(): string {
+ const rawValue = this.#rawValue;
+ if (!rawValue) return '';
+
+ const numericValue = this.#parsedValue;
+ if (numericValue === null) return rawValue;
+ if (!this.#hasValidConfiguration(rawValue)) return rawValue;
+
+ const resolvedUnit = this.unit ?? this.#resolveAutoUnit(numericValue, this.display);
+ const unitIndex = UNITS.indexOf(resolvedUnit);
+ const base = this.display === 'binary' ? 1024 : 1000;
+ const convertedValue = numericValue / base ** unitIndex;
+
+ try {
+ return `${this.#formatNumber(convertedValue)} ${this.#formatLabel(resolvedUnit, convertedValue)}`;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ LogService.warn(`format-bytes: ${message}`);
+ return rawValue;
+ }
+ }
+
+ render() {
+ return html`${this.#formattedBytes}`;
+ }
+
+ #onSlotChange() {
+ this.requestUpdate();
+ }
+}
diff --git a/projects/core/src/format-bytes/index.ts b/projects/core/src/format-bytes/index.ts
new file mode 100644
index 000000000..b315b510e
--- /dev/null
+++ b/projects/core/src/format-bytes/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-bytes.js';
diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts
index 5e235dbf6..0896abab4 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(133.6);
+ expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(134.5);
// 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);
diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js
index de8027b74..86ab92590 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 */ `
Dropdown Group
Dropzone
File
+ Format Bytes
Format Datetime
Format Number
Format Relative Time
diff --git a/projects/site/src/docs/elements/format-bytes.md b/projects/site/src/docs/elements/format-bytes.md
new file mode 100644
index 000000000..c50253881
--- /dev/null
+++ b/projects/site/src/docs/elements/format-bytes.md
@@ -0,0 +1,43 @@
+---
+{
+ title: 'Format Bytes',
+ layout: 'docs.11ty.js',
+ tag: 'nve-format-bytes'
+}
+---
+
+## Installation
+
+{% install 'nve-format-bytes' %}
+
+Format Bytes converts byte counts into readable decimal or binary units. Supply the byte count as text content to provide an SSR fallback, or set the `value` property or attribute for JavaScript and bound data. If both are present, `value` takes precedence.
+
+The `locale` property controls number formatting. Unit labels remain lowercase English strings in every locale.
+
+## Default
+
+{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Default' %}
+
+## Unit
+
+{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Unit' %}
+
+## Unit Display
+
+{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'UnitDisplay' %}
+
+## Precision
+
+{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Precision' %}
+
+## Locale
+
+{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Locale' %}
+
+## Value
+
+{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Value' %}
+
+## Display
+
+{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Display' %}