{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"A video thumbnail that morphs into a full accessible player inside a modal, with a custom keyboard-operable control bar.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport SmoothButton from \"@/components/smoothui/smooth-button\";\nimport {\n  Maximize,\n  Minimize,\n  Pause,\n  Play,\n  Volume2,\n  VolumeX,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\n\n/* -------------------------------------------------------------------------- */\n/* Constants                                                                  */\n/* -------------------------------------------------------------------------- */\n\nconst SPRING_PANEL = { bounce: 0.1, duration: 0.25, type: \"spring\" as const };\nconst BACKDROP_DURATION = 0.2;\nconst SEEK_STEP_SECONDS = 5;\nconst SECONDS_PER_MINUTE = 60;\nconst TIME_PAD_LENGTH = 2;\nconst THUMBNAIL_PLAY_ICON_SIZE = 22;\n\n/** Control-bar buttons sit on video, so the ghost variant is tinted white via\n *  the `color` axis' own custom property rather than a text/hover override. */\nconst CONTROL_BUTTON_CLASS = \"shrink-0 [--btn:#fff]\";\n\nconst FOCUSABLE_SELECTOR =\n  'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])';\n\n/* -------------------------------------------------------------------------- */\n/* Public types                                                               */\n/* -------------------------------------------------------------------------- */\n\nexport type VideoModalCaption = {\n  /** Human-readable track name, e.g. \"English\". */\n  label: string;\n  src: string;\n  /** BCP 47 language tag, e.g. \"en\". */\n  srcLang: string;\n};\n\nexport type VideoModalProps = {\n  /** Starts playback once the modal finishes opening. Ignored under reduced motion. */\n  autoPlayOnOpen?: boolean;\n  captions?: VideoModalCaption[];\n  className?: string;\n  onOpenChange?: (open: boolean) => void;\n  /** Controlled open state. Omit to let the component own it. */\n  open?: boolean;\n  poster?: string;\n  /** Renders the built-in control bar instead of the browser's native one. */\n  showCustomControls?: boolean;\n  src: string;\n  /** CSS `aspect-ratio` value for the closed thumbnail, e.g. \"16/9\". */\n  thumbnailAspect?: string;\n  title: string;\n};\n\n/* -------------------------------------------------------------------------- */\n/* Helpers                                                                    */\n/* -------------------------------------------------------------------------- */\n\nconst formatTime = (seconds: number): string => {\n  if (!Number.isFinite(seconds) || seconds < 0) {\n    return \"0:00\";\n  }\n  const minutes = Math.floor(seconds / SECONDS_PER_MINUTE);\n  const remainder = Math.floor(seconds % SECONDS_PER_MINUTE);\n  return `${minutes}:${remainder.toString().padStart(TIME_PAD_LENGTH, \"0\")}`;\n};\n\n/* -------------------------------------------------------------------------- */\n/* Custom control bar                                                         */\n/* -------------------------------------------------------------------------- */\n\ntype VideoModalControlsProps = {\n  currentTime: number;\n  duration: number;\n  isFullscreen: boolean;\n  isMuted: boolean;\n  isPlaying: boolean;\n  onScrubKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void;\n  onScrubPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onToggleFullscreen: () => void;\n  onToggleMute: () => void;\n  onTogglePlay: () => void;\n  progress: number;\n};\n\nconst VideoModalControls = ({\n  currentTime,\n  duration,\n  isFullscreen,\n  isMuted,\n  isPlaying,\n  onScrubKeyDown,\n  onScrubPointerDown,\n  onToggleFullscreen,\n  onToggleMute,\n  onTogglePlay,\n  progress,\n}: VideoModalControlsProps) => (\n  <div className=\"absolute inset-x-0 bottom-0 flex items-center gap-3 bg-gradient-to-t from-black/85 to-transparent px-4 py-3 text-white\">\n    <SmoothButton\n      aria-label={isPlaying ? \"Pause\" : \"Play\"}\n      className={cn(CONTROL_BUTTON_CLASS, \"[&_svg]:size-[18px]\")}\n      onClick={onTogglePlay}\n      shape=\"pill\"\n      size=\"icon-sm\"\n      variant=\"ghost\"\n    >\n      {isPlaying ? (\n        <Pause aria-hidden=\"true\" fill=\"currentColor\" />\n      ) : (\n        <Play aria-hidden=\"true\" fill=\"currentColor\" />\n      )}\n    </SmoothButton>\n\n    <div\n      aria-label=\"Seek\"\n      aria-valuemax={Math.round(duration)}\n      aria-valuemin={0}\n      aria-valuenow={Math.round(currentTime)}\n      aria-valuetext={`${formatTime(currentTime)} of ${formatTime(duration)}`}\n      className=\"relative h-1.5 flex-1 cursor-pointer touch-none rounded-full bg-white/25\"\n      onKeyDown={onScrubKeyDown}\n      onPointerDown={onScrubPointerDown}\n      role=\"slider\"\n      tabIndex={0}\n    >\n      <div\n        className=\"pointer-events-none absolute inset-y-0 left-0 rounded-full bg-white\"\n        style={{ width: `${progress}%` }}\n      />\n    </div>\n\n    <span className=\"shrink-0 whitespace-nowrap text-xs tabular-nums\">\n      {formatTime(currentTime)} / {formatTime(duration)}\n    </span>\n\n    <SmoothButton\n      aria-label={isMuted ? \"Unmute\" : \"Mute\"}\n      className={CONTROL_BUTTON_CLASS}\n      onClick={onToggleMute}\n      shape=\"pill\"\n      size=\"icon-sm\"\n      variant=\"ghost\"\n    >\n      {isMuted ? (\n        <VolumeX aria-hidden=\"true\" />\n      ) : (\n        <Volume2 aria-hidden=\"true\" />\n      )}\n    </SmoothButton>\n\n    <SmoothButton\n      aria-label={isFullscreen ? \"Exit fullscreen\" : \"Enter fullscreen\"}\n      className={CONTROL_BUTTON_CLASS}\n      onClick={onToggleFullscreen}\n      shape=\"pill\"\n      size=\"icon-sm\"\n      variant=\"ghost\"\n    >\n      {isFullscreen ? (\n        <Minimize aria-hidden=\"true\" />\n      ) : (\n        <Maximize aria-hidden=\"true\" />\n      )}\n    </SmoothButton>\n  </div>\n);\n\n/* -------------------------------------------------------------------------- */\n/* Component                                                                  */\n/* -------------------------------------------------------------------------- */\n\n/**\n * A thumbnail that morphs into a full player.\n *\n * The thumbnail and the modal frame share one `layoutId`, so opening reads as\n * the same rectangle growing into place rather than a new one appearing on\n * top of it. The thumbnail stays mounted — invisible — for exactly as long as\n * the modal is open, because Motion needs a live node with that id to measure\n * the box it is morphing from and back into.\n */\nconst VideoModal = ({\n  autoPlayOnOpen = true,\n  captions = [],\n  className,\n  onOpenChange,\n  open,\n  poster,\n  showCustomControls = true,\n  src,\n  thumbnailAspect = \"16/9\",\n  title,\n}: VideoModalProps) => {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n  const frameLayoutId = `${useId()}-frame`;\n\n  const [mounted, setMounted] = useState(false);\n  const [internalOpen, setInternalOpen] = useState(false);\n  const isControlled = open !== undefined;\n  const isOpen = isControlled ? open : internalOpen;\n\n  const [isPlaying, setIsPlaying] = useState(false);\n  const [isMuted, setIsMuted] = useState(false);\n  const [isFullscreen, setIsFullscreen] = useState(false);\n  const [currentTime, setCurrentTime] = useState(0);\n  const [duration, setDuration] = useState(0);\n\n  const videoRef = useRef<HTMLVideoElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const previousOverflowRef = useRef<string | null>(null);\n\n  useEffect(() => setMounted(true), []);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!isControlled) {\n        setInternalOpen(next);\n      }\n      onOpenChange?.(next);\n    },\n    [isControlled, onOpenChange]\n  );\n\n  const close = useCallback(() => setOpen(false), [setOpen]);\n\n  // Body scroll locks while the modal is open, and only while it is open.\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n    previousOverflowRef.current = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = previousOverflowRef.current ?? \"\";\n    };\n  }, [isOpen]);\n\n  // Focus enters the panel on open and returns to the trigger on close.\n  useEffect(() => {\n    if (isOpen) {\n      panelRef.current?.focus();\n    } else {\n      triggerRef.current?.focus();\n    }\n  }, [isOpen]);\n\n  // Escape closes the modal; Tab is trapped inside the panel.\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        close();\n        return;\n      }\n      if (event.key !== \"Tab\" || !panelRef.current) {\n        return;\n      }\n      const focusable = Array.from(\n        panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)\n      );\n      const [first] = focusable;\n      const last = focusable.at(-1);\n      if (event.shiftKey && document.activeElement === first) {\n        event.preventDefault();\n        last?.focus();\n      } else if (!event.shiftKey && document.activeElement === last) {\n        event.preventDefault();\n        first?.focus();\n      }\n    };\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n  }, [isOpen, close]);\n\n  // The video pauses whenever the modal closes or the tab goes hidden — an\n  // autoplaying video nobody can see is just a battery drain.\n  useEffect(() => {\n    const video = videoRef.current;\n    if (!video) {\n      return;\n    }\n    if (!isOpen) {\n      video.pause();\n      return;\n    }\n    if (autoPlayOnOpen && !shouldReduceMotion) {\n      video.play().catch(() => {\n        // Autoplay can be blocked by the browser; the play button still works.\n      });\n    }\n    const handleVisibility = () => {\n      if (document.hidden) {\n        video.pause();\n      }\n    };\n    document.addEventListener(\"visibilitychange\", handleVisibility);\n    return () =>\n      document.removeEventListener(\"visibilitychange\", handleVisibility);\n  }, [isOpen, autoPlayOnOpen, shouldReduceMotion]);\n\n  useEffect(() => {\n    const video = videoRef.current;\n    if (!video) {\n      return;\n    }\n    const handlePlay = () => setIsPlaying(true);\n    const handlePause = () => setIsPlaying(false);\n    const handleTimeUpdate = () => setCurrentTime(video.currentTime);\n    const handleLoadedMetadata = () => setDuration(video.duration || 0);\n    const handleVolumeChange = () => setIsMuted(video.muted);\n    video.addEventListener(\"play\", handlePlay);\n    video.addEventListener(\"pause\", handlePause);\n    video.addEventListener(\"timeupdate\", handleTimeUpdate);\n    video.addEventListener(\"loadedmetadata\", handleLoadedMetadata);\n    video.addEventListener(\"volumechange\", handleVolumeChange);\n    return () => {\n      video.removeEventListener(\"play\", handlePlay);\n      video.removeEventListener(\"pause\", handlePause);\n      video.removeEventListener(\"timeupdate\", handleTimeUpdate);\n      video.removeEventListener(\"loadedmetadata\", handleLoadedMetadata);\n      video.removeEventListener(\"volumechange\", handleVolumeChange);\n    };\n    // The <video> only exists once the portal has mounted and the modal is\n    // open, both of which land a render after this effect first runs. With an\n    // empty dependency array it found a null ref and never re-ran, so the\n    // control bar's play state, mute state and scrub position never tracked\n    // actual playback.\n  }, [mounted, isOpen]);\n\n  useEffect(() => {\n    const handleFullscreenChange = () =>\n      setIsFullscreen(Boolean(document.fullscreenElement));\n    document.addEventListener(\"fullscreenchange\", handleFullscreenChange);\n    return () =>\n      document.removeEventListener(\"fullscreenchange\", handleFullscreenChange);\n  }, []);\n\n  const togglePlay = () => {\n    const video = videoRef.current;\n    if (!video) {\n      return;\n    }\n    if (video.paused) {\n      video.play().catch(() => {\n        // Ignore — nothing useful to surface for a blocked play() here.\n      });\n    } else {\n      video.pause();\n    }\n  };\n\n  const toggleMute = () => {\n    const video = videoRef.current;\n    if (video) {\n      video.muted = !video.muted;\n    }\n  };\n\n  const toggleFullscreen = () => {\n    if (document.fullscreenElement) {\n      document.exitFullscreen().catch(() => {});\n    } else {\n      panelRef.current?.requestFullscreen().catch(() => {});\n    }\n  };\n\n  const seekTo = (time: number) => {\n    const video = videoRef.current;\n    if (!video || duration <= 0) {\n      return;\n    }\n    video.currentTime = Math.min(Math.max(time, 0), duration);\n  };\n\n  const handleScrubPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const track = event.currentTarget;\n    track.setPointerCapture(event.pointerId);\n    const scrub = (clientX: number) => {\n      const rect = track.getBoundingClientRect();\n      const ratio = Math.min(\n        Math.max((clientX - rect.left) / rect.width, 0),\n        1\n      );\n      seekTo(ratio * duration);\n    };\n    scrub(event.clientX);\n    const handleMove = (moveEvent: PointerEvent) => scrub(moveEvent.clientX);\n    const handleUp = () => {\n      track.removeEventListener(\"pointermove\", handleMove);\n      track.removeEventListener(\"pointerup\", handleUp);\n    };\n    track.addEventListener(\"pointermove\", handleMove);\n    track.addEventListener(\"pointerup\", handleUp);\n  };\n\n  const handleScrubKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    switch (event.key) {\n      case \"ArrowRight\":\n        event.preventDefault();\n        seekTo(currentTime + SEEK_STEP_SECONDS);\n        break;\n      case \"ArrowLeft\":\n        event.preventDefault();\n        seekTo(currentTime - SEEK_STEP_SECONDS);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        seekTo(0);\n        break;\n      case \"End\":\n        event.preventDefault();\n        seekTo(duration);\n        break;\n      default:\n        break;\n    }\n  };\n\n  const progress = duration > 0 ? (currentTime / duration) * 100 : 0;\n\n  return (\n    <>\n      <button\n        aria-hidden={isOpen}\n        aria-label={`Play ${title}`}\n        className={cn(\n          \"group relative block w-full cursor-pointer overflow-hidden rounded-xl border border-border bg-muted text-left\",\n          className\n        )}\n        onClick={() => setOpen(true)}\n        ref={triggerRef}\n        tabIndex={isOpen ? -1 : 0}\n        type=\"button\"\n      >\n        <motion.div\n          animate={isOpen ? { opacity: 0 } : { opacity: 1 }}\n          className=\"relative w-full\"\n          layoutId={shouldReduceMotion ? undefined : frameLayoutId}\n          style={{ aspectRatio: thumbnailAspect }}\n          transition={shouldReduceMotion ? { duration: 0 } : SPRING_PANEL}\n        >\n          {poster ? (\n            <img\n              alt=\"\"\n              className=\"size-full object-cover\"\n              height={720}\n              src={poster}\n              width={1280}\n            />\n          ) : (\n            <div className=\"size-full bg-foreground/10\" />\n          )}\n          <span className=\"absolute inset-0 flex items-center justify-center bg-black/20 transition-colors group-hover:bg-black/30\">\n            <span className=\"flex size-14 items-center justify-center rounded-full bg-white/90 text-black shadow-lg transition-transform group-hover:scale-105\">\n              <Play\n                aria-hidden=\"true\"\n                fill=\"currentColor\"\n                size={THUMBNAIL_PLAY_ICON_SIZE}\n              />\n            </span>\n          </span>\n        </motion.div>\n        <span className=\"sr-only\">{title}</span>\n      </button>\n\n      {mounted\n        ? createPortal(\n            <AnimatePresence>\n              {isOpen ? (\n                <div className=\"fixed inset-0 z-50 flex items-center justify-center p-4\">\n                  <motion.div\n                    animate={{ opacity: 1 }}\n                    className=\"absolute inset-0 bg-black/60 backdrop-blur-sm\"\n                    exit={{ opacity: 0 }}\n                    initial={\n                      shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }\n                    }\n                    onClick={close}\n                    transition={{\n                      duration: shouldReduceMotion ? 0 : BACKDROP_DURATION,\n                    }}\n                  />\n\n                  <motion.div\n                    animate={{ opacity: 1 }}\n                    aria-label={title}\n                    aria-modal=\"true\"\n                    className=\"relative z-10 w-full max-w-3xl overflow-hidden rounded-xl bg-black outline-none\"\n                    exit={\n                      shouldReduceMotion\n                        ? { opacity: 0, transition: { duration: 0 } }\n                        : { opacity: 0 }\n                    }\n                    initial={\n                      shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }\n                    }\n                    layoutId={shouldReduceMotion ? undefined : frameLayoutId}\n                    ref={panelRef}\n                    role=\"dialog\"\n                    tabIndex={-1}\n                    transition={\n                      shouldReduceMotion ? { duration: 0 } : SPRING_PANEL\n                    }\n                  >\n                    {/* biome-ignore lint/a11y/useMediaCaption: the rule only recognises an unconditional <track> literal, so it cannot see the mapped tracks below. Satisfying it with an extra always-present <track> shipped one bogus empty caption track per video, which is worse than this suppression. */}\n                    <video\n                      className=\"block max-h-[80vh] w-full\"\n                      controls={!showCustomControls}\n                      playsInline\n                      poster={poster}\n                      ref={videoRef}\n                      src={src}\n                    >\n                      {captions.length > 0 ? (\n                        captions.map((caption) => (\n                          <track\n                            key={caption.srcLang}\n                            kind=\"captions\"\n                            label={caption.label}\n                            src={caption.src}\n                            srcLang={caption.srcLang}\n                          />\n                        ))\n                      ) : (\n                        <track kind=\"captions\" />\n                      )}\n                    </video>\n\n                    {showCustomControls ? (\n                      <VideoModalControls\n                        currentTime={currentTime}\n                        duration={duration}\n                        isFullscreen={isFullscreen}\n                        isMuted={isMuted}\n                        isPlaying={isPlaying}\n                        onScrubKeyDown={handleScrubKeyDown}\n                        onScrubPointerDown={handleScrubPointerDown}\n                        onToggleFullscreen={toggleFullscreen}\n                        onToggleMute={toggleMute}\n                        onTogglePlay={togglePlay}\n                        progress={progress}\n                      />\n                    ) : null}\n\n                    <SmoothButton\n                      aria-label=\"Close video\"\n                      className=\"absolute top-3 right-3 [--btn-fg:#fff] [--btn-hover:rgb(0_0_0/0.7)] [--btn:rgb(0_0_0/0.5)]\"\n                      onClick={close}\n                      shape=\"pill\"\n                      size=\"icon-sm\"\n                      variant=\"solid\"\n                    >\n                      <X aria-hidden=\"true\" />\n                    </SmoothButton>\n                  </motion.div>\n                </div>\n              ) : null}\n            </AnimatePresence>,\n            document.body\n          )\n        : null}\n    </>\n  );\n};\n\nexport default VideoModal;\n","path":"index.tsx","target":"components/smoothui/video-modal/index.tsx","type":"registry:ui"}],"name":"video-modal","registryDependencies":["https://smoothui.dev/r/smooth-button.json"],"title":"Video Modal","type":"registry:ui"}