Café hero section with a play button over a coffee cupvideo-hero-lcp-wtc

A background video is usually the heaviest thing on the first screen. Done the common way, the browser starts pulling several megabytes of video while it is still trying to paint the page, and Largest Contentful Paint (LCP) slips.

The fix keeps the video and changes the order: preload the poster image from <head> so it paints first, and give the video its file only after the page has loaded.

What you end up with:

  • The poster image painted first, as the LCP element
  • The video downloaded and played once the page finishes loading
  • No video at all for visitors with reduced motion or Save-Data turned on
  • A pause button, so the loop can be stopped

Ingredients

  • A poster image in WebP or AVIF, the same aspect ratio as the video. This is what the browser paints first, so keep it small.
  • The video in WebM, plus an MP4 (H.264) fallback for older iPhones that can’t play WebM.
  • The page’s featured image set to that poster. Step 5 reads it from there, so editors change it in the usual place.

Step 1: The video tag

The tag carries no autoplay and no real video URL, so the browser has nothing to download until the script says so.

hero.phpHTML
<section class="hero">
    <video class="hero__video" muted loop playsinline preload="none"
        width="1920" height="1080"
        poster="/wp-content/uploads/2026/10/hero-poster.webp"
        aria-hidden="true">
        <source data-src="/wp-content/uploads/2026/10/hero.webm" type="video/webm">
        <source data-src="/wp-content/uploads/2026/10/hero.mp4" type="video/mp4">
    </video>

    <button type="button" class="hero__toggle" hidden>Pause video</button>

    <div class="hero__content">
        <h1>Your headline</h1>
    </div>
</section>

What each attribute does

Attribute Why it’s there
muted Browsers only let a video play without a click when it’s muted.
loop Keeps the background running.
playsinline Stops iPhones from opening the video full screen.
preload="none" Asks the browser not to fetch any of the video up front.
width / height Reserves the space, so nothing shifts when the video arrives.
poster The image shown until the video plays. This is your LCP element.
aria-hidden="true" The video is decoration, so screen readers skip it.
data-src on each <source> No real URL in the markup, so there is nothing to download yet.

WebM comes first and MP4 second: the browser plays the first source it supports. The pause button starts hidden and appears only once the video is actually playing. The poster path is hardcoded here to keep the example short; Step 5 swaps it for the featured image.

The layout CSS puts the video behind the content and the button in a corner:

style.cssCSS
.hero {
    position: relative;
    overflow: hidden;
}

.hero__video {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.hero__content {
    position: relative;
    z-index: 1;
}

.hero__toggle {
    position: absolute;
    right: 24px;
    bottom: 24px;
    z-index: 2;
    min-height: 44px;
    padding: 0 16px;
}

Step 2: Why there’s no autoplay

The visitor still sees the video start on its own, about a second after the page loads. What changes is when the file starts downloading.

autoplay cancels preload="none". An autoplaying video is needed right away, so browsers ignore preload and start fetching as soon as they read the tag. That download competes with what the first screen actually needs: the CSS, the fonts and the poster. On the throttled mobile connection PageSpeed Insights simulates, that is often enough to push LCP past your target.

With a script, the order is fixed. The page loads, the poster paints and LCP is recorded. Only then does the script give the video its URLs and call play().

Why data-src and not src. preload is only a hint. With a real src on the <source>, some browsers still fetch the start of the file to read its length and size. With data-src there is no URL at all until the script sets one.

The script also lets you skip the video entirely for visitors who asked for less motion or less data, which the autoplay attribute can’t do.

Step 3: Leave fetchpriority and loading off the video

Both attributes look like they should speed up the poster, but neither does anything useful on a <video>.

Attribute Works on On <video>
fetchpriority="high" <img>, <link>, <script> Not a video attribute, so browsers ignore it. The poster’s priority doesn’t change.
loading="eager" <img>, <iframe> Changes nothing. Eager is already the default, and it says nothing about the poster.

They are harmless to the page, but they mislead whoever maintains the template into thinking the poster is already prioritized, and HTML validators flag them.

A poster also can’t take srcset or fetchpriority of its own. The one way to raise its priority is a preload link in <head>, which is Step 5.

Step 4: The script

Once the page has loaded, the script copies each data-src into src, loads the video and plays it. It’s plain JavaScript with no jQuery.

assets/js/hero-video.jsJavaScript
( function () {
    var hero = document.querySelector( '.hero' );
    if ( ! hero ) {
        return;
    }

    var video = hero.querySelector( '.hero__video' );
    var toggle = hero.querySelector( '.hero__toggle' );
    if ( ! video ) {
        return;
    }

    function showToggle() {
        if ( ! toggle ) {
            return;
        }

        toggle.hidden = false;
        toggle.addEventListener( 'click', function () {
            if ( video.paused ) {
                video.play().catch( function () {} );
                toggle.textContent = 'Pause video';
            } else {
                video.pause();
                toggle.textContent = 'Play video';
            }
        } );
    }

    function start() {
        var reduceMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
        var saveData = navigator.connection && navigator.connection.saveData;
        if ( reduceMotion || saveData ) {
            return; // The poster stays.
        }

        video.querySelectorAll( 'source[data-src]' ).forEach( function ( source ) {
            source.src = source.dataset.src;
        } );

        video.addEventListener( 'playing', showToggle, { once: true } );
        video.load();
        video.play().catch( function () {} ); // Blocked (e.g. Low Power Mode): the poster stays.
    }

    if ( document.readyState === 'complete' ) {
        start();
    } else {
        window.addEventListener( 'load', start );
    }
} )();

What each part does:

  • load, not DOMContentLoaded. load waits for the images and preloads, including the poster, so the video never competes with LCP. The readyState check covers a script that arrives after load has already fired.
  • Reduced motion and Save-Data. Visitors who asked for less motion or less data keep the poster and never download the video.
  • play().catch(). Browsers can still refuse to play, for example in iPhone Low Power Mode. The catch keeps that quiet, the poster stays, and the pause button stays hidden.
  • The pause button. It appears on the video’s first playing event, so it only shows when there is something to pause. Anything that moves on its own for more than five seconds needs a way to stop it (WCAG 2.2.2). Its label changes between Pause and Play, so screen readers always hear what it will do.

Enqueue it in the footer with defer, and only where the hero is used:

functions.phpPHP
add_action( 'wp_enqueue_scripts', function () {
    if ( ! is_front_page() ) { // Swap in your own check for pages with the hero.
        return;
    }

    wp_enqueue_script(
        'hero-video',
        get_theme_file_uri( 'assets/js/hero-video.js' ),
        array(),
        '1.0.0',
        array(
            'strategy'  => 'defer',
            'in_footer' => true,
        )
    );
} );

Step 5: Preload the featured image as the poster

Use WordPress’s own featured image as the poster, and preload that same image from <head>. Editors change it in the usual place, and one URL feeds both.

The preload, in functions.php:

functions.phpPHP
add_action( 'wp_head', function () {
    // Same pages as the script, and only where there is one featured image.
    if ( ! is_front_page() || ! is_singular() ) {
        return;
    }

    $poster = get_the_post_thumbnail_url( get_queried_object_id(), 'full' );
    if ( ! $poster ) {
        return; // No featured image, no preload.
    }

    printf(
        '<link rel="preload" as="image" href="%s" fetchpriority="high">' . "\n",
        esc_url( $poster )
    );
}, 1 );

The poster, in the hero template, read from the same call:

hero.phpPHP
<?php $poster = get_the_post_thumbnail_url( get_queried_object_id(), 'full' ); ?>
<video class="hero__video" muted loop playsinline preload="none"
    width="1920" height="1080"
    <?php if ( $poster ) : ?>poster="<?php echo esc_url( $poster ); ?>"<?php endif; ?>
    aria-hidden="true">
  • Priority 1 prints the link near the top of <head>, before the stylesheets, so the browser finds the poster as early as it can.
  • Same size in both places. If the preload asks for full and the template outputs large, the browser downloads two images. Change both together.
  • Same pages as the script. If you add the hero to other templates, widen both checks together. Keep is_singular(): on archives, and on a front page that lists posts, get_queried_object_id() isn’t a single page, and the call would read the wrong post’s image.
  • Upload the poster as WebP or AVIF. WordPress accepts WebP since 5.8 and AVIF since 6.5.

Taste test

Five checks confirm the poster paints first and the video waits:

  1. In DevTools, open the Network tab, tick Disable cache, filter to Img and Media, and reload.
  2. The poster: it should be one of the first requests, at High in the Priority column, and requested only once.
  3. The video: its request should start after the red load-event line in the waterfall, and the Initiator column should name your script.
  4. Reduced motion: in DevTools Rendering, emulate prefers-reduced-motion: reduce and reload. There should be no video request at all.
  5. PageSpeed Insights on mobile: the LCP element should be the poster image.

Before you ship

  • The video tag has no autoplay, and its sources use data-src.
  • No fetchpriority or loading on the <video>.
  • The poster is preloaded once, at the same size the template outputs.
  • The script is deferred and enqueued only on pages with the hero.
  • Reduced motion and Save-Data visitors get the poster only.
  • The pause button appears once the video plays, and its label switches.

Let the poster win the first screen. The video can wait a second. The house rule