Mobile

Mini Player

A now-playing bar docked above the tab bar that a swipe up, a tap or a key morphs into a full-screen player, with safe-area padding and a read-only timeline when no seek handler is wired.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ChevronDown,
  ChevronUp,
  CircleAlert,
  LoaderCircle,
  Music,
  Pause,
  Play,
  RotateCcw,
  SkipBack,
  SkipForward,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/mini-player.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "MiniPlayer" component (lucide-react icons,
Pointer Events, no animation library, no media engine).

It is the persistent now-playing chrome of a phone app: a bar docked above the
tab bar that morphs into a full-screen player. The docked bar is the resting
state — there is no closed state and nothing ever unmounts, so playback and
scroll position survive every transition.

Contract
- forwardRef <div> extending React.HTMLAttributes<HTMLDivElement>, spreading the
  rest of the props on the root.
- track: { id, title, subtitle?, artwork?, duration? } | null. null renders no
  DOM at all (nothing queued = nothing docked). duration missing or 0 means a
  live stream.
- variant?: "bar" | "pill" | "edge" (default "bar") — floating card above a tab
  bar / narrow capsule in the thumb arc / full-bleed welded to the bottom edge.
- status?: "ready" | "buffering" | "error" (default "ready"), each a first-class
  render branch rather than a trailing &&.
- position?: number (seconds, default 0) — the playhead is INJECTED. The
  component never reads a clock, so the same props always paint the same frame
  and SSR cannot drift.
- Controlled + uncontrolled for both stateful axes:
  playing / defaultPlaying / onPlayingChange, and
  expanded / defaultExpanded / onExpandedChange.
- onSeek?: (seconds) => void, onSkipNext?, onSkipPrevious?, onRetry?: () => void.
- dockOffset?: number (default 0) — the content height of the chrome underneath
  (a tab bar), in px. The home-indicator inset is added on top of it.
- label?: string (default "Now playing"), labels?: Partial<MiniPlayerLabels> for
  every visible / assistive string.

Behavior
- One morph value drives both surfaces: 0 = docked bar, 1 = full player. It is
  DERIVED (dragMorph ?? (expanded ? 1 : 0)), never mirrored into an effect —
  the finger owns it during a gesture, the state owns it the rest of the time.
- Gesture (Pointer Events only, never separate mouse/touch handlers):
  pointerdown records the pointerId, the press point, the panel height and the
  morph at press time. On pointermove, once travel passes 4px, the axis is
  decided ONCE: it must be more vertical than horizontal and must push the
  surface the way it can go (up from the bar, down from the panel); a gesture
  that fails is dropped for the rest of its life so a diagonal flick never
  steals a scroll. On success re-baseline the start point (so the morph does not
  jump the threshold), setPointerCapture on the element that started the drag,
  and paint morph = clamp01(startMorph - dy / panelHeight) inside one
  requestAnimationFrame per frame. No rubber band: both ends are real resting
  places, not a dismissal.
- Release: velocity (px/ms, smoothed 0.3 old / 0.7 new so one jittery frame is
  not a fling) beyond ±0.5 decides by direction; otherwise the nearest end wins
  at 0.5. Clear the drag and commit the state in the same handler so the
  transition is back on before the next paint and the surface eases home. A
  controlled parent that refuses the change simply leaves `expanded` alone and
  the surface springs back. pointercancel settles without committing.
- A drag that moved anything sets a one-shot ref that swallows the click in
  onClickCapture on the root — read and written synchronously in the handlers,
  never state. A tap under the threshold never arms it, so buttons keep working.
- Keyboard / button parity: the bar's whole info block is a button
  (aria-expanded + aria-controls) that expands on Enter/Space and ArrowUp; the
  panel header's chevron collapses on Enter/Space and ArrowDown; Escape on the
  panel collapses (handled on the panel, not window, so a nested overlay is not
  closed by the same key).
- Focus: after a morph, focus moves to the deliberate successor (collapse button
  when expanding, expand button when collapsing) — but only when focus already
  sits inside the component or on <body>, so a programmatic expand never yanks
  the caret out of a form elsewhere on the page.
- Docked-but-hidden surfaces use `inert`, never unmounting: the collapsed panel
  and the covered bar leave the tab order and the a11y tree, and come back with
  their scroll and playback untouched.
- Timeline: with onSeek it is a real <input type="range"> (aria-valuetext =
  "0:42 / 3:34"); without one it is a role="progressbar" — a rail that looks
  draggable but ignores you is a lie. With no duration it collapses to a Live
  chip. Skip buttons render only when their handler exists.
- status="error": the rail and the secondary line turn destructive, and the
  transport becomes a retry button when onRetry exists. Without onRetry it stays
  a play button reporting aria-disabled with a handler guard — never the native
  disabled attribute, because the user may be standing on it.
- Cleanup: the pending rAF is cancelled on unmount, pointer capture is released
  on the same node that took it, and the reduced-motion matchMedia subscription
  is torn down by useSyncExternalStore.

Rendering & styling
- Semantic tokens only, monochrome first: bg-card / border for the docked bar,
  bg-background for the panel, bg-muted + text-muted-foreground for secondary
  matter, bg-foreground/10 for the capsule's progress fill, text-destructive +
  border-destructive/30 + bg-destructive/5 for the refusal. The transport is the
  only element that INVERTS (bg-foreground text-background) — priority without
  spending a colour.
- Radius ladder: rounded-2xl card / rounded-lg inner blocks / rounded chips.
  Type ladder: 16px extrabold panel title, 13px semibold bar title, 11px meta,
  10px uppercase eyebrow; every time is tabular-nums.
- The root is `absolute inset-0` inside the consumer's shell, pointer-events-none,
  with only the bar and the panel taking events — the same component works inside
  a device mock and inside a real `fixed inset-0` app shell.
- Safe area: the bar pads calc(env(safe-area-inset-bottom) + dockOffset px), the
  panel pads env(safe-area-inset-top) at the header and
  calc(env(safe-area-inset-bottom) + 1rem) under the transport.
- touch-action: `touch-none` on the docked bar and the panel's grab zone so the
  browser never competes with the swipe, `touch-pan-y overscroll-contain` on the
  panel's scroll area. Nothing calls preventDefault, so no listener has to be
  non-passive.
- Every hit area is at least 44px (size-11 buttons, min-h-11 on the info block),
  nothing depends on hover, and focus-visible rings are on every control.
- prefers-reduced-motion (subscribed with useSyncExternalStore, not read once):
  the morph transition and the buffering spin are dropped; the player still
  expands, collapses and reports its state.

Customization levers
- Variants: `bar` / `pill` / `edge` differ in attachment, radius and how progress
  is drawn; a fourth is one class branch plus one progress branch — the gesture
  and state machine are shared.
- Physics: DRAG_START_PX (4), FLING_VELOCITY (0.5 px/ms), COMMIT_RATIO (0.5),
  SETTLE_MS (320) and the easing curve are the whole feel. Lower COMMIT_RATIO to
  ~0.3 for a player that wants to open, raise FLING_VELOCITY for a heavier one.
- Density: the bar is p-2 with a size-11 artwork tile; the panel's artwork is
  `max-w-52` — drop it to max-w-40 for short shells, or swap it for a video
  frame.
- Blocks: the panel is header / artwork / title / timeline / transport. Adding a
  queue list, lyrics or an AirPlay row means inserting one child into the scroll
  area; removing skip buttons means not passing their handlers.
- Chrome offset: dockOffset is the tab bar's content height; pass 0 when the
  player itself is the bottom-most chrome so it owns the home-indicator inset.
- Tokens: swap bg-foreground/text-background on the transport for
  bg-primary/text-primary-foreground on a branded theme, and the capsule fill
  from bg-foreground/10 to bg-primary/15.
- Media: the component drives nothing. Wire playing / position / status to
  <audio>, Howler, an HLS player or the Media Session API in the consumer.

Concepts

  • No closed state — a sheet has open and closed; this has docked and full. Both surfaces stay mounted and share one morph value, which is why the gesture can be reversed mid-flight and why nothing about playback restarts when it folds back.
  • Reversible morph — the finger owns the morph while it is down (dragMorph ?? (expanded ? 1 : 0)), the state owns it afterwards. Nothing is copied between the two, so there is no frame where they disagree.
  • Claim once, or hand it back — the axis test runs on the first decisive movement only. A gesture that fails it is dropped for the rest of its life, so a diagonal flick can never steal a list scroll halfway through.
  • Inert instead of unmount — the collapsed panel and the covered bar are hidden with inert, so they vanish from the tab order and the a11y tree while keeping their DOM, scroll offset and media element.
  • Injected playheadposition is a prop. The component reads no clock, which is what makes a paused frame identical every render, keeps SSR honest, and lets the same chrome sit on top of <audio>, HLS or a native bridge.
  • dockOffset, then the inset — the bar pads calc(env(safe-area-inset-bottom) + dockOffset): the tab bar's height is design, the home indicator is hardware, and only the OS knows the second number.

On This Page