Skip to main content
astrocraft-ui/ components · 101

Video Player

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add media/video-player

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add media/video-player --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add media/video-player --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add media/video-player --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add media/video-player --theme tailwind --bridge lumos

Live demo

VideoPlayer

Custom controls over a real <video>. The seek bar is an <input type="range">, so it arrived with arrow keys, Home/End, Page Up/Down and an announced value already working. Focus the player and try space, ← →, ↑↓, m, f. Captions stay the browser's: the <track> below is slotted straight into the media element.

Disable JavaScript and reload — the <video> ships with controls and the script only takes them away once it has found a replacement.

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`FullscreenButton.astro``fullscreen-button`——`[aria-pressed]`
`PlayPauseButton.astro``pause-icon` `play-icon` `play-pause-button`——`[aria-pressed]`
`SeekBar.astro``seek-bar` `seek-bar-input` `seek-bar-time`——`[disabled]`
`VideoPlayer.astro``video-player` `video-player-media`———
`VolumeControl.astro``volume-control`———

Source

What the command copies — 7 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/media/video-player/FullscreenButton.astro
---
// src/components/ui/media/video-player/FullscreenButton.astro — VideoPlayer part (see ../../README.md).
// Puts the PLAYER into fullscreen, not the <video>: fullscreening the media element hands the
// browser's own controls back and leaves every custom control in this folder off-screen.
//
// The script hides it outright where the Fullscreen API is unavailable or the document forbids it
// (an iframe without `allowfullscreen` is the common one) — with the `hidden` attribute, because a
// button that cannot do its job is worse than one that is not there. `aria-pressed` follows the
// document's real fullscreen state, so leaving fullscreen with Escape still un-presses it.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"button"> & { label?: string };

const { label = "Fullscreen", class: className, ...rest } = Astro.props;
---

<button
  type="button"
  aria-pressed="false"
  aria-label={label}
  data-slot="fullscreen-button"
  class={className}
  {...rest}
>
  <slot>
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      aria-hidden="true"
    >
      <path d="M8 3H5a2 2 0 0 0-2 2v3"></path>
      <path d="M16 3h3a2 2 0 0 1 2 2v3"></path>
      <path d="M8 21H5a2 2 0 0 1-2-2v-3"></path>
      <path d="M16 21h3a2 2 0 0 0 2-2v-3"></path>
    </svg>
  </slot>
</button>
src/components/ui/media/video-player/PlayPauseButton.astro
---
// src/components/ui/media/video-player/PlayPauseButton.astro — VideoPlayer part (see ../../README.md).
// One toggle button, not two. The VideoPlayer script keeps `aria-pressed` in step with the media
// element's own `play` / `pause` events — so the button reports the video's state rather than the
// state of the last click, and stays right when the video ends, stalls, or is started from
// somewhere else entirely.
//
// The glyph swap is pure CSS off that same `aria-pressed` (structure.css), exactly as PasswordInput
// swaps its eye: one source of truth, and no class written from JavaScript. Both glyphs are slot
// fallbacks — pass `slot="play"` / `slot="pause"` to use your own.
import "../../../../styles/structure.css";

import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"button"> & { label?: string };

const { label = "Play", class: className, ...rest } = Astro.props;
---

<button
  type="button"
  aria-pressed="false"
  aria-label={label}
  data-slot="play-pause-button"
  class={className}
  {...rest}
>
  <span data-slot="play-icon">
    <slot name="play">
      <svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <path d="M7 4v16l13-8z"></path>
      </svg>
    </slot>
  </span>
  <span data-slot="pause-icon">
    <slot name="pause">
      <svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <path d="M8 4v16M16 4v16"></path>
      </svg>
    </slot>
  </span>
</button>
src/components/ui/media/video-player/SeekBar.astro
---
// src/components/ui/media/video-player/SeekBar.astro — VideoPlayer part (see ../../README.md).
// A real <input type="range"> over `currentTime`, which is the whole reason this is three lines of
// markup rather than a div with a pointer handler: the browser already gives a range input arrow
// keys, Home/End, Page Up/Down, a focus ring, a touch target and an announced value. Re-implementing
// that on a <div role="slider"> is how hand-rolled players lose their keyboard.
//
// It renders `max="0"` and `disabled`, and the script lifts both once `loadedmetadata` reports a
// duration. That is not a loading state for show — until the duration is known there is no range to
// scrub within, and a slider whose maximum is a guess reports positions that are wrong.
//
// `step="1"` because the step is the ARROW KEY distance, and one second per press is the only value
// that makes the keyboard usable. Page Up/Down covers longer jumps natively.
//
// The <output> is the clock for the eye and is `aria-hidden`: the input's own `aria-valuetext` says
// the same position in words (see media-time.ts), and two readings of one number is one too many.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & { label?: string };

const { label = "Seek", class: className, ...rest } = Astro.props;
---

<div data-slot="seek-bar" class={className} {...rest}>
  <input
    type="range"
    min="0"
    max="0"
    step="1"
    value="0"
    disabled
    aria-label={label}
    aria-valuetext="0 seconds"
    data-slot="seek-bar-input"
  />
  <output aria-hidden="true" data-slot="seek-bar-time">0:00</output>
  <slot />
</div>
src/components/ui/media/video-player/VideoPlayer.astro
---
// src/components/ui/media/video-player/VideoPlayer.astro — headless primitive (see ../../README.md).
// Custom controls over a REAL <video>. Everything hard about video stays the browser's: decoding,
// buffering, Picture-in-Picture, AirPlay, and — the one that matters most here — captions, which are
// rendered and positioned by the UA from the <track> elements you slot in. A player that reimplements
// caption rendering is a player that gets caption rendering wrong.
//
// It ships `controls` and the script takes them away, and only once it has found a control of ours to
// replace them with. That ordering is the whole progressive-enhancement story: if the bundle fails,
// is blocked, or simply has not run yet, the native controls are there and the video is watchable.
//
// Slot your <source> and <track> elements with `slot="media"`; everything else in the default slot is
// the control bar. Props spread onto the <video> (`src`, `poster`, `preload`, `muted`, `loop`), and
// `class` lands on the wrapper, which is also the fullscreen target.
//
// The wrapper is focusable so the keyboard shortcuts have somewhere to live: space/k play and pause,
// ←/→ seek five seconds, ↑/↓ change volume, m mutes, f goes fullscreen. Each one steps aside for the
// control that legitimately owns that key — arrows belong to a focused range input, space to a
// focused button — so the shortcuts never take a key from the control under the user's finger.
import "../../../../styles/structure.css";

// Two rules fire here and both are blind to a component library. The tab stop is what gives the
// keyboard shortcuts somewhere to live, on a wrapper that owns a media element rather than inert
// content; and the captions the second rule wants are <track> elements the CONSUMER slots in, so
// no amount of markup in this file could satisfy it.
/* eslint-disable astro/jsx-a11y/no-noninteractive-tabindex, astro/jsx-a11y/media-has-caption */
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"video"> & { label?: string };

const { label = "Video player", class: className, ...rest } = Astro.props;
---

<div role="group" aria-label={label} tabindex="0" data-slot="video-player" class={className}>
  <video controls playsinline data-slot="video-player-media" {...rest}>
    <slot name="media" />
  </video>
  <slot />
</div>

<script>
  import { onReadyOnce } from "../../_once";
  import { formatTime, spokenTime } from "./media-time";

  const SEEK_STEP = 5;
  const VOLUME_STEP = 0.05;

  const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

  function wire(root: HTMLElement) {
    const media = root.querySelector<HTMLVideoElement>('[data-slot="video-player-media"]');
    if (!media) return;

    const play = root.querySelector<HTMLButtonElement>('[data-slot="play-pause-button"]');
    const seek = root.querySelector<HTMLInputElement>('[data-slot="seek-bar-input"]');
    const clock = root.querySelector<HTMLElement>('[data-slot="seek-bar-time"]');
    const volume = root.querySelector<HTMLInputElement>('[data-slot="volume-control"]');
    const fullscreen = root.querySelector<HTMLButtonElement>('[data-slot="fullscreen-button"]');
    if (!play && !seek && !volume && !fullscreen) return;

    // Only now, with a replacement in hand. See the header: the native controls are the fallback.
    media.controls = false;

    /** The furthest `currentTime` may go — `Infinity` for a live stream, whose duration never settles. */
    const end = () => (Number.isFinite(media.duration) ? media.duration : Infinity);

    const toggle = () => {
      if (!media.paused) {
        media.pause();
        return;
      }
      media.play().catch(() => {
        // Autoplay policy refused it. Not an error to report: no `play` event fires either, so
        // `aria-pressed` stays false and the button still describes the video correctly.
      });
    };

    const reflectPlaying = () => play?.setAttribute("aria-pressed", String(!media.paused));
    for (const type of ["play", "pause", "ended"]) media.addEventListener(type, reflectPlaying);
    play?.addEventListener("click", toggle);
    media.addEventListener("click", toggle);

    // THE SCRUB GUARD. Writing `.value` to a range input mid-drag interrupts the browser's own drag,
    // and `timeupdate` fires four times a second — so without this the bar fights the thumb under
    // the user's finger. `change` is the belt to pointerup's braces: it fires on release even when
    // the pointer came up somewhere else entirely.
    let scrubbing = false;
    seek?.addEventListener("pointerdown", () => {
      scrubbing = true;
    });
    for (const type of ["pointerup", "pointercancel", "change"]) {
      seek?.addEventListener(type, () => {
        scrubbing = false;
      });
    }

    const reflectTime = () => {
      if (clock)
        clock.textContent = `${formatTime(media.currentTime)} / ${formatTime(media.duration)}`;
      if (!seek || scrubbing) return;
      seek.value = String(media.currentTime);
      seek.setAttribute("aria-valuetext", spokenTime(media.currentTime));
    };
    media.addEventListener("timeupdate", reflectTime);

    // The range is unusable, and lies, until the duration is known: `max` defaults to 100, so a
    // slider rendered before metadata would report "50" for the middle of a video of unknown length.
    const reflectDuration = () => {
      if (!seek) return;
      const duration = Number.isFinite(media.duration) ? Math.floor(media.duration) : 0;
      seek.max = String(duration);
      seek.disabled = duration === 0;
      reflectTime();
    };
    for (const type of ["loadedmetadata", "durationchange"]) {
      media.addEventListener(type, reflectDuration);
    }

    seek?.addEventListener("input", () => {
      media.currentTime = seek.valueAsNumber;
      seek.setAttribute("aria-valuetext", spokenTime(seek.valueAsNumber));
    });

    const reflectVolume = () => {
      if (!volume) return;
      const level = media.muted ? 0 : media.volume;
      volume.value = String(level);
      volume.setAttribute("aria-valuetext", `${Math.round(level * 100)}%`);
    };
    media.addEventListener("volumechange", reflectVolume);
    volume?.addEventListener("input", () => {
      media.volume = volume.valueAsNumber;
      // Dragging to silence IS muting, so the slider and `muted` can never disagree — which is what
      // lets `m` and the slider be the same control rather than two that contradict each other.
      media.muted = volume.valueAsNumber === 0;
    });

    if (fullscreen) {
      // A button that cannot work is worse than one that is not there — an iframe without
      // `allowfullscreen` is the usual reason, and nothing about the page says so.
      if (!document.fullscreenEnabled) fullscreen.hidden = true;
      fullscreen.addEventListener("click", () => {
        const request =
          document.fullscreenElement === root
            ? document.exitFullscreen()
            : root.requestFullscreen();
        request.catch(() => {
          // Refused (no user gesture, or a policy) — `fullscreenchange` does not fire, so the
          // button's `aria-pressed` already says the truth.
        });
      });
      // `fullscreenchange` is dispatched at the element itself, so this catches Escape and the
      // browser's own exit as well as our button.
      root.addEventListener("fullscreenchange", () => {
        fullscreen.setAttribute("aria-pressed", String(document.fullscreenElement === root));
      });
    }

    root.addEventListener("keydown", (event) => {
      const target = event.target;
      const onRange = target instanceof HTMLInputElement && target.type === "range";
      // Typing beats every shortcut. A search box inside a player is unusual but not impossible, and
      // a player that eats "f" is a player you cannot type "film" into.
      if (
        (target instanceof HTMLInputElement && !onRange) ||
        target instanceof HTMLTextAreaElement ||
        target instanceof HTMLSelectElement ||
        (target instanceof HTMLElement && target.isContentEditable)
      ) {
        return;
      }
      const key = event.key.toLowerCase();
      // Keys the focused control already owns, and owns better: a range input's arrows move it by
      // its own step, a button's space presses it.
      if (onRange && key.startsWith("arrow")) return;
      if (
        (key === " " || key === "enter") &&
        target instanceof Element &&
        target.closest("button, a, [role='button']")
      ) {
        return;
      }

      if (key === " " || key === "k") toggle();
      else if (key === "arrowleft")
        media.currentTime = clamp(media.currentTime - SEEK_STEP, 0, end());
      else if (key === "arrowright")
        media.currentTime = clamp(media.currentTime + SEEK_STEP, 0, end());
      else if (key === "arrowup") media.volume = clamp(media.volume + VOLUME_STEP, 0, 1);
      else if (key === "arrowdown") media.volume = clamp(media.volume - VOLUME_STEP, 0, 1);
      else if (key === "m") media.muted = !media.muted;
      else if (key === "f") fullscreen?.click();
      else return;
      // Reached only by a key we handled — space would scroll the page, arrows would too.
      event.preventDefault();
    });

    reflectPlaying();
    reflectDuration();
    reflectVolume();
  }

  onReadyOnce('[data-slot="video-player"]', wire);
</script>
src/components/ui/media/video-player/VolumeControl.astro
---
// src/components/ui/media/video-player/VolumeControl.astro — VideoPlayer part (see ../../README.md).
// A range over `volume`, 0–1. Dragging it to zero mutes and lifting it off zero unmutes, so the
// control has no second state to keep in sync and no mute button to contradict it — `m` on the
// keyboard does the same thing through the same slider, which is why it stays honest.
//
// `aria-valuetext` is a percentage because "0.35" is not a volume anyone recognises.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"input"> & { label?: string };

const { label = "Volume", class: className, ...rest } = Astro.props;
---

<input
  type="range"
  min="0"
  max="1"
  step="0.05"
  value="1"
  aria-label={label}
  aria-valuetext="100%"
  data-slot="volume-control"
  class={className}
  {...rest}
/>
src/components/ui/media/video-player/index.ts
import FullscreenButton from "./FullscreenButton.astro";
import PlayPauseButton from "./PlayPauseButton.astro";
import SeekBar from "./SeekBar.astro";
import VideoPlayer from "./VideoPlayer.astro";
import VolumeControl from "./VolumeControl.astro";

export { FullscreenButton, PlayPauseButton, SeekBar, VideoPlayer, VolumeControl };
export default VideoPlayer;
src/components/ui/media/video-player/media-time.ts
// src/components/ui/media/video-player/media-time.ts — the two ways a media position has to be written:
// one for the eye and one for a screen reader. Pure, so both are checkable without a DOM or a video
// (see media-time.test.ts).
//
// They are separate functions because "1:23" is the wrong string to hand to `aria-valuetext`.
// Screen readers do not agree on what a colon in a number means — one says "one twenty-three",
// another "one colon twenty three", a third spells the digits — so the seek bar announces words and
// the visible readout stays the clock every video player has ever shown.

const clamp = (seconds: number): number =>
  Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0;

const plural = (n: number, unit: string): string => `${n} ${unit}${n === 1 ? "" : "s"}`;

/**
 * A media position as a clock: `m:ss`, or `h:mm:ss` once there is an hour to show.
 *
 * Guards against the values a media element actually reports: `duration` is `NaN` until metadata
 * loads and `Infinity` for a live stream, and either one reaches the screen as "NaN:aN" if it is
 * simply formatted.
 *
 * @param seconds - a `currentTime` or `duration`
 * @returns the clock string; `"0:00"` for anything not a positive finite number
 * @example formatTime(83) // => "1:23"
 */
export function formatTime(seconds: number): string {
  const total = clamp(seconds);
  const s = String(total % 60).padStart(2, "0");
  const m = Math.floor(total / 60) % 60;
  const h = Math.floor(total / 3600);
  return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
}

/**
 * The same position in words, for `aria-valuetext` on the seek bar.
 *
 * @example spokenTime(83) // => "1 minute 23 seconds"
 * @example spokenTime(0)  // => "0 seconds"
 */
export function spokenTime(seconds: number): string {
  const total = clamp(seconds);
  const parts: string[] = [];
  if (total >= 3600) parts.push(plural(Math.floor(total / 3600), "hour"));
  if (total >= 60) parts.push(plural(Math.floor(total / 60) % 60, "minute"));
  // The seconds are always spoken, so that a whole minute reads "2 minutes 0 seconds" rather than
  // "2 minutes" — which is the same string the reader would get for a duration it does not know.
  parts.push(plural(total % 60, "second"));
  return parts.join(" ");
}

What you get

The component source, copied into your project by npx astrocraft-ui add media/video-player — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.