a YouTube video as a picture until it is asked for, then its player in place
npx shadcn add @ag/youtubeAlready installed? Preview an update
npx shadcn add @ag/youtube --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-13
- The thumbnail arrives through
Imagewith YouTube's sizes as its chain (maxres, then hq, the 120 px grey YouTube sends for a maxres it never made passed over byminWidth): no frame of grey, no pop, and a thumbnail cached before hydration is never left blank. Installingyoutubenow bringsimage.
the demo above, verbatim
"use client"
import { Sample } from "@/app/samples"
import { YouTube } from "@/registry/base-nova/ui/youtube"
// #region facts
// `mise youtube https://youtu.be/aqz-KE-bpKQ`, kept beside the page.
const BUNNY = {
id: "aqz-KE-bpKQ",
short: false,
title: "Big Buck Bunny 60fps 4K - Official Blender Foundation Short Film",
channel: "Blender",
}
// #endregion
export default function YouTubeDemo() {
return (
<div className="space-y-8">
<Sample
name="facts"
glance
title="a video, with its facts"
description="Only the thumbnail loads; pressing it (or Enter) puts YouTube's player in its place, playing."
with="facts"
>
<YouTube video={BUNNY} className="max-w-[42rem]" />
</Sample>
<Sample
name="link"
title="from a link alone"
description="Any link YouTube hands out, a start time kept: the picture and the play mark, no words."
>
<YouTube
video="https://www.youtube.com/watch?v=aqz-KE-bpKQ&t=1m5s"
className="max-w-[42rem]"
/>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A YOUTUBE VIDEO ON THE PAGE, as a picture until it is asked for: the video's own
// thumbnail, its title and channel, a play mark; pressed (or Enter, Space), YouTube's
// player replaces it in place and plays. Until then nothing of YouTube's loads but one
// image, so a page of ten videos costs ten pictures, not ten players (the facade every
// fast site uses), and nothing tracks a reader who never presses (the player is
// youtube-nocookie's). A Short stands upright.
//
// The facts are data (`lib/youtube-data`: the id from any link YouTube hands out, the
// title and channel from its oEmbed at authoring time); without a title the button is
// named for the video and the picture carries no words.
import { Play } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
type YouTubeRef,
youtubeOf,
} from "@/registry/base-nova/lib/youtube-data"
import { Image } from "@/registry/base-nova/ui/image"
export interface YouTubeProps {
/** the video: a link YouTube hands out, or its facts */
video: string | (YouTubeRef & { title?: string; channel?: string })
className?: string
}
/** The largest thumbnail YouTube keeps for every video, then the one it always has. */
const thumbnails = (id: string) => [
`https://i.ytimg.com/vi/${id}/maxresdefault.jpg`,
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
]
export function YouTube({ video, className }: YouTubeProps) {
const facts: (YouTubeRef & { title?: string; channel?: string }) | null =
typeof video === "string" ? youtubeOf(video) : video
if (!facts) throw new Error(`youtube: ${String(video)} names no video`)
const { title, channel } = facts
const [playing, setPlaying] = React.useState(false)
const [first, ...rest] = thumbnails(facts.id) as [string, ...string[]]
const params = new URLSearchParams({
autoplay: "1",
rel: "0",
playsinline: "1",
...(facts.start !== undefined && { start: String(facts.start) }),
})
return (
<div
data-slot="youtube"
data-short={facts.short || undefined}
className={cn(
"relative w-full overflow-hidden rounded-xl bg-black aspect-video data-short:mx-auto data-short:aspect-[9/16] data-short:max-w-[22rem]",
className,
)}
>
{playing ? (
<iframe
title={title ?? "YouTube video"}
src={`https://www.youtube-nocookie.com/embed/${facts.id}?${params}`}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
allowFullScreen
className="absolute inset-0 size-full border-0"
/>
) : (
<button
type="button"
aria-label={title ? `play ${title}` : "play the video"}
onClick={() => setPlaying(true)}
className="group/yt absolute inset-0 size-full cursor-pointer outline-none"
>
{/* YouTube's own thumbnail, largest first: maxres is not made for every
video, and the 120 px grey it answers with instead is told apart by
its width (`minWidth`), so the always-there one stands in. */}
<Image
src={first}
fallbacks={rest}
minWidth={121}
alt=""
loading="lazy"
decoding="async"
className="absolute inset-0 size-full object-cover group-hover/yt:scale-[1.015]"
/>
{(title || channel) && (
<span className="absolute inset-x-0 top-0 flex flex-col items-start gap-0.5 bg-linear-to-b from-black/70 to-transparent px-4 pt-3 pb-8 text-left">
{title && (
<span className="line-clamp-2 text-[0.95em] font-medium text-white/95">
{title}
</span>
)}
{channel && (
<span className="text-[0.8em] text-white/65">{channel}</span>
)}
</span>
)}
<span className="absolute top-1/2 left-1/2 grid size-14 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-black/55 text-white ring-1 ring-white/15 backdrop-blur-md transition-[background-color,scale] duration-200 group-hover/yt:scale-105 group-hover/yt:bg-black/70 group-focus-visible/yt:ring-2 group-focus-visible/yt:ring-white/80">
<Play className="ml-0.5 size-6 fill-current" />
</span>
</button>
)}
</div>
)
}// A YOUTUBE VIDEO'S FACTS, framework-free: which video a link names (every form YouTube
// hands out: watch?v=, youtu.be/, /shorts/, /embed/, /live/, with a start time as
// t=1m30s, t=90 or start=90), and its title and channel from YouTube's oEmbed, the
// endpoint its own embeds read, no key. Fetched once at authoring time and shipped as
// data beside the link (`mise youtube <url>`), the way tweet and web-preview do, so a
// page never depends on YouTube answering to draw.
export interface YouTubeRef {
id: string
/** seconds in, when the link names a start */
start?: number
/** a Short: drawn upright, 9:16 */
short: boolean
}
export interface YouTubeFacts extends YouTubeRef {
title: string
channel: string
channelUrl: string
}
const ID = /^[\w-]{11}$/
/** `1m30s`, `90s`, `90`, `1h2m3s` as seconds. */
function secondsOf(t: string | null): number | undefined {
if (!t) return undefined
if (/^\d+$/.test(t)) return Number(t)
const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(t)
if (!m || t === "") return undefined
return Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)
}
/** The video a link names, or null when it names none. */
export function youtubeOf(link: string): YouTubeRef | null {
let url: URL
try {
url = new URL(link)
} catch {
return null
}
const host = url.hostname.replace(/^(?:www|m|music)\./, "")
const start = secondsOf(
url.searchParams.get("t") ?? url.searchParams.get("start"),
)
let id: string | null = null
let short = false
if (host === "youtu.be") id = url.pathname.slice(1).split("/")[0] ?? null
else if (host === "youtube.com" || host === "youtube-nocookie.com") {
const [, first, second] = url.pathname.split("/")
if (first === "watch") id = url.searchParams.get("v")
else if (first === "shorts" || first === "embed" || first === "live") {
id = second ?? null
short = first === "shorts"
}
}
if (!id || !ID.test(id)) return null
return { id, short, ...(start !== undefined && { start }) }
}
/** A video's title and channel, from YouTube's oEmbed. Screams on a link that names no
* video or a video YouTube will not describe (private, removed). */
export async function youtubeFacts(link: string): Promise<YouTubeFacts> {
const ref = youtubeOf(link)
if (!ref) throw new Error(`youtube-data: ${link} names no video`)
const watch = `https://www.youtube.com/watch?v=${ref.id}`
const res = await fetch(
`https://www.youtube.com/oembed?url=${encodeURIComponent(watch)}&format=json`,
)
if (!res.ok)
throw new Error(`youtube-data: oEmbed answered ${res.status} for ${ref.id}`)
const o = (await res.json()) as {
title: string
author_name: string
author_url: string
}
return {
...ref,
title: o.title,
channel: o.author_name,
channelUrl: o.author_url,
}
}