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 guide is part of the Responsive Video in Next.js and React cluster 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.

Video.js lazy initialization sequence Timeline showing HTML parse β†’ CSS containment reserves space β†’ IntersectionObserver fires β†’ dynamic import loads bundle β†’ videojs() constructor runs HTML parse + CSS Space reserved contain + aspect-ratio IO fires threshold 0.1 videojs() runs fluid + responsive CLS = 0 throughout LCP not blocked β†’ time β†’

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.

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.

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

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