Video Player
Free · MITHeadless, 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
npx astrocraft-ui add media/video-playerPlain-CSS theme — no build step
npx astrocraft-ui add media/video-player --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add media/video-player --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add media/video-player --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add media/video-player --theme tailwind --bridge lumosLive 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.
| Component | Slots | Variants | Runtime state | Native 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 — 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 — 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 — 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 — 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 — 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}
/>
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 — 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.