Media

Video Hover Preview

A thumbnail card that plays a muted clip on hover or keyboard focus — dwell-delayed lazy load, pointer-x scrubbing, hairline progress, and a poster fallback when the clip fails.

Preview in your theme

Loading preview…

"use client"

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

export interface VideoHoverPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Still frame shown before, during buffering, and after the preview. The only asset fetched on first paint. */
  poster: string
  /** Short preview clip (a few seconds of self-hosted mp4/webm). Requested only once `hoverDelay` has elapsed. */
  src: string
  /** Accessible name for the whole card — becomes the container's aria-label; poster and video are decorative layers. */
  alt: string
  /** Map pointer x across the card width to playback position (seek-on-move), rAF-throttled. */

Installation

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

Prompt

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

Build a React + TypeScript + Tailwind "VideoHoverPreview" component
(lucide-react for the spinner and warning icons; no other dependencies).

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: poster: string (still frame, the only asset fetched on first
  paint); src: string (a short preview clip — a few seconds of self-hosted
  mp4/webm, NOT the full feature); alt: string (accessible name for the
  whole card); scrub?: boolean (default true); hoverDelay?: number (default
  400, clamped to >= 0); muted?: boolean (default true); loop?: boolean
  (default true); showProgress?: boolean (default true); durationLabel?:
  string — a PRE-FORMATTED runtime like "12:04"; the component never
  formats time itself, so locale/format decisions stay with the caller and
  there is no Intl call to get wrong. className merged via cn(); children
  render on top of every overlay so consumers can stretch a link or drop a
  title chip into the frame.
- Internal state machine: status = "idle" | "loading" | "playing" | "error",
  plus a progress percentage. "active" (src attached) is derived as
  status === "loading" || status === "playing" — never stored separately.

Behavior
- Lazy by construction: the <video> renders with preload="none" AND no src
  attribute at all while idle (src={active ? src : undefined}). A media
  element with no source performs zero network activity, so an idle grid of
  50 cards costs 50 poster images and nothing else. This is the whole point
  of the component — do not "optimise" it into an always-attached src.
- Hover intent: pointerenter starts a setTimeout(hoverDelay). Only when it
  fires does status flip to "loading", which is what attaches src. Sweeping
  the pointer across a grid therefore fires zero requests. pointerleave and
  pointercancel clear the timer.
- Cancelling a load already in flight (slow network, user left after the
  delay elapsed): removing the src attribute alone does NOT abort the
  request. In the effect that watches "active", when it goes false: pause(),
  removeAttribute("src"), then call video.load() — load() is what aborts the
  in-flight fetch and resets the element to NETWORK_EMPTY. Guard it with a
  ref that tracks whether a src was ever attached, so the mount-time run is
  a no-op.
- Starting playback: in that same effect, when active goes true, set
  currentTime = 0 and call play(). Handle the promise rejection by name:
  AbortError means we ourselves paused/reloaded before play() landed (fast
  grid sweep) and must be ignored; NotAllowedError means the browser blocked
  unmuted autoplay — fall back to "idle" silently rather than lying about a
  load failure; anything else becomes "error".
- Scrubbing (scrub=true): on pointermove over a PLAYING card, read the
  card's bounding rect synchronously (currentTarget is nulled after
  dispatch), compute ratio = clamp01((clientX - rect.left) / rect.width),
  stash it in a ref, and schedule one rAF if none is pending. Inside the rAF
  set currentTime = min(ratio * duration, duration - 0.05) — the 0.05s tail
  guard matters: seeking exactly to duration fires "ended" immediately, so
  scrubbing to the right edge would kill the preview. Skip when duration is
  NaN/0 (metadata not in yet). One seek per frame maximum.
- Leaving: pointerleave (or blur) pauses, resets currentTime to 0 via the
  cancel path, clears the pending rAF, and returns to the poster.
- Keyboard parity: the root is a focusable container (tabIndex 0,
  role="group", aria-label={alt}). Focus starts the preview too, but only
  pointer-free focus: a mouse press also fires focus, and if both focus and
  click started playback they would cancel each other out and clicking would
  appear to do nothing. Detect that with a ref set in onPointerDown and
  cleared in onClick/onBlur — do NOT test :focus-visible inside the focus
  handler, Safari does not guarantee it has been applied yet, and a
  false negative there silently kills keyboard access. Keyboard focus skips
  hoverDelay entirely: tabbing is deliberate, sweeping a mouse is not.
  Ignore focus/blur whose relatedTarget is inside the card (internal focus
  moves must not restart the clip).
- Touch: pointer events with pointerType === "touch" are ignored for hover
  and scrub — a tap emits pointerenter too, so hover previews would fire on
  every scroll-by, and a horizontal drag belongs to the page, not the card.
  Touch users get click-to-play / click-to-stop instead.
- prefers-reduced-motion (read via useSyncExternalStore over matchMedia with
  a false server snapshot): no preview starts on hover or on focus at all.
  Proximity-triggered motion is exactly what vestibular sensitivity flags,
  so playback becomes opt-in — click to play, click again to stop. The card
  is still fully functional, just never autonomous.
- Intent tracking: a ref records whether the current preview was started by
  "hover" or by "click". Click-started previews survive pointerleave/blur
  (the user asked for them); only hover/focus-started ones are cancelled on
  leave. Click on an idle card starts it; click on an active card stops it.
- Errors: the video's onError sets status "error", which keeps the poster
  visible and shows a small non-blocking badge. Ignore the error when no src
  is attached (our own abort) and when MediaError code is MEDIA_ERR_ABORTED.
  The failure is sticky per src so a broken clip is not re-requested on
  every pass; it resets through a render-phase adjust-state (compare a
  prevSrc state value) when the src prop changes, so a recycled card in a
  virtualised grid is never permanently poisoned by one bad URL.
- loop=false: when the clip ends, fall back to the poster instead of
  freezing on the last frame.
- muted is written imperatively in a tiny effect (video.muted = muted)
  because React's attribute sync for that property is unreliable.
- Cleanup on unmount: clear the dwell timeout, cancel the pending rAF, and
  pause the video (captured at mount, not read during cleanup render).
  A mountedRef is set to true INSIDE an effect body (not only cleared in
  cleanup) so StrictMode's mount→cleanup→mount does not leave a live
  instance believing it is unmounted.

Rendering & styling
- Root: relative aspect-video w-full cursor-pointer overflow-hidden
  rounded-xl border bg-muted, plus focus-visible:ring-2 ring-ring
  ring-offset-2 ring-offset-background. Consumer className is merged last so
  aspect/rounding are overridable.
- Layers, all absolutely positioned and all pointer-events-none so a
  stretched <a> passed as children stays clickable: poster <img alt="">
  (decorative — the root's aria-label already names the card), then the
  <video aria-hidden="true" playsInline preload="none" tabIndex={-1}>
  object-cover, opacity-0 → opacity-100 only in "playing"
  (transition-opacity duration-300 motion-reduce:transition-none, so the
  fade is a cut for reduced-motion users but the preview still works).
- Overlays: a spinner (lucide Loader2, animate-spin motion-reduce:animate-none)
  top-right while loading; a bottom-right duration chip using
  bg-background/85 + tabular-nums; an "error" badge top-left with a lucide
  TriangleAlert and text-muted-foreground text-xs (visible but deliberately
  not shouting); a 1px progress rail on the bottom edge, bg-foreground/15
  with a bg-primary fill whose width is an inline percentage and which
  transitions on [width] only (naming the property explicitly, not
  "transition-all").
- Semantic tokens only: bg-muted / bg-card / bg-background/85 / bg-primary /
  text-foreground / text-muted-foreground / border / ring — no hardcoded
  colors, so the card inherits any theme and dark mode for free.

Customization levers
- Dwell tuning: hoverDelay is the single knob for the browse-vs-fetch
  tradeoff — 150-250ms for a small, deliberate list; 600ms+ for a dense grid
  on metered connections. Setting 0 makes it fire on entry (and re-adds the
  sweep cost you were avoiding).
- Frame shape: replace aspect-video via className (aspect-square for a
  music-style grid, aspect-[2/3] for posters) — object-cover keeps the crop
  centered either way.
- Chrome: drop showProgress and durationLabel for a bare frame, or move the
  duration chip to another corner. To add a title/CTA overlay, pass it as
  children — children render above every overlay layer.
- Whole-card link: keep the root focusable, or render
  <a className="absolute inset-0" aria-label={...}> as children and pass
  tabIndex={-1} on the root so there is exactly one tab stop; focus events
  from that inner link still bubble up and trigger the preview.
- Scrub feel: swap the linear pointer-x mapping for a segmented one (snap to
  N chapter thumbnails) by quantising the ratio before multiplying by
  duration; or drive currentTime from a keyboard ArrowLeft/ArrowRight
  handler on the root to give keyboard users the same scrubbing.
- Audio: muted={false} is supported but expect the browser to block
  autoplay and the card to stay on the poster — a "hover to unmute" button
  gated behind a first click is the realistic pattern.
- Source flexibility: swap the single src for a <source> list (webm + mp4)
  if you need codec fallbacks; the attach/detach logic is unchanged as long
  as the sources are only rendered while active.

Concepts

  • Dwell-gated fetch — the hoverDelay timer is what separates "the pointer passed over this card" from "the user is looking at this card"; the network request lives on the far side of that timer, so a sweep across a grid costs nothing.
  • Abort by re-load — dropping the src attribute leaves an in-flight media fetch running; calling load() afterwards is what actually cancels it and returns the element to NETWORK_EMPTY. Cancellation is a two-step move, not one.
  • Proportional scrub — pointer x across the card maps linearly to position in the clip (ratio x duration), throttled to one seek per animation frame, with a 0.05s tail guard so the right edge does not land on ended.
  • Motion consent — under prefers-reduced-motion nothing plays by proximity; hover and focus stop triggering and playback becomes click-initiated. Reduced motion here removes autonomy, not capability.
  • Start-intent memory — the component remembers whether the preview was started by hover or by click; click-started previews keep playing when the pointer leaves, hover-started ones do not.
  • Sticky-but-resettable failure — a load error keeps the poster and a quiet badge, and is not retried on every pass; it clears through a render-phase state adjustment when src changes, so recycled cards in a virtualised grid never inherit an old failure.

On This Page