Skip to content
ilinxa/pro-ui

Video Player

alphav0.2.0

Video element wrapper — autoplay-friendly defaults, slot-based controls, carousel-coordinated pause, and double-tap gestures.

Category: MediaUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/video-player

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/video-player-fixtures

CLI 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

demo.tsxtsx
"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>&lt;track&gt;</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=&#123;false&#125;</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: renderVideoElement for full takeover.
  • Don't expect fullscreen / volume slider / PiP in v0.1 default controls. Add via renderControls if 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 labels if you care about React.memo — hoist the labels object to module scope. Same for tracks arrays.

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 — preserve aria-pressed on toggle buttons + aria-label on 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

Tags

video-playervideomediaplayercarousel

Dependencies

shadcn primitives: button
npm peer deps: lucide-react@^1.11.0