responsive-video-source-selection
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:
<!-- BROKEN in Chrome & Firefox -->
<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
mediaattribute 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(); // Required to apply new source
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'ssrcattribute in Elements panel
Example
From this project's VideoLightbox.tsx:
// Select video source based on viewport width at open time.
// <source media="..."> is unreliable in <video> across browsers,
// so we use JS-based selection instead.
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
mediaattribute 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.matchMediamock 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
- MDN:
<source>element — documents thatmediais allowed for<picture>parent, not<audio>/<video> - Can I Use: source media attribute — shows fragmented browser support
- Firefox Bug 1513109 —
mediaattribute does not work on video source element - Chrome does not support media queries on video source tags — JS workaround approach
- Scott Jehl: Responsive Video — history and current state of responsive video standards
- Filament Group: HTML Video Sources Should Be Responsive — advocacy for spec support