shshadcn-htmx

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.json

2. Use it

components/ui/media-player.tsx
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
components/ui/media-player.tsx
/** @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

templates/components/media-player.html
{% 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
templates/components/media-player.html
{# 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

components/media-player.tmpl
{{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
components/media-player.tmpl
{{/*
  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

lib/my_app_web/components/media_player.ex
<.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
lib/my_app_web/components/media_player.ex
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

snippets/media-player.html
<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
snippets/media-player.html
<!--
  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&#39;http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg&#39;%20width%3D&#39;640&#39;%20height%3D&#39;360&#39;%3E%3Cdefs%3E%3ClinearGradient%20id%3D&#39;g&#39;%20x1%3D&#39;0&#39;%20y1%3D&#39;0&#39;%20x2%3D&#39;1&#39;%20y2%3D&#39;1&#39;%3E%3Cstop%20offset%3D&#39;0&#39;%20stop-color%3D&#39;%25230f172a&#39;%2F%3E%3Cstop%20offset%3D&#39;1&#39;%20stop-color%3D&#39;%25237c3aed&#39;%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D&#39;640&#39;%20height%3D&#39;360&#39;%20fill%3D&#39;url(%2523g)&#39;%2F%3E%3Ctext%20x%3D&#39;50%25&#39;%20y%3D&#39;50%25&#39;%20fill%3D&#39;white&#39;%20font-family%3D&#39;sans-serif&#39;%20font-size%3D&#39;26&#39;%20text-anchor%3D&#39;middle&#39;%20dominant-baseline%3D&#39;middle&#39;%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>

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&#39;http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg&#39;%20width%3D&#39;640&#39;%20height%3D&#39;360&#39;%3E%3Cdefs%3E%3ClinearGradient%20id%3D&#39;g&#39;%20x1%3D&#39;0&#39;%20y1%3D&#39;0&#39;%20x2%3D&#39;1&#39;%20y2%3D&#39;1&#39;%3E%3Cstop%20offset%3D&#39;0&#39;%20stop-color%3D&#39;%25230f172a&#39;%2F%3E%3Cstop%20offset%3D&#39;1&#39;%20stop-color%3D&#39;%25237c3aed&#39;%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D&#39;640&#39;%20height%3D&#39;360&#39;%20fill%3D&#39;url(%2523g)&#39;%2F%3E%3Ctext%20x%3D&#39;50%25&#39;%20y%3D&#39;50%25&#39;%20fill%3D&#39;white&#39;%20font-family%3D&#39;sans-serif&#39;%20font-size%3D&#39;26&#39;%20text-anchor%3D&#39;middle&#39;%20dominant-baseline%3D&#39;middle&#39;%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>

PropTypeDefaultDescription
kind"video"|"audio""video"
Render <video> (aspect-ratio framed) or <audio> (full-width control bar).MDN<video>
srcstring
Single media URL. Use sources instead when offering multiple formats.
sourcesMediaSource[]
Multiple encodings ({ src, type }) tried in order; the browser plays the first format it can decode.MDN<source>
tracksMediaTrack[]
WebVTT text tracks ({ src, kind, srclang, label, default }) for captions, subtitles, descriptions, or chapters the native UI can toggle.MDN<track>
posterstring
Video only. Image shown while the video downloads, before the first frame is available.
rationumber|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
controlsbooleantrue
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.
loopbooleanfalse
Seek back to the start when playback reaches the end.
mutedbooleanfalse
Start with the audio muted.
autoplaybooleanfalse
Begin playback as soon as possible. Most browsers block autoplay of unmuted media.
playsinlinebooleanfalse
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).
ariaLabelstring
Accessible name for the player when no caption track identifies the media (recommended for audio).
childrenChild
No-support fallback content (e.g. download links) shown to browsers that cannot render <video>/<audio>.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference