Media

Video Embed

A lite facade for YouTube/Vimeo — a poster and play button stand in for the real iframe until the user clicks, so no third-party embed script ever loads uninvited.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Play } from "lucide-react"
import { cn } from "@/lib/utils"

export interface VideoEmbedProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Embed host — picks the iframe src template, allow list, and default poster. */
  provider?: "youtube" | "vimeo"
  /** Provider-specific video id (YouTube's 11-char id, or Vimeo's numeric id). */
  videoId: string
  /** Accessible name — announced by the play button and passed straight through to the iframe's title. */
  title: string
  /** Cover shown before activation. Defaults to YouTube's public thumbnail; Vimeo has no public

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/video-embed.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "VideoEmbed" component (lucide-react
for the play icon).

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: provider?: "youtube" | "vimeo" (default "youtube"); videoId: string
  (the provider's id — YouTube's 11-char id or Vimeo's numeric id); title:
  string (required — accessible name, announced on the play button and
  passed straight through to the iframe's title attribute); poster?: string
  (cover image URL; defaults to YouTube's public
  `https://i.ytimg.com/vi/<videoId>/hqdefault.jpg` thumbnail when
  provider="youtube" — Vimeo has no public thumbnail API, so pass one
  explicitly or the cover renders blank and a dev-only console warning
  fires); aspect?: string (CSS aspect-ratio, default "16/9"); className
  merged via cn().

Behavior
- One-way state: an "active" boolean starts false and only ever flips to
  true — there is no un-clicking back to the poster.
- Before activation, the entire card is a single
  <button type="button" aria-label={`Play video: ${title}`}> so it's
  reachable and fully operable by keyboard (Enter/Space via native button
  semantics — no custom key handling needed) and announced correctly by
  screen readers.
- Clicking (or keyboard-activating) the button flips active to true. Once
  active, the button is replaced by a real <iframe> pointed at
  https://www.youtube-nocookie.com/embed/<id>?autoplay=1 (YouTube) or
  https://player.vimeo.com/video/<id>?autoplay=1 (Vimeo), with a full allow
  list (accelerometer/autoplay/clipboard-write/encrypted-media/gyroscope/
  picture-in-picture/web-share for YouTube; autoplay/fullscreen/
  picture-in-picture/clipboard-write/encrypted-media for Vimeo),
  allowFullScreen, and title passed straight through — this swap is the
  entire "lite facade" trick: zero iframe, zero third-party JS, zero
  network request to the video host until this exact moment.
- No timers, listeners, or observers are created, so there's nothing to
  clean up on unmount either way.

Rendering & styling
- Frame: relative w-full overflow-hidden rounded-xl border bg-muted, inline
  style aspectRatio from the aspect prop — semantic tokens only.
- Poster (inactive state): a plain <img loading="lazy"
  className="size-full object-cover"> filling the button — lazy-loaded and
  framework-agnostic (no next/image binding), decorative alt="".
- Title bar: an aria-hidden bottom overlay, bg-gradient-to-t
  from-background/90 to-transparent, holding a truncate text-sm
  font-medium text-foreground label so long titles never wrap or overflow
  the card.
- Play affordance: an aria-hidden centered rounded-xl bg-foreground/80 box
  (size-14) holding a lucide Play icon styled fill-background text-background
  (a solid, background-colored triangle) — transition-colors
  group-hover:bg-primary swaps the box to the brand accent on hover.
- Active state: the iframe fills the frame with absolute inset-0 size-full.
- Focus ring: focus-visible:ring-2 focus-visible:ring-inset
  focus-visible:ring-ring on the button (inset so it never clips against
  the frame's overflow-hidden).

Customization levers
- Play affordance shape/color: swap the rounded-xl box for a circle
  (rounded-full), change bg-foreground/80 for another token, or drop the
  lucide icon for a pure-CSS border-triangle if you want zero icon
  dependency.
- Poster fidelity: swap hqdefault for YouTube's higher-res maxresdefault
  (not guaranteed to exist for every video) or the smaller sddefault.
- Provider coverage: add another host (e.g. Bilibili) by extending the
  embed-URL and allow-list lookup tables with its template — the
  button/poster/iframe swap logic doesn't change.
- Structured data: pair this with a VideoObject JSON-LD <script> at the
  page level for SEO — out of scope for the component itself, since it's
  page metadata, not markup this component should own.
- Consent gating: for a GDPR-style "double-click" consent flow, insert a
  third state between "poster" and "active" gated on a stored consent
  flag (show a consent notice instead of the poster until accepted) — the
  underlying button → iframe swap stays the same, just gated behind one
  extra condition.

Concepts

  • Facade pattern — the poster stands in for the real embed until interaction; zero iframe and zero third-party script request happen until the user opts in.
  • One-way activationactive is a single boolean that only ever flips forward; there's no toggled-off state to design back into.
  • Deferred network cost — the entire performance win in one sentence: nothing past the poster <img> touches the network until the click happens.
  • Privacy-enhanced embed hostsyoutube-nocookie.com / player.vimeo.com avoid setting tracking cookies before the user has asked to watch.
  • Provider-agnostic contractprovider only picks the embed URL template and allow list; the button/poster/iframe swap machinery is identical either way.

On This Page