Have you ever clicked on a video and a black rectangle is staring back at you? No thumbnail, no controls, just emptiness. That’s the default HTML5 <video> experience, and it makes your content feel unfinished before the user even presses play.
In this guide, we’ll fix that. You’ll build a full course catalog where every card shows a smart-cropped thumbnail derived directly from the video and opens a professional branded player with poster frames, scrub previews, and adaptive quality all powered by Cloudinary. By the end it’ll look like it belongs on a real learning platform.
- Live demo: react-course-preview-player.vercel.app
- Full source: github.com/musebe/react-course-preview-player
| Tool | Why |
|---|---|
| React 19 + TypeScript | Modern, type-safe component model |
| Vite | Instant dev server, native ESM, great code-splitting |
| Tailwind CSS v4 | Utility-first styling with no separate CSS files |
| shadcn/ui (base-nova) | Accessible components built on @base-ui/react |
| Cloudinary Video Player SDK | Branded player with thumbnails, quality selector, seek previews |
| Cloudinary URL-Gen SDK | Build transformation URLs in TypeScript instead of hand-writing strings |
Start from the official Vite React TypeScript template, then add the packages this project needs.
npm create vite@latest react-course-preview-player -- --template react-ts
cd react-course-preview-player
npm install
Code language: CSS (css)
npm install cloudinary-video-player @cloudinary/url-gen @cloudinary/react
npm install tailwindcss @tailwindcss/vite
npx shadcn@latest init
Code language: CSS (css)
cloudinary-video-player is the main SDK that powers the player UI. @cloudinary/url-gen gives us a typed API to build transformation URLs for thumbnails without writing raw strings by hand. @cloudinary/react provides the React wrapper for image delivery.
Every Cloudinary account has a Cloud Name it appears in your dashboard and in every asset URL:
https://res.cloudinary.com/YOUR-CLOUD-NAME/video/upload/...
Rather than hardcoding it, you’ll read it from an environment variable. This means the same codebase can point at a demo account in development and a production account in deployment without changing a single line of code.
Create a .env file at the project root:
VITE_CLOUDINARY_CLOUD_NAME=your-cloud-name
The VITE_ prefix is required Vite only exposes variables with that prefix to the browser bundle. Variables without it stay server-side only. Override it locally with .env.local (which is gitignored, so credentials never get committed):
# .env.local your real account, never committed
VITE_CLOUDINARY_CLOUD_NAME=demo-article-projects
Code language: PHP (php)
Tell TypeScript that this variable exists by adding types to src/vite-env.d.ts:
interface ImportMetaEnv {
readonly VITE_CLOUDINARY_CLOUD_NAME: string;
}
Code language: PHP (php)
Without this, TypeScript would complain that import.meta.env.VITE_CLOUDINARY_CLOUD_NAME is an unknown property. The readonly modifier also prevents you from accidentally reassigning it.
Now create a single shared Cloudinary instance in src/lib/cloudinary.ts. Every component that needs to talk to Cloudinary imports from here one place to update if anything changes:
import { Cloudinary } from '@cloudinary/url-gen';
export const CLOUD_NAME = import.meta.env.VITE_CLOUDINARY_CLOUD_NAME;
export const cld = new Cloudinary({
cloud: { cloudName: CLOUD_NAME },
url: { secure: true }, // always HTTPS
});
Code language: JavaScript (javascript)
secure: true ensures every generated URL uses HTTPS. Without it Cloudinary would default to HTTP in some contexts, which triggers mixed-content warnings in browsers.
View the full file here: src/lib/cloudinary.ts
Before building any UI, you’ll define the shape of our data. A typed Course interface means TypeScript will catch mistakes at compile time if you forget a required field or pass the wrong type anywhere, you know before the browser ever runs the code.
export interface Chapter {
title: string;
startTime: number; // seconds from start
duration: number;
}
export interface Course {
id: string;
title: string;
instructor: string;
videoPublicId: string; // the path in your Cloudinary media library
videoVersion?: string; // explained in Step 6 needed for certain path formats
duration: number;
level: 'Beginner' | 'Intermediate' | 'Advanced';
category: string;
rating: number;
students: number;
description: string;
chapters: Chapter[];
tags: string[];
}
Code language: PHP (php)
videoPublicId is the key field it is the identifier Cloudinary assigns to every asset. You can find it in the Media Library. It often looks like folder/subfolder/filename. This single string is all you’ll need to derive the thumbnail, the poster frame, and to stream the video.
videoVersion is optional and only needed for certain public ID formats. We’ll explain exactly why in Step 6.
The actual course data lives in src/data/courses.ts. Here is one entry to illustrate the shape:
{
id: 'react-fundamentals',
title: ',
instructor: 'Sarah Chen',
videoPublicId: 'cld_video_upload/tzvekhwhsll7c8sjb40v',
videoVersion: '1779134980',
duration: 7320,
level: 'Beginner',
category: 'Frontend',
rating: 4.9,
students: 24_831,
chapters: [
{ title: 'Introduction & Setup', startTime: 0, duration: 300 },
{ title: 'JSX & Components', startTime: 300, duration: 480 },
],
tags: ['React', 'JavaScript', 'Hooks'],
}
Code language: PHP (php)
View the full file here: src/data/courses.ts
This is the first place Cloudinary genuinely changes your workflow. Normally you’d have to generate thumbnails yourself, export a frame in video editing software, upload it separately, and manage two assets per video (forever). With Cloudinary, you don’t have to.
Cloudinary has a little-known trick: Append .jpg to a video URL and it returns a single frame as an image. Add transformation parameters before the filename to control exactly which frame you get and how it’s shaped.
src/components/VideoThumbnail.tsx:
export function VideoThumbnail({ publicId, version, alt, className }) {
const vSeg = version ? `v${version}/` : '';
const src =
`https://res.cloudinary.com/${CLOUD_NAME}/video/upload/` +
`so_2,c_fill,w_640,h_360,g_auto/f_jpg,q_auto/` +
`${vSeg}${publicId}.jpg`;
return (
<img
src={src}
alt={alt}
width={640}
height={360}
loading="lazy" // browser skips this until it's near the viewport
decoding="async" // doesn't block the main thread while decoding
className={className}
/>
);
}
Code language: JavaScript (javascript)
Let’s break down the transformation string so_2,c_fill,w_640,h_360,g_auto/f_jpg,q_auto:
| Transformation | What It Does |
|---|---|
so_2 |
Grab the frame at exactly 2 seconds. This skips opening slates, countdown screens, or blank intros that often appear in the first second. |
c_fill |
Fill the exact dimensions requested no letterboxing, no empty space. |
w_640,h_360 |
Output at 640×360 (16:9). |
g_auto |
AI smart-crop Cloudinary detects the main subject and keeps it centred in the frame, even when cropping aggressively. |
f_jpg |
Output as JPEG the right format for photographic video frames. |
q_auto |
Automatically picks the highest quality that still compresses well. Cloudinary analyses the image content and chooses the setting you never manually tune quality again. |
The / between groups creates a chained transformation Cloudinary processes them in order. First it crops the frame, then it applies format and quality settings to the output of that crop.
The result: one URL, zero extra uploads, and every card thumbnail is always in sync with the video. When Cloudinary changes the video, the thumbnail updates automatically on the next request.
View the full file here: src/components/VideoThumbnail.tsx
Each course card uses the VideoThumbnail component as its background image and adds a play button overlay that animates in on hover. The entire card is a clickable button accessible via keyboard as well as mouse.
src/components/CourseCard.tsx:
<Card
role="button"
tabIndex={0}
onClick={() => onPlay(course)}
onKeyDown={e => e.key === 'Enter' && onPlay(course)}
className="group overflow-hidden cursor-pointer hover:-translate-y-1 hover:shadow-lg transition-all"
>
<div className="relative overflow-hidden" style={{ aspectRatio: '16/9' }}>
<VideoThumbnail
publicId={course.videoPublicId}
version={course.videoVersion}
alt={`${course.title} thumbnail`}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/>
{/* Overlay appears on hover the `group` class on the card makes this possible */}
<div className="absolute inset-0 bg-background/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<div className="size-14 rounded-full bg-primary flex items-center justify-center shadow-xl">
<PlayCircle className="size-7 text-primary-foreground" />
</div>
</div>
</div>
</Card>
Code language: HTML, XML (xml)
The group class on the parent card is a Tailwind feature, which lets child elements respond to the parent’s hover state with group-hover:. When the card is hovered, the overlay fades in (opacity-0 → opacity-100) and the thumbnail zooms slightly (scale-[1.04]). These two small CSS transitions together create the polished “interactive card” feel without a single line of JavaScript.
View the full file here: src/components/CourseCard.tsx
When a user clicks a card, a dialog opens containing the full Cloudinary Video Player. This is where a plain black box becomes a branded, professional experience.
Here’s something that trips up most React developers working with dialogs. The dialog renders into a portal and React mounts it as a child of document.body, completely outside the component tree that holds your useEffect.
This creates a timing problem: When useEffect fires after the component mounts, the <div> inside the dialog doesn’t exist yet because the portal hasn’t attached to the DOM. A plain useRef reads as null every single time, and the player never initialises.
The fix is a callback ref instead of a ref object, you’ll pass React a function. React calls that function with the DOM element the instant it is attached, and with null when it’s removed. Wrapping it in useState means the state update triggers a re-render, which reruns useEffect with a real, live DOM element.
src/components/CoursePlayer.tsx:
// useState gives us a way to re-trigger useEffect when the element attaches
const [container, setContainer] = useState<HTMLDivElement | null>(null);
// useCallback with [] ensures setContainer is stable across renders
const containerRef = useCallback((node: HTMLDivElement | null) => {
setContainer(node);
}, []);
useEffect(() => {
if (!container) return; // null on the first render safely skip
// One requestAnimationFrame lets the dialog's entry animation
// complete before the SDK manipulates the DOM
const rafId = requestAnimationFrame(() => {
// Create the <video> element imperatively and hand it to the SDK
const el = document.createElement('video');
el.className = 'cld-video-player cld-fluid';
container.appendChild(el);
const player = videoPlayer(el, {
cloudName: CLOUD_NAME,
controls: true,
fluid: true, // fills whatever container it is placed in
seekThumbnails: true, // frame previews on scrub
qualitySelector: true, // adaptive bitrate
skin: 'dark',
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
});
// Tell the player which video to load just the publicId, no URL building needed
player.source({ publicId: course.videoPublicId, info: { title: course.title } });
// Set the poster image directly on the <video> element
el.poster = posterSrc;
// Update React state so the loading skeleton hides when the player is ready
player.on('ready', () => setPlayerState('ready'));
player.on('error', () => setPlayerState('error'));
});
// Cleanup: runs when the dialog closes or the course changes
return () => {
cancelAnimationFrame(rafId);
player.dispose(); // releases SDK resources
el.remove(); // removes the <video> element from the DOM
};
}, [container, course.id]); // re-runs whenever the container attaches or the course changes
// The div that receives the callback ref
<div ref={containerRef} className="w-full h-full" />
Code language: JavaScript (javascript)
Setting seekThumbnails: true tells the Cloudinary player to request a storyboard sprite — a single image that contains many small frames from across the video timeline. When the user hovers over the progress bar, the player crops the right section of the sprite and shows it as a tooltip preview.
This is a feature users expect from premium platforms like YouTube and Netflix. With Cloudinary you get it with one boolean.
The poster is the still image shown before the user presses play. You’ll build its URL with the same version-anchored pattern as the card thumbnail, but at a larger size (w_1280,h_720) since it fills the full player area:
const vSeg = course.videoVersion ? `v${course.videoVersion}/` : '';
const posterSrc =
`https://res.cloudinary.com/${CLOUD_NAME}/video/upload/` +
`so_2,c_fill,w_1280,h_720,g_auto/f_jpg,q_auto/` +
`${vSeg}${course.videoPublicId}.jpg`;
Code language: JavaScript (javascript)
Set this directly on el.poster after the player initialises. This bypasses the SDK’s own poster mechanism, which would prepend so_2 without the version anchor and break the URL for certain public ID formats (explained next).
Some Cloudinary public IDs start with a path that begins with a string like cld_video_upload/. Cloudinary’s URL parser treats everything between /upload/ and the version or public ID as transformation parameters. When it sees cld at the start of what should be the public ID, it reads cld as a transformation name and video_upload as its value and rejects the request with a 400 error.
# ❌ Parser misreads "cld" as a transformation param → 400
/video/upload/so_2/cld_video_upload/my-video.jpg
# ✅ Version number (v + digits) acts as a hard boundary
# Parser sees: transforms → version → publicId
/video/upload/so_2,c_fill,.../v1779134980/cld_video_upload/my-video.jpg
The same problem hits the player’s default adaptive streaming URL. By default the player tries to load an HLS manifest at a URL like sp_auto/cld_video_upload/... and gets a 400 for the same reason. The fix is to tell the player to use MP4-only delivery for these courses, which skips HLS entirely:
player.source({
publicId: course.videoPublicId,
// Without this, the player tries HLS first → URL parsing bug → 400 → black screen
sourceTypes: ['mp4'],
});
Code language: CSS (css)
You’ll also disable seekThumbnails and qualitySelector for these courses because both generate URLs with transforms prepended to the public ID. Four of the six courses get the full feature set; the two with cld_video_upload/ paths get a clean MP4 stream. This is the only reason the videoVersion field exists on the Course type; it’s the flag that tells the player and thumbnail components to apply the version anchor.
View the full file here: src/components/CoursePlayer.tsx
The Cloudinary Video Player SDK is approximately 500 KB of JavaScript. If you import it at the top of your app, every visitor downloads that bundle on the initial page load even if they never click a single course card.
React.lazy() solves this by code-splitting CoursePlayer into its own separate chunk. Vite sees the dynamic import and creates a separate file during the build. That file is only requested when the component is first rendered which in our case means when a user actually clicks a course card.
// The SDK bundle is excluded from the initial page load entirely
const CoursePlayer = lazy(() =>
import('@/components/CoursePlayer').then(m => ({ default: m.CoursePlayer }))
);
// Suspense handles the brief moment between click and chunk download
// fallback={null} because the CoursePlayer has its own skeleton loading state
{activeCourse && (
<Suspense fallback={null}>
<CoursePlayer course={activeCourse} onClose={() => setActiveCourse(null)} />
</Suspense>
)}
Code language: JavaScript (javascript)
The .then(m => ({ default: m.CoursePlayer })) is needed because CoursePlayer is a named export, not a default export. React.lazy() requires a default export, so we reshape the module on the fly.
To verify this is working: Open the Network tab in DevTools and hard-refresh the page. You’ll see no Cloudinary SDK chunk on load. Click a course card and a new chunk appears in the network waterfall. That’s lazy loading working exactly as intended your initial bundle stays lean and fast.
View the full file here: src/App.tsx
Two CSS lines prevent horizontal scroll on mobile a common issue when mixing fixed-width elements with fluid layouts:
/* src/index.css */
html { overflow-x: clip; } /* clips overflow without breaking position:fixed elements */
body { overflow-x: hidden; } /* belt and suspenders */
Code language: CSS (css)
overflow-x: clip is important here overflow-x: hidden on html breaks position: fixed elements like modals and sticky headers. clip hides the overflow without creating a new scroll container, so fixed elements continue to work correctly.
The feature strip converts from a CSS grid on desktop to a horizontal scroll carousel on mobile, without any JavaScript:
/* Mobile: scrollable horizontal carousel */
.feature-strip {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
/* Desktop: back to a normal grid */
@media (min-width: 640px) {
.feature-strip {
display: grid;
grid-template-columns: repeat(3, 1fr);
overflow-x: visible;
}
}
Code language: CSS (css)
| Feature | What It Does | Cloudinary Docs |
|---|---|---|
| Video Player SDK | Full branded player, not plain HTML5 | cloudinary_video_player |
so_2 frame extraction |
Poster and thumbnail from 2-second mark | Video manipulation |
c_fill, g_auto |
AI smart-crop to 16:9, subject-aware | Auto gravity |
q_auto |
Optimal quality chosen automatically | Image optimization |
seekThumbnails |
Frame previews on progress bar scrub | Player customization |
qualitySelector |
Adaptive bitrate based on connection | Player API reference |
Starting from a blank Vite project, you now have a:
- Course catalog where thumbnails are generated automatically from the video no design work, no separate image uploads, no synchronization headaches.
- Professional branded video player with seek thumbnails, quality selection, and playback speed control.
- Poster frame that matches the thumbnail — same frame, same smart-crop — automatically.
- Player that adds zero bytes to the initial page load, and the 500 KB SDK only downloads when a user actually opens a video.
- Layout that works on every screen size with just CSS.
The best part: point it at any video already in your Cloudinary account and everything thumbnail, poster, seek thumbnails is generated on the fly. No extra steps, no extra tools.
- Live demo: react-course-preview-player.vercel.app
- Full source code: github.com/musebe/react-course-preview-player
Clone it, swap in your cloud name and video public IDs, and you have a production-ready course preview player running in minutes. Ready to start building with Cloudinary? Sign up for a free account today.
Why is a standard HTML5 video element insufficient for modern e-learning platforms?
Standard HTML5 video elements often present users with a blank black box on load. They require manual creation and management of separate image files for custom posters and video seek previews, which drastically complicates asset management.How does Cloudinary generate video thumbnails and poster frames on demand?
Cloudinary extracts frames directly from video assets by altering the file extension to an image format like JPG. Adding URL transformation parameters allows you to specify an exact timestamp, size, and smart-cropping rules on the fly.How do you resolve timing issues when initializing a third-party player inside a React dialog portal?
You can use a React callback ref instead of a standard useRef. The callback function is invoked precisely when the DOM element mounts or unmounts, providing a stable container instance for secure player initialization.What are seek thumbnails and how are they implemented in Cloudinary?
Seek thumbnails are hover previews shown when scrubbing the progress bar. Cloudinary automatically generates a single storyboard sprite sheet containing timed video frames, which the player displays dynamically as the user hovers over the timeline.How do you prevent a large video player SDK from impacting initial web page loading performance?
You can dynamically import the player component using React lazy loading and Suspense. This splits the heavy SDK into a separate bundle that the browser only downloads when a user clicks to play a video.