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 init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/video-playerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/video-player-fixturesPreview
Click the big play button → controls auto-hide after 2s during playback. Hover or move the mouse to bring them back.
Demo source
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