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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/media/video-player/FullscreenButton.astro
---
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
---
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
---
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
---
import "../../../../styles/structure.css";

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;

    media.controls = false;

    const end = () => (Number.isFinite(media.duration) ? media.duration : Infinity);

    const toggle = () => {
      if (!media.paused) {
        media.pause();
        return;
      }
      media.play().catch(() => {
      });
    };

    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);

    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);

    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;
      media.muted = volume.valueAsNumber === 0;
    });

    if (fullscreen) {
      if (!document.fullscreenEnabled) fullscreen.hidden = true;
      fullscreen.addEventListener("click", () => {
        const request =
          document.fullscreenElement === root
            ? document.exitFullscreen()
            : root.requestFullscreen();
        request.catch(() => {
        });
      });
      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";
      if (
        (target instanceof HTMLInputElement && !onRange) ||
        target instanceof HTMLTextAreaElement ||
        target instanceof HTMLSelectElement ||
        (target instanceof HTMLElement && target.isContentEditable)
      ) {
        return;
      }
      const key = event.key.toLowerCase();
      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;
      event.preventDefault();
    });

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

  onReadyOnce('[data-slot="video-player"]', wire);
</script>
src/components/ui/media/video-player/VolumeControl.astro
---
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
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"}`;

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}`;
}

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"));
  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.