Components
Media Player
A styled native <video> / <audio> with multiple <source> formats, <track> captions, a poster, and aspect-ratio framing. The browser ships the entire accessible playback UI — play, scrub, volume, captions, fullscreen, Picture-in-Picture — so there are no custom controls and zero JavaScript.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/media-player.json2. Use it
import { MediaPlayer } from "@/components/ui/media-player"
// Video with multiple formats + captions + poster
<MediaPlayer
poster="/poster.jpg"
sources={[
{ src: "/clip.webm", type: "video/webm" },
{ src: "/clip.mp4", type: "video/mp4" },
]}
tracks={[{ src: "/clip.en.vtt", kind: "captions", srclang: "en", label: "English", default: true }]}
/>
// Audio — native control bar
<MediaPlayer kind="audio" ariaLabel="Episode 12"
sources={[{ src: "/episode-12.mp3", type: "audio/mpeg" }]} />Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { Child } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Media Player — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A styled native <video controls> / <audio controls>. The browser ships a
// complete, accessible playback UI — play/pause, scrubber, volume, captions
// toggle, fullscreen, Picture-in-Picture — so we render the real platform
// element and only frame it (rounded card, aspect-ratio, poster). No custom
// controls, no JavaScript: the platform already does playback, and we never
// emulate a feature the browser ships (see AGENTS.md rule 4).
//
// Multiple <source> children let the browser pick the first format it can
// decode (webm → mp4 fallback, etc.); <track kind="captions"> overlays
// WebVTT subtitles/captions the native UI can toggle.
//
// Built on:
// - <video> — embeds a media player with native controls (play, seek,
// volume, fullscreen, PiP). `controls`, `poster`, `preload`, `loop`,
// `muted`, `playsinline`, `crossorigin`, `width`/`height`.
// repos/mdn/files/en-us/web/html/reference/elements/video/index.md
// - <audio> — same playback API without a visual frame; the native
// control bar is the whole UI.
// repos/mdn/files/en-us/web/html/reference/elements/audio/index.md
// - <source> — one media resource per format; the browser uses the first
// it can play. `src` + `type` (MIME, optionally with codecs).
// repos/mdn/files/en-us/web/html/reference/elements/source/index.md
// - <track> — timed WebVTT text track (captions/subtitles/descriptions/
// chapters). `kind`, `src`, `srclang`, `label`, `default`.
// repos/mdn/files/en-us/web/html/reference/elements/track/index.md
//
// CSS framing reuses the aspect-ratio approach (native `aspect-ratio`, no
// padding hack):
// repos/mdn/files/en-us/web/css/reference/properties/aspect-ratio/index.md
//
// Accessibility:
// - Native controls are keyboard-operable and labelled by the user agent.
// - Provide a <track kind="captions"> for spoken content (WCAG 1.2.2).
// - When no caption track exists, pass `ariaLabel` so AT announces what
// the player plays. The text inside the element is the no-support
// fallback shown to browsers that can't render <video>/<audio>.
export type MediaSource = {
// URL of the media resource.
src: string
// MIME type, optionally with a codecs parameter, e.g. 'video/webm',
// 'video/mp4; codecs="avc1.42E01E"'. Lets the browser skip formats it
// can't decode without downloading them.
type?: string
}
export type MediaTrackKind =
| "captions"
| "subtitles"
| "descriptions"
| "chapters"
| "metadata"
export type MediaTrack = {
// Address of the .vtt WebVTT file (same-origin unless the player carries a
// crossorigin attribute).
src: string
// How the timed text is used. Defaults to "subtitles" per the spec.
kind?: MediaTrackKind
// BCP 47 language tag, required when kind is "subtitles".
srclang?: string
// Human-readable name shown in the native captions menu.
label?: string
// Enable this track by default (at most one per media element).
default?: boolean
}
export type MediaPlayerKind = "video" | "audio"
const root =
"group/media-player relative block w-full overflow-hidden rounded-lg border bg-card"
// The media element itself. For video we stretch it to the framed box and
// letterbox with object-contain (cover would crop the picture); audio is a
// full-width native control bar.
const mediaClasses: Record<MediaPlayerKind, string> = {
video: "block size-full bg-black object-contain",
audio: "block w-full",
}
// Turn a "w/h" ratio string or number into a Tailwind aspect utility,
// mirroring registry/ui/aspect-ratio.tsx so the two components frame media
// identically.
const NAMED_RATIO: Record<string, string> = {
"1/1": "aspect-square",
"16/9": "aspect-video",
}
function ratioClass(ratio: number | string): string {
if (typeof ratio === "number") return `aspect-[${ratio}]`
const key = ratio.replace(/\s+/g, "")
return NAMED_RATIO[key] ?? `aspect-[${key}]`
}
type MediaPlayerProps = {
// "video" (default) frames a <video> in an aspect-ratio box; "audio"
// renders a full-width <audio> control bar.
kind?: MediaPlayerKind
// Single source shortcut. For multiple formats pass `sources` instead.
src?: string
// Multiple encodings, tried in order until one plays.
sources?: MediaSource[]
// Caption / subtitle tracks (WebVTT).
tracks?: MediaTrack[]
// Video only: image shown before the first frame is available.
poster?: string
// Width-to-height frame ratio for video (ignored for audio). A number
// (1.778) or a "w/h" string ("16/9", "4/3"). Defaults to 16:9.
ratio?: number | string
// Native playback hints / flags.
controls?: boolean
preload?: "none" | "metadata" | "auto"
loop?: boolean
muted?: boolean
autoplay?: boolean
// Video only: play inline rather than forcing fullscreen on mobile.
playsinline?: boolean
// CORS mode for cross-origin media (needed for cross-origin tracks).
crossorigin?: "anonymous" | "use-credentials"
// Accessible name when there is no caption track to identify the media.
ariaLabel?: string
class?: ClassValue
id?: string
// No-support fallback content (links to download the media, etc.). Also
// receives <source>/<track> if you'd rather pass them as children than via
// the `sources` / `tracks` props.
children?: Child
// Forward hx-*, data-*, aria-*, and standard attributes onto the root.
[key: string]: unknown
}
export function MediaPlayer(props: MediaPlayerProps) {
const {
kind = "video",
src,
sources,
tracks,
poster,
ratio = "16/9",
controls = true,
preload,
loop,
muted,
autoplay,
playsinline,
crossorigin,
ariaLabel,
class: className,
id,
children,
...rest
} = props
const isVideo = kind === "video"
const sourceEls = (sources ?? []).map((s) => (
<source data-slot="media-player-source" src={s.src} type={s.type} />
))
const trackEls = (tracks ?? []).map((t) => (
<track
data-slot="media-player-track"
kind={t.kind ?? "subtitles"}
src={t.src}
srclang={t.srclang}
label={t.label}
default={t.default}
/>
))
const common = {
"data-slot": "media-player-media",
src,
controls: controls ? true : undefined,
preload,
loop: loop ? true : undefined,
muted: muted ? true : undefined,
autoplay: autoplay ? true : undefined,
crossorigin,
"aria-label": ariaLabel,
}
const media = isVideo ? (
<video
{...common}
poster={poster}
playsinline={playsinline ? true : undefined}
class={mediaClasses.video}
>
{sourceEls}
{trackEls}
{children}
</video>
) : (
<audio {...common} class={mediaClasses.audio}>
{sourceEls}
{trackEls}
{children}
</audio>
)
return (
<div
id={id}
data-slot="media-player"
data-kind={kind}
class={cn(root, isVideo && ratioClass(ratio), !isVideo && "p-2", className)}
{...rest}
>
{media}
</div>
)
}
1. Save the file
Copy media-player.html into templates/components/.
2. Use it
{% from "components/media-player.html" import media_player %}
{% call media_player(
poster="/poster.jpg",
sources=[{"src": "/clip.webm", "type": "video/webm"},
{"src": "/clip.mp4", "type": "video/mp4"}],
tracks=[{"src": "/clip.en.vtt", "kind": "captions",
"srclang": "en", "label": "English", "default": True}]) %}
<a href="/clip.mp4">Download the video</a>
{% endcall %}View source
{# Media Player macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/media-player.tsx.
A styled native <video controls> / <audio controls>. The browser ships
the full accessible playback UI (play, seek, volume, captions, fullscreen,
PiP); we only frame it. Zero JS — we never emulate platform features.
MDN <video>: repos/mdn/files/en-us/web/html/reference/elements/video/index.md
MDN <audio>: repos/mdn/files/en-us/web/html/reference/elements/audio/index.md
MDN <source>: repos/mdn/files/en-us/web/html/reference/elements/source/index.md
MDN <track>: repos/mdn/files/en-us/web/html/reference/elements/track/index.md
`sources` is a list of {src, type} dicts; `tracks` a list of
{src, kind, srclang, label, default} dicts. The slotted {{ caller() }} is
the no-support fallback (download links, etc.).
Usage:
{% from "components/media-player.html" import media_player %}
{% call media_player(
sources=[{"src": "/clip.webm", "type": "video/webm"},
{"src": "/clip.mp4", "type": "video/mp4"}],
tracks=[{"src": "/clip.en.vtt", "kind": "captions",
"srclang": "en", "label": "English", "default": True}],
poster="/poster.jpg") %}
<a href="/clip.mp4">Download the video</a>
{% endcall %} #}
{% macro media_player(kind="video", src=none, sources=[], tracks=[], poster=none, ratio="16/9", controls=true, preload=none, loop=false, muted=false, autoplay=false, playsinline=false, crossorigin=none, aria_label=none, id=none, extra_class="", **attrs) %}
{%- set is_video = kind == "video" -%}
{%- set ratio_class -%}
{%- if ratio == "1/1" -%}aspect-square{%- elif ratio == "16/9" -%}aspect-video{%- else -%}aspect-[{{ ratio | replace(' ', '') }}]{%- endif -%}
{%- endset -%}
<div
{%- if id %} id="{{ id }}"{% endif %}
data-slot="media-player"
data-kind="{{ kind }}"
class="group/media-player relative block w-full overflow-hidden rounded-lg border bg-card {% if is_video %}{{ ratio_class }}{% else %}p-2{% endif %} {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>
{%- if is_video %}
<video data-slot="media-player-media"
{%- if src %} src="{{ src }}"{% endif %}
{%- if controls %} controls{% endif %}
{%- if poster %} poster="{{ poster }}"{% endif %}
{%- if preload %} preload="{{ preload }}"{% endif %}
{%- if loop %} loop{% endif %}
{%- if muted %} muted{% endif %}
{%- if autoplay %} autoplay{% endif %}
{%- if playsinline %} playsinline{% endif %}
{%- if crossorigin %} crossorigin="{{ crossorigin }}"{% endif %}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
class="block size-full bg-black object-contain">
{%- for s in sources %}
<source data-slot="media-player-source" src="{{ s.src }}"{% if s.type %} type="{{ s.type }}"{% endif %}>
{%- endfor %}
{%- for t in tracks %}
<track data-slot="media-player-track" kind="{{ t.kind | default('subtitles') }}" src="{{ t.src }}"{% if t.srclang %} srclang="{{ t.srclang }}"{% endif %}{% if t.label %} label="{{ t.label }}"{% endif %}{% if t.default %} default{% endif %}>
{%- endfor %}
{{ caller() }}
</video>
{%- else %}
<audio data-slot="media-player-media"
{%- if src %} src="{{ src }}"{% endif %}
{%- if controls %} controls{% endif %}
{%- if preload %} preload="{{ preload }}"{% endif %}
{%- if loop %} loop{% endif %}
{%- if muted %} muted{% endif %}
{%- if autoplay %} autoplay{% endif %}
{%- if crossorigin %} crossorigin="{{ crossorigin }}"{% endif %}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
class="block w-full">
{%- for s in sources %}
<source data-slot="media-player-source" src="{{ s.src }}"{% if s.type %} type="{{ s.type }}"{% endif %}>
{%- endfor %}
{%- for t in tracks %}
<track data-slot="media-player-track" kind="{{ t.kind | default('subtitles') }}" src="{{ t.src }}"{% if t.srclang %} srclang="{{ t.srclang }}"{% endif %}{% if t.label %} label="{{ t.label }}"{% endif %}{% if t.default %} default{% endif %}>
{%- endfor %}
{{ caller() }}
</audio>
{%- endif %}
</div>
{% endmacro %}
1. Save the file
Add media-player.tmpl alongside your templates.
2. Use it
{{template "media-player" (dict
"Poster" "/poster.jpg"
"Sources" (list (dict "Src" "/clip.webm" "Type" "video/webm")
(dict "Src" "/clip.mp4" "Type" "video/mp4"))
"Tracks" (list (dict "Src" "/clip.en.vtt" "Kind" "captions"
"Srclang" "en" "Label" "English" "Default" true)))}}View source
{{/*
Media Player template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/media-player.tsx.
A styled native <video controls> / <audio controls>. The browser ships the
full accessible playback UI (play, seek, volume, captions, fullscreen, PiP);
we only frame it. Zero JS — we never emulate platform features.
MDN <video>: repos/mdn/files/en-us/web/html/reference/elements/video/index.md
MDN <audio>: repos/mdn/files/en-us/web/html/reference/elements/audio/index.md
MDN <source>: repos/mdn/files/en-us/web/html/reference/elements/source/index.md
MDN <track>: repos/mdn/files/en-us/web/html/reference/elements/track/index.md
type MediaSource struct { Src, Type string }
type MediaTrack struct { Src, Kind, Srclang, Label string; Default bool }
type MediaPlayerArgs struct {
Kind string // "video" (default) | "audio"
Src string // single-source shortcut
Sources []MediaSource
Tracks []MediaTrack
Poster string // video only
Ratio string // "16/9" (default) | "1/1" | "4/3" | …
Controls bool // default true (pass explicitly)
Preload string // none | metadata | auto
Loop bool
Muted bool
Autoplay bool
Playsinline bool // video only
Crossorigin string // anonymous | use-credentials
AriaLabel string
ID string
Class string
Body template.HTML // no-support fallback (download links)
}
*/}}
{{define "media-player"}}
{{- $kind := or .Kind "video" -}}
{{- $isVideo := eq $kind "video" -}}
{{- $ratio := or .Ratio "16/9" -}}
{{- $ratioClass := printf "aspect-[%s]" $ratio -}}
{{- if eq $ratio "1/1" -}}{{- $ratioClass = "aspect-square" -}}{{- else if eq $ratio "16/9" -}}{{- $ratioClass = "aspect-video" -}}{{- end -}}
<div {{if .ID}}id="{{.ID}}" {{end}}data-slot="media-player" data-kind="{{$kind}}" class="group/media-player relative block w-full overflow-hidden rounded-lg border bg-card {{if $isVideo}}{{$ratioClass}}{{else}}p-2{{end}} {{.Class}}">
{{- if $isVideo}}
<video data-slot="media-player-media"{{if .Src}} src="{{.Src}}"{{end}}{{if .Controls}} controls{{end}}{{if .Poster}} poster="{{.Poster}}"{{end}}{{if .Preload}} preload="{{.Preload}}"{{end}}{{if .Loop}} loop{{end}}{{if .Muted}} muted{{end}}{{if .Autoplay}} autoplay{{end}}{{if .Playsinline}} playsinline{{end}}{{if .Crossorigin}} crossorigin="{{.Crossorigin}}"{{end}}{{if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}} class="block size-full bg-black object-contain">
{{- range .Sources}}
<source data-slot="media-player-source" src="{{.Src}}"{{if .Type}} type="{{.Type}}"{{end}}>
{{- end}}
{{- range .Tracks}}
<track data-slot="media-player-track" kind="{{or .Kind "subtitles"}}" src="{{.Src}}"{{if .Srclang}} srclang="{{.Srclang}}"{{end}}{{if .Label}} label="{{.Label}}"{{end}}{{if .Default}} default{{end}}>
{{- end}}
{{htmlSafe .Body}}
</video>
{{- else}}
<audio data-slot="media-player-media"{{if .Src}} src="{{.Src}}"{{end}}{{if .Controls}} controls{{end}}{{if .Preload}} preload="{{.Preload}}"{{end}}{{if .Loop}} loop{{end}}{{if .Muted}} muted{{end}}{{if .Autoplay}} autoplay{{end}}{{if .Crossorigin}} crossorigin="{{.Crossorigin}}"{{end}}{{if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}} class="block w-full">
{{- range .Sources}}
<source data-slot="media-player-source" src="{{.Src}}"{{if .Type}} type="{{.Type}}"{{end}}>
{{- end}}
{{- range .Tracks}}
<track data-slot="media-player-track" kind="{{or .Kind "subtitles"}}" src="{{.Src}}"{{if .Srclang}} srclang="{{.Srclang}}"{{end}}{{if .Label}} label="{{.Label}}"{{end}}{{if .Default}} default{{end}}>
{{- end}}
{{htmlSafe .Body}}
</audio>
{{- end}}
</div>
{{end}}
1. Save the file
Drop media_player.ex into lib/my_app_web/components/.
2. Use it
<.media_player poster="/poster.jpg">
<:source src="/clip.webm" type="video/webm" />
<:source src="/clip.mp4" type="video/mp4" />
<:track src="/clip.en.vtt" kind="captions" srclang="en" label="English" default />
<a href="/clip.mp4">Download the video</a>
</.media_player>View source
defmodule ShadcnHtmx.Components.MediaPlayer do
@moduledoc """
Media Player — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/media-player.tsx.
A styled native `<video controls>` / `<audio controls>`. The browser ships
the full accessible playback UI — play/pause, scrubber, volume, captions
toggle, fullscreen, Picture-in-Picture — so we render the real platform
element and only frame it (rounded card, aspect-ratio, poster). No custom
controls, no JavaScript: we never emulate a feature the browser ships.
* MDN <video>:
repos/mdn/files/en-us/web/html/reference/elements/video/index.md
* MDN <audio>:
repos/mdn/files/en-us/web/html/reference/elements/audio/index.md
* MDN <source>:
repos/mdn/files/en-us/web/html/reference/elements/source/index.md
* MDN <track>:
repos/mdn/files/en-us/web/html/reference/elements/track/index.md
`:source` slots are <source> rows ({src, type}); `:track` slots are
WebVTT <track> rows ({src, kind, srclang, label, default}). The inner block
is the no-support fallback (download links).
## Examples
<.media_player poster="/poster.jpg">
<:source src="/clip.webm" type="video/webm" />
<:source src="/clip.mp4" type="video/mp4" />
<:track src="/clip.en.vtt" kind="captions" srclang="en" label="English" default />
<a href="/clip.mp4">Download the video</a>
</.media_player>
<.media_player kind="audio">
<:source src="/song.mp3" type="audio/mpeg" />
</.media_player>
"""
use Phoenix.Component
@root "group/media-player relative block w-full overflow-hidden rounded-lg border bg-card"
attr :kind, :string, default: "video", values: ~w(video audio)
attr :src, :string, default: nil
attr :poster, :string, default: nil
attr :ratio, :string, default: "16/9"
attr :controls, :boolean, default: true
attr :preload, :string, default: nil
attr :loop, :boolean, default: false
attr :muted, :boolean, default: false
attr :autoplay, :boolean, default: false
attr :playsinline, :boolean, default: false
attr :crossorigin, :string, default: nil
attr :aria_label, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :source do
attr :src, :string, required: true
attr :type, :string
end
slot :track do
attr :src, :string, required: true
attr :kind, :string
attr :srclang, :string
attr :label, :string
attr :default, :boolean
end
slot :inner_block
def media_player(assigns) do
assigns =
assigns
|> assign(:root, @root)
|> assign(:is_video, assigns.kind == "video")
|> assign(:ratio_class, ratio_class(assigns.ratio))
~H"""
<div
data-slot="media-player"
data-kind={@kind}
class={[
@root,
@is_video && @ratio_class,
!@is_video && "p-2",
@class
]}
{@rest}
>
<video
:if={@is_video}
data-slot="media-player-media"
src={@src}
controls={@controls}
poster={@poster}
preload={@preload}
loop={@loop}
muted={@muted}
autoplay={@autoplay}
playsinline={@playsinline}
crossorigin={@crossorigin}
aria-label={@aria_label}
class="block size-full bg-black object-contain"
>
<source
:for={s <- @source}
data-slot="media-player-source"
src={s.src}
type={Map.get(s, :type)}
/>
<track
:for={t <- @track}
data-slot="media-player-track"
kind={Map.get(t, :kind, "subtitles")}
src={t.src}
srclang={Map.get(t, :srclang)}
label={Map.get(t, :label)}
default={Map.get(t, :default, false)}
/>
{render_slot(@inner_block)}
</video>
<audio
:if={!@is_video}
data-slot="media-player-media"
src={@src}
controls={@controls}
preload={@preload}
loop={@loop}
muted={@muted}
autoplay={@autoplay}
crossorigin={@crossorigin}
aria-label={@aria_label}
class="block w-full"
>
<source
:for={s <- @source}
data-slot="media-player-source"
src={s.src}
type={Map.get(s, :type)}
/>
<track
:for={t <- @track}
data-slot="media-player-track"
kind={Map.get(t, :kind, "subtitles")}
src={t.src}
srclang={Map.get(t, :srclang)}
label={Map.get(t, :label)}
default={Map.get(t, :default, false)}
/>
{render_slot(@inner_block)}
</audio>
</div>
"""
end
defp ratio_class("1/1"), do: "aspect-square"
defp ratio_class("16/9"), do: "aspect-video"
defp ratio_class(ratio), do: "aspect-[#{String.replace(ratio, " ", "")}]"
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<div data-slot="media-player" data-kind="video"
class="group/media-player relative block w-full overflow-hidden rounded-lg border bg-card aspect-video">
<video data-slot="media-player-media" controls preload="metadata"
poster="/poster.jpg" class="block size-full bg-black object-contain">
<source data-slot="media-player-source" src="/clip.webm" type="video/webm">
<source data-slot="media-player-source" src="/clip.mp4" type="video/mp4">
<track data-slot="media-player-track" kind="captions" src="/clip.en.vtt"
srclang="en" label="English" default>
<a href="/clip.mp4">Download the MP4</a>.
</video>
</div>View source
<!--
shadcn-htmx — raw HTML media-player snippets.
A styled native <video controls> / <audio controls>. The browser ships the
full accessible playback UI (play, seek, volume, captions, fullscreen, PiP);
we only frame it with a rounded card + native aspect-ratio. Zero JavaScript
— Tailwind theme tokens only, and we never emulate platform features.
MDN <video>: repos/mdn/files/en-us/web/html/reference/elements/video/index.md
MDN <audio>: repos/mdn/files/en-us/web/html/reference/elements/audio/index.md
MDN <source>: repos/mdn/files/en-us/web/html/reference/elements/source/index.md
MDN <track>: repos/mdn/files/en-us/web/html/reference/elements/track/index.md
ROOT (video): group/media-player relative block w-full overflow-hidden
rounded-lg border bg-card + aspect utility (aspect-video, …)
ROOT (audio): same, swap the aspect utility for p-2
MEDIA: video → block size-full bg-black object-contain
audio → block w-full
-->
<!-- Video, 16:9, multiple formats + English captions + poster -->
<div data-slot="media-player" data-kind="video"
class="group/media-player relative block w-full overflow-hidden rounded-lg border bg-card aspect-video">
<video data-slot="media-player-media" controls preload="metadata"
poster="/poster.jpg" playsinline
class="block size-full bg-black object-contain">
<source data-slot="media-player-source" src="/clip.webm" type="video/webm">
<source data-slot="media-player-source" src="/clip.mp4" type="video/mp4">
<track data-slot="media-player-track" kind="captions" src="/clip.en.vtt"
srclang="en" label="English" default>
<!-- No-support fallback -->
Your browser does not support the video element.
<a href="/clip.mp4">Download the MP4 video</a>.
</video>
</div>
<!-- Audio — native control bar is the whole UI -->
<div data-slot="media-player" data-kind="audio"
class="group/media-player relative block w-full overflow-hidden rounded-lg border bg-card p-2">
<audio data-slot="media-player-media" controls preload="metadata"
aria-label="Episode 12 — The Platform Strikes Back"
class="block w-full">
<source data-slot="media-player-source" src="/episode-12.ogg" type="audio/ogg">
<source data-slot="media-player-source" src="/episode-12.mp3" type="audio/mpeg">
Your browser does not support the audio element.
<a href="/episode-12.mp3">Download the MP3</a>.
</audio>
</div>
Examples
Video — multiple formats, poster, and captions
Two <source> encodings let the browser pick the first it can decode; a <track kind="captions"> overlays WebVTT subtitles the native UI can toggle.
This is the real platform <video controls> — every button you see (play, scrubber, volume, captions, fullscreen, PiP) is the browser's own UI, keyboard-operable and labelled by the user agent. The poster shows before the first frame; the captions button appears because there is a <track>. No JavaScript runs here.
<MediaPlayer
poster="/poster.jpg"
sources={[
{ src: "/clip.webm", type: "video/webm" },
{ src: "/clip.mp4", type: "video/mp4" },
]}
tracks={[{ src: "/clip.en.vtt", kind: "captions", srclang: "en", label: "English", default: true }]}
/>{% call media_player(
poster="/poster.jpg",
sources=[{"src": "/clip.webm", "type": "video/webm"},
{"src": "/clip.mp4", "type": "video/mp4"}],
tracks=[{"src": "/clip.en.vtt", "kind": "captions",
"srclang": "en", "label": "English", "default": True}]) %}
<a href="/clip.mp4">Download the video</a>
{% endcall %}{{template "media-player" (dict
"Poster" "/poster.jpg"
"Sources" (list (dict "Src" "/clip.webm" "Type" "video/webm")
(dict "Src" "/clip.mp4" "Type" "video/mp4"))
"Tracks" (list (dict "Src" "/clip.en.vtt" "Kind" "captions"
"Srclang" "en" "Label" "English" "Default" true)))}}<.media_player poster="/poster.jpg">
<:source src="/clip.webm" type="video/webm" />
<:source src="/clip.mp4" type="video/mp4" />
<:track src="/clip.en.vtt" kind="captions" srclang="en" label="English" default />
</.media_player><div class="w-full max-w-md">
<div data-slot="media-player" data-kind="video" class="group/media-player relative block w-full overflow-hidden rounded-lg border bg-card aspect-video">
<video data-slot="media-player-media" controls="" preload="none" poster="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'640'%20height%3D'360'%3E%3Cdefs%3E%3ClinearGradient%20id%3D'g'%20x1%3D'0'%20y1%3D'0'%20x2%3D'1'%20y2%3D'1'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%25230f172a'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%25237c3aed'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D'640'%20height%3D'360'%20fill%3D'url(%2523g)'%2F%3E%3Ctext%20x%3D'50%25'%20y%3D'50%25'%20fill%3D'white'%20font-family%3D'sans-serif'%20font-size%3D'26'%20text-anchor%3D'middle'%20dominant-baseline%3D'middle'%3EPoster%20%E2%80%94%20press%20play%3C%2Ftext%3E%3C%2Fsvg%3E" class="block size-full bg-black object-contain">
<track data-slot="media-player-track" kind="captions" src="data:text/vtt,WEBVTT%0A%0A00%3A00.000%20--%3E%2000%3A05.000%0ANative%20captions%2C%20toggled%20from%20the%20platform%20UI.%0A" srclang="en" label="English" default=""/>
Your browser does not support the video element.
</video>
</div>
</div>Further reading
Aspect-ratio framing
The wrapper carries a native aspect-ratio so the player reserves its space before the media loads — no layout shift. Pass ratio="4/3", "1/1", or any w/h.
The frame uses the native CSS aspect-ratio property (the same mechanism as the Aspect Ratio component), and the video letterboxes inside it with object-contain so nothing is cropped. Here the frame is 4/3.
<MediaPlayer ratio="4/3" poster="/poster.jpg"
sources={[{ src: "/clip.mp4", type: "video/mp4" }]} />{% call media_player(ratio="4/3", poster="/poster.jpg",
sources=[{"src": "/clip.mp4", "type": "video/mp4"}]) %}{% endcall %}{{template "media-player" (dict "Ratio" "4/3" "Poster" "/poster.jpg"
"Sources" (list (dict "Src" "/clip.mp4" "Type" "video/mp4")))}}<.media_player ratio="4/3" poster="/poster.jpg">
<:source src="/clip.mp4" type="video/mp4" />
</.media_player><div class="w-full max-w-md">
<div data-slot="media-player" data-kind="video" class="group/media-player relative block w-full overflow-hidden rounded-lg border bg-card aspect-[4/3]">
<video data-slot="media-player-media" controls="" preload="none" poster="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'640'%20height%3D'360'%3E%3Cdefs%3E%3ClinearGradient%20id%3D'g'%20x1%3D'0'%20y1%3D'0'%20x2%3D'1'%20y2%3D'1'%3E%3Cstop%20offset%3D'0'%20stop-color%3D'%25230f172a'%2F%3E%3Cstop%20offset%3D'1'%20stop-color%3D'%25237c3aed'%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D'640'%20height%3D'360'%20fill%3D'url(%2523g)'%2F%3E%3Ctext%20x%3D'50%25'%20y%3D'50%25'%20fill%3D'white'%20font-family%3D'sans-serif'%20font-size%3D'26'%20text-anchor%3D'middle'%20dominant-baseline%3D'middle'%3EPoster%20%E2%80%94%20press%20play%3C%2Ftext%3E%3C%2Fsvg%3E" class="block size-full bg-black object-contain">Your browser does not support the video element.</video>
</div>
</div>Further reading
Audio — native control bar
kind="audio" renders <audio controls>: a full-width native control bar with no video frame. Give it an ariaLabel so AT announces what it plays.
For audio the control bar is the whole UI, so there is no aspect-ratio frame — just a padded card. Because there is no caption track to name the media, pass ariaLabel so assistive tech announces the episode title.
<MediaPlayer
kind="audio"
ariaLabel="Episode 12 — The Platform Strikes Back"
sources={[
{ src: "/episode-12.ogg", type: "audio/ogg" },
{ src: "/episode-12.mp3", type: "audio/mpeg" },
]}
/>{% call media_player(kind="audio",
aria_label="Episode 12",
sources=[{"src": "/episode-12.mp3", "type": "audio/mpeg"}]) %}{% endcall %}{{template "media-player" (dict "Kind" "audio" "AriaLabel" "Episode 12"
"Sources" (list (dict "Src" "/episode-12.mp3" "Type" "audio/mpeg")))}}<.media_player kind="audio" aria_label="Episode 12">
<:source src="/episode-12.mp3" type="audio/mpeg" />
</.media_player><div class="w-full max-w-md">
<div data-slot="media-player" data-kind="audio" class="group/media-player relative block w-full overflow-hidden rounded-lg border bg-card p-2">
<audio data-slot="media-player-media" controls="" preload="none" aria-label="Episode 12 — The Platform Strikes Back" class="block w-full">Your browser does not support the audio element.</audio>
</div>
</div>Further reading
API Reference
<MediaPlayer>
| Prop | Type | Default | Description |
|---|---|---|---|
kind | "video"|"audio" | "video" | Render <video> (aspect-ratio framed) or <audio> (full-width control bar).MDN<video> |
src | string | — | Single media URL. Use sources instead when offering multiple formats. |
sources | MediaSource[] | — | Multiple encodings ({ src, type }) tried in order; the browser plays the first format it can decode.MDN<source> |
tracks | MediaTrack[] | — | WebVTT text tracks ({ src, kind, srclang, label, default }) for captions, subtitles, descriptions, or chapters the native UI can toggle.MDN<track> |
poster | string | — | Video only. Image shown while the video downloads, before the first frame is available. |
ratio | number|string | "16/9" | Video only. Frame ratio: a number (1.778) or a "w/h" string. Maps to a Tailwind aspect-* utility ("1/1" -> aspect-square, "16/9" -> aspect-video, anything else -> aspect-[w/h]). Ignored for audio.MDNaspect-ratio |
controls | boolean | true | Show the browser's native playback controls (play, seek, volume, captions, fullscreen). |
preload | "none"|"metadata"|"auto" | — | Hint for how much media to fetch before playback. The spec advises metadata. |
loop | boolean | false | Seek back to the start when playback reaches the end. |
muted | boolean | false | Start with the audio muted. |
autoplay | boolean | false | Begin playback as soon as possible. Most browsers block autoplay of unmuted media. |
playsinline | boolean | false | Video only. Play inline rather than forcing fullscreen on mobile. |
crossorigin | "anonymous"|"use-credentials" | — | CORS mode for cross-origin media (required when caption tracks are cross-origin). |
ariaLabel | string | — | Accessible name for the player when no caption track identifies the media (recommended for audio). |
children | Child | — | No-support fallback content (e.g. download links) shown to browsers that cannot render <video>/<audio>. |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |