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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/video-hover-preview.jsonPrompt
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
hoverDelaytimer 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
srcattribute leaves an in-flight media fetch running; callingload()afterwards is what actually cancels it and returns the element toNETWORK_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 onended. - Motion consent — under
prefers-reduced-motionnothing 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
srcchanges, so recycled cards in a virtualised grid never inherit an old failure.
Video Player
A self-hosted video player with a custom control skin — scrub with buffered ranges, volume memory, speed menu, subtitles, fullscreen, PiP, keyboard shortcuts and a real error state.
Camera Capture
A real getUserMedia webcam capturer — live preview, countdown shutter, mirrored canvas export, and a keep/retake review step that hands back a JPEG Blob.