TourKit
@tour-kit/mediaHeadless

MediaHeadless

HeadlessMedia: unstyled media primitive exposing playback state, duration, progress, and control actions via render props

Overview

MediaHeadless provides all the logic for media playback without any UI. Use render props to build completely custom media players that match your design system perfectly.

Basic Usage

import { MediaHeadless } from '@tour-kit/media'

<MediaHeadless src="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
  {({ isPlaying, play, pause }) => (
    <div>
      <div className="video-embed">{/* Embed rendered here */}</div>
      <button onClick={isPlaying ? pause : play}>
        {isPlaying ? '⏸ Pause' : '▶ Play'}
      </button>
    </div>
  )}
</MediaHeadless>

Props

Prop

Type

Render Props

The render function receives an object with the following properties:

Prop

Type

Examples

Custom YouTube Player

Build a branded video player with custom controls:

<MediaHeadless src="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
  {({
    embedUrl,
    isPlaying,
    isLoaded,
    currentTime,
    duration,
    play,
    pause,
    containerRef
  }) => (
    <div className="custom-player">
      {/* Video iframe */}
      <div ref={containerRef} className="video-container">
        {embedUrl && (
          <iframe
            src={embedUrl}
            className="w-full h-full"
            allow="autoplay; encrypted-media"
            allowFullScreen
          />
        )}
      </div>

      {/* Custom controls */}
      <div className="controls">
        <button
          onClick={isPlaying ? pause : play}
          disabled={!isLoaded}
          className="play-button"
        >
          {isPlaying ? '⏸' : '▶'}
        </button>

        <div className="progress-bar">
          <div
            className="progress-fill"
            style={{ width: `${(currentTime / duration) * 100}%` }}
          />
        </div>

        <span className="time">
          {formatTime(currentTime)} / {formatTime(duration)}
        </span>
      </div>
    </div>
  )}
</MediaHeadless>

Native Video with Seek Controls

Full control over native video playback:

<MediaHeadless src="/videos/tutorial.mp4" type="video">
  {({ src, isPlaying, currentTime, duration, play, pause, seek }) => (
    <div className="video-player">
      <video
        src={src}
        onPlay={play}
        onPause={pause}
        onTimeUpdate={(e) => updateCurrentTime(e.currentTarget.currentTime)}
        onLoadedMetadata={(e) => updateDuration(e.currentTarget.duration)}
      />

      <div className="controls">
        <button onClick={isPlaying ? pause : play}>
          {isPlaying ? 'Pause' : 'Play'}
        </button>

        <input
          type="range"
          min="0"
          max={duration}
          value={currentTime}
          onChange={(e) => seek(Number(e.target.value))}
          className="seek-bar"
        />

        <div className="skip-buttons">
          <button onClick={() => seek(Math.max(0, currentTime - 10))}>
            ⏪ -10s
          </button>
          <button onClick={() => seek(Math.min(duration, currentTime + 10))}>
            +10s ⏩
          </button>
        </div>
      </div>
    </div>
  )}
</MediaHeadless>

Loading and Error States

Handle all media states gracefully:

<MediaHeadless
  src="https://vimeo.com/123456789"
  onError={(error) => console.error('Media failed:', error)}
>
  {({ isLoaded, hasError, errorMessage, embedUrl, containerRef }) => (
    <div className="media-wrapper">
      {!isLoaded && !hasError && (
        <div className="loading-state">
          <Spinner />
          <p>Loading video...</p>
        </div>
      )}

      {hasError && (
        <div className="error-state">
          <p>Failed to load video</p>
          <p className="error-message">{errorMessage}</p>
        </div>
      )}

      {isLoaded && !hasError && (
        <div ref={containerRef}>
          <iframe src={embedUrl} />
        </div>
      )}
    </div>
  )}
</MediaHeadless>

Responsive Player with Thumbnails

Show thumbnails while loading:

<MediaHeadless src="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
  {({ embedUrl, isLoaded, isPlaying, play, containerRef }) => (
    <div className="responsive-player">
      {!isPlaying && (
        <div className="thumbnail" onClick={play}>
          <img
            src="/thumbnails/video.jpg"
            alt="Video thumbnail"
            className="w-full"
          />
          <button className="play-overlay">▶ Play Video</button>
        </div>
      )}

      {(isPlaying || isLoaded) && (
        <div ref={containerRef} className="video-embed">
          <iframe src={embedUrl} />
        </div>
      )}
    </div>
  )}
</MediaHeadless>

Playlist Navigation

Build a custom video playlist:

function VideoPlaylist({ videos }) {
  const [currentIndex, setCurrentIndex] = useState(0)
  const currentVideo = videos[currentIndex]

  return (
    <div className="playlist">
      <MediaHeadless
        key={currentVideo.id}
        src={currentVideo.url}
        onComplete={() => {
          // Auto-advance to next video
          if (currentIndex < videos.length - 1) {
            setCurrentIndex(currentIndex + 1)
          }
        }}
      >
        {({ embedUrl, containerRef }) => (
          <div ref={containerRef}>
            <iframe src={embedUrl} />
          </div>
        )}
      </MediaHeadless>

      <div className="playlist-nav">
        {videos.map((video, index) => (
          <button
            key={video.id}
            onClick={() => setCurrentIndex(index)}
            className={index === currentIndex ? 'active' : ''}
          >
            {video.title}
          </button>
        ))}
      </div>
    </div>
  )
}

Picture-in-Picture Mode

Add custom PiP controls:

<MediaHeadless src="/videos/demo.mp4" type="video">
  {({ containerRef, isPlaying }) => {
    const [isPiP, setIsPiP] = useState(false)

    const togglePiP = async () => {
      const video = containerRef.current?.querySelector('video')
      if (!video) return

      if (document.pictureInPictureElement) {
        await document.exitPictureInPicture()
        setIsPiP(false)
      } else {
        await video.requestPictureInPicture()
        setIsPiP(true)
      }
    }

    return (
      <div className="pip-player">
        <div ref={containerRef}>
          <video src="/videos/demo.mp4" />
        </div>
        <button onClick={togglePiP}>
          {isPiP ? '📺 Exit PiP' : '🖼 Picture-in-Picture'}
        </button>
      </div>
    )
  }}
</MediaHeadless>

Analytics Integration

Track detailed engagement metrics:

<MediaHeadless
  src="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
  onPlay={() => analytics.track('Video Play')}
  onPause={() => analytics.track('Video Pause')}
  onComplete={() => analytics.track('Video Complete')}
>
  {({ embedUrl, currentTime, duration, containerRef }) => {
    // Track watch progress
    useEffect(() => {
      if (currentTime > 0) {
        const progress = (currentTime / duration) * 100
        if (progress >= 25 && progress < 26) {
          analytics.track('Video 25% Complete')
        }
        if (progress >= 50 && progress < 51) {
          analytics.track('Video 50% Complete')
        }
        if (progress >= 75 && progress < 76) {
          analytics.track('Video 75% Complete')
        }
      }
    }, [currentTime, duration])

    return (
      <div ref={containerRef}>
        <iframe src={embedUrl} />
      </div>
    )
  }}
</MediaHeadless>

GIF Player with Custom Controls

Build a custom GIF player:

<MediaHeadless src="/animations/demo.gif" type="gif">
  {({ src, isPlaying, play, pause }) => {
    const [showStatic, setShowStatic] = useState(true)

    return (
      <div className="gif-player">
        <img
          src={isPlaying ? src : '/images/demo-poster.jpg'}
          alt="Demo animation"
        />

        <button
          onClick={() => {
            if (isPlaying) {
              pause()
              setShowStatic(true)
            } else {
              play()
              setShowStatic(false)
            }
          }}
          className="control-button"
        >
          {isPlaying ? '⏸ Pause' : '▶ Play'}
        </button>
      </div>
    )
  }}
</MediaHeadless>

Accessibility

When building custom UIs, ensure accessibility:

Keyboard Navigation

Add keyboard support to custom controls:

<MediaHeadless src="video.mp4">
  {({ isPlaying, play, pause, seek, currentTime }) => (
    <div
      onKeyDown={(e) => {
        if (e.key === ' ' || e.key === 'k') {
          e.preventDefault()
          isPlaying ? pause() : play()
        }
        if (e.key === 'ArrowLeft') {
          seek(currentTime - 5)
        }
        if (e.key === 'ArrowRight') {
          seek(currentTime + 5)
        }
      }}
      tabIndex={0}
    >
      {/* Player UI */}
    </div>
  )}
</MediaHeadless>

ARIA Attributes

Properly label interactive elements:

<button
  onClick={isPlaying ? pause : play}
  aria-label={isPlaying ? 'Pause video' : 'Play video'}
  aria-pressed={isPlaying}
>
  {isPlaying ? '⏸' : '▶'}
</button>

<div
  role="progressbar"
  aria-valuenow={currentTime}
  aria-valuemin={0}
  aria-valuemax={duration}
  aria-label="Video progress"
>
  {/* Progress bar */}
</div>

Reduced Motion

Respect motion preferences:

<MediaHeadless src="animation.json" type="lottie">
  {({ prefersReducedMotion, containerRef }) => (
    <div ref={containerRef}>
      {prefersReducedMotion ? (
        <img src="/fallback-image.svg" alt="Static illustration" />
      ) : (
        <LottieAnimation />
      )}
    </div>
  )}
</MediaHeadless>

TypeScript

Full type safety with render props:

import type { MediaHeadlessRenderProps } from '@tour-kit/media'

function CustomPlayer({ src }: { src: string }) {
  return (
    <MediaHeadless src={src}>
      {(props: MediaHeadlessRenderProps) => {
        const { isPlaying, play, pause, currentTime, duration } = props

        return (
          <div>
            {/* Your custom UI with full type safety */}
          </div>
        )
      }}
    </MediaHeadless>
  )
}

See Also

On this page