How to Preview HTML Video on Hover
Build an accessible HTML video hover preview with muted autoplay, touch controls, loading strategy, and reliable fallbacks.
Use pointer events to start a muted inline video, and stop it when the pointer leaves. Handle the Promise returned by video.play(), keep a poster visible, and provide a real play button for touch and keyboard users. Hover is an enhancement; it cannot be the only way to play video.
Basic hover preview
This example restarts each preview from the beginning. Remove the currentTime assignment if you want a preview to resume where it stopped.
<div class="video-preview" tabindex="0" aria-label="Preview video">
<video muted playsinline preload="metadata" poster="preview.jpg">
<source src="preview.mp4" type="video/mp4">
</video>
<button type="button" class="play-button">Play preview</button>
</div>
const preview = document.querySelector('.video-preview');
const video = preview.querySelector('video');
const button = preview.querySelector('.play-button');
function startPreview() {
video.muted = true;
const playback = video.play();
if (playback) {
playback.catch(() => {
// Keep the poster and explicit play control available.
});
}
}
function stopPreview() {
video.pause();
video.currentTime = 0;
}
preview.addEventListener('pointerenter', startPreview);
preview.addEventListener('pointerleave', stopPreview);
preview.addEventListener('focusin', startPreview);
preview.addEventListener('focusout', stopPreview);
button.addEventListener('click', () => {
video.muted = false;
video.controls = true;
video.play().catch(() => {
// The user can use the native controls or try again.
});
});
pointerenter and pointerleave cover a mouse or pen without bubbling through child elements. focusin gives keyboard users a preview when the card receives focus. The button starts normal playback with sound when the user explicitly activates it.
Styling the preview card
.video-preview {
position: relative;
width: 320px;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #111;
}
.video-preview video,
.video-preview .play-button {
width: 100%;
height: 100%;
}
.video-preview video {
display: block;
object-fit: cover;
}
.play-button {
position: absolute;
inset: auto 1rem 1rem auto;
width: auto !important;
height: auto !important;
padding: .5rem .75rem;
z-index: 1;
}
.video-preview:focus-visible {
outline: 3px solid currentColor;
outline-offset: 3px;
}
Keep the button visible on touch devices. You can hide it for fine pointers with a media query, but do not remove the only playback control for coarse pointers or keyboard users.
Autoplay rules and muted playback
A script call to play() can be rejected by the browser. Muted or silent video is less likely to be blocked; MDN notes that autoplay blocking does not apply to video without an audio track or with muted audio. Set video.muted = true before calling play(), and always catch the rejection. playsinline requests playback inside the element and is required for autoplay in Safari examples, but no attribute guarantees playback on every mobile browser or embedded webview.
The muted Boolean attribute is presence-based: muted="false" still means muted. Use a property assignment when changing it in JavaScript.
Loading choices: poster, preload and lazy loading
| Setting | Use it when | Trade-off |
|---|---|---|
poster |
Always | Shows a static frame while data loads or playback fails. |
preload="none" |
Many cards or expensive media | Lowest initial download; first hover may wait for network. |
preload="metadata" |
A moderate number of previews | Loads dimensions and duration without requesting the whole file. |
preload="auto" |
A small, prominent set | Allows the browser to download more media and use more bandwidth. |
loading="lazy" |
Videos below the fold | Defers loading near the viewport; browser support and timing vary. |
These are hints, not guarantees. Autoplay can take precedence over preload. For large grids, start with posters, preload="metadata" or none, and lazy loading for distant cards.
Touch, keyboard and reduced-motion behavior
Touch screens do not provide a reliable hover state. Keep the explicit button and make it keyboard accessible. A focus-triggered preview supplements the control; it does not replace it. If focus moves from the card to its button, do not pause prematurely. One approach is to pause only when focus leaves the whole preview:
preview.addEventListener('focusout', (event) => {
if (!preview.contains(event.relatedTarget)) stopPreview();
});
Respect users who prefer less motion by skipping automatic playback and showing the poster:
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduceMotion) {
preview.addEventListener('pointerenter', startPreview);
preview.addEventListener('focusin', startPreview);
}
For full playback, retain native controls or provide equivalent controls for play, pause, seeking and volume.
Multiple preview cards
Give every card its own video and listeners. If only one preview should play at a time, pause the previous video:
const previews = [...document.querySelectorAll('.video-preview')];
let activeVideo = null;
for (const card of previews) {
const video = card.querySelector('video');
card.addEventListener('pointerenter', () => {
if (activeVideo && activeVideo !== video) {
activeVideo.pause();
activeVideo.currentTime = 0;
}
activeVideo = video;
video.muted = true;
video.play().catch(() => {});
});
card.addEventListener('pointerleave', () => {
video.pause();
video.currentTime = 0;
if (activeVideo === video) activeVideo = null;
});
}
Waiting for media and detecting failures
video.addEventListener('loadedmetadata', () => {
// Duration and dimensions are available.
});
video.addEventListener('canplay', () => {
// Enough data is available to begin, although buffering can still occur.
});
video.addEventListener('playing', () => {
preview.classList.add('is-playing');
});
video.addEventListener('pause', () => {
preview.classList.remove('is-playing');
});
video.addEventListener('error', () => {
preview.classList.add('is-unavailable');
});
Keep the poster and button when an error event fires. Check that the source URL is reachable, the server returns a video content type, and the codec is supported by the target browser.
Common problems and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
play() rejects |
Autoplay policy, audio track, or an unavailable source | Mute before playback, catch the Promise, keep a poster and explicit play button, and inspect the media error. |
| Nothing happens on a phone | There is no hover input | Use a visible tap target and native controls. |
| Video opens fullscreen | Inline playback was not requested or supported | Add playsinline; still allow fullscreen for deliberate playback. |
| Preview pauses while activating the button | focusout handler pauses before the click |
Pause only when focus leaves the entire card, using relatedTarget. |
| Poster flashes forever | Source, codec, CORS or server response problem | Inspect the Network panel and video.error; verify the URL and encoding. |
| Page downloads too much | Many videos use auto or autoplay immediately |
Use posters, metadata/none, lazy loading and one active preview. |
| Hover feels delayed | Large file or deferred load | Use a small preview encode, preload metadata for visible cards, and keep a poster during startup. |
Performance, reliability and privacy checklist
- Encode a short, appropriately sized preview rather than the full source.
- Serve byte-range requests and cache media at the edge when possible.
- Do not start every card on page load; limit playback to the active card.
- Use a poster with the same aspect ratio to avoid layout shift.
- Test pointer, keyboard and touch paths separately.
- Verify captions or a transcript for meaningful audio and provide a full player for controls.
- Consider bandwidth limits and data-saving preferences before loading many previews.
Or skip the browser setup
If you need screenshots of pages that contain these video previews, ScreenshotNeo captures a URL with one GET request. It accepts consent banners before capture and removes 60+ known consent platforms, newsletter popups and chat widgets. Bot checks, blank pages, timeouts, failed loads and cache hits are not billed; response headers identify the page verdict and billing result. Its MCP server provides take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients.
See the ScreenshotNeo API docs for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/video-page -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/video-page"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/video-page' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
There is a free tier of 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 shots, and every feature is on every plan. Create your free ScreenshotNeo account.
FAQ
Should I reset currentTime?
Reset it when every hover should show the same opening moment. Leave it unchanged to resume.
Can hover previews play with sound?
Do not depend on that. Autoplay policies commonly block audible script-started playback. Start muted, then let an explicit user action enable sound.
Is preload a guarantee?
No. It is a browser hint, and autoplay or resource conditions can change what is fetched.
Do I need a JavaScript library?
No. The native video element, pointer events and a small Promise check are sufficient.


