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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/video-embed.jsonPrompt
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 activation —
activeis 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 hosts —
youtube-nocookie.com/player.vimeo.comavoid setting tracking cookies before the user has asked to watch. - Provider-agnostic contract —
provideronly picks the embed URL template and allow list; the button/poster/iframe swap machinery is identical either way.