Tech 4 min read

Lazy-load local videos in Astro with preload="metadata" and a rehype plugin

IkesanContents

This is a follow-up to my previous YouTube lazy-loading article. This time I am making local videos (WebM/MP4) lighter.

Problem: video files are heavy

If you embed a video in an article and write a plain <video> tag, it looks like this:

<video src="/images/demo.webm" controls></video>

With this approach, the entire video starts downloading as soon as the page opens. Even if the reader might never watch it.

Solution: preload=“metadata” + click-to-play

Combine two mechanisms:

  1. Use preload="metadata" to load only the metadata and the first frame
  2. Show a play button and start playback on click

For YouTube, I could fetch a thumbnail image from i.ytimg.com, but local videos do not have that kind of service. Instead, I use preload="metadata".

<!-- Load everything (heavy) -->
<video src="video.webm"></video>

<!-- Load only metadata (light) -->
<video src="video.webm" preload="metadata"></video>

When you specify preload="metadata", the browser fetches the video’s metadata, such as duration and resolution, plus the first few frames. That is automatically shown as the thumbnail.

Implementation

1. Create a rehype plugin

import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';

export function rehypeVideoEmbed() {
  return (tree: Root) => {
    visit(tree, 'element', (node: Element, index, parent) => {
      if (node.tagName !== 'video') return;
      if (!parent || typeof index !== 'number') return;

      const src = node.properties?.src as string | undefined;
      if (!src) return;

      // Keep the original attributes
      const originalProps = { ...node.properties };

      // Remove controls and set preload="metadata"
      delete originalProps.controls;
      originalProps.preload = 'metadata';

      // Create a wrapper div
      const wrapper: Element = {
        type: 'element',
        tagName: 'div',
        properties: {
          className: ['video-embed'],
        },
        children: [
          {
            type: 'element',
            tagName: 'video',
            properties: originalProps,
            children: [],
          },
          {
            type: 'element',
            tagName: 'button',
            properties: {
              className: ['video-play-button'],
              'aria-label': 'Play video',
            },
            children: [],
          },
        ],
      };

      // Replace the original node with the wrapper
      (parent as Element).children[index] = wrapper;
    });
  };
}

Differences from the YouTube version:

  • It targets an existing video tag instead of an iframe
  • It does not add a thumbnail image; it keeps the video tag itself and uses preload="metadata" to show the thumbnail
  • It removes controls until playback begins

2. Add the plugin to Astro config

import { rehypeVideoEmbed } from './src/lib/rehype-video-embed';

export default defineConfig({
  markdown: {
    rehypePlugins: [
      rehypeRaw,
      rehypeYouTubeEmbed,
      rehypeVideoEmbed,  // add this
    ],
  },
});

3. Style it with CSS

.video-embed {
  position: relative;
  width: 100%;
  max-width: 560px;
  margin: 1.5rem 0;
  border-radius: 0.5rem;
  overflow: hidden;
  cursor: pointer;
  background: var(--secondary);
}

.video-embed video {
  width: 100%;
  height: auto;
  display: block;
  transition: opacity 0.2s;
}

.video-embed:hover video {
  opacity: 0.8;
}

.video-embed.playing {
  cursor: default;
}

.video-embed.playing:hover video {
  opacity: 1;
}

.video-play-button {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 68px;
  height: 48px;
  background: rgba(0, 0, 0, 0.7);
  border: none;
  border-radius: 12px;
  cursor: pointer;
  transition: background 0.2s, transform 0.2s;
  display: flex;
  align-items: center;
  justify-content: center;
}

.video-play-button::before {
  content: '';
  border-style: solid;
  border-width: 10px 0 10px 18px;
  border-color: transparent transparent transparent white;
  margin-left: 4px;
}

.video-embed:hover .video-play-button {
  background: rgba(0, 0, 0, 0.9);
  transform: translate(-50%, -50%) scale(1.1);
}

.video-embed.playing .video-play-button {
  display: none;
}

The styling is similar to the YouTube version, but I use a black play button instead of YouTube red.

4. JavaScript for click handling

function initVideoEmbeds() {
  const embeds = document.querySelectorAll('.video-embed');

  embeds.forEach((embed) => {
    if (embed.dataset.initialized) return;
    embed.dataset.initialized = 'true';

    const video = embed.querySelector('video');
    if (!video) return;

    embed.addEventListener('click', () => {
      if (embed.classList.contains('playing')) return;

      embed.classList.add('playing');
      video.controls = true;
      video.play();
    });

    video.addEventListener('ended', () => {
      embed.classList.remove('playing');
      video.controls = false;
    });
  });
}

initVideoEmbeds();
document.addEventListener('astro:page-load', initVideoEmbeds);

Differences from the YouTube version:

  • Manipulates an existing video tag instead of generating an iframe dynamically
  • Adds the controls attribute dynamically
  • Restores the original state when playback ends

Usage

Just write a video tag in Markdown:

<video src="/images/demo.webm" muted loop playsinline></video>

You do not need to write controls manually. It is added automatically after the click. Attributes such as muted, loop, and playsinline are kept as-is.

Results

  • Initial load is lighter: only metadata and the first frame are fetched
  • No thumbnail image needed: the browser displays it automatically with preload="metadata"
  • No change to existing markup: you can keep writing a plain video tag
  • Resets after playback ends: it can be clicked again to play another time

Lazy loading now works for both YouTube videos and local videos.