Implementing Responsive Video with Video.js
Video.js introduces a ~150 KB JavaScript bundle into a page’s critical path, and without deliberate layout reservation the player’s async hydration causes layout shifts that collapse CLS scores to 0.15–0.35. This page sits under Responsive Video in Next.js and React and addresses the exact combination of CSS containment, fluid mode configuration, and IntersectionObserver-based lazy hydration that eliminates that reflow while keeping LCP competitive.
Prerequisite checklist
The following diagram shows the initialization sequence this page implements: CSS containment reserves the viewport slot during the HTML parse phase, then the player bundle loads lazily once the wrapper intersects the viewport.
Step 1: Reserve aspect ratio with CSS containment
Apply contain: layout style paint to the wrapper before any JavaScript executes. This isolates the rendering context so the player’s internal DOM mutations never cause a reflow that escapes the boundary.
.video-wrapper {
container-type: inline-size; /* enables @container queries on children */
aspect-ratio: 16 / 9; /* reserves exact pixel height at render time */
background: #000;
/* Prevents layout/style/paint from propagating outside the boundary.
Trade-off: child elements cannot be positioned relative to the viewport. */
contain: layout style paint;
}
/* Fallback for browsers without aspect-ratio (pre-Chrome 88, pre-Safari 15) */
@supports not (aspect-ratio: 16/9) {
.video-wrapper {
position: relative;
padding-bottom: 56.25%; /* 9/16 = 0.5625 — equivalent to 16:9 */
height: 0;
overflow: hidden;
}
.video-wrapper video,
.video-wrapper .video-js {
position: absolute;
top: 0; left: 0;
width: 100%;
height: 100%;
}
}
Tradeoff: contain: layout style paint disables position: fixed and overflow: visible escapes for any descendant. If you need a fullscreen overlay or a tooltip that breaks out of the wrapper, wrap the player and the overlay separately, or drop to contain: layout only.
Step 2: HTML structure and preload strategy
Explicit width and height attributes on <video> are critical for LCP: they populate the browser’s intrinsic size map during the HTML parse, before CSS or JS executes.
<div class="video-wrapper">
<video
id="responsive-player"
class="video-js vjs-default-skin vjs-big-play-centered"
width="1280" <!-- intrinsic width — used by browser layout engine before CSS -->
height="720" <!-- intrinsic height — prevents CLS if CSS loads late -->
preload="metadata" <!-- fetches duration/dimensions only; avoids bandwidth waste -->
poster="/assets/poster-optimized.webp"
controls
playsinline <!-- required for iOS Safari inline playback (no forced fullscreen) -->
>
<!-- Order matters: browser picks first format it can decode -->
<source src="/media/hero-720p.webm" type='video/webm; codecs="vp9"'>
<source src="/media/hero-720p.mp4" type='video/mp4; codecs="avc1.42E01E"'>
</video>
</div>
Warning: Omitting the codecs string in the type attribute forces the browser to issue a speculative network probe to determine decodability. On slow connections this delays source selection by 200–400 ms.
Step 3: Initialize with fluid mode and IntersectionObserver
// player-init.js — loaded via dynamic import(), not synchronously
import videojs from 'video.js';
const initVideoPlayer = (elementId) => {
const player = videojs(elementId, {
fluid: true, // scales proportionally to container width — required for responsive layout
responsive: true, // recalculates breakpoint classes on ResizeObserver events
fill: false, // prevents player from overriding container to 100vw × 100vh
preload: 'metadata',
html5: {
vhs: {
// Forces Video.js VHS (HLS/DASH) handler instead of native MSE.
// Provides consistent adaptive bitrate behaviour across Chrome, Firefox, and Safari.
overrideNative: true
},
nativeVideoTracks: false, // disable native track selection — VHS manages this
nativeAudioTracks: false,
nativeTextTracks: false
}
});
return player;
};
// Lazy hydration — defer the ~150 KB bundle until the player enters the viewport
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
initVideoPlayer(entry.target.id);
observer.unobserve(entry.target); // initialize once, then stop observing
}
});
}, {
threshold: 0.1 // fire when 10% visible — balances early init vs. paint-blocking
// above-the-fold players: set threshold: 0 to trigger immediately
});
const playerEl = document.getElementById('responsive-player');
if (playerEl) observer.observe(playerEl);
Tradeoff: threshold: 0.1 adds ~80–120 ms of head-start before the player is fully visible. On very slow 3G connections this can still result in a brief uninitialized-controls flash. For above-the-fold hero videos, set threshold: 0 and preload the script with <link rel="modulepreload"> to eliminate the gap.
The three sizing options above are frequently confused because their names suggest they compose. They do not. fluid makes the player derive its height from its width and the source aspect ratio. fill makes the player adopt its container’s box outright, ignoring aspect ratio entirely. responsive is orthogonal to both: it only toggles Video.js’s breakpoint classes (vjs-layout-small, vjs-layout-tiny, and friends) so the control bar sheds buttons as the player narrows. When fluid and fill are both set, fill wins and your reserved aspect ratio is discarded at the first ResizeObserver tick — which is precisely the moment CLS is measured.
The “safe under contain” column is the one that catches teams out. fill: true asks the player to read its container’s computed height, but contain: layout prevents the player’s own size from feeding back into that container — so on the first paint the container is the reserved 16:9 box, and after fill applies the player renders at whatever height the containment boundary froze. The result is a letterboxed or cropped video with no console warning. Keep fill: false explicit rather than relying on the default, so a future config merge cannot flip it.
Step 4: React component with proper cleanup
In React, the useEffect cleanup function must call player.dispose() to prevent Video.js from leaking event listeners and DOM nodes when the component unmounts — a common source of memory leaks in Next.js App Router navigations.
// components/VideoPlayer.tsx
import { useEffect, useRef } from 'react';
interface VideoPlayerProps {
src: { webm: string; mp4: string };
poster: string;
aspectRatio?: '16/9' | '4/3' | '21/9';
}
export function VideoPlayer({ src, poster, aspectRatio = '16/9' }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const playerRef = useRef<ReturnType<typeof import('video.js')['default']> | null>(null);
useEffect(() => {
let player: ReturnType<typeof import('video.js')['default']> | null = null;
// Dynamic import keeps Video.js out of the main bundle (Next.js code-splits on import())
import('video.js').then(({ default: videojs }) => {
if (!videoRef.current) return;
player = videojs(videoRef.current, {
fluid: true, // proportional scaling — see Step 3 for flag semantics
responsive: true,
fill: false,
preload: 'metadata',
html5: { vhs: { overrideNative: true } }
});
playerRef.current = player;
});
return () => {
// Cleanup: dispose releases ResizeObserver, event listeners, and the DOM node
if (playerRef.current) {
playerRef.current.dispose();
playerRef.current = null;
}
};
}, []); // empty deps — initialize once per mount
return (
<div
className="video-wrapper"
style={{ aspectRatio, contain: 'layout style paint', background: '#000' }}
>
<video
ref={videoRef}
id="responsive-player"
className="video-js vjs-default-skin vjs-big-play-centered"
width={1280} /* intrinsic dimensions for layout stability before JS */
height={720}
preload="metadata"
poster={poster}
controls
playsInline /* iOS Safari requires this to avoid forced-fullscreen */
>
<source src={src.webm} type='video/webm; codecs="vp9"' />
<source src={src.mp4} type='video/mp4; codecs="avc1.42E01E"' />
</video>
</div>
);
}
Verification steps
1. Confirm CLS is below 0.05 in Chrome DevTools: Open DevTools → Performance tab → record a full page load. In the “Experience” lane, look for Layout Shift records. A properly contained player produces zero shifts during hydration.
2. Check LCP candidate in the Network panel:
DevTools → Network → filter by Media. The poster .webp should appear before video.js bundle. If the bundle appears first, add <link rel="preload" as="image" href="/assets/poster-optimized.webp" fetchpriority="high"> to <head> — see preload vs. prefetch for video and image assets for the full preload strategy.
3. Verify bundle is deferred, not render-blocking:
# Run Lighthouse from CLI and check the "Render-blocking resources" audit
npx lighthouse https://your-site.dev/video-page \
--only-audits=render-blocking-resources,largest-contentful-paint,cumulative-layout-shift \
--output json | jq '.audits["render-blocking-resources"].details.items[].url'
# video.js must NOT appear in this list
4. Confirm fluid mode is active after mount:
// Paste in DevTools console after the player initialises
const p = videojs.getPlayer('responsive-player');
console.log(p.isFluid()); // must log: true
console.log(p.currentWidth(), p.currentHeight()); // must reflect container width, not 1280×720
Common mistakes and fixes
Mistake 1 — Missing contain property on the wrapper
Without contain: layout style paint, Video.js’s internal ResizeObserver fires during initialization and triggers a layout recalculation on the ancestor chain. Fix: always add contain: layout style paint to the wrapper element before injecting the player.
Mistake 2 — Loading Video.js synchronously in <script src>
A synchronous <script src="video.min.js"> adds ~150 KB to the render-blocking chain and delays LCP by 300–800 ms on mobile. Fix: use import('video.js') inside a useEffect or attach defer to the script tag so the browser can parse HTML in parallel.
Mistake 3 — Using fill: true on a fluid container
Setting both fluid: true and fill: true makes Video.js ignore the container’s aspect ratio and stretch to 100vw × 100vh. These two options are mutually exclusive. Fix: use fluid: true alone for responsive scaling, or fill: true only when the container already has an explicit height (e.g., a fullscreen modal).
Mistake 4 — Omitting player.dispose() in React cleanup
In Next.js App Router, components unmount during client-side navigation. Leaving player.dispose() out causes Video.js to re-initialize on the same DOM node, throwing "Player "responsive-player" is already initialized" and leaking memory. Fix: always call dispose() in the useEffect return function (see Step 4).
Mistake 5 — Setting preload="auto" for below-the-fold players
preload="auto" tells the browser it may buffer the entire video. On a page with multiple players this saturates the connection and delays other critical resources. Fix: use preload="metadata" for all below-the-fold players; switch to preload="auto" only after user interaction or after the IntersectionObserver fires and the user has had time to signal intent.
Mistake 6 — Initializing on a <video> node React still owns
videojs() does not decorate the element you pass it; it moves that element inside a generated wrapper <div class="video-js"> and rewrites its class list. React’s reconciler has no knowledge of that mutation, so any subsequent re-render that touches the video element’s props — a changed poster, a new src, a conditional className — will attempt to patch a node that is no longer where the virtual DOM believes it is, throwing NotFoundError: Failed to execute 'removeChild'. Fix: give the wrapper a stable key, never let React re-render the <video> node’s props after mount, and drive runtime changes through the player API (player.poster(url), player.src({ src, type })) instead of through JSX.
Mistake 7 — Assuming dispose() is synchronous with unmount in Strict Mode
React 18 Strict Mode in development mounts, unmounts, and remounts every component once. With an async import('video.js') in the effect, the dynamic import from the first mount can resolve after the cleanup has already run, initializing a player onto a node that is about to be discarded — and leaving the second, real player unable to attach. Fix: track a cancelled flag in the effect closure and bail out of the .then() when it is set, in addition to calling dispose().
Expected Core Web Vitals deltas
| Metric | Unoptimized baseline | With this implementation | Primary mechanism |
|---|---|---|---|
| CLS | 0.15–0.35 | < 0.05 | CSS containment + pre-allocated aspect ratio |
| LCP | — | −300 ms to −600 ms | Metadata preload + deferred JS unblocks critical path |
| INP | > 200 ms | < 200 ms | Player hydration isolated from main-thread blocking via dynamic import |
| Initial JS payload | ~150 KB (blocking) | ~150 KB (lazy) | Dynamic import defers bundle until IntersectionObserver fires |
The CLS row is the one worth plotting, because the two numbers straddle the Core Web Vitals pass mark rather than merely improving on it. An unoptimized player does not produce a slightly worse score — it produces a failing one, and the failure is deterministic rather than probabilistic, since the shift happens on every cold load at the moment the player’s DOM replaces the bare <video> node.
LCP and INP move for different reasons and are worth attributing separately. The LCP gain comes almost entirely from getting the 150 KB bundle off the render-blocking chain, not from anything the player does once it runs; the INP gain comes from the fact that videojs() construction — which builds several dozen DOM nodes and attaches its own ResizeObserver — now happens while the user is scrolling toward the element rather than during the initial interaction window.
Browser compatibility
| Feature | Chrome 85+ | Firefox 93+ | Safari 14 | Safari 16 | Edge 18+ |
|---|---|---|---|---|---|
aspect-ratio CSS |
Yes | Yes | No (use padding-bottom fallback) | Yes | No (use padding-bottom fallback) |
contain: layout style paint |
Yes | Yes | Partial (layout + paint only) | Yes | Partial |
IntersectionObserver |
Yes | Yes | Yes (12.1+) | Yes | Yes (polyfill for 18) |
Video.js fluid mode |
Yes | Yes | Yes | Yes | Yes |
codecs in type attribute |
Yes | Yes | Yes | Yes | Yes |
Related
- Responsive Video in Next.js and React — parent overview: codec variants, source negotiation, and accessibility
- Using Next/Image with custom loader configurations — applying the same deferred-hydration pattern to image delivery
- Advanced IntersectionObserver patterns for media — root margin tuning and multi-player staggering
- Preload vs. prefetch for video and image assets — when to use
<link rel="preload">for poster images and video segments