| name | responsive-video-source-selection |
| description | Fix for responsive video source selection failing in Chrome and Firefox when using
<source media="..."> inside <video>. Use when: (1) building a video player that needs
different sources for mobile vs desktop (portrait vs landscape), (2) <source media>
attribute is ignored and wrong video loads, (3) need cross-browser responsive video
without HLS/DASH streaming. Covers the matchMedia JS workaround pattern for React
components with poster frame selection.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-02-03T00:00:00.000Z" |
Responsive Video Source Selection
Problem
The HTML <source media="..."> attribute inside <video> elements does NOT work
reliably across browsers for responsive video selection. Chrome/Blink and Firefox
ignore the media attribute entirely, always selecting the first <source> that
matches by type. Only Safari/WebKit respects it.
This means code like this only works in Safari:
<video controls>
<source src="portrait.mp4" type="video/mp4" media="(max-width: 639px)" />
<source src="landscape.mp4" type="video/mp4" />
</video>
Chrome and Firefox will always load portrait.mp4 (the first matching source)
regardless of viewport width.
Context / Trigger Conditions
- Building a video lightbox or player with different video files for mobile vs desktop
- Using
<source media="(max-width: ...)"> inside <video> and the wrong video loads
- Portrait video loads on desktop, or landscape video loads on mobile
- Works in Safari but fails in Chrome/Edge/Firefox
- The
media attribute was removed from the HTML spec in 2014, re-added in 2023, but
Chrome and Firefox still don't implement it
Solution
Use JavaScript window.matchMedia() to select the correct source at render time:
React Pattern (Recommended)
const [isPortrait, setIsPortrait] = useState(false);
useEffect(() => {
if (isOpen) {
setIsPortrait(window.matchMedia('(max-width: 639px)').matches);
}
}, [isOpen]);
const videoSrc = isPortrait ? portraitSrc : landscapeSrc;
const posterSrc = isPortrait ? portraitPoster : landscapePoster;
return (
<video
key={videoSrc} // Force remount when source changes
controls
playsInline
poster={posterSrc}
preload="metadata"
>
<source src={videoSrc} type="video/mp4" />
</video>
);
Key Details
-
key={videoSrc} on the <video> element forces React to remount it when the
source changes. Without this, swapping <source> children alone won't trigger a
reload in all browsers.
-
Evaluate at open/mount time, not continuously. Video source selection is a
one-time decision per playback session (unlike images, you can't seamlessly swap
video mid-stream without losing playback position).
-
Select poster frame too — if you have responsive poster frames, select them
with the same logic.
-
Single <source> — since JS already selected the right file, you only need
one <source> element (not two with media queries).
Vanilla JS Pattern
const mql = window.matchMedia('(max-width: 639px)');
const video = document.querySelector('video');
video.src = mql.matches ? 'portrait.mp4' : 'landscape.mp4';
video.load();
Verification
- Open Chrome DevTools → Network tab
- Open the video player on desktop → verify landscape.mp4 loads (not portrait)
- Use DevTools responsive mode at 390px width → reload → verify portrait.mp4 loads
- Check the
<source> element's src attribute in Elements panel
Example
From this project's VideoLightbox.tsx:
const [isPortrait, setIsPortrait] = useState(false);
useEffect(() => {
if (isOpen) {
setIsPortrait(window.matchMedia('(max-width: 639px)').matches);
}
}, [isOpen]);
const videoSrc = isPortrait ? portraitSrc : landscapeSrc;
const posterSrc = isPortrait ? portraitPoster : landscapePoster;
Notes
- The
media attribute on <source> works reliably inside <picture> — this issue
is specific to <video> and <audio>.
- The attribute was removed from the WHATWG HTML5 spec in 2014, re-added in 2023,
but Chrome (Blink) and Firefox have not re-implemented support.
- Safari/WebKit never removed support, so it works there. Don't be fooled by Safari-only
testing.
- For production video delivery at scale, consider HLS/DASH streaming which handles
adaptive bitrate natively. The JS workaround is best for simple use cases (1-2 video
variants).
- Remember to test with
window.matchMedia mock in unit tests:
function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
References