diff --git a/packages/html/src/define/ui/thumbnail.ts b/packages/html/src/define/ui/thumbnail.ts
new file mode 100644
index 00000000..49476c67
--- /dev/null
+++ b/packages/html/src/define/ui/thumbnail.ts
@@ -0,0 +1,9 @@
+import { ThumbnailElement } from '../../ui/thumbnail/thumbnail-element';
+
+customElements.define(ThumbnailElement.tagName, ThumbnailElement);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ [ThumbnailElement.tagName]: ThumbnailElement;
+ }
+}
diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts
index 0f2d4412..a6d98d8c 100644
--- a/packages/html/src/index.ts
+++ b/packages/html/src/index.ts
@@ -25,6 +25,7 @@ export { PlayButtonElement } from './ui/play-button/play-button-element';
export { PlaybackRateButtonElement } from './ui/playback-rate-button/playback-rate-button-element';
export { PosterElement } from './ui/poster/poster-element';
export { SeekButtonElement } from './ui/seek-button/seek-button-element';
+export { ThumbnailElement } from './ui/thumbnail/thumbnail-element';
export { TimeElement } from './ui/time/time-element';
export { TimeGroupElement } from './ui/time/time-group-element';
export { TimeSeparatorElement } from './ui/time/time-separator-element';
diff --git a/packages/html/src/ui/thumbnail/thumbnail-element.ts b/packages/html/src/ui/thumbnail/thumbnail-element.ts
new file mode 100644
index 00000000..4b7b0dd2
--- /dev/null
+++ b/packages/html/src/ui/thumbnail/thumbnail-element.ts
@@ -0,0 +1,177 @@
+import {
+ type MediaTextTrackState,
+ mapCuesToThumbnails,
+ ThumbnailCore,
+ ThumbnailDataAttrs,
+ type ThumbnailImage,
+ type ThumbnailResizeResult,
+} from '@videojs/core';
+import type { ThumbnailHandle } from '@videojs/core/dom';
+import { applyElementProps, applyStateDataAttrs, createThumbnail, selectTextTrack } from '@videojs/core/dom';
+import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
+
+import { playerContext } from '../../player/context';
+import { PlayerController } from '../../player/player-controller';
+import { MediaElement } from '../media-element';
+
+const SHADOW_CSS = `\
+:host {
+ display: inline-block;
+ overflow: hidden;
+}
+img {
+ display: block;
+}`;
+
+export class ThumbnailElement extends MediaElement {
+ static readonly tagName = 'media-thumbnail';
+
+ static override properties = {
+ time: { type: Number },
+ crossOrigin: { type: String, attribute: 'crossorigin' },
+ loading: { type: String },
+ fetchPriority: { type: String, attribute: 'fetchpriority' },
+ } satisfies PropertyDeclarationMap;
+
+ time = 0;
+ crossOrigin: ThumbnailCore.Props['crossOrigin'];
+ loading: ThumbnailCore.Props['loading'];
+ fetchPriority: ThumbnailCore.Props['fetchPriority'];
+
+ readonly #core = new ThumbnailCore();
+ readonly #img = document.createElement('img');
+ readonly #textTracks = new PlayerController(this, playerContext, selectTextTrack);
+
+ #thumbnails: ThumbnailImage[] = [];
+ #externalThumbnails: ThumbnailImage[] | undefined;
+ #lastTextTrack: MediaTextTrackState | undefined;
+ #handle: ThumbnailHandle | null = null;
+
+ constructor() {
+ super();
+
+ const shadow = this.attachShadow({ mode: 'open' });
+
+ const style = document.createElement('style');
+ style.textContent = SHADOW_CSS;
+ shadow.appendChild(style);
+
+ this.#img.alt = '';
+ this.#img.setAttribute('part', 'img');
+ this.#img.setAttribute('aria-hidden', 'true');
+ this.#img.setAttribute('decoding', 'async');
+ shadow.appendChild(this.#img);
+ }
+
+ /**
+ * Set thumbnail images directly, bypassing the automatic `