Private, performant YouTube embeds for React. Under 5KB gzipped.
Interactive demo with all features and code examples • Updated with each release
YouTube's standard iframe embed can add over 500KB to your page and make dozens of network requests before the user even clicks play. This component fixes that:
- ✅ Tiny – Under 5KB gzipped total (JS + CSS)
- ✅ Fast – Loads only a thumbnail until the user clicks
- ✅ Private – No YouTube cookies or tracking by default
- ✅ SEO-Friendly – Structured data for search engines
- ✅ Accessible – Full keyboard navigation and screen reader support
- ✅ TypeScript – Complete type definitions included
The result? Faster page loads, better privacy, and a superior user experience.
npm install react-lite-youtube-embedimport LiteYouTubeEmbed from 'react-lite-youtube-embed';
import 'react-lite-youtube-embed/dist/LiteYouTubeEmbed.css';
export default function App() {
return (
<LiteYouTubeEmbed
id="dQw4w9WgXcQ"
title="Rick Astley - Never Gonna Give You Up"
/>
);
}That's it. You now have a performant, private YouTube embed.
Privacy-Enhanced Mode is the default. Videos load from youtube-nocookie.com, blocking YouTube cookies and tracking until the user explicitly clicks play.
// Default: Privacy-Enhanced Mode (youtube-nocookie.com)
<LiteYouTubeEmbed id="VIDEO_ID" title="Video Title" />
// Opt into standard YouTube (with cookies)
<LiteYouTubeEmbed id="VIDEO_ID" title="Video Title" cookie={true} />Enable lazy loading for images to defer offscreen thumbnails and boost Lighthouse scores:
<LiteYouTubeEmbed
id="VIDEO_ID"
title="Video Title"
lazyLoad={true}
/>Impact: Defers loading offscreen images, reduces bandwidth, improves mobile performance.
Help search engines discover your videos with structured data:
<LiteYouTubeEmbed
id="VIDEO_ID"
title="Video Title"
seo={{
name: "Full Video Title",
description: "Video description for search engines",
uploadDate: "2024-01-15T08:00:00Z",
duration: "PT3M33S"
}}
/>Includes:
- JSON-LD VideoObject structured data
- Noscript fallback for non-JS users
- Google Rich Results eligibility
Fetch metadata automatically:
./scripts/fetch-youtube-metadata.sh VIDEO_ID --format reactReact to player state changes, playback controls, quality, and errors. All core events are fully tested and verified working!
import LiteYouTubeEmbed, { PlayerState, PlayerError } from 'react-lite-youtube-embed';
<LiteYouTubeEmbed
id="VIDEO_ID"
title="Video Title"
enableJsApi
// Simple handlers (✅ All verified)
onPlay={() => console.log('Started')}
onPause={() => console.log('Paused')}
onEnd={() => console.log('Finished')}
// Advanced handlers (✅ All verified)
onStateChange={(e) => console.log('State:', e.state)}
onPlaybackRateChange={(rate) => console.log('Speed:', rate)}
onPlaybackQualityChange={(quality) => console.log('Quality:', quality)}
onError={(code) => console.error('Error:', code)}
/>Control the player via YouTube's iframe API using refs:
function VideoPlayer() {
const playerRef = useRef(null);
const [isReady, setIsReady] = useState(false);
const handlePause = () => {
playerRef.current?.contentWindow?.postMessage(
'{"event":"command","func":"pauseVideo"}',
'*'
);
};
return (
<>
<LiteYouTubeEmbed
id="VIDEO_ID"
title="Video Title"
ref={playerRef}
enableJsApi
onIframeAdded={() => setIsReady(true)}
/>
{isReady && <button onClick={handlePause}>Pause</button>}
</>
);
}npm install react-lite-youtube-embedyarn add react-lite-youtube-embednpm install @ibrahimcesar/react-lite-youtube-embedSee GITHUB_PACKAGES.md for authentication details.
| Prop | Type | Description |
|---|---|---|
| id | string |
YouTube video or playlist ID |
| title | string |
Video title for iframe (accessibility requirement) |
| Prop | Type | Default | Description |
|---|---|---|---|
| cookie | boolean |
false |
Use standard YouTube (true) or Privacy-Enhanced Mode (false) |
| lazyLoad | boolean |
false |
Enable native lazy loading for thumbnails |
| poster | string |
"hqdefault" |
Thumbnail quality: "default", "mqdefault", "hqdefault", "sddefault", "maxresdefault" |
| params | string |
"" |
Additional URL parameters (e.g., "start=90&end=120") |
| enableJsApi | boolean |
false |
Enable iframe API for programmatic control |
| playlist | boolean |
false |
Set to true if ID is a playlist |
| Prop | Type | Description |
|---|---|---|
| onReady | (event) => void |
Player is ready to receive commands |
| onPlay | () => void |
Video started playing |
| onPause | () => void |
Video was paused |
| onEnd | () => void |
Video finished playing |
| onBuffering | () => void |
Video is buffering |
| onStateChange | (event) => void |
Player state changed |
| onError | (code) => void |
Player encountered an error |
| onPlaybackRateChange | (rate) => void |
Playback speed changed |
| onPlaybackQualityChange | (quality) => void |
Video quality changed |
| Prop | Type | Default | Description |
|---|---|---|---|
| adNetwork | boolean |
false |
Preconnect to Google's ad network |
| alwaysLoadIframe | boolean |
false |
Load iframe immediately (not recommended) |
| announce | string |
"Watch" |
Screen reader announcement text |
| aspectHeight | number |
9 |
Custom aspect ratio height |
| aspectWidth | number |
16 |
Custom aspect ratio width |
| autoplay | boolean |
false |
Autoplay video (requires muted={true}) |
| focusOnLoad | boolean |
false |
Focus iframe when loaded |
| muted | boolean |
false |
Mute video audio |
| noscriptFallback | boolean |
true |
Include noscript tag with YouTube link |
| onIframeAdded | () => void |
- | Callback when iframe loads (use for ref availability) |
| playlistCoverId | string |
- | Video ID for playlist cover image |
| referrerPolicy | string |
"strict-origin-when-cross-origin" |
Iframe referrer policy |
| seo | VideoSEO |
- | SEO metadata object |
| stopOnEnd | boolean |
false |
Stop video when it ends to prevent related videos |
| style | object |
{} |
Custom container styles |
| thumbnail | string |
- | Custom thumbnail image URL |
| webp | boolean |
false |
Use WebP format for thumbnails |
| Prop | Type | Default | Description |
|---|---|---|---|
| wrapperClass | string |
"yt-lite" |
Main wrapper class |
| playerClass | string |
"lty-playbtn" |
Play button class |
| iframeClass | string |
"" |
Iframe element class |
| activeClass | string |
"lyt-activated" |
Class when activated |
| containerElement | string |
"article" |
HTML element for container |
| Prop | Replacement | Note |
|---|---|---|
| noCookie | Use cookie prop |
Inverted logic for clarity |
| rel | Use resourceHint |
Conflicted with YouTube's rel parameter |
→ See all props with examples in the demo
import 'react-lite-youtube-embed/dist/LiteYouTubeEmbed.css';For Next.js, Remix, or other frameworks, copy the CSS to your global stylesheet. See CSS source
Use CSS-in-JS or pass custom class names:
<LiteYouTubeEmbed
id="VIDEO_ID"
title="Video Title"
wrapperClass="my-custom-wrapper"
playerClass="my-custom-button"
activeClass="video-playing"
/>Automatically return to thumbnail when the video ends:
<LiteYouTubeEmbed
id="VIDEO_ID"
title="Video Title"
enableJsApi
stopOnEnd={true}
params="rel=0"
/>function VideoGallery() {
return videos.map(video => (
<LiteYouTubeEmbed
key={video.id}
id={video.id}
title={video.title}
lazyLoad
onPlay={() => analytics.track('video_play', { id: video.id })}
onEnd={() => analytics.track('video_complete', { id: video.id })}
/>
));
}function Playlist() {
const videos = ['video1', 'video2', 'video3'];
const [currentIndex, setCurrentIndex] = useState(0);
return (
<LiteYouTubeEmbed
id={videos[currentIndex]}
title={`Video ${currentIndex + 1}`}
enableJsApi
onEnd={() => {
if (currentIndex < videos.length - 1) {
setCurrentIndex(currentIndex + 1);
}
}}
/>
);
}function CustomPlayer() {
const playerRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
const togglePlayPause = () => {
const command = isPlaying ? 'pauseVideo' : 'playVideo';
playerRef.current?.contentWindow?.postMessage(
`{"event":"command","func":"${command}"}`,
'*'
);
};
return (
<>
<LiteYouTubeEmbed
id="VIDEO_ID"
title="Video Title"
ref={playerRef}
enableJsApi
alwaysLoadIframe
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
/>
<button onClick={togglePlayPause}>
{isPlaying ? 'Pause' : 'Play'}
</button>
</>
);
}Using Next.js 13+ App Router or any server-side rendering framework? See the SSR Guide for:
- Setup instructions
- Troubleshooting common issues
- Best practices
- TypeScript configuration
Full TypeScript support is included. Import types as needed:
import LiteYouTubeEmbed, {
PlayerState,
PlayerError,
VideoSEO,
PlayerReadyEvent,
PlayerStateChangeEvent
} from 'react-lite-youtube-embed';Improve your video discoverability in search engines with structured data and fallback links.
By default, search engine crawlers cannot discover videos embedded with lite embeds because:
- No followable links exist before user interaction
- No structured metadata for search engines to index
- The facade pattern is invisible to crawlers
This component now supports JSON-LD structured data and noscript fallbacks to solve these issues.
<LiteYouTubeEmbed
id="L2vS_050c-M"
title="What's new in Material Design"
seo={{
name: "What's new in Material Design for the web",
description: "Learn about the latest Material Design updates presented at Chrome Dev Summit 2019",
uploadDate: "2019-11-11T08:00:00Z",
duration: "PT15M33S"
}}
/>This generates:
- ✅ JSON-LD structured data following schema.org VideoObject
- ✅ Noscript fallback with direct YouTube link
- ✅ Google rich results eligibility (video carousels, thumbnails in search)
There are several ways to get complete video metadata for SEO:
The fastest way is to visit the YouTube video page and get the info directly:
- Open the video: Visit
https://www.youtube.com/watch?v=VIDEO_ID - Get the duration: Look at the video player (e.g., "4:23")
- Convert duration to ISO 8601:
- 4:23 →
PT4M23S(4 minutes 23 seconds) - 1:30:45 →
PT1H30M45S(1 hour 30 minutes 45 seconds) - 15:00 →
PT15M(15 minutes)
- 4:23 →
- Get upload date: Shown below video title (e.g., "Dec 5, 2018")
- Convert date to ISO 8601 UTC format:
- Format:
YYYY-MM-DDTHH:MM:SSZ(theZindicates UTC timezone) - Dec 5, 2018 →
2018-12-05T08:00:00Z - Jun 5, 2025 →
2025-06-05T08:00:00Z - Note: The specific time (08:00:00) is not critical for SEO - the date is what matters. You can use
00:00:00Zor any time if you don't know the exact upload time.
- Format:
Use the included script to fetch title and thumbnail:
# Make the script executable (first time only)
chmod +x scripts/fetch-youtube-metadata.sh
# Fetch metadata in JSON format
./scripts/fetch-youtube-metadata.sh dQw4w9WgXcQ
# Get ready-to-use React component code
./scripts/fetch-youtube-metadata.sh dQw4w9WgXcQ --format reactNote: This script uses YouTube's oEmbed API which only provides basic info (title, author, thumbnail). You'll need to add uploadDate and duration manually.
Requirements: curl and jq must be installed.
For complete automation, use YouTube's official API:
- Get a free API key: https://console.cloud.google.com/apis/credentials
- Make API request:
curl "https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=VIDEO_ID&key=YOUR_API_KEY" - Extract values from response:
snippet.publishedAt→ uploadDatecontentDetails.duration→ duration (already in ISO 8601 format!)snippet.description→ description
API Limits: Free tier provides 10,000 quota units/day (sufficient for most use cases)
interface VideoSEO {
name?: string; // Video title (falls back to title prop)
description?: string; // Video description (50-160 chars recommended)
uploadDate?: string; // ISO 8601 date (e.g., "2024-01-15T08:00:00Z")
duration?: string; // ISO 8601 duration (e.g., "PT3M33S")
thumbnailUrl?: string; // Custom thumbnail (auto-generated if omitted)
contentUrl?: string; // YouTube watch URL (auto-generated)
embedUrl?: string; // Embed URL (auto-generated)
}ISO 8601 duration format: PT#H#M#S
"PT3M33S"- 3 minutes 33 seconds"PT15M"- 15 minutes"PT1H30M"- 1 hour 30 minutes"PT2H15M30S"- 2 hours 15 minutes 30 seconds
Test your structured data:
Get real-time notifications when the YouTube player changes state, encounters errors, or when users interact with playback controls.
CRITICAL: Events require both of these:
enableJsApi={true}- Enables YouTube's JavaScript APIref={yourRef}- A React ref MUST be passed to the component (used to communicate with YouTube's iframe)
Without a ref, events will NOT work!
This event system relies on YouTube's internal postMessage API, which is not officially documented by Google and may change at any time without prior notice. While we strive to keep the implementation up-to-date, YouTube could modify their iframe communication protocol in future updates, potentially breaking event functionality.
Recommendations:
- Test events thoroughly in your production environment
- Have fallback behavior if events stop working
- Monitor for breaking changes when updating YouTube embed URLs
- Consider this when building critical features that depend on events
| Event | Status | Notes |
|---|---|---|
onIframeAdded |
✅ Verified Working | Fires when iframe is added to DOM |
onReady |
✅ Verified Working | Fires when YouTube player initializes |
onStateChange |
✅ Verified Working | Fires on all state changes |
onPlay |
✅ Verified Working | Convenience wrapper for PLAYING state |
onPause |
✅ Verified Working | Convenience wrapper for PAUSED state |
onEnd |
✅ Verified Working | Convenience wrapper for ENDED state |
onBuffering |
✅ Verified Working | Convenience wrapper for BUFFERING state |
onPlaybackRateChange |
✅ Verified Working | Fires when speed changes (use ⚙️ settings) |
onPlaybackQualityChange |
✅ Verified Working | Fires when quality changes (use ⚙️ settings) |
onError |
Should work but not confirmed with invalid video |
Technical Note: YouTube sends state changes, playback rate, and quality changes via infoDelivery postMessage events. This library handles this automatically.
import { useRef } from 'react';
import LiteYouTubeEmbed, { PlayerState, PlayerError } from 'react-lite-youtube-embed';
function App() {
const ytRef = useRef(null); // ⚠️ REQUIRED for events to work!
return (
<LiteYouTubeEmbed
id="dQw4w9WgXcQ"
title="Rick Astley - Never Gonna Give You Up"
ref={ytRef} // ⚠️ CRITICAL: Must pass ref
enableJsApi // ⚠️ REQUIRED for events
// Simple convenience handlers
onPlay={() => console.log('Video started playing')}
onPause={() => console.log('Video paused')}
onEnd={() => console.log('Video ended')}
// Advanced state change handler
onStateChange={(event) => {
console.log('State:', event.state);
console.log('Current time:', event.currentTime);
}}
// Advanced playback handlers
onPlaybackRateChange={(rate) => console.log('Speed:', rate)}
onPlaybackQualityChange={(quality) => console.log('Quality:', quality)}
// Error handling
onError={(errorCode) => {
if (errorCode === PlayerError.VIDEO_NOT_FOUND) {
alert('Video not available');
}
}}
/>
);
}onReady(event: PlayerReadyEvent)
Fires when the player is loaded and ready to receive commands.
onReady={(event) => {
console.log(`Player ready for: ${event.videoId}`);
}}onStateChange(event: PlayerStateChangeEvent)
Fires whenever the player's state changes.
onStateChange={(event) => {
switch (event.state) {
case PlayerState.PLAYING:
console.log('Playing at', event.currentTime, 'seconds');
break;
case PlayerState.PAUSED:
console.log('Paused');
break;
case PlayerState.ENDED:
console.log('Video finished');
break;
}
}}PlayerState values:
PlayerState.UNSTARTED(-1)PlayerState.ENDED(0)PlayerState.PLAYING(1)PlayerState.PAUSED(2)PlayerState.BUFFERING(3)PlayerState.CUED(5)
onError(errorCode: PlayerError)
Fires when the player encounters an error.
onError={(code) => {
switch (code) {
case PlayerError.INVALID_PARAM:
console.error('Invalid video parameter');
break;
case PlayerError.VIDEO_NOT_FOUND:
console.error('Video not found or removed');
break;
case PlayerError.NOT_EMBEDDABLE:
console.error('Video cannot be embedded');
break;
}
}}PlayerError codes:
PlayerError.INVALID_PARAM(2)PlayerError.HTML5_ERROR(5)PlayerError.VIDEO_NOT_FOUND(100)PlayerError.NOT_EMBEDDABLE(101)PlayerError.NOT_EMBEDDABLE_DISGUISED(150)
Simple wrappers for common use cases:
<LiteYouTubeEmbed
id="VIDEO_ID"
title="Video Title"
enableJsApi
onPlay={() => analytics.track('video_play')}
onPause={() => analytics.track('video_pause')}
onEnd={() => loadNextVideo()}
onBuffering={() => showLoadingSpinner()}
/>onPlaybackRateChange(playbackRate: number)
Fires when playback speed changes. To test this event, click the ⚙️ settings button in the YouTube player and change the playback speed.
Common values: 0.25, 0.5, 1, 1.5, 2.
onPlaybackRateChange={(rate) => {
console.log(`Playback speed: ${rate}x`);
// Example: Save user's preferred playback speed
localStorage.setItem('preferredSpeed', rate.toString());
}}onPlaybackQualityChange(quality: string)
Fires when video quality changes (either automatically or manually). To test this event manually, click the ⚙️ settings button in the YouTube player and change the video quality.
Common values: "small" (240p), "medium" (360p), "large" (480p), "hd720", "hd1080", "hd1440", "hd2160" (4K).
onPlaybackQualityChange={(quality) => {
console.log(`Quality changed to: ${quality}`);
// Example: Track quality changes for analytics
analytics.track('video_quality_change', {
quality,
timestamp: Date.now()
});
}}The live demo includes an Event Status Tracker that visually shows which events have fired during your interaction with the video. Each event displays:
- ⏸️ Gray background - Event has not fired yet
- ✅ Green background - Event has fired at least once
This interactive tracker helps you:
- Understand when different events fire
- Test your event handlers
- Learn about YouTube's event system
- Verify events are working correctly
Try it yourself:
- Visit the live demo
- Scroll to the Events section
- Play the video and watch events light up
- Change playback speed and quality via the ⚙️ settings button
function VideoWithAnalytics() {
const [playStartTime, setPlayStartTime] = useState(null);
return (
<LiteYouTubeEmbed
id="dQw4w9WgXcQ"
title="My Video"
enableJsApi
onReady={() => analytics.track('video_ready')}
onPlay={() => {
setPlayStartTime(Date.now());
analytics.track('video_play');
}}
onEnd={() => {
const watchTime = Date.now() - playStartTime;
analytics.track('video_complete', { watchTime });
}}
onError={(code) => analytics.track('video_error', { errorCode: code })}
/>
);
}function VideoPlaylist() {
const videos = ['dQw4w9WgXcQ', 'abc123def', 'xyz789uvw'];
const [currentIndex, setCurrentIndex] = useState(0);
return (
<LiteYouTubeEmbed
id={videos[currentIndex]}
title={`Video ${currentIndex + 1}`}
enableJsApi
onEnd={() => {
if (currentIndex < videos.length - 1) {
setCurrentIndex(currentIndex + 1);
}
}}
onError={() => {
// Skip to next video on error
if (currentIndex < videos.length - 1) {
setCurrentIndex(currentIndex + 1);
}
}}
/>
);
}enableJsApi={true}
onIframeAdded callback to know when ready, or use alwaysLoadIframe={true} (not recommended for privacy/performance).
You can programmatically control the YouTube player via YouTube's IFrame Player API using refs and postMessage.
⚠️ Important: This requiresenableJsApi={true}. The ref is only available after the user clicks the poster (useonIframeAddedcallback to know when ready).
function VideoPlayer() {
const ytRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
return (
<div>
<button
onClick={() => {
setIsPlaying((oldState) => !oldState);
ytRef.current?.contentWindow?.postMessage(
`{"event": "command", "func": "${isPlaying ? "pauseVideo" : "playVideo"}"}`,
"*",
);
}}
>
{isPlaying ? 'Pause' : 'Play'}
</button>
<LiteYouTubeEmbed
title="My Video"
id="L2vS_050c-M"
ref={ytRef}
enableJsApi
alwaysLoadIframe
/>
</div>
);
}Important: The ref only becomes available after the user clicks the poster.
const videoRef = useRef(null);
const handleIframeAdded = () => {
console.log("Iframe loaded and ready!");
if (videoRef.current) {
videoRef.current.contentWindow?.postMessage(
'{"event":"command","func":"playVideo"}',
'*'
);
}
};
return (
<LiteYouTubeEmbed
id="VIDEO_ID"
title="My Video"
ref={videoRef}
onIframeAdded={handleIframeAdded}
enableJsApi
/>
);// This won't work - iframe doesn't exist yet!
useEffect(() => {
if (videoRef.current) {
console.log("This never runs");
}
}, []); // Empty deps - runs before iframe existsShort answer: No, this is a YouTube platform limitation.
What changed: In September 2018, YouTube changed the rel=0 parameter to only limit related videos to the same channel, not hide them completely.
Best solution: Use the built-in stopOnEnd prop:
<LiteYouTubeEmbed
id="VIDEO_ID"
title="Video Title"
enableJsApi
stopOnEnd={true}
params="rel=0"
/>This automatically stops the video when it ends and returns to the thumbnail view, preventing related videos from showing.
→ See more solutions in the docs
See the SSR Guide for detailed Next.js setup instructions and troubleshooting.
Yes! Set playlist={true} and optionally provide a playlistCoverId:
<LiteYouTubeEmbed
id="PLAYLIST_ID"
title="My Playlist"
playlist={true}
playlistCoverId="VIDEO_ID"
/>Yes! Use the thumbnail prop to provide a custom image URL:
<LiteYouTubeEmbed
id="VIDEO_ID"
title="Video Title"
thumbnail="https://example.com/custom-thumbnail.jpg"
/>Or choose a different YouTube thumbnail quality with poster:
<LiteYouTubeEmbed
id="VIDEO_ID"
title="Video Title"
poster="maxresdefault"
/>We welcome contributions! See CONTRIBUTING.md for guidelines.
# Install dependencies
npm install
# Run tests
npm test
# Run tests in watch mode
npm run test:watch
# Build
npm run build
# Lint
npm run lint
# Format
npm run formatThis package includes:
- ✅ SLSA Build Level 3 Provenance - Cryptographically signed build provenance
- ✅ CodeQL Analysis - Automated security scanning
- ✅ Dependency Audits - Regular security updates
Verify package authenticity:
npm audit signaturesSee .github/SLSA.md for more details.
MIT © Ibrahim Cesar
See LICENSE for full details.
- Paul Irish (@paulirish) - Original Lite YouTube Embed
- Addy Osmani (@addyosmani) - Adaptive Loading concepts
Made with 🧩 in Brazil 🇧🇷
