How to Generate Video Thumbnails in React
Capture a video frame in React with HTML video, canvas, seek handling, CORS support, cleanup, and production troubleshooting.
Direct answer: Create an HTML <video> element, wait for metadata, seek to the timestamp you want, wait for the seek to finish, draw the video into a canvas, then export the canvas with toBlob(). In React, keep the video and canvas in refs and cancel older captures when the source changes.
1. A complete React thumbnail component
This component captures one frame from a local file or URL and returns an object URL that can be displayed in an <img>. It waits for metadata and seek completion, validates dimensions, cleans up listeners, and prevents an earlier request from replacing a newer thumbnail.
import { useEffect, useRef, useState } from "react";
function waitForEvent(target, eventName, signal, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
let timer;
const cleanup = () => {
target.removeEventListener(eventName, onEvent);
signal?.removeEventListener("abort", onAbort);
clearTimeout(timer);
};
const onEvent = () => {
cleanup();
resolve();
};
const onAbort = () => {
cleanup();
reject(new DOMException("Capture cancelled", "AbortError"));
};
target.addEventListener(eventName, onEvent, { once: true });
signal?.addEventListener("abort", onAbort, { once: true });
timer = setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for ${eventName}`));
}, timeoutMs);
});
}
function seekVideo(video, time, signal) {
return new Promise((resolve, reject) => {
let timer;
const cleanup = () => {
video.removeEventListener("seeked", onSeeked);
video.removeEventListener("error", onError);
signal?.removeEventListener("abort", onAbort);
clearTimeout(timer);
};
const finish = () => {
cleanup();
resolve();
};
const onSeeked = () => {
if (video.readyState > 1) finish();
};
const onError = () => {
cleanup();
reject(video.error || new Error("Video seek failed"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Capture cancelled", "AbortError"));
};
video.addEventListener("seeked", onSeeked);
video.addEventListener("error", onError);
signal?.addEventListener("abort", onAbort, { once: true });
timer = setTimeout(() => {
cleanup();
reject(new Error("Timed out waiting for the requested frame"));
}, 30000);
const bounded = Number.isFinite(video.duration)
? Math.min(Math.max(0, time), Math.max(0, video.duration - 0.001))
: Math.max(0, time);
if (Math.abs(video.currentTime - bounded) < 0.001 && video.readyState > 1) {
finish();
return;
}
video.currentTime = bounded;
});
}
function canvasToBlob(canvas, type = "image/jpeg", quality = 0.85) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) resolve(blob);
else reject(new Error("The browser could not encode the thumbnail"));
}, type, quality);
});
}
export function VideoThumbnail({
src,
time = 0,
width,
format = "image/jpeg",
quality = 0.85,
crossOrigin,
alt = "Video thumbnail"
}) {
const videoRef = useRef(null);
const canvasRef = useRef(null);
const [thumbnailUrl, setThumbnailUrl] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
const controller = new AbortController();
let nextUrl = null;
setError(null);
async function capture() {
try {
if (!src) throw new Error("A video source is required");
video.crossOrigin = crossOrigin || "";
video.preload = "auto";
video.src = src;
video.load();
if (video.readyState < 1) {
await waitForEvent(video, "loadedmetadata", controller.signal);
}
if (!video.videoWidth || !video.videoHeight) {
throw new Error("The video has no decoded dimensions");
}
await seekVideo(video, time, controller.signal);
if (video.readyState < 2) {
await waitForEvent(video, "loadeddata", controller.signal);
}
const outputWidth = width || video.videoWidth;
const outputHeight = Math.round(outputWidth * video.videoHeight / video.videoWidth);
if (!outputWidth || !outputHeight) throw new Error("Canvas dimensions must be nonzero");
canvas.width = outputWidth;
canvas.height = outputHeight;
const context = canvas.getContext("2d", { alpha: false });
context.drawImage(video, 0, 0, outputWidth, outputHeight);
const blob = await canvasToBlob(canvas, format, quality);
nextUrl = URL.createObjectURL(blob);
setThumbnailUrl(previous => {
if (previous) URL.revokeObjectURL(previous);
return nextUrl;
});
} catch (captureError) {
if (captureError.name !== "AbortError") setError(captureError.message);
}
}
capture();
return () => {
controller.abort();
video.pause();
video.removeAttribute("src");
video.load();
if (nextUrl) URL.revokeObjectURL(nextUrl);
};
}, [src, time, width, format, quality, crossOrigin]);
return (
<div>
<video ref={videoRef} muted playsInline hidden />
<canvas ref={canvasRef} hidden />
{thumbnailUrl && <img src={thumbnailUrl} alt={alt} />}
{error && <p role="alert">Could not create thumbnail: {error}</p>}
</div>
);
}
drawImage() accepts an HTMLVideoElement, and the frame should be drawn only after media data is available. See MDN’s drawImage(), canvas image tutorial, and video element reference.
2. Use the component after upload
import { useState } from "react";
import { VideoThumbnail } from "./VideoThumbnail";
export default function UploadPreview() {
const [url, setUrl] = useState(null);
function onFileChange(event) {
const file = event.target.files?.[0];
if (file) setUrl(URL.createObjectURL(file));
}
return (
<section>
<input type="file" accept="video/*" onChange={onFileChange} />
{url && <VideoThumbnail src={url} time={1.5} width={640} />}
</section>
);
}
When the selected file changes, revoke the object URL created for the previous file when it is no longer needed. For a simple form, you can keep the current URL in state and revoke it before replacing it or when the component unmounts.
3. Choose the capture timestamp
The time prop is measured in seconds. A value of zero requests the first frame, but some videos have an unusable opening frame. A practical default is a small offset such as 0.5 or 1.5 seconds. Clamp the value to the duration because seeking beyond the end can fail or produce an unexpected frame. Live streams and media with an unknown duration need a different policy: capture only after a frame is available and avoid assuming that duration is finite.
4. Set output size and image format
- Use the intrinsic
videoWidthandvideoHeightwhen preserving source resolution matters. - Set a deliberate output width for cards and grids. Calculate height from the source aspect ratio to avoid stretching.
- Use JPEG for photographic video, WebP when your browser support target allows it, and PNG when lossless output or transparency is required.
- Prefer
toBlob()and an object URL for an image file.toDataURL()creates a larger base64 string and is better suited to small inline data.
const blob = await canvasToBlob(canvas, "image/webp", 0.82);
const previewUrl = URL.createObjectURL(blob);
// Upload blob with FormData, or display previewUrl in an img element.
// Call URL.revokeObjectURL(previewUrl) when the image is discarded.
Canvas serialization is described in MDN’s still-photo example.
5. Cross-origin video and CORS
A same-origin video can normally be drawn and exported. For a video hosted on another origin, set crossOrigin before assigning src, and configure the video server to return an Access-Control-Allow-Origin value that permits your page. The attribute alone does not grant permission. Without server cooperation, drawing the frame taints the canvas and toBlob() or toDataURL() throws a security error.
<video crossOrigin="anonymous" src="https://cdn.example.com/video.mp4" />
Use anonymous when the resource is public and does not require credentials. Use use-credentials only when the server supports credentialed CORS and you intentionally need cookies. See MDN’s CORS canvas guide and crossorigin attribute reference.
6. Optional frame-synchronized capture
For one thumbnail after a seek, the seeked event is usually sufficient. For repeated analysis, animated previews, or work that must align with compositor-presented frames, feature-detect requestVideoFrameCallback(). MDN marks it as Baseline 2024, so older browsers may not provide it.
function waitForPresentedFrame(video) {
return new Promise(resolve => {
if ("requestVideoFrameCallback" in video) {
video.requestVideoFrameCallback(() => resolve());
} else {
requestAnimationFrame(() => resolve());
}
});
}
await seekVideo(video, 4, signal);
await waitForPresentedFrame(video);
context.drawImage(video, 0, 0, canvas.width, canvas.height);
Read the requestVideoFrameCallback() reference before using it in a broad browser support matrix.
7. Handling local files, remote URLs, and uploads
| Source | Recommended approach | Important detail |
|---|---|---|
| Local file input | URL.createObjectURL(file) |
Revoke the object URL when replaced or unmounted. |
| Same-origin URL | Assign directly to video.src |
Ensure the server returns a supported video MIME type. |
| Cross-origin URL | Set crossOrigin before src |
The server must send compatible CORS headers. |
| Protected media | Fetch with authorization, then create a Blob URL | Do not expose private tokens in a public video URL. |
Browser codec support differs. If your users upload varied formats, validate or transcode on the server and provide source formats appropriate to your target browsers.
8. Troubleshooting checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| Black or empty thumbnail | Capture ran before frame data was ready | Wait for loadedmetadata, seek completion, and readyState > 1. |
videoWidth is zero |
Metadata did not load or the source failed | Handle error, verify the URL and MIME type, and wait for loadedmetadata. |
| SecurityError during export | Canvas was tainted by cross-origin media | Set crossOrigin before loading and configure Access-Control-Allow-Origin on the media server. |
| Timeout while seeking | Broken URL, unsupported codec, stalled network, or invalid time | Check video.error, clamp the timestamp, and provide a fallback frame or message. |
| Thumbnail has the wrong proportions | Canvas dimensions were forced independently | Compute height from videoWidth/videoHeight, or deliberately crop with a separate layout. |
| Old thumbnail appears after a new selection | A slower previous capture finished later | Abort the prior operation and ignore stale results, as the component above does. |
| Memory grows after many captures | Object URLs were never revoked | Call URL.revokeObjectURL() when each preview is replaced or removed. |
| Works in one browser but not another | Codec or API support differs | Use compatible source formats and feature-detect optional APIs. |
9. Performance, reliability, and cost
- Decode only what you need: capture at the card’s display size instead of exporting a full 4K frame for a 320-pixel card.
- Avoid unnecessary playback: seeking directly to one timestamp is cheaper than playing the entire file.
- Limit concurrency: generating many thumbnails at once can consume memory and decoder resources; queue work for large libraries.
- Keep UI responsive: generate thumbnails after the upload interaction or move server-side processing to a background job for large files.
- Make failures visible: distinguish unsupported media, network errors, invalid timestamps, CORS failures, and encoding failures so users can retry appropriately.
- Cache results: if the source and timestamp have not changed, reuse the generated thumbnail instead of decoding again.
- Browser cost: this approach uses the user’s device CPU, memory, network, and video decoder. There is no API charge for the browser APIs themselves.
10. Or skip the browser setup
If what you need is a screenshot of a webpage that contains a video, ScreenshotNeo provides a website screenshot API. It is not a replacement for extracting a decoded video frame, but it can capture the rendered page or video landing screen without maintaining browser automation.
See the ScreenshotNeo documentation for the request options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/video -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"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/video' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
- Cookie banners, newsletter popups, and chat widgets are removed before the shot.
- Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; response headers identify the page verdict and billing result.
- An MCP server lets AI agents such as Claude and Cursor take screenshots.
- 1,000 screenshots each month are free with no card, and paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account.
11. FAQ
Can I capture a frame without showing the video?
Yes. Keep the video element hidden or off-screen, but it must load and decode the media before the canvas draw.
Why does setting currentTime immediately fail?
Seeking is asynchronous. Wait for seeked and verify that frame data is available before calling drawImage().
Should I use toDataURL() or toBlob()?
Use toBlob() for uploads and object URLs. Use toDataURL() when a small inline string is specifically required.
Can React Server Components generate the frame?
No. HTML video, canvas, and browser media events require a client component. Generate the thumbnail in the browser or use a server-side media pipeline.
Can this extract thumbnails from DRM-protected video?
Not reliably through a normal canvas workflow. Protected playback and browser security policies may prevent frame extraction; use an authorized server-side pipeline when required.


