Lazy-load local videos in Astro with preload="metadata" and a rehype plugin
Contents
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:
- Use
preload="metadata"to load only the metadata and the first frame - 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
videotag instead of an iframe - It does not add a thumbnail image; it keeps the
videotag itself and usespreload="metadata"to show the thumbnail - It removes
controlsuntil 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
videotag instead of generating an iframe dynamically - Adds the
controlsattribute 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.