← ui

media-line

ui

how far a video has played: a track, what has loaded, the played part

npx shadcn add @ag/media-line
Already installed? Preview an update
npx shadcn add @ag/media-line --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-11
  • New: the one drawing of how far a video or a clip has played: a track, what of it has loaded lighter on it, and the played part solid from the start to where it is now, its edge the position. It draws in the text's colour (white over a picture, a player's accent); moments or chapters go in as children, placed with lineShare; style places a line (one chapter of a longer bar). The gallery's cells draw their strip with it, and player's bar each of its chapters, so the two say progress the same way. player now pulls in media-line.
Updated 2026-10-11 · release notes

also called seek bar

shelf media

uses clip

used by player, film-mosaic

the demo above, verbatim

media-line.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import { MediaLine } from "@/registry/base-nova/ui/media-line"

// #region states
// A thirty-second file at four moments of its life: nothing loaded yet, a third of it
// loaded and nothing played, playing with the next stretch loaded ahead, and played
// through. The played part is solid to where it is now; what has loaded is lighter
// ahead of it.
const STATES = [
  { name: "not loaded", at: null, loaded: [] },
  { name: "loading", at: 0, loaded: [{ from: 0, to: 10_000 }] },
  { name: "playing", at: 12_000, loaded: [{ from: 0, to: 21_000 }] },
  { name: "played through", at: 30_000, loaded: [{ from: 0, to: 30_000 }] },
]
// #endregion

export default function Demo() {
  return (
    <div className="space-y-12">
      <Sample
        name="states"
        glance
        with="states"
        title="what has loaded, what has played"
      >
        <div className="grid gap-6 sm:grid-cols-2">
          {STATES.map((s) => (
            <div key={s.name} className="space-y-2">
              <MediaLine
                duration={30_000}
                at={s.at}
                loaded={s.loaded}
                className="h-1.5 rounded-full text-foreground"
              />
              <p className="text-xs text-muted-foreground">{s.name}</p>
            </div>
          ))}
        </div>
      </Sample>
      <Sample
        name="over-a-picture"
        title="over a picture"
        description="in white on a dark ground at the picture's foot, as a gallery cell draws it"
      >
        <div className="relative aspect-video max-w-md overflow-hidden rounded-xl bg-[url(/glass-valley.jpg)] bg-cover">
          <div className="absolute inset-x-0 bottom-0 h-1.5 bg-black/40">
            <MediaLine
              duration={30_000}
              at={12_000}
              loaded={[{ from: 0, to: 21_000 }]}
              className="size-full text-white"
            />
          </div>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

media-line.tsx
import type * as React from "react"
import { cn } from "@/lib/utils"
import type { Span } from "@/registry/base-nova/lib/clip"

// A MEDIUM'S LINE, the one drawing of how far a video or a clip has played: a track,
// what of it has loaded lighter on it, and the played part solid from the start to
// where it is now, its edge the position (no mark of its own: a fill says how far and
// where at once). Every player and every preview draws its progress with this, so a
// line means the same thing everywhere. It draws in the text's colour (`text-*` on it
// or above it: white over a picture, a player's accent), the track and the loaded
// part at fixed shares of it. Size it with `className` (height, rounding); what a
// consumer marks along it (moments, chapters) goes in as children, over the played
// part, each placed with `lineShare`.

/** Where `ms` falls along a line of `duration` ms, as a CSS percentage, held to the
 *  line's ends. */
export const lineShare = (ms: number, duration: number) =>
  `${(Math.min(Math.max(ms, 0), duration) / duration) * 100}%`

export function MediaLine({
  duration,
  at,
  loaded = [],
  className,
  style,
  children,
}: {
  /** The medium's length, ms; more than zero. */
  duration: number
  /** Where it is now, ms, held to the line's ends; null before it has a position
   *  (nothing played). */
  at: number | null
  /** What of it has loaded (a video's buffered ranges), ms. */
  loaded?: readonly Span[]
  className?: string
  /** Where the line is placed (one chapter of a longer bar). */
  style?: React.CSSProperties
  /** Marks along it, over the played part. */
  children?: React.ReactNode
}) {
  if (!(duration > 0)) throw new Error(`media-line: a duration of ${duration}`)
  return (
    <div
      data-slot="media-line"
      className={cn("relative overflow-hidden bg-current/20", className)}
      style={style}
    >
      {loaded.map((s) => (
        <div
          key={s.from}
          data-slot="media-line-loaded"
          className="absolute inset-y-0 left-(--media-line-from) w-(--media-line-span) bg-current/25"
          style={
            {
              "--media-line-from": lineShare(s.from, duration),
              "--media-line-span": lineShare(s.to - s.from, duration),
            } as React.CSSProperties
          }
        />
      ))}
      {at !== null && (
        <div
          data-slot="media-line-played"
          className="absolute inset-y-0 left-0 w-(--media-line-played) bg-current"
          style={
            {
              "--media-line-played": lineShare(at, duration),
            } as React.CSSProperties
          }
        />
      )}
      {children}
    </div>
  )
}