diff --git a/apps/sandbox/app/shared/react/use-placeholder.ts b/apps/sandbox/app/shared/react/use-placeholder.ts
new file mode 100644
index 00000000..cee854cb
--- /dev/null
+++ b/apps/sandbox/app/shared/react/use-placeholder.ts
@@ -0,0 +1,8 @@
+import { useMemo } from 'react';
+import { getPlaceholderSrc } from '../sources';
+import { useSource } from './use-source';
+
+export function usePlaceholder() {
+ const source = useSource();
+ return useMemo(() => getPlaceholderSrc(source), [source]);
+}
diff --git a/apps/sandbox/app/shared/sources.ts b/apps/sandbox/app/shared/sources.ts
index a9e75d59..c3511b90 100644
--- a/apps/sandbox/app/shared/sources.ts
+++ b/apps/sandbox/app/shared/sources.ts
@@ -91,6 +91,11 @@ export function getPosterSrc(source: SourceId): string | undefined {
return id ? `https://image.mux.com/${id}/thumbnail.jpg` : undefined;
}
+export function getPlaceholderSrc(source: SourceId): string | undefined {
+ const id = getMuxAssetId(source);
+ return id ? `https://image.mux.com/${id}/thumbnail.jpg?width=20` : undefined;
+}
+
export function getStoryboardSrc(source: SourceId): string | undefined {
// Storyboards aren't generated for live streams, so skip the request entirely.
if (isLiveSource(source)) return undefined;
diff --git a/apps/sandbox/templates/html-mux-video/main.ts b/apps/sandbox/templates/html-mux-video/main.ts
index ef28b9e5..257ac73c 100644
--- a/apps/sandbox/templates/html-mux-video/main.ts
+++ b/apps/sandbox/templates/html-mux-video/main.ts
@@ -12,7 +12,7 @@ import {
onSkinChange,
onSourceChange,
} from '@app/shared/sandbox-listener';
-import { getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources';
+import { getPlaceholderSrc, getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources';
const html = String.raw;
@@ -26,12 +26,13 @@ async function render() {
const storyboard = getStoryboardSrc(state.source);
const poster = getPosterSrc(state.source);
+ const placeholder = getPlaceholderSrc(state.source);
const mediaAttrs = renderMediaAttrs(state);
const playerTag = live ? 'live-video-player' : 'video-player';
document.getElementById('root')!.innerHTML = html`
<${playerTag}>
- <${tag} class="aspect-video max-w-4xl mx-auto">
+ <${tag} class="aspect-video max-w-4xl mx-auto"${placeholder ? ` placeholdersrc="${placeholder}"` : ''}>
${renderStoryboard(storyboard)}
diff --git a/apps/sandbox/templates/react-mux-video/main.tsx b/apps/sandbox/templates/react-mux-video/main.tsx
index 43e17fe7..b95b38d4 100644
--- a/apps/sandbox/templates/react-mux-video/main.tsx
+++ b/apps/sandbox/templates/react-mux-video/main.tsx
@@ -5,6 +5,7 @@ import { Storyboard } from '@app/shared/react/storyboard';
import { useAutoplay } from '@app/shared/react/use-autoplay';
import { useLoop } from '@app/shared/react/use-loop';
import { useMuted } from '@app/shared/react/use-muted';
+import { usePlaceholder } from '@app/shared/react/use-placeholder';
import { usePoster } from '@app/shared/react/use-poster';
import { usePreload } from '@app/shared/react/use-preload';
import { useSkin } from '@app/shared/react/use-skin';
@@ -25,6 +26,7 @@ function App() {
const source = useSource();
const styling = useMemo(readStyling, []);
const poster = usePoster();
+ const placeholder = usePlaceholder();
const storyboard = useStoryboard();
const live = isLiveSource(source);
const autoplay = useAutoplay();
@@ -37,6 +39,7 @@ function App() {
+```
+
+The `placeholder` prop is accepted on `BaseVideoSkinProps` and all skin variants (`VideoSkin`, `LiveVideoSkin`, and their minimal equivalents). When provided, the skin sets `--media-poster-placeholder` as an inline CSS custom property on the container element:
+
+```tsx
+const containerStyle = placeholder
+ ? ({ '--media-poster-placeholder': `url(${placeholder})`, ...style } as CSSProperties)
+ : style;
+```
+
+The skin CSS then renders the placeholder via `::before` on the container, with an `opacity` fade-in triggered by `:has(> img[data-visible])` once the full poster is loaded:
+
+```css
+.media-default-skin::before {
+ /* positioned layer behind the poster */
+ background-image: var(--media-poster-placeholder, none);
+ filter: blur(var(--media-poster-placeholder-blur, 20px));
+ opacity: 0;
+ transition: opacity 0.25s;
+}
+.media-default-skin:has(> img[data-visible])::before {
+ opacity: 1;
+}
+```
+
+The placeholder is intentionally hidden until the skin detects a visible poster (`data-visible`). This avoids a flash of the blurred image when no poster is shown (e.g. after playback starts).
+
+### HTML
+
+```html
+
+
+
+```
+
+`PosterElement` observes the `placeholdersrc` attribute and sets `--media-poster-placeholder` as an inline CSS custom property on itself:
+
+```ts
+// In PosterElement.attributeChangedCallback
+if (newValue) {
+ this.style.setProperty('--media-poster-placeholder', `url(${newValue})`);
+} else {
+ this.style.removeProperty('--media-poster-placeholder');
+}
+```
+
+The skin picks up the variable via `::before` on `media-poster`. No opacity transition is needed on the HTML path — the `media-poster` element itself transitions in via its existing `opacity` rule keyed on `[data-visible]`, so the `::before` appears and disappears with it.
+
+```css
+.media-default-skin media-poster::before {
+ background-image: var(--media-poster-placeholder, none);
+ filter: blur(var(--media-poster-placeholder-blur, 20px));
+}
+```
+
+## How It Works
+
+The placeholder is a separate absolutely-positioned layer rendered via CSS `::before`, not part of the `
` element itself. This avoids interfering with the poster's `object-fit`/`object-position` or `src` loading.
+
+**React path:**
+
+1. Skin container gets `--media-poster-placeholder` via inline style.
+2. `::before` on the container renders the blurred placeholder at `opacity: 0`.
+3. When the `
` inside gets `data-visible`, `:has()` flips `::before` to `opacity: 1` — the placeholder fades in.
+4. When the poster hides (after playback starts), the container's `opacity` transitions to `0`, taking `::before` with it.
+
+**HTML path:**
+
+1. `PosterElement` sets `--media-poster-placeholder` on itself via `attributeChangedCallback`.
+2. `::before` on `media-poster` renders the blurred placeholder, always visible while the element is visible.
+3. `media-poster[data-visible]` / `media-poster:not([data-visible])` control the element's own opacity, so placeholder visibility is tied to the element's lifecycle.
+
+## CSS Custom Properties
+
+| Property | Value |
+| --- | --- |
+| `--media-poster-placeholder` | Set by the component/element to `url(...)` |
+| `--media-poster-placeholder-blur` | Controls blur radius; defaults to `20px` |
+| `--media-object-position` | Aligns placeholder to match poster position |
+| `--media-object-fit` | Sizes placeholder to match poster fit |
+
+`background-size` and `background-position` use `--media-object-fit` and `--media-object-position` so the placeholder aligns exactly with the poster.
+
+## Skin Integration
+
+Both `default` and `minimal` CSS skins implement both paths identically.
+
+**HTML path** — `::before` on `media-poster`:
+
+```css
+.media-default-skin media-poster::before {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ content: "";
+ background-image: var(--media-poster-placeholder, none);
+ background-repeat: no-repeat;
+ background-position: var(--media-object-position, center);
+ background-size: var(--media-object-fit, contain);
+ filter: blur(var(--media-poster-placeholder-blur, 20px));
+}
+```
+
+**React path** — `::before` on the skin container with fade-in:
+
+```css
+.media-default-skin::before {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ content: "";
+ background-image: var(--media-poster-placeholder, none);
+ background-repeat: no-repeat;
+ background-position: var(--media-object-position, center);
+ background-size: var(--media-object-fit, contain);
+ opacity: 0;
+ filter: blur(var(--media-poster-placeholder-blur, 20px));
+ transition: opacity 0.25s;
+}
+.media-default-skin:has(> img[data-visible])::before {
+ opacity: 1;
+}
+```
+
+Tailwind skin variants wire `--media-poster-placeholder` the same way as the CSS skins — via inline style on the container — and rely on the same `::before` rules.
+
+## Accessibility
+
+The placeholder is purely decorative — a blurred version of the poster that exists only to fill space during loading. Rendering it as a CSS `background-image` on a `::before` pseudo-element is semantically correct: it carries no meaning for assistive technology and requires no `alt` text or ARIA attributes.
+
+User-provided `alt` text on the main `
` is unaffected.
+
+## Naming
+
+| Platform | Attribute / Prop | Rationale |
+| --- | --- | --- |
+| HTML | `placeholdersrc` | Lowercase HTML attribute convention; matches Media Chrome |
+| React | `placeholder` | CamelCase React prop convention; matches Mux Player |
diff --git a/packages/html/src/define/skin-element.ts b/packages/html/src/define/skin-element.ts
index 517f3125..d1a3f436 100644
--- a/packages/html/src/define/skin-element.ts
+++ b/packages/html/src/define/skin-element.ts
@@ -18,6 +18,23 @@ const sharedSheet = createShadowStyle(sharedStyles);
* via `adoptedStyleSheets` (or `