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
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
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

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

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