Video Player
alphav0.2.0Video element wrapper — autoplay-friendly defaults, slot-based controls, carousel-coordinated pause, and double-tap gestures.
Context
Use anywhere user-generated or editorial video plays — Instagram-post media, story viewers, news article inline videos, event recordings, product previews. The isActive prop pauses the video cleanly when its slide goes off-screen, so consumers (carousels, story viewers) coordinate playback via a single boolean. Custom control UI via the renderControls slot; default overlay matches the kasder play/pause/mute pattern with auto-hide. Double-tap callback fires through useDoubleTap (also exported standalone for non-video double-tap-to-like). Migration origin: kasder kas-social-front-v0 PostVideoPlayer.tsx; second ship in the 8-component social-posts-system arc; first occupant of the `media` category.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/video-playerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/video-player-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Click the big play button → controls auto-hide after 2s during playback. Hover or move the mouse to bring them back.
Demo source
"use client"; import { useState } from "react";import { Pause, Play } from "lucide-react";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { cn } from "@/lib/utils";import { VideoPlayer } from "./video-player";import { SAMPLE_LOOP_VIDEO_URL, SAMPLE_POSTER_URL, SAMPLE_TRACKS_EN, SAMPLE_VIDEO_URL, SAMPLE_VIDEO_URL_B, SAMPLE_VIDEO_URL_C,} from "./dummy-data";import type { VideoState } from "./types"; function formatTime(seconds: number): string { if (!Number.isFinite(seconds)) return "0:00"; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${s.toString().padStart(2, "0")}`;} function CustomControlsRenderer({ isPlaying, duration, currentTime, togglePlay,}: VideoState) { const progress = duration > 0 ? (currentTime / duration) * 100 : 0; return ( <div className="pointer-events-none absolute inset-0 flex flex-col justify-end"> <div className="pointer-events-auto bg-linear-to-t from-black/70 to-transparent p-3"> <div className="mb-2 flex items-center gap-2"> <Button type="button" variant="secondary" size="icon" className="h-8 w-8 rounded-full bg-background/80" onClick={togglePlay} aria-label={isPlaying ? "Pause" : "Play"} > {isPlaying ? ( <Pause className="h-4 w-4" aria-hidden="true" /> ) : ( <Play className="h-4 w-4 fill-current" aria-hidden="true" /> )} </Button> <span className="font-mono text-xs text-white tabular-nums"> {formatTime(currentTime)} / {formatTime(duration)} </span> </div> <div className="h-1 w-full overflow-hidden rounded-full bg-white/20"> <div className="h-full bg-primary transition-[width] duration-150" style={{ width: `${progress}%` }} /> </div> </div> </div> );} function CarouselDemo() { const videos = [SAMPLE_VIDEO_URL, SAMPLE_VIDEO_URL_B, SAMPLE_VIDEO_URL_C]; const [activeIdx, setActiveIdx] = useState(0); return ( <div className="space-y-4"> <div className="grid gap-3 sm:grid-cols-3"> {videos.map((src, idx) => ( <div key={src} className={cn( "aspect-video overflow-hidden rounded-md ring-2 transition-all", idx === activeIdx ? "ring-primary" : "opacity-60 ring-transparent", )} > <VideoPlayer src={src} isActive={idx === activeIdx} autoPlay /> </div> ))} </div> <div className="flex flex-wrap items-center gap-2"> <span className="text-xs text-muted-foreground">Active slide:</span> {videos.map((_, idx) => ( <Button key={idx} type="button" size="sm" variant={idx === activeIdx ? "default" : "outline"} onClick={() => setActiveIdx(idx)} > Video {idx + 1} </Button> ))} </div> <p className="text-xs text-muted-foreground"> Only the active slide plays. Switching pauses the previous video cleanly via the <code>isActive</code> prop — exactly the contract that <code>media-carousel</code> will use. </p> </div> );} export default function VideoPlayerDemo() { return ( <Tabs defaultValue="default"> <SwipeTabsList> <TabsTrigger value="default">Default</TabsTrigger> <TabsTrigger value="custom-controls">Custom controls</TabsTrigger> <TabsTrigger value="captions">Captions</TabsTrigger> <TabsTrigger value="decorative">Decorative</TabsTrigger> <TabsTrigger value="carousel">isActive (carousel)</TabsTrigger> </SwipeTabsList> <TabsContent value="default" className="mt-6"> <div className="aspect-video overflow-hidden rounded-md"> <VideoPlayer src={SAMPLE_VIDEO_URL} poster={SAMPLE_POSTER_URL} /> </div> <p className="mt-3 text-xs text-muted-foreground"> Click the big play button → controls auto-hide after 2s during playback. Hover or move the mouse to bring them back. </p> </TabsContent> <TabsContent value="custom-controls" className="mt-6"> <div className="aspect-video overflow-hidden rounded-md"> <VideoPlayer src={SAMPLE_VIDEO_URL} poster={SAMPLE_POSTER_URL} renderControls={(state) => <CustomControlsRenderer {...state} />} /> </div> <p className="mt-3 text-xs text-muted-foreground"> The default control overlay is fully replaced via{" "} <code>renderControls</code> — slot receives full{" "} <code>VideoState</code> (isPlaying, currentTime, duration, togglePlay, etc.). </p> </TabsContent> <TabsContent value="captions" className="mt-6"> <div className="aspect-video overflow-hidden rounded-md"> <VideoPlayer src={SAMPLE_VIDEO_URL} poster={SAMPLE_POSTER_URL} tracks={SAMPLE_TRACKS_EN} /> </div> <p className="mt-3 text-xs text-muted-foreground"> Caption track rendered via the <code>tracks</code> prop. Browsers expose them in their native subtitle UI (CC button in fullscreen). Sample track URL is a placeholder — verifies the{" "} <code><track></code> element renders. </p> </TabsContent> <TabsContent value="decorative" className="mt-6"> <div className="aspect-video overflow-hidden rounded-md"> <VideoPlayer src={SAMPLE_LOOP_VIDEO_URL} controls={false} autoPlay loop /> </div> <p className="mt-3 text-xs text-muted-foreground"> <code>controls={false}</code> + <code>autoPlay</code> +{" "} <code>loop</code> — silent decorative background. No control overlay; click-to-toggle still works. Keyboard (Space, M) still works if focused. </p> </TabsContent> <TabsContent value="carousel" className="mt-6"> <CarouselDemo /> </TabsContent> </Tabs> );} Usage
When to use
Reach for VideoPlayer anywhere user-generated or editorial video plays — Instagram-post media, story viewers, news article inline videos, event recordings, product previews. The isActive prop pauses cleanly when the video goes off-screen, so consumers (carousels, story viewers) coordinate playback via a single boolean. Custom control UI via the renderControls slot; default overlay matches the kasder-style play / pause / mute pattern with auto-hide.
Basic example
import { VideoPlayer } from "@/components/video-player";
export function Example() {
return (
<div className="aspect-video">
<VideoPlayer src={post.videoUrl} poster={post.posterUrl} />
</div>
);
}Carousel coordination via isActive
The host (carousel / story viewer) tracks which slide is current and sets isActive per slide. Inactive videos pause cleanly; active videos resume on consumer-driven gesture (no auto-resume).
{slides.map((item, idx) => (
<CarouselSlide key={item.id}>
{item.type === "video" ? (
<VideoPlayer
src={item.url}
poster={item.poster}
isActive={idx === currentIndex}
onDoubleTap={() => onLike(post.id)}
/>
) : (
<img src={item.url} alt={item.alt} />
)}
</CarouselSlide>
))}Decorative background loop
<VideoPlayer
src="/hero/loop.mp4"
controls={false}
autoPlay
loop
/>Custom controls via renderControls
The slot fully replaces the default overlay. Receives the entire VideoState (isPlaying, isMuted, isLoaded, duration, currentTime, togglePlay, toggleMute). The video ref is intentionally NOT exposed — it would let consumers bypass our state machine.
<VideoPlayer
src={reel.videoUrl}
renderControls={({ isPlaying, currentTime, duration, togglePlay }) => (
<ReelsControlOverlay
isPlaying={isPlaying}
progress={currentTime / duration}
onPlayToggle={togglePlay}
/>
)}
/>Captions via tracks
<VideoPlayer
src={article.videoUrl}
poster={article.videoPoster}
objectFit="contain"
controlsAutoHideMs={0}
tracks={[
{ kind: "captions", src: "/captions/en.vtt", srcLang: "en", label: "English", default: true },
{ kind: "captions", src: "/captions/tr.vtt", srcLang: "tr", label: "Türkçe" },
]}
labels={{ videoLabel: "Article video: Sustainable cities" }}
/>Localized labels
const TR_LABELS = {
play: "Oynat",
pause: "Duraklat",
mute: "Sesi Kapat",
unmute: "Sesi Aç",
videoLabel: "Etkinlik videosu",
};
<VideoPlayer src={event.recordingUrl} labels={TR_LABELS} />Standalone useDoubleTap hook
Exported from index.ts for non-video double-tap-to-like contexts (image carousels, photo viewers, card double-tap-to-favorite).
import { useDoubleTap } from "@/components/video-player";
function PhotoWithLike({ src, onLike }: { src: string; onLike: () => void }) {
const handleTap = useDoubleTap(onLike);
return <img src={src} onClick={handleTap} className="..." />;
}
// Or with a custom window:
const handleTap = useDoubleTap(onLike, { windowMs: 400 });Anti-patterns
- Don't expect a video ref in
renderControls— exposing it would let consumers bypass our state machine and cause desync. State + dispatchers only. v0.2 candidate:renderVideoElementfor full takeover. - Don't expect fullscreen / volume slider / PiP in v0.1 default controls. Add via
renderControlsif needed. - Don't expect HLS / DASH / streaming format support — raw
<video src>only in v0.1. - Don't expect a buffering spinner / error UI — browser shows the poster on slow load + load failure. v0.2 candidates.
- Don't auto-resume when
isActiveflips back to true. The component pauses on deactivation but doesn't auto-play on reactivation — consumer drives the play gesture. - Don't inline define
labelsif you care aboutReact.memo— hoist the labels object to module scope. Same fortracksarrays.
Accessibility
<video tabIndex={0}>— keyboard focusable; Space toggles play/pause, M toggles mute (focus-only, per Q-P4 lock).- Mute toggle gets
aria-pressed={isMuted}; label switches between "Mute" (action) and "Unmute" (action) so AT announces the action that the button will perform. - Caption tracks via the
tracksprop expose in the browser's native subtitle UI (CC button in fullscreen). - Under
prefers-reduced-motion: reduce, the auto-hide timer is skipped entirely — controls stay visible. - Custom
renderControlsis the consumer's responsibility — preservearia-pressedon toggle buttons +aria-labelon icon-only triggers.
Features
- Muted autoplay-friendly defaults (muted=true, loop=true, playsInline=true)
- isActive prop pauses cleanly when false (carousel coordination)
- Slot-based controls via renderControls(state) — full takeover
- Default overlay: big play button + bottom-right mute + bottom-left pause indicator + 2s auto-hide during playback
- Auto-hide skipped under prefers-reduced-motion
- Caption tracks via tracks: VideoTrack[] (rendered as <track> children)
- objectFit: cover | contain (default cover)
- Keyboard: Space=play/pause, M=mute (focus-required)
- rAF-throttled onTimeUpdate (perf-safe — caps at display refresh rate)
- Lifecycle callbacks: onPlay / onPause / onEnded / onTimeUpdate / onLoadedMetadata / onError
- Public useDoubleTap hook export for non-video consumers
- i18n via 5-key labels object
- useReducer state machine — atomic transitions, browser drives state, we mirror it
- loadstart event clears stale state on src change (no transient duration / currentTime mismatches)
- a11y: aria-label on <video>, aria-pressed on mute, aria-label on each control