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