diff --git a/projects/core/src/combobox/combobox.test.lighthouse.ts b/projects/core/src/combobox/combobox.test.lighthouse.ts
index 37f30dfc2a..b3db47d8ab 100644
--- a/projects/core/src/combobox/combobox.test.lighthouse.ts
+++ b/projects/core/src/combobox/combobox.test.lighthouse.ts
@@ -25,7 +25,7 @@ describe('combobox lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.kb).toBeLessThan(36.9);
+ expect(report.payload.javascript.kb).toBeLessThan(37);
});
test('combobox multi select with large dataset should meet lighthouse benchmarks', async () => {
@@ -42,6 +42,6 @@ describe('combobox lighthouse report', () => {
expect(report.scores.performance).toBeGreaterThanOrEqual(96);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.kb).toBeLessThan(36.3);
+ expect(report.payload.javascript.kb).toBeLessThan(36.4);
});
});
diff --git a/projects/core/src/internal/controllers/i18n.controller.test.ts b/projects/core/src/internal/controllers/i18n.controller.test.ts
index 3a6b6799e8..d4e443c37f 100644
--- a/projects/core/src/internal/controllers/i18n.controller.test.ts
+++ b/projects/core/src/internal/controllers/i18n.controller.test.ts
@@ -76,6 +76,8 @@ describe('i18n.controller', () => {
browseFiles: 'browse files',
maxFileSize: 'max file size',
currentTime: 'current time',
+ enableLoop: 'enable looping',
+ disableLoop: 'disable looping',
enterFullscreen: 'enter full screen',
exitFullscreen: 'exit full screen',
muteMedia: 'mute media',
@@ -147,6 +149,8 @@ describe('i18n.controller', () => {
browseFiles: 'browse files',
maxFileSize: 'max file size',
currentTime: 'current time',
+ enableLoop: 'enable looping',
+ disableLoop: 'disable looping',
enterFullscreen: 'enter full screen',
exitFullscreen: 'exit full screen',
muteMedia: 'mute media',
diff --git a/projects/core/src/internal/services/i18n.service.test.ts b/projects/core/src/internal/services/i18n.service.test.ts
index a58d73c860..f5105832bc 100644
--- a/projects/core/src/internal/services/i18n.service.test.ts
+++ b/projects/core/src/internal/services/i18n.service.test.ts
@@ -50,6 +50,8 @@ describe('I18nService', () => {
maxFileSize: 'max file size',
create: 'create',
currentTime: 'current time',
+ enableLoop: 'enable looping',
+ disableLoop: 'disable looping',
enterFullscreen: 'enter full screen',
exitFullscreen: 'exit full screen',
muteMedia: 'mute media',
diff --git a/projects/core/src/internal/services/i18n.service.ts b/projects/core/src/internal/services/i18n.service.ts
index f5407a5de2..d666d8b955 100644
--- a/projects/core/src/internal/services/i18n.service.ts
+++ b/projects/core/src/internal/services/i18n.service.ts
@@ -42,6 +42,8 @@ export interface I18nStrings {
maxFileSize: string;
create: string;
currentTime: string;
+ enableLoop: string;
+ disableLoop: string;
enterFullscreen: string;
exitFullscreen: string;
muteMedia: string;
@@ -96,6 +98,8 @@ const i18nRegistry = {
maxFileSize: 'max file size',
create: 'create',
currentTime: 'current time',
+ enableLoop: 'enable looping',
+ disableLoop: 'disable looping',
enterFullscreen: 'enter full screen',
exitFullscreen: 'exit full screen',
muteMedia: 'mute media',
diff --git a/projects/core/src/notification/notification.test.lighthouse.ts b/projects/core/src/notification/notification.test.lighthouse.ts
index 2f0387419c..16c46bd9f7 100644
--- a/projects/core/src/notification/notification.test.lighthouse.ts
+++ b/projects/core/src/notification/notification.test.lighthouse.ts
@@ -17,6 +17,6 @@ describe('notification lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.kb).toBeLessThan(25.9);
+ expect(report.payload.javascript.kb).toBeLessThan(26);
});
});
diff --git a/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts b/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts
index 58cdf5c4ae..d9c8d6c6ee 100644
--- a/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts
+++ b/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts
@@ -16,6 +16,6 @@ describe('resize-handle lighthouse report', () => {
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.5);
+ expect(report.payload.javascript.kb).toBeLessThan(15.6);
});
});
diff --git a/projects/core/src/time/time.test.lighthouse.ts b/projects/core/src/time/time.test.lighthouse.ts
index 64d4df8ec8..a1308a892a 100644
--- a/projects/core/src/time/time.test.lighthouse.ts
+++ b/projects/core/src/time/time.test.lighthouse.ts
@@ -22,6 +22,6 @@ describe('time lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.kb).toBeLessThan(25.9);
+ expect(report.payload.javascript.kb).toBeLessThan(26);
});
});
diff --git a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts
index 3c9aa2da90..177faca5ee 100644
--- a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts
+++ b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts
@@ -10,7 +10,7 @@ import noInvalidInvokerTriggers from './no-invalid-invoker-triggers.js';
const rule = noInvalidInvokerTriggers as unknown as JSRuleDefinition;
const validElements =
- 'nve-button, nve-icon-button, nve-menu-item, nve-sort-button, nve-tabs-item, nve-tag, nve-steps-item, nve-copy-button, nve-media-fullscreen-button, nve-media-mute-button, nve-media-pause-button, nve-media-playback-rate-select, nve-media-seek-button, nve-media-time-range, nve-media-volume-range';
+ 'nve-button, nve-icon-button, nve-menu-item, nve-sort-button, nve-tabs-item, nve-tag, nve-steps-item, nve-copy-button, nve-media-fullscreen-button, nve-media-loop-button, nve-media-mute-button, nve-media-pause-button, nve-media-playback-rate-select, nve-media-seek-button, nve-media-time-range, nve-media-volume-range';
describe('noInvalidInvokerTriggers', () => {
let tester: RuleTester;
@@ -69,6 +69,7 @@ describe('noInvalidInvokerTriggers', () => {
' ',
' ',
' ',
+ ' ',
' '
],
invalid: []
diff --git a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts
index 68627af4f0..a890ed5b02 100644
--- a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts
+++ b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts
@@ -26,6 +26,7 @@ const BUTTON_TYPE_ELEMENTS = [
'nve-steps-item',
'nve-copy-button',
'nve-media-fullscreen-button',
+ 'nve-media-loop-button',
'nve-media-mute-button',
'nve-media-pause-button',
'nve-media-playback-rate-select',
diff --git a/projects/media/.visual/media-loop-button.dark.png b/projects/media/.visual/media-loop-button.dark.png
new file mode 100644
index 0000000000..5ea79e674e
--- /dev/null
+++ b/projects/media/.visual/media-loop-button.dark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bedf1979d7bdc83ac850c1d25e0e6400f1b7de3b73ff3949e8be1ee69c9d73a8
+size 1489
diff --git a/projects/media/.visual/media-loop-button.png b/projects/media/.visual/media-loop-button.png
new file mode 100644
index 0000000000..408b924934
--- /dev/null
+++ b/projects/media/.visual/media-loop-button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c3f3ffc3922f291bb72f5feb0fa62acbd9927213b352932b2492e247a6a9ae0e
+size 1453
diff --git a/projects/media/package.json b/projects/media/package.json
index 192bc3054d..44b33c634e 100644
--- a/projects/media/package.json
+++ b/projects/media/package.json
@@ -77,6 +77,18 @@
"types": "./dist/fullscreen-button/index.d.ts",
"default": "./dist/fullscreen-button/index.js"
},
+ "./loop-button": {
+ "types": "./dist/loop-button/index.d.ts",
+ "default": "./dist/loop-button/index.js"
+ },
+ "./loop-button/define.js": {
+ "types": "./dist/loop-button/define.d.ts",
+ "default": "./dist/loop-button/define.js"
+ },
+ "./loop-button/index.js": {
+ "types": "./dist/loop-button/index.d.ts",
+ "default": "./dist/loop-button/index.js"
+ },
"./mute-button": {
"types": "./dist/mute-button/index.d.ts",
"default": "./dist/mute-button/index.js"
diff --git a/projects/media/src/controller/controller.examples.ts b/projects/media/src/controller/controller.examples.ts
index 511bf0915c..74e65db664 100644
--- a/projects/media/src/controller/controller.examples.ts
+++ b/projects/media/src/controller/controller.examples.ts
@@ -27,6 +27,7 @@ export const Default = {
+
diff --git a/projects/media/src/controller/controller.loop.test.ts b/projects/media/src/controller/controller.loop.test.ts
new file mode 100644
index 0000000000..e8c4bac570
--- /dev/null
+++ b/projects/media/src/controller/controller.loop.test.ts
@@ -0,0 +1,182 @@
+// 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, emulateClick, removeFixture } from '@internals/testing';
+import { mediaCommands, type MediaCommand, type MediaCommandEvent } from '../internal/media-command.js';
+import { mediaStateChange } from '../internal/media-state.js';
+import { MediaLoopButton } from '../loop-button/loop-button.js';
+import { MediaController } from './controller.js';
+import '../loop-button/define.js';
+import './define.js';
+
+describe.each(['video', 'audio'] as const)(`${MediaController.metadata.tag} %s looping`, tag => {
+ let fixture: HTMLElement;
+ let controller: MediaController;
+ let media: HTMLMediaElement;
+ let button: MediaLoopButton;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html`
+
+ ${tag === 'video' ? html` ` : html` `}
+
+
+ `);
+ controller = getElement(fixture, MediaController.metadata.tag);
+ media = getElement(controller, tag);
+ button = getElement(fixture, MediaLoopButton.metadata.tag);
+ await elementIsStable(controller);
+ await elementIsStable(button);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ removeFixture(fixture);
+ });
+
+ it('should expose the initial native loop setting', () => {
+ expect(media.loop).toBe(true);
+ expect(controller.mediaState.loop).toBe(true);
+ expect(Object.isFrozen(controller.mediaState)).toBe(true);
+ expect(controller.hasAttribute('loop')).toBe(true);
+ expect(button.pressed).toBe(true);
+ });
+
+ it('should apply explicit and toggle commands without changing playback', async () => {
+ const play = vi.spyOn(media, 'play');
+ const pause = vi.spyOn(media, 'pause');
+ const time = media.currentTime;
+ const listener = vi.fn();
+ controller.addEventListener(mediaStateChange, listener);
+ dispatchCommand(controller, mediaCommands.disableLoop);
+ expect(media.loop).toBe(false);
+ expect(controller.mediaState.loop).toBe(false);
+ expect(controller.hasAttribute('loop')).toBe(false);
+ expect(listener).toHaveBeenCalledOnce();
+ await elementIsStable(button);
+ expect(button.pressed).toBe(false);
+ expect(listener).toHaveBeenCalledOnce();
+
+ dispatchCommand(controller, mediaCommands.disableLoop);
+ expect(listener).toHaveBeenCalledOnce();
+ dispatchCommand(controller, mediaCommands.enableLoop);
+ expect(media.loop).toBe(true);
+ dispatchCommand(controller, mediaCommands.toggleLoop);
+ expect(media.loop).toBe(false);
+ dispatchCommand(controller, mediaCommands.toggleLoop);
+ expect(media.loop).toBe(true);
+ await elementIsStable(button);
+ expect(button.pressed).toBe(true);
+ expect(listener).toHaveBeenCalledTimes(4);
+ expect(media.currentTime).toBe(time);
+ expect(play).not.toHaveBeenCalled();
+ expect(pause).not.toHaveBeenCalled();
+ });
+
+ it('should toggle native looping through the button', async () => {
+ await emulateClick(button);
+ await elementIsStable(button);
+ expect(media.loop).toBe(false);
+ expect(button.pressed).toBe(false);
+ await emulateClick(button);
+ await elementIsStable(button);
+ expect(media.loop).toBe(true);
+ expect(button.pressed).toBe(true);
+ });
+
+ it('should sync direct native property and attribute changes while paused', async () => {
+ expect(media.paused).toBe(true);
+ const listener = vi.fn();
+ controller.addEventListener(mediaStateChange, listener);
+ media.loop = false;
+ await elementIsStable(button);
+ expect(controller.mediaState.loop).toBe(false);
+ expect(button.pressed).toBe(false);
+ expect(controller.hasAttribute('loop')).toBe(false);
+ expect(listener).toHaveBeenCalledOnce();
+
+ media.setAttribute('loop', '');
+ await elementIsStable(button);
+ expect(controller.mediaState.loop).toBe(true);
+ expect(button.pressed).toBe(true);
+ expect(listener).toHaveBeenCalledTimes(2);
+ media.setAttribute('loop', '');
+ media.setAttribute('preload', 'none');
+ await elementIsStable(button);
+ expect(listener).toHaveBeenCalledTimes(2);
+ });
+
+ it('should read replacement media state and stop observing the previous media', async () => {
+ const previous = media;
+ const replacement = document.createElement(tag);
+ media.replaceWith(replacement);
+ await elementIsStable(controller);
+ await elementIsStable(button);
+ expect(controller.mediaState.loop).toBe(false);
+ expect(button.pressed).toBe(false);
+
+ const listener = vi.fn();
+ controller.addEventListener(mediaStateChange, listener);
+ previous.loop = false;
+ await elementIsStable(controller);
+ expect(listener).not.toHaveBeenCalled();
+ replacement.loop = true;
+ await elementIsStable(button);
+ expect(controller.mediaState.loop).toBe(true);
+ expect(button.pressed).toBe(true);
+ expect(listener).toHaveBeenCalledOnce();
+ });
+
+ it('should release observers when disconnected and bind them on reconnection', async () => {
+ button.commandForElement = controller;
+ await elementIsStable(button);
+ controller.remove();
+ expect(controller.mediaState.loop).toBe(false);
+ const listener = vi.fn();
+ controller.addEventListener(mediaStateChange, listener);
+ media.loop = false;
+ media.loop = true;
+ const replacement = document.createElement(tag);
+ replacement.loop = true;
+ media.replaceWith(replacement);
+ await elementIsStable(button);
+ expect(listener).not.toHaveBeenCalled();
+ fixture.prepend(controller);
+ await elementIsStable(controller);
+ await elementIsStable(button);
+ expect(controller.mediaState.loop).toBe(true);
+ expect(button.pressed).toBe(true);
+ replacement.loop = false;
+ await elementIsStable(button);
+ expect(controller.mediaState.loop).toBe(false);
+ expect(button.pressed).toBe(false);
+ });
+
+ it('should warn and ignore loop commands when media is missing', async () => {
+ media.remove();
+ await elementIsStable(controller);
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ dispatchCommand(controller, mediaCommands.enableLoop);
+ dispatchCommand(controller, mediaCommands.disableLoop);
+ dispatchCommand(controller, mediaCommands.toggleLoop);
+ expect(controller.mediaState.loop).toBe(false);
+ expect(warn).toHaveBeenCalledTimes(3);
+ expect(warn).toHaveBeenCalledWith('nve-media-controller missing media element');
+ });
+});
+
+function getElement(root: ParentNode, selector: string): T {
+ const element = root.querySelector(selector);
+ if (!element) {
+ throw new Error(`Missing element: ${selector}`);
+ }
+ return element;
+}
+
+function dispatchCommand(controller: MediaController, command: MediaCommand) {
+ const event = new Event('command') as MediaCommandEvent;
+ Object.defineProperty(event, 'command', { value: command });
+ controller.dispatchEvent(event);
+}
diff --git a/projects/media/src/controller/controller.ts b/projects/media/src/controller/controller.ts
index 87c00b6735..f0d478d898 100644
--- a/projects/media/src/controller/controller.ts
+++ b/projects/media/src/controller/controller.ts
@@ -41,6 +41,9 @@ const mediaEventTypes = [
* @command --mute - Mute the media element.
* @command --unmute - Unmute the media element.
* @command --toggle-mute - Toggle media muted state.
+ * @command --enable-loop - Enable continuous media looping.
+ * @command --disable-loop - Disable media looping.
+ * @command --toggle-loop - Toggle media looping.
* @command --seek - Move playback to the source valueAsNumber.
* @command --seek-start - Move playback to zero seconds.
* @command --seek-end - Move playback to the finite duration.
@@ -68,6 +71,8 @@ export class MediaController extends LitElement {
#media: HTMLMediaElement | null = null;
+ #loopObserver: MutationObserver | null = null;
+
#currentTime = 0;
#fullscreen = false;
@@ -81,6 +86,9 @@ export class MediaController extends LitElement {
[mediaCommands.mute, () => this.#setMuted(true)],
[mediaCommands.unmute, () => this.#setMuted(false)],
[mediaCommands.toggleMuted, () => this.#toggleMuted()],
+ [mediaCommands.enableLoop, () => this.#setLoop(true)],
+ [mediaCommands.disableLoop, () => this.#setLoop(false)],
+ [mediaCommands.toggleLoop, () => this.#toggleLoop()],
[mediaCommands.seek, source => this.#seekToSourceValue(source)],
[mediaCommands.seekToStart, () => this.#setCurrentTime(0)],
[mediaCommands.seekToEnd, () => this.#seekToEnd()],
@@ -114,6 +122,9 @@ export class MediaController extends LitElement {
super.connectedCallback();
this.addEventListener('command', this.#onCommand as EventListener);
globalThis.document?.addEventListener('fullscreenchange', this.#syncFullscreen);
+ if (this.hasUpdated) {
+ this.#syncMediaSlot();
+ }
}
disconnectedCallback() {
@@ -138,6 +149,10 @@ export class MediaController extends LitElement {
};
#syncMediaSlot = () => {
+ if (!this.isConnected) {
+ return;
+ }
+
const slot = this.renderRoot.querySelector('slot');
const media = (slot?.assignedElements({ flatten: true }) ?? []).filter(isSupportedMediaElement);
if (media.length > 1) {
@@ -153,8 +168,13 @@ export class MediaController extends LitElement {
}
this.#media && mediaEventTypes.forEach(type => this.#media?.removeEventListener(type, this.#syncMediaState));
+ this.#loopObserver?.disconnect();
this.#media = media;
this.#media && mediaEventTypes.forEach(type => this.#media?.addEventListener(type, this.#syncMediaState));
+ if (media) {
+ this.#loopObserver ??= new MutationObserver(this.#syncMediaState);
+ this.#loopObserver.observe(media, { attributes: true, attributeFilter: ['loop'] });
+ }
this.#syncMediaState();
}
@@ -178,6 +198,7 @@ export class MediaController extends LitElement {
setBooleanAttribute(this, 'ended', state.ended);
setBooleanAttribute(this, 'seeking', state.seeking);
setBooleanAttribute(this, 'fullscreen', state.fullscreen);
+ setBooleanAttribute(this, 'loop', state.loop);
setNumberAttribute(this, 'current-time', state.currentTime);
setNumberAttribute(this, 'duration', state.duration);
setNumberAttribute(this, 'volume', state.volume);
@@ -219,6 +240,22 @@ export class MediaController extends LitElement {
}
}
+ #setLoop(loop: boolean) {
+ const media = this.#getMedia();
+ if (media) {
+ media.loop = loop;
+ this.#syncMediaState();
+ }
+ }
+
+ #toggleLoop() {
+ const media = this.#getMedia();
+ if (media) {
+ media.loop = !media.loop;
+ this.#syncMediaState();
+ }
+ }
+
#seekToSourceValue(source: MediaCommandSource | null) {
const value = getSourceNumber(source);
Number.isFinite(value) ? this.#setCurrentTime(value) : this.#warnInvalidCommandValue(mediaCommands.seek);
@@ -318,6 +355,7 @@ function getMediaState(media: HTMLMediaElement | null) {
return {
paused: true,
muted: false,
+ loop: false,
ended: false,
seeking: false,
currentTime: 0,
@@ -331,6 +369,7 @@ function getMediaState(media: HTMLMediaElement | null) {
buffered: getMediaTimeSpans(media.buffered),
paused: media.paused,
muted: media.muted,
+ loop: media.loop,
ended: media.ended,
seeking: media.seeking,
currentTime: getFiniteNumber(media.currentTime, 0),
diff --git a/projects/media/src/internal/button-form-control-usage.test.ts b/projects/media/src/internal/button-form-control-usage.test.ts
index 227f7fe81f..7fa6937ead 100644
--- a/projects/media/src/internal/button-form-control-usage.test.ts
+++ b/projects/media/src/internal/button-form-control-usage.test.ts
@@ -6,10 +6,12 @@ import { afterEach, describe, expect, it } from 'vitest';
import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing';
import type { ButtonFormControlMixinInstance } from '@nvidia-elements/forms/mixins';
import { MediaFullscreenButton } from '../fullscreen-button/fullscreen-button.js';
+import { MediaLoopButton } from '../loop-button/loop-button.js';
import { MediaMuteButton } from '../mute-button/mute-button.js';
import { MediaPauseButton } from '../pause-button/pause-button.js';
import { MediaSeekButton } from '../seek-button/seek-button.js';
import '../fullscreen-button/define.js';
+import '../loop-button/define.js';
import '../mute-button/define.js';
import '../pause-button/define.js';
import '../seek-button/define.js';
@@ -30,6 +32,11 @@ const usages: ButtonUsage[] = [
template: html` `,
submitTemplate: html` `
},
+ {
+ tag: MediaLoopButton.metadata.tag,
+ template: html` `,
+ submitTemplate: html` `
+ },
{
tag: MediaMuteButton.metadata.tag,
checkbox: true,
diff --git a/projects/media/src/internal/media-command.ts b/projects/media/src/internal/media-command.ts
index 117555162b..190aa289b1 100644
--- a/projects/media/src/internal/media-command.ts
+++ b/projects/media/src/internal/media-command.ts
@@ -8,6 +8,9 @@ export const mediaCommands = {
mute: '--mute',
unmute: '--unmute',
toggleMuted: '--toggle-mute',
+ enableLoop: '--enable-loop',
+ disableLoop: '--disable-loop',
+ toggleLoop: '--toggle-loop',
seek: '--seek',
seekToStart: '--seek-start',
seekToEnd: '--seek-end',
diff --git a/projects/media/src/internal/media-state.test.ts b/projects/media/src/internal/media-state.test.ts
index 04f2506d4d..9c9db1143b 100644
--- a/projects/media/src/internal/media-state.test.ts
+++ b/projects/media/src/internal/media-state.test.ts
@@ -5,6 +5,11 @@ import { describe, expect, it } from 'vitest';
import { createMediaState, getTargetMediaState, isMediaState, mediaStatesEqual } from './media-state.js';
describe(createMediaState.name, () => {
+ it('should default loop state to false', () => {
+ expect(createMediaState().loop).toBe(false);
+ expect(createMediaState({ loop: true }).loop).toBe(true);
+ });
+
it('should copy and freeze buffered time spans', () => {
const span = { start: 0, end: 10 };
const buffered = [span];
@@ -19,6 +24,12 @@ describe(createMediaState.name, () => {
});
describe(isMediaState.name, () => {
+ it('should require a boolean loop state', () => {
+ expect(isMediaState({ ...createMediaState(), loop: undefined })).toBe(false);
+ expect(isMediaState({ ...createMediaState(), loop: 'true' })).toBe(false);
+ expect(isMediaState(createMediaState({ loop: true }))).toBe(true);
+ });
+
it('should validate ordered and disjoint buffered time spans', () => {
expect(
isMediaState(
@@ -60,6 +71,11 @@ describe(getTargetMediaState.name, () => {
});
describe(mediaStatesEqual.name, () => {
+ it('should detect loop-only changes', () => {
+ expect(mediaStatesEqual(createMediaState(), createMediaState({ loop: true }))).toBe(false);
+ expect(mediaStatesEqual(createMediaState({ loop: true }), createMediaState({ loop: true }))).toBe(true);
+ });
+
it('should compare buffered time spans by value', () => {
const state = createMediaState({
buffered: [
diff --git a/projects/media/src/internal/media-state.ts b/projects/media/src/internal/media-state.ts
index 3836265a0c..27001590fa 100644
--- a/projects/media/src/internal/media-state.ts
+++ b/projects/media/src/internal/media-state.ts
@@ -14,6 +14,7 @@ export interface MediaState {
readonly duration: number;
readonly ended: boolean;
readonly fullscreen: boolean;
+ readonly loop: boolean;
readonly muted: boolean;
readonly paused: boolean;
readonly playbackRate: number;
@@ -25,7 +26,7 @@ export type MediaStateChangeEvent = CustomEvent;
export const mediaStateChange = 'media-state-change';
-const booleanStateKeys = ['ended', 'fullscreen', 'muted', 'paused', 'seeking'] as const;
+const booleanStateKeys = ['ended', 'fullscreen', 'loop', 'muted', 'paused', 'seeking'] as const;
const numberStateKeys = ['currentTime', 'duration', 'playbackRate', 'volume'] as const;
@@ -37,6 +38,7 @@ const defaultMediaState: MediaState = {
duration: 0,
ended: false,
fullscreen: false,
+ loop: false,
muted: false,
paused: true,
playbackRate: 1,
@@ -61,15 +63,8 @@ export function isMediaState(value: unknown): value is MediaState {
export function mediaStatesEqual(a: MediaState, b: MediaState) {
return (
mediaTimeSpansEqual(a.buffered, b.buffered) &&
- a.currentTime === b.currentTime &&
- a.duration === b.duration &&
- a.ended === b.ended &&
- a.fullscreen === b.fullscreen &&
- a.muted === b.muted &&
- a.paused === b.paused &&
- a.playbackRate === b.playbackRate &&
- a.seeking === b.seeking &&
- a.volume === b.volume
+ booleanStateKeys.every(key => a[key] === b[key]) &&
+ numberStateKeys.every(key => a[key] === b[key])
);
}
diff --git a/projects/media/src/loop-button/define.ts b/projects/media/src/loop-button/define.ts
new file mode 100644
index 0000000000..fe1c544320
--- /dev/null
+++ b/projects/media/src/loop-button/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 { MediaLoopButton } from '@nvidia-elements/media/loop-button';
+
+define(MediaLoopButton);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'nve-media-loop-button': MediaLoopButton;
+ }
+}
diff --git a/projects/media/src/loop-button/index.ts b/projects/media/src/loop-button/index.ts
new file mode 100644
index 0000000000..f93201b5c5
--- /dev/null
+++ b/projects/media/src/loop-button/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 './loop-button.js';
diff --git a/projects/media/src/loop-button/loop-button.css b/projects/media/src/loop-button/loop-button.css
new file mode 100644
index 0000000000..4fc0cb99ef
--- /dev/null
+++ b/projects/media/src/loop-button/loop-button.css
@@ -0,0 +1,2 @@
+/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */
+/* SPDX-License-Identifier: Apache-2.0 */
diff --git a/projects/media/src/loop-button/loop-button.examples.ts b/projects/media/src/loop-button/loop-button.examples.ts
new file mode 100644
index 0000000000..7fce28db56
--- /dev/null
+++ b/projects/media/src/loop-button/loop-button.examples.ts
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html } from 'lit';
+
+export default {
+ title: 'Media/LoopButton',
+ component: 'nve-media-loop-button'
+};
+
+/**
+ * @summary Loop button connected to a video controller. Use with playback controls to repeat a recording continuously until the user disables looping.
+ */
+export const Default = {
+ render: () => html`
+
+
+
+
+
+
+
+ `
+};
+
+/**
+ * @summary Video with looping enabled in native markup. Use when a recording should repeat from the first playback and the loop button should show its current state.
+ */
+export const InitiallyLooping = {
+ render: () => html`
+
+
+
+
+
+
+
+ `
+};
diff --git a/projects/media/src/loop-button/loop-button.test.axe.ts b/projects/media/src/loop-button/loop-button.test.axe.ts
new file mode 100644
index 0000000000..384940d091
--- /dev/null
+++ b/projects/media/src/loop-button/loop-button.test.axe.ts
@@ -0,0 +1,25 @@
+// 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 { MediaLoopButton } from './loop-button.js';
+import './define.js';
+
+describe(MediaLoopButton.metadata.tag, () => {
+ it('should pass axe check', async () => {
+ const fixture = await createFixture(html`
+
+
+
+ `);
+ const element = fixture.querySelector(MediaLoopButton.metadata.tag) as MediaLoopButton;
+ await elementIsStable(element);
+
+ const results = await runAxe([MediaLoopButton.metadata.tag]);
+ expect(results.violations.length).toBe(0);
+ removeFixture(fixture);
+ });
+});
diff --git a/projects/media/src/loop-button/loop-button.test.lighthouse.ts b/projects/media/src/loop-button/loop-button.test.lighthouse.ts
new file mode 100644
index 0000000000..d6f4a1586e
--- /dev/null
+++ b/projects/media/src/loop-button/loop-button.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('media loop button lighthouse report', () => {
+ test('media loop button should meet lighthouse benchmarks', async () => {
+ const report = await lighthouseRunner.getReport('nve-media-loop-button', /* 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(21.5);
+ });
+});
diff --git a/projects/media/src/loop-button/loop-button.test.ssr.ts b/projects/media/src/loop-button/loop-button.test.ssr.ts
new file mode 100644
index 0000000000..4b53a4d514
--- /dev/null
+++ b/projects/media/src/loop-button/loop-button.test.ssr.ts
@@ -0,0 +1,16 @@
+// 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 { MediaLoopButton } from './loop-button.js';
+import './define.js';
+
+describe(MediaLoopButton.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(MediaLoopButton.metadata.tag)).toBe(true);
+ });
+});
diff --git a/projects/media/src/loop-button/loop-button.test.ts b/projects/media/src/loop-button/loop-button.test.ts
new file mode 100644
index 0000000000..dadc629bc2
--- /dev/null
+++ b/projects/media/src/loop-button/loop-button.test.ts
@@ -0,0 +1,208 @@
+// 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 { userEvent } from 'vitest/browser';
+import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing';
+import { mediaCommands } from '../internal/media-command.js';
+import { createMediaState, mediaStateChange, type MediaState } from '../internal/media-state.js';
+import { MediaLoopButton } from './loop-button.js';
+import './define.js';
+
+type MediaStateTarget = HTMLElement & { mediaState: MediaState };
+
+describe(MediaLoopButton.metadata.tag, () => {
+ let fixture: HTMLElement;
+ let button: MediaLoopButton;
+ let target: MediaStateTarget;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html`
+
+
+ `);
+ button = getElement(fixture, MediaLoopButton.metadata.tag);
+ target = getElement(fixture, '#target');
+ setMediaState(target, { loop: false });
+ await elementIsStable(button);
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ });
+
+ it('should define a command button with unpressed state', () => {
+ expect(customElements.get(MediaLoopButton.metadata.tag)).toBeDefined();
+ expect(button.type).toBe('button');
+ expect(button.command).toBe(mediaCommands.toggleLoop);
+ expect(button.pressed).toBe(false);
+ expect(button._internals.ariaPressed).toBe('false');
+ expect(button._internals.role).toBe('button');
+ });
+
+ it('should dispatch the loop command without optimistically changing state', async () => {
+ const event = untilEvent(target, 'command');
+ await emulateClick(button);
+ const command = await event;
+ expect(command.command).toBe(mediaCommands.toggleLoop);
+ expect(command.source).toBe(button);
+ expect(button.pressed).toBe(false);
+ });
+
+ it('should preserve explicit commands', async () => {
+ button.command = mediaCommands.enableLoop;
+ await elementIsStable(button);
+ const event = untilEvent(target, 'command');
+ await emulateClick(button);
+ expect((await event).command).toBe(mediaCommands.enableLoop);
+ });
+
+ it('should not submit its form by default', async () => {
+ const form = document.createElement('form');
+ fixture.append(form);
+ form.append(button);
+ const submit = vi.fn((event: SubmitEvent) => event.preventDefault());
+ form.addEventListener('submit', submit);
+ await elementIsStable(button);
+ await emulateClick(button);
+ expect(submit).not.toHaveBeenCalled();
+ expect([...new FormData(form)]).toEqual([]);
+ });
+
+ it('should sync pressed, ARIA, and icon state without emitting interaction events', async () => {
+ const input = vi.fn();
+ const change = vi.fn();
+ const command = vi.fn();
+ button.addEventListener('input', input);
+ button.addEventListener('change', change);
+ target.addEventListener('command', command);
+ expect(getElement(button.renderRoot, 'nve-icon').getAttribute('name')).toBe('looping-off');
+
+ setMediaState(target, { loop: true });
+ await elementIsStable(button);
+ expect(button.pressed).toBe(true);
+ expect(button.hasAttribute('pressed')).toBe(true);
+ expect(button.matches(':state(pressed)')).toBe(true);
+ expect(button._internals.ariaPressed).toBe('true');
+ expect(getElement(button.renderRoot, 'nve-icon').getAttribute('name')).toBe('looping');
+
+ setMediaState(target, { loop: false });
+ await elementIsStable(button);
+ expect(button.pressed).toBe(false);
+ expect(button.hasAttribute('pressed')).toBe(false);
+ expect(button.matches(':state(pressed)')).toBe(false);
+ expect(button._internals.ariaPressed).toBe('false');
+ expect(input).not.toHaveBeenCalled();
+ expect(change).not.toHaveBeenCalled();
+ expect(command).not.toHaveBeenCalled();
+ });
+
+ it('should read initial state and detach from its previous command target', async () => {
+ const next = document.createElement('div');
+ const nextTarget = Object.assign(next, { mediaState: createMediaState({ loop: true }) });
+ fixture.append(nextTarget);
+ button.commandForElement = nextTarget;
+ await elementIsStable(button);
+ expect(button.pressed).toBe(true);
+
+ setMediaState(target, { loop: false });
+ await elementIsStable(button);
+ expect(button.pressed).toBe(true);
+
+ button.commandForElement = null;
+ button.removeAttribute('commandfor');
+ await elementIsStable(button);
+ expect(button.pressed).toBe(false);
+ setMediaState(nextTarget, { loop: true });
+ await elementIsStable(button);
+ expect(button.pressed).toBe(false);
+ });
+
+ it('should clear stale state and subscribe again after reconnection', async () => {
+ setMediaState(target, { loop: true });
+ await elementIsStable(button);
+ button.remove();
+ expect(button.pressed).toBe(false);
+ setMediaState(target, { loop: false });
+ fixture.append(button);
+ await elementIsStable(button);
+ setMediaState(target, { loop: true });
+ await elementIsStable(button);
+ expect(button.pressed).toBe(true);
+ });
+
+ it('should ignore invalid controller snapshots', async () => {
+ target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: { loop: true } }));
+ await elementIsStable(button);
+ expect(button.pressed).toBe(false);
+ });
+
+ it.each([
+ [mediaCommands.toggleLoop, 'enable looping', 'repeat recording'],
+ [mediaCommands.enableLoop, 'enable looping', 'repeat recording'],
+ [mediaCommands.disableLoop, 'disable looping', 'stop repeating']
+ ])('should use a stable localized name for %s in both states', async (command, defaultName, localizedName) => {
+ button.command = command;
+ await elementIsStable(button);
+ expect(button._internals.ariaLabel).toBe(defaultName);
+ button.i18n = { enableLoop: 'repeat recording', disableLoop: 'stop repeating' };
+ await elementIsStable(button);
+ expect(button._internals.ariaLabel).toBe(localizedName);
+ expect(button._internals.ariaPressed).toBe('false');
+ setMediaState(target, { loop: true });
+ await elementIsStable(button);
+ expect(button._internals.ariaLabel).toBe(localizedName);
+ expect(button._internals.ariaPressed).toBe('true');
+ setMediaState(target, { loop: false });
+ await elementIsStable(button);
+ expect(button._internals.ariaLabel).toBe(localizedName);
+ expect(button._internals.ariaPressed).toBe('false');
+ button.i18n = { enableLoop: undefined, disableLoop: undefined };
+ await elementIsStable(button);
+ expect(button._internals.ariaLabel).toBe(null);
+ });
+
+ it('should preserve an explicit accessible name', async () => {
+ button.ariaLabel = 'repeat this clip';
+ setMediaState(target, { loop: true });
+ await elementIsStable(button);
+ await expect.element(button).toHaveAccessibleName('repeat this clip');
+ });
+
+ it('should preserve authored pressed markup and custom icon content', async () => {
+ const standalone = await createFixture(html`
+ repeat
+ `);
+ try {
+ const control = getElement(standalone, MediaLoopButton.metadata.tag);
+ await elementIsStable(control);
+ expect(control.pressed).toBe(true);
+ expect(control._internals.ariaPressed).toBe('true');
+ const slot = getElement(control.renderRoot, 'slot');
+ expect(slot.assignedElements()[0]?.textContent).toBe('repeat');
+ } finally {
+ removeFixture(standalone);
+ }
+ });
+
+ it.each(['Enter', ' '])('should dispatch a command on the %s key', async key => {
+ button.focus();
+ const event = untilEvent(target, 'command');
+ await userEvent.keyboard(key === 'Enter' ? '{Enter}' : ' ');
+ expect((await event).command).toBe(mediaCommands.toggleLoop);
+ });
+});
+
+function getElement(root: ParentNode, selector: string): T {
+ const element = root.querySelector(selector);
+ if (!element) {
+ throw new Error(`Missing element: ${selector}`);
+ }
+ return element;
+}
+
+function setMediaState(target: MediaStateTarget, state: Partial) {
+ target.mediaState = createMediaState(state);
+ target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: target.mediaState }));
+}
diff --git a/projects/media/src/loop-button/loop-button.test.visual.ts b/projects/media/src/loop-button/loop-button.test.visual.ts
new file mode 100644
index 0000000000..0735d94f7d
--- /dev/null
+++ b/projects/media/src/loop-button/loop-button.test.visual.ts
@@ -0,0 +1,31 @@
+// 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('media loop button visual', () => {
+ test('media loop button should match visual baseline', async () => {
+ const report = await visualRunner.render('media-loop-button', template());
+ expect(report.maxDiffPercentage).toBeLessThan(1);
+ });
+
+ test('media loop button should match visual baseline dark theme', async () => {
+ const report = await visualRunner.render('media-loop-button.dark', template('dark'));
+ expect(report.maxDiffPercentage).toBeLessThan(1);
+ });
+});
+
+function template(theme: '' | 'dark' = '') {
+ return /* html */ `
+
+
+
+
+
+
+ `;
+}
diff --git a/projects/media/src/loop-button/loop-button.ts b/projects/media/src/loop-button/loop-button.ts
new file mode 100644
index 0000000000..780cbdd628
--- /dev/null
+++ b/projects/media/src/loop-button/loop-button.ts
@@ -0,0 +1,100 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { property } from 'lit/decorators/property.js';
+import type { PropertyValues } from 'lit';
+import { html, LitElement } from 'lit';
+import { ButtonFormControlMixin } from '@nvidia-elements/forms/mixins';
+import { attachInternals, I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal';
+import { Icon } from '@nvidia-elements/core/icon';
+import { MediaStateController } from '../internal/controllers/media-state.controller.js';
+import { mediaCommands } from '../internal/media-command.js';
+import type { MediaState } from '../internal/media-state.js';
+import mediaButtonStyles from '../internal/media-button.css?inline';
+import styles from './loop-button.css?inline';
+
+/**
+ * @element nve-media-loop-button
+ * @description Requests continuous looping changes for the controlled media.
+ * @documentation https://nvidia.github.io/elements/docs/media/loop-button/
+ * @since 0.0.0
+ * @entrypoint \@nvidia-elements/media/loop-button
+ * @slot - Optional custom icon content.
+ * @cssprop --background
+ * @cssprop --color
+ * @cssprop --border
+ * @cssprop --border-radius
+ * @cssprop --height
+ * @cssprop --width
+ * @cssprop --padding
+ * @csspart icon - The fallback icon element.
+ * @aria https://www.w3.org/WAI/ARIA/apg/patterns/button/
+ * @stable false
+ */
+@scopedRegistry()
+export class MediaLoopButton extends ButtonFormControlMixin(LitElement) {
+ static styles = useStyles([mediaButtonStyles, styles]);
+
+ static elementDefinitions = {
+ [Icon.metadata.tag]: Icon
+ };
+
+ static readonly metadata = {
+ tag: 'nve-media-loop-button',
+ version: '0.0.0'
+ };
+
+ override command: string = mediaCommands.toggleLoop;
+
+ @property({ type: Boolean, reflect: true, noAccessor: true }) declare pressed: boolean;
+
+ #i18nController: I18nController = new I18nController(this);
+
+ /** Enables updating internal string values for internationalization. */
+ @property({ type: Object }) i18n = this.#i18nController.i18n;
+
+ #mediaStateController = new MediaStateController(this, mediaState => this.#syncPressedState(mediaState));
+
+ constructor() {
+ super();
+ this.type = 'button';
+ this.pressed = false;
+ }
+
+ render() {
+ return html`
+
+
+
+
+
+ `;
+ }
+
+ get #iconName() {
+ return this.pressed ? 'looping' : 'looping-off';
+ }
+
+ get #defaultAriaLabel() {
+ return (this.command === mediaCommands.disableLoop ? this.i18n.disableLoop : this.i18n.enableLoop) ?? null;
+ }
+
+ override updated(changedProperties: PropertyValues) {
+ super.updated(changedProperties);
+ this.#syncDefaultAriaLabel();
+ }
+
+ #syncDefaultAriaLabel() {
+ attachInternals(this);
+ this._internals.ariaLabel = this.#defaultAriaLabel;
+ }
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ this.#syncPressedState(null);
+ }
+
+ #syncPressedState = (state: MediaState | null) => {
+ this.pressed = state?.loop ?? false; // eslint-disable-line local/stateless-property -- Loop button mirrors the target controller state.
+ };
+}
diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js
index b44cc996f8..08617b8716 100644
--- a/projects/site/src/_11ty/layouts/common.js
+++ b/projects/site/src/_11ty/layouts/common.js
@@ -322,6 +322,7 @@ export const renderDocsNav = data => /* html */ `
Controller
Pause Button
Playback Rate Select
+ Loop Button
Mute Button
Seek Button
Time Range
diff --git a/projects/site/src/docs/elements/index.11ty.js b/projects/site/src/docs/elements/index.11ty.js
index 84b93d6c4b..bdc4a3e4b0 100644
--- a/projects/site/src/docs/elements/index.11ty.js
+++ b/projects/site/src/docs/elements/index.11ty.js
@@ -237,6 +237,7 @@ const previewOverrides = {
'nve-monaco-input': renderMonacoMock('nve-monaco-input'),
'nve-monaco-problems': renderMonacoMock('nve-monaco-problems'),
'nve-media-fullscreen-button': /* html */ ` `,
+ 'nve-media-loop-button': /* html */ ` `,
'nve-media-mute-button': /* html */ ` `,
'nve-media-pause-button': /* html */ ` `,
'nve-media-playback-rate-select': /* html */ ` `,
diff --git a/projects/site/src/docs/media/controller.md b/projects/site/src/docs/media/controller.md
index 5ecc8a99d1..7e1be74af9 100644
--- a/projects/site/src/docs/media/controller.md
+++ b/projects/site/src/docs/media/controller.md
@@ -31,7 +31,7 @@
## State
-Read the latest immutable state from `mediaState`. The state includes `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `currentTime`, `duration`, `volume`, `playbackRate`, and `buffered`.
+Read the latest immutable state from `mediaState`. The state includes `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `loop`, `currentTime`, `duration`, `volume`, `playbackRate`, and `buffered`.
The `buffered` value contains ordered, disjoint time spans in seconds. The controller copies these spans from the native media element instead of exposing the browser-owned `TimeRanges` object.
@@ -49,6 +49,6 @@ console.log(controller.mediaState);
controller.addEventListener('media-state-change', event => console.log(event.detail));
```
-The controller continues to reflect `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `current-time`, `duration`, `volume`, and `playback-rate` for styling and inspection. Buffered spans remain available through `mediaState` and `media-state-change`. The native media element still emits events such as `play`, `pause`, `progress`, `timeupdate`, `durationchange`, `volumechange`, and `ratechange`.
+The controller continues to reflect `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `loop`, `current-time`, `duration`, `volume`, and `playback-rate` for styling and inspection. Buffered spans remain available through `mediaState` and `media-state-change`. The native media element still emits events such as `play`, `pause`, `progress`, `timeupdate`, `durationchange`, `volumechange`, and `ratechange`.
The controller does not include `src`, `poster`, `autoplay`, track, or caption APIs. Use native media markup for media ownership and compose the Elements controls around it.
diff --git a/projects/site/src/docs/media/loop-button.md b/projects/site/src/docs/media/loop-button.md
new file mode 100644
index 0000000000..a692f0e66f
--- /dev/null
+++ b/projects/site/src/docs/media/loop-button.md
@@ -0,0 +1,30 @@
+---
+{
+ title: 'Media Loop Button',
+ layout: 'docs.11ty.js',
+ tag: 'nve-media-loop-button',
+ hideExamplesTab: true
+}
+---
+
+## Installation
+
+{% install 'nve-media-loop-button' %}
+
+## Default
+
+{% example '@nvidia-elements/media/loop-button/loop-button.examples.json', 'Default' %}
+
+## Initial State
+
+Set `loop` on the native `video` or `audio` element to enable continuous replay before the first playback. The controller reads that setting and keeps the button in sync with changes to the native `loop` property or attribute.
+
+{% example '@nvidia-elements/media/loop-button/loop-button.examples.json', 'InitiallyLooping' %}
+
+## Commands
+
+The controller supports `--enable-loop`, `--disable-loop`, and `--toggle-loop`. Use the button's `command` attribute to request a specific setting instead of toggling.
+
+```html
+
+```