← ui

youtube

ui

a YouTube video as a picture until it is asked for, then its player in place

npx shadcn add @ag/youtube
Already installed? Preview an update
npx shadcn add @ag/youtube --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-13
  • The thumbnail arrives through Image with YouTube's sizes as its chain (maxres, then hq, the 120 px grey YouTube sends for a maxres it never made passed over by minWidth): no frame of grey, no pop, and a thumbnail cached before hydration is never left blank. Installing youtube now brings image.
Updated 2026-10-13 · release notes

also called lite youtube

shelf social & links

uses image

used by markdown

the demo above, verbatim

youtube.demo.tsx
"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

youtube.tsx
"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>
  )
}
youtube-data.ts
// 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,
  }
}