Now Playing Mini Player — Free HTML CSS JS Snippet

Now Playing Mini Player · Cards · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

FLIP shared-element morph: measured translate+scale transform, not a plain modal fade
Transform delta computed live from getBoundingClientRect() on every expand/collapse, never hardcoded
transform-origin: top left kept consistent with how the translate/scale math is derived
Single requestAnimationFrame loop drives both the scrubber progress and the equalizer bar heights
Draggable and click-to-seek scrubber with a synced numeric time label
Play/pause icon morph shared between the mini bar and the expanded sheet
Equalizer bars freeze in place on pause instead of resetting to zero
transitionend listener filtered to the transform property so display:none only applies once the shrink animation truly finishes

About this UI Snippet

Now Playing Mini Player — FLIP Shared-Element Morph, Live Scrubber & Pulsing Equalizer in Vanilla JS

Screenshot of the Now Playing Mini Player snippet rendered live

Music apps like Spotify and Apple Music do not open their full player as a plain modal that fades in from nowhere — the mini bar at the bottom of the screen visibly grows into the full-screen player, so the album art you were just looking at stays the same object the whole time, just bigger. That continuity is what makes the transition feel physical instead of like two disconnected screens. This snippet reproduces that exact effect using the FLIP technique (First, Last, Invert, Play), the same general approach behind iOS and Android "hero" transitions between a list item and its detail view, built with nothing but getBoundingClientRect(), CSS transforms, and one requestAnimationFrame-driven loop for the animated equalizer bars.

What FLIP actually stands for and why each step exists

FLIP is a sequence, not a single trick. First: before anything changes, measure the mini player's exact position and size on screen with getBoundingClientRect(). Last: make the destination element (the full sheet) visible at its natural, final layout — full-screen, no transform — and measure ITS rect too. Invert: compute the delta between those two rects (how far left/up the sheet's top-left corner needs to shift, and what scale factor would shrink its width/height down to the mini player's dimensions) and apply that as a transform immediately, with transitions disabled, so the sheet is technically full-size in the DOM but visually rendered exactly where the mini player was. Play: on the very next frame, remove the inverted transform and re-enable transitions, so the browser animates the transform from "shrunk down to mini-player size and position" to "none" (its natural full-size state) — which reads as the sheet growing out of the mini player.

Why the transform has to be computed, not guessed

The delta values are never hardcoded pixel numbers. expand() reads miniRect.left - sheetRect.left and miniRect.top - sheetRect.top for the translate offset, and miniRect.width / sheetRect.width plus the same ratio for height as the scale factor. This means the morph works correctly regardless of where the mini player happens to sit or how large the sheet is at any given viewport size — the same code that works in a 320px-wide mobile mockup would work identically if the container were resized, because the transform is derived from live measurements taken at the moment of the click rather than a fixed guess.

Why the transform-origin matters

sheet.style.transformOrigin is set to top left to match how the translate+scale math is computed — the delta values describe how the sheet's own top-left corner needs to move and shrink to land on the mini player's top-left corner. If the transform-origin were left at its default of center, the same translate/scale numbers would scale the sheet from its middle outward, producing a transform that visually drifts away from the mini player's actual position instead of growing cleanly out of it.

Reversing the morph on collapse

collapse() runs the identical FLIP math in the opposite direction: it measures the sheet's current (full-size) rect as the starting point, temporarily reveals the still-hidden mini player to read its target rect, then re-hides it, computes the same translate/scale delta, and animates the sheet's transform FROM none TO that inverted position — shrinking the sheet back down onto the mini player rather than just fading it away. A transitionend listener (checked against e.propertyName === 'transform' so it does not fire early on the opacity or border-radius transitions running in parallel) finally sets display: none on the sheet and restores the mini player's visibility once the shrink animation has actually finished.

The equalizer bars and progress loop

A single requestAnimationFrame loop drives two unrelated things every frame: it advances progress (a 0-1 float) by dt / DURATION seconds elapsed, which updates the scrubber fill width and handle position and the current-time label — and, only while playing is true, it assigns each equalizer bar a fresh randomized height, capped higher in the expanded sheet view (32px) than in the compact mini bar (15px). When paused, the bars simply stop being reassigned and freeze at whatever height they last had, rather than resetting to zero, which reads as "sound stopped" more naturally than an abrupt flatline.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Give this snippet's JS to an AI assistant like Claude and ask it to trace through exactly why the transform has to be computed from live getBoundingClientRect() calls rather than hardcoded, and why transform-origin: top left has to match how the delta is derived — it is easy to copy the FLIP pattern without understanding why those two details are load-bearing. It is also worth asking whether the mini-player "briefly show, measure, re-hide" trick in collapse() could be replaced with a cleaner approach, like keeping the mini player permanently in the layout at opacity: 0 with pointer-events: none instead of toggling a hidden class, so its rect is always measurable without a flash. For extending the snippet, ask for a swipe-down-to-collapse gesture using pointer events, a queue/playlist drawer that slides up from the bottom of the expanded sheet, or wiring the equalizer to real frequency data via the Web Audio API AnalyserNode instead of randomized bar heights.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a "now playing" mini music player in plain HTML, CSS, and JavaScript that expands into a full player sheet using a FLIP shared-element transition — no animation library, no framework.

Requirements:
- A compact mini player bar fixed near the bottom of its container showing album art, title, artist, a small pulsing equalizer, and a play/pause button.
- Clicking the mini bar must expand it into a full-size player sheet using the FLIP technique: measure the mini bar's getBoundingClientRect() BEFORE anything changes, make the full sheet visible at its natural full-size layout and measure that rect too, compute the translate offset and width/height scale ratio between the two rects, apply that as an instant (no-transition) transform so the sheet is technically full-size but visually sits exactly on top of the mini bar, then on the next animation frame remove the transform with a transition enabled so it animates smoothly from "shrunk to mini-bar size" to its natural full size.
- Set transform-origin to top left on the sheet, consistent with how the translate/scale delta is computed from the two rects' top-left corners.
- Reverse the exact same FLIP math when collapsing, so the sheet visibly shrinks back down into the mini bar's position and size before being hidden, with a transitionend listener (filtered to the transform property specifically) that swaps display back only once the shrink animation has actually finished.
- A working scrubber/progress bar in the expanded sheet: click-to-seek and drag-to-seek, both updating a numeric current-time label, driven by a single requestAnimationFrame loop.
- Play/pause button whose icon morphs between a play triangle and a pause icon, synced between the mini bar and the expanded sheet.
- A small set of equalizer bars that get randomized heights every animation frame while playing, and freeze in place (not reset to zero) when paused.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Watch the mini bar at the bottomThe compact player shows album art, title, artist, three small equalizer bars pulsing at random heights, and a play/pause button — all inside a 60px-tall dark bar pinned to the bottom of the stage.
  2. 2
    Click anywhere on the mini bar to expand itThe full player sheet visibly grows out of the mini bar's exact position and size using a FLIP transform, landing at full-screen size roughly 420ms later with an eased deceleration curve.
  3. 3
    Drag or click the scrubber in the expanded sheetClick anywhere on the progress track to seek instantly, or press and drag the circular handle to scrub continuously — both update the current-time label in real time.
  4. 4
    Press play/pause in either viewToggling from the mini bar or the full sheet stays in sync between both — the icon morphs between a play triangle and a pause double-bar, and the equalizer bars freeze in place when paused.
  5. 5
    Tap the chevron to collapse the sheetThe full player shrinks back down using the reverse FLIP transform, visually retreating into the exact mini-bar position it grew from, and the mini bar fades back in once the shrink finishes.

Real-world uses

Common Use Cases

Music and podcast player interfaces
The canonical use case — a persistent mini player that expands to a full now-playing screen, matching the mobile music player screen mockup and pairing well with an audio waveform visualizer for a richer expanded view.
MEDIA
Video and livestream mini-to-full transitions
The same FLIP technique works for a picture-in-picture video bar that expands into a full video player — swap the album art for a video thumbnail and the scrubber logic carries over directly.
Teaching the FLIP animation technique
A concrete, readable reference for First-Last-Invert-Play shared-element transitions, the same mechanism used by page-transition libraries and iOS/Android hero animations, useful alongside a modal when comparing morph transitions to plain fade/scale modals.
Sticky action bars that expand to detail panels
Reuse the morph pattern for any "small persistent bar expands to full detail" UI — a shopping cart summary bar that grows into a full checkout panel, or a notification toast that expands into a full alert detail view.
Portfolio and product demo micro-interactions
A polished, self-contained piece to showcase FLIP-based motion design skills in a portfolio, alongside other card-based UI snippets that demonstrate interaction craft rather than static layout.

Got questions?

Frequently Asked Questions

FLIP stands for First, Last, Invert, Play — measure the starting rect, measure the natural ending rect, invert the difference into a transform applied instantly with no transition, then remove that transform on the next frame so the browser animates from the inverted position to the natural one. A plain fade-in modal has no visual connection to where the trigger element was, which reads as two disconnected screens. FLIP keeps the album art, title, and general shape visually continuous, which is what makes the morph feel like the same object growing rather than a new screen appearing.

The translate and scale deltas in this snippet are computed from the two rects' top-left corners (miniRect.left - sheetRect.left, etc). That math only produces a visually correct morph if the browser also scales the element from its top-left corner — with the default center origin, the same numbers would scale the sheet outward from its middle, causing it to visibly drift away from the mini player's actual position instead of growing cleanly out of it.

To compute the shrink target, the code needs the mini player's real on-screen rect via getBoundingClientRect() — but that only returns meaningful, non-zero dimensions if the element is actually laid out and visible. The mini player is briefly un-hidden just long enough to measure it, then hidden again immediately so it does not double up visually with the still-shrinking sheet, and its .hidden class is only permanently removed once the sheet's shrink transition genuinely finishes.

Yes. In React, keep expanded as component state, and run the getBoundingClientRect() measurement plus the inverted-transform assignment inside a useLayoutEffect that fires synchronously after the expanded state changes but before the browser paints (useEffect alone can flash the unanimated final state for one frame). Cancel the shared requestAnimationFrame loop in the cleanup function of the useEffect that starts it. In Vue, do the measurement in a watcher on expanded using nextTick, and stop the rAF loop in onUnmounted. In Angular, trigger it from ngOnChanges or a signal effect and cancel the frame in ngOnDestroy. In every framework, keep the rAF-driven equalizer and scrubber loop outside the framework's render cycle for smoothness, same as in this vanilla version.

Replace the DURATION constant and the progress variable's manual increment with an actual HTMLAudioElement: listen for its timeupdate event to set progress = audio.currentTime / audio.duration instead of advancing it manually in the rAF loop, and in seek(), set audio.currentTime = pct * audio.duration instead of just updating the visual progress variable. The rAF loop can keep driving the equalizer bars independently since real audio has no built-in frequency data unless you also wire up the Web Audio API's AnalyserNode.