A thumbnail with a play button that opens a video in a focus-trapped dialog, for a video file, an embed URL or a YouTube id from the privacy-friendly domain, mounting the player only while open.
import { VideoDialog } from "@/components/ballmac/video-dialog"
function thumb() {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f46e5"/><stop offset=".55" stop-color="#7c3aed"/><stop offset="1" stop-color="#db2777"/></linearGradient><radialGradient id="r" cx=".25" cy=".2" r=".8"><stop offset="0" stop-color="rgba(255,255,255,.35)"/><stop offset="1" stop-color="rgba(255,255,255,0)"/></radialGradient></defs><rect width="960" height="540" fill="url(#g)"/><rect width="960" height="540" fill="url(#r)"/><g fill="rgba(255,255,255,.16)"><rect x="90" y="110" width="360" height="22" rx="11"/><rect x="90" y="150" width="260" height="14" rx="7"/><rect x="90" y="330" width="220" height="110" rx="14"/><rect x="330" y="330" width="220" height="110" rx="14"/><rect x="570" y="190" width="300" height="250" rx="18"/></g></svg>`
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
}
export default function VideoDialogDemo() {
return (
<div className="w-full max-w-xl">
<VideoDialog title="Product tour: build a dashboard in 3 minutes" youtubeId="aqz-KE-bpKQ" thumbnail={thumb()} duration="3:12" />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/video-dialogInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/dialog @ballmac/i18nCopy the source into your project.
components/ballmac/video-dialog.tsx// Ballmac UI: Video Dialog. https://ui.ballmac.com/components/video-dialog "use client" import * as React from "react" import { Play, X } from "lucide-react" import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ballmac/dialog" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type VideoDialogProps = Omit<React.ComponentProps<"button">, "title" | "children"> & { /** Name of the video. Read by screen readers and used as the frame's title. */ title: string /** A video file to play with the browser's own player. */ src?: string /** A page to embed instead, such as a Vimeo player URL. */ embedUrl?: string /** A YouTube video id. Plays from the privacy-friendly youtube-nocookie.com domain. */ youtubeId?: string /** Image shown on the button. */ thumbnail?: string /** Describes the thumbnail for screen readers. Leave empty when the title already says it. */ thumbnailAlt?: string /** Length shown on the button, such as "2:41". */ duration?: string /** Shape of the video frame. */ aspect?: "video" | "square" | "cinema" /** Controlled open state. */ open?: boolean /** Called when the dialog opens or closes. */ onOpenChange?: (open: boolean) => void /** Classes for the thumbnail button. */ className?: string } const ASPECT = { video: "aspect-video", square: "aspect-square", cinema: "aspect-[21/9]" } function embedFor({ embedUrl, youtubeId }: Pick<VideoDialogProps, "embedUrl" | "youtubeId">) { if (youtubeId) return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(youtubeId)}?autoplay=1&rel=0&modestbranding=1` return embedUrl } function VideoDialog({ title, src, embedUrl, youtubeId, thumbnail, thumbnailAlt = "", duration, aspect = "video", open, onOpenChange, className, ...props }: VideoDialogProps) { const msg = useMessages() const embed = embedFor({ embedUrl, youtubeId }) const [loaded, setLoaded] = React.useState(false) return ( <Dialog open={open} onOpenChange={(next) => { if (!next) setLoaded(false); onOpenChange?.(next) }}> <DialogTrigger data-slot="video-dialog-trigger" className={cn( "group/video relative block w-full overflow-hidden rounded-2xl border bg-muted text-start outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background", ASPECT[aspect], className )} {...props} > {thumbnail ? ( // eslint-disable-next-line @next/next/no-img-element <img src={thumbnail} alt={thumbnailAlt} className="absolute inset-0 size-full object-cover transition-transform duration-500 ease-out group-hover/video:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover/video:scale-100" /> ) : ( <span aria-hidden="true" className="absolute inset-0 bg-[radial-gradient(120%_120%_at_20%_10%,color-mix(in_oklab,var(--chart-1)_35%,var(--card)),var(--card))]" /> )} <span aria-hidden="true" className="absolute inset-0 bg-gradient-to-t from-black/55 via-black/10 to-transparent" /> <span className="absolute inset-0 flex items-center justify-center"> <span aria-hidden="true" className="relative flex size-16 items-center justify-center rounded-full bg-white/95 text-black shadow-[0_8px_30px_rgb(0_0_0/0.35)] transition-transform duration-200 group-hover/video:scale-110 group-focus-visible/video:scale-110 motion-reduce:transition-none sm:size-20"> <span className="absolute inset-0 rounded-full bg-white/70 opacity-0 group-hover/video:animate-ping motion-reduce:animate-none" /> <Play className="relative ms-1 size-6 fill-current sm:size-7" /> </span> </span> <span className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 p-4 text-white"> <span className="text-sm font-medium drop-shadow-sm sm:text-base"> <span className="sr-only">{msg("video-dialog.playVideo", "Play video: {title}", { title })}</span> <span aria-hidden="true">{title}</span> </span> {duration && <span className="rounded-md bg-black/60 px-1.5 py-0.5 font-mono text-xs tabular-nums backdrop-blur">{duration}</span>} </span> </DialogTrigger> <DialogContent showCloseButton={false} className="w-[min(64rem,calc(100%-1.5rem))] max-w-none gap-0 overflow-hidden border-0 bg-black p-0 text-white sm:max-w-5xl sm:p-0"> <DialogTitle className="sr-only">{title}</DialogTitle> <DialogDescription className="sr-only">{msg("video-dialog.videoPlayerPressEscapeTo", "Video player. Press Escape to close.")}</DialogDescription> <div className={cn("relative w-full bg-black", ASPECT[aspect])}> {!loaded && <span aria-hidden="true" className="absolute inset-0 animate-pulse bg-white/5 motion-reduce:animate-none" />} {src ? ( <video src={src} poster={thumbnail} controls autoPlay playsInline onLoadedData={() => setLoaded(true)} className="absolute inset-0 size-full"> <track kind="captions" /> </video> ) : embed ? ( <iframe src={embed} title={title} allow="autoplay; encrypted-media; picture-in-picture; fullscreen" allowFullScreen referrerPolicy="strict-origin-when-cross-origin" onLoad={() => setLoaded(true)} className="absolute inset-0 size-full border-0" /> ) : null} </div> <DialogClose aria-label={msg("video-dialog.closeVideo", "Close video")} className="absolute top-2.5 end-2.5 inline-flex size-9 items-center justify-center rounded-full bg-black/60 text-white outline-none backdrop-blur transition-colors hover:bg-black/80 focus-visible:ring-[3px] focus-visible:ring-white/60" > <X aria-hidden="true" className="size-4" /> </DialogClose> </DialogContent> </Dialog> ) } export { VideoDialog, type VideoDialogProps }Update the import paths to match your project setup.
Usage
import { VideoDialog } from "@/components/ballmac/video-dialog"The full example is in the Code tab above.
Examples
Shapes
import { VideoDialog } from "@/components/ballmac/video-dialog"
export default function VideoDialogAspects() {
return (
<div className="grid w-full max-w-xl gap-3 sm:grid-cols-2">
<VideoDialog title="Quick start" youtubeId="aqz-KE-bpKQ" aspect="square" duration="1:05" />
<VideoDialog title="Customer story" youtubeId="aqz-KE-bpKQ" aspect="cinema" className="sm:col-span-1" duration="4:20" />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
title*Name of the video. Read by screen readers and used as the frame's title. | string | — |
srcA video file to play with the browser's own player. | string | — |
embedUrlA page to embed instead, such as a Vimeo player URL. | string | — |
youtubeIdA YouTube video id. Plays from the privacy-friendly youtube-nocookie.com domain. | string | — |
thumbnailImage shown on the button. | string | — |
thumbnailAltDescribes the thumbnail for screen readers. Leave empty when the title already says it. | string | "" |
durationLength shown on the button, such as "2:41". | string | — |
aspectShape of the video frame. | "video" | "square" | "cinema" | "video" |
openControlled open state. | boolean | — |
onOpenChangeCalled when the dialog opens or closes. | (open: boolean) => void | — |
classNameClasses for the thumbnail button. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space | Opens the video |
| Escape | Closes it and returns focus to the thumbnail |
| Screen readers | Button reads 'Play video: Title'; the dialog is named by the title |
Use with AI
<VideoDialog title thumbnail duration youtubeId | embedUrl | src aspect />. The player is created on open and removed on close, so nothing loads or keeps playing until asked. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Video Dialog (@ballmac/video-dialog) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Product demos and launch videos on landing pages
- Tutorial thumbnails
Not for
- Inline background video
- Audio only (use an audio player)
Registry JSON: https://ui.ballmac.com/r/video-dialog.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react