How to Create a Website Video Preview
Build a fast, accessible website video preview with a poster image, muted hover playback, lazy loading, captions, and reliable fallbacks.
Use a poster-first <video> element. Show a useful still image immediately, defer video downloads until intent, start muted inline playback only on hover or focus, and keep an explicit play control and text fallback. This approach remains usable when autoplay is blocked, motion is reduced, or the browser cannot play the source.
1. The complete poster-first implementation
This example supports a poster, WebM and MP4 sources, captions, keyboard focus, hover and focus previews, reduced-motion preferences, and an explicit play button.
<article class="preview-card">
<a class="preview-link" href="/demo">Product demo</a>
<div class="preview-frame">
<video
class="preview"
muted
playsinline
preload="none"
poster="/media/demo-poster.jpg"
aria-describedby="demo-summary"
>
<source src="/media/demo.webm" type="video/webm">
<source src="/media/demo.mp4" type="video/mp4">
<track
kind="captions"
srclang="en"
src="/media/demo.en.vtt"
label="English"
>
<p id="demo-fallback">
This browser cannot play the preview.
<a href="/media/demo.mp4">Download the video</a>.
</p>
</video>
<button class="preview-play" type="button" aria-label="Play product demo">
Play
</button>
</div>
<p id="demo-summary">A 12-second demonstration of the product workflow.</p>
</article>
.preview-card {
max-width: thirtyrem;
}
.preview-frame {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #111;
}
.preview {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-play {
position: absolute;
inset: auto 1rem 1rem auto;
z-index: 1;
}
.preview-card:focus-within,
.preview-card:hover {
outline: 3px solid #2563eb;
outline-offset: 4px;
}
@media (prefers-reduced-motion: reduce) {
.preview {
transition: none;
}
}
Replace thirtyrem with a valid CSS length such as 30rem.
const card = document.querySelector('.preview-card');
const video = card.querySelector('video');
const playButton = card.querySelector('.preview-play');
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
async function startPreview() {
if (reduceMotion.matches) return;
try {
video.currentTime = 0;
await video.play();
playButton.hidden = true;
} catch {
// Autoplay can be rejected. The poster and play button remain usable.
playButton.hidden = false;
}
}
function stopPreview() {
video.pause();
video.currentTime = 0;
playButton.hidden = false;
}
card.addEventListener('mouseenter', startPreview);
card.addEventListener('focusin', startPreview);
card.addEventListener('mouseleave', stopPreview);
card.addEventListener('focusout', stopPreview);
playButton.addEventListener('click', async () => {
try {
if (video.paused) {
await video.play();
playButton.textContent = 'Pause';
playButton.hidden = false;
} else {
video.pause();
playButton.textContent = 'Play';
}
} catch {
playButton.hidden = false;
}
});
The poster is displayed while the video loads. The muted and playsinline attributes make hover playback eligible in more browsers, but script playback can still be rejected by browser policy or user settings. Always catch the promise returned by play(). See MDN’s video element reference.
2. Choose the preview trigger
| Trigger | Use it when | Implementation |
|---|---|---|
| Explicit click | The video carries important information or audio | Keep the poster and play button; call video.play() from the click handler |
| Hover | Desktop users need a quick visual scan | Start on mouseenter, stop on mouseleave |
| Keyboard focus | The card is reachable by keyboard | Pair hover with focusin and focusout |
| IntersectionObserver | You want previews near the viewport in a feed | Load or play only while the card is sufficiently visible |
Do not make hover the only way to discover or play a video. A visible link, button, or fallback paragraph gives keyboard and touch users an equivalent path.
3. Pick a loading strategy
| Setting | Effect | Good default |
|---|---|---|
preload="none" |
Expresses that no video data is needed before intent | Large grids and carousels |
preload="metadata" |
Allows metadata such as duration and dimensions to load | Interfaces that display duration before play |
preload="auto" |
Allows the browser to download more of the video | Only when immediate playback is important |
loading="lazy" |
Where supported, defers video and poster loading near the viewport | Below-the-fold cards |
Autoplay takes precedence over preload. Do not add autoplay="false": Boolean HTML attributes are enabled by their presence. Remove autoplay or control playback in JavaScript. See MDN’s preload documentation.
4. Generate and serve a good poster
- Export a representative frame that still makes sense without playback.
- Use the same aspect ratio as the video to avoid layout shifts and cropping.
- Serve an appropriately sized image instead of a full-resolution source for every card.
- Keep the poster available even when the video request is deferred or fails.
- Use a stable URL and a cache policy suitable for your deployment.
A poster is also the fallback for blocked autoplay, slow connections, unsupported codecs, and reduced-motion users. If you need a web page capture as a poster source, ScreenshotNeo can render a clean image of the page before you add your own video asset.
5. Add captions and alternatives
Use a WebVTT captions file for dialogue, meaningful sound effects, and other relevant audio:
WEBVTT
00:00.000 --> 00:03.000
Open the dashboard.
00:03.000 --> 00:07.500
Select a project and review the latest result.
00:07.500 --> 00:12.000
Export the report.
WCAG 2.2 requires captions for prerecorded synchronized media when the audio contains relevant information. Provide a text alternative or audio description when visual information is necessary. The W3C technique H95 documents the <track kind="captions"> pattern.
- Keep the video and play control keyboard reachable.
- Show a visible focus state.
- Respect
prefers-reduced-motion: reduceby suppressing nonessential hover playback. - For motion that starts automatically and lasts more than five seconds, provide pause, stop, or hide controls. See WCAG pause, stop, hide.
6. Lazy-start previews with IntersectionObserver
const cards = document.querySelectorAll('.preview-card');
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const video = entry.target.querySelector('video');
if (!video) return;
if (entry.isIntersecting) {
// Keep the poster until the user focuses or hovers the card.
entry.target.dataset.nearViewport = 'true';
} else {
video.pause();
video.currentTime = 0;
}
});
}, { rootMargin: '200px 0px', threshold: 0.25 });
cards.forEach((card) => observer.observe(card));
This pattern prevents off-screen previews from continuing to play. Combine it with preload="none" when most cards are never opened.
7. Browser and media compatibility
- Provide at least WebM and MP4 sources with accurate MIME types.
- Keep the fallback paragraph inside
<video>for browsers that cannot play the element. - Use
playsinlinewhen the preview should stay in the page on mobile browsers. - Do not rely on audible autoplay. Modern browsers commonly block videos with an unmuted audio track.
- Test the poster, play button, captions, source selection, and fallback independently.
8. Performance, reliability, and delivery cost
- Keep preview clips short and compress them for their display size.
- Use
preload="none"for large collections andmetadataonly when the interface needs duration or dimensions. - Use lazy loading for below-the-fold cards where the target browsers support it.
- Serve media through infrastructure that can handle the expected bandwidth and concurrent range requests; measure your own traffic rather than relying on a universal file-size target.
- Stop playback when a card leaves the viewport or loses focus.
- Log rejected
play()promises and media errors so you can distinguish policy blocks from missing files, CORS problems, and codec failures. - Cache immutable poster and video URLs, and version filenames when replacing assets.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Hover playback does nothing | play() was rejected or the video has audible audio |
Keep muted, catch the promise, and retain an explicit play button |
| The video downloads on every page load | preload="auto" or autoplay is enabled |
Use preload="none" and start playback only after intent |
| The poster is missing | Bad URL, server error, unsupported format, or lazy-loading timing | Open the poster URL directly, check response headers, and keep a CSS background or fallback visual if needed |
| Video is cropped unexpectedly | object-fit: cover or mismatched aspect ratios |
Use a matching poster ratio or switch to contain |
| Captions do not appear | Incorrect VTT syntax, path, language, or track mode | Validate the VTT file, use kind="captions", and select the track in browser controls |
| Mobile opens a fullscreen player | Inline playback was not requested | Add playsinline and test on the target mobile browsers |
| Cards consume too much bandwidth | Every card preloads or continues playing off-screen | Use preload="none", lazy loading, IntersectionObserver, and short clips |
Autoplay starts despite autoplay="false" |
Boolean attributes are enabled by presence | Remove the attribute entirely |
10. Or skip the browser setup
For a clean page image you can use as a poster source, ScreenshotNeo accepts one GET request and returns PNG, JPEG, WebP, or PDF. Its consent step accepts cookie banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be disabled. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. The service also provides an MCP server for AI agents through take_screenshot, get_page_info, and capture_pdf.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
See the ScreenshotNeo API documentation for all capture options. Free accounts include 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 shots, and every feature is included on every plan. Create a free ScreenshotNeo account.
11. FAQ
Should a preview autoplay with sound?
No. Use muted inline playback for an optional visual preview, and require a click when sound or important information is involved.
Is a poster required?
It is the most reliable first state and fallback. Use one even when the video normally starts quickly.
Which preload value should I choose?
Start with none for grids, use metadata when you need duration or dimensions, and reserve auto for previews where early loading is intentional.
How do I support users who do not want animation?
Check prefers-reduced-motion: reduce, skip hover playback, and leave the poster and an explicit play control available.
Can a preview replace the full video page?
No. Link the card to a page or player with complete controls, captions, transcript or equivalent alternatives, and the full experience.


