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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/mini-player.jsonPrompt
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 playhead —
positionis 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.
Offline Banner
A safe-area connectivity banner for phones that lists the changes waiting, sends them when the link returns, and can be swiped into a peek tab that keeps the count.
Download Row
A per-transfer mobile row whose rate and ETA disappear the moment bytes stop arriving, with pause, resume, retry and a press-and-hold cancel that answers to the keyboard.