Player
Midnight Drive
Nova Lane · Neon Roads
Try it
The numbered controls work. The greyed ones fill out the page and do nothing. There is no audio: the playhead is an AnimeJS animation.
- Drag the seek bar. The time follows the thumb while you drag, and playback jumps there when you let go. While playing, the thumb moves with the playhead.
- Play, pause, skip. The play button's icon swaps with a small pop. The level meters bounce while playing and settle when paused. On a new track the cover fades up, the title scrambles into the new name, then the artist line fades in.
- Drag the volume slider. The level meters get taller or shorter with the volume.
- Click a track in the playlist to play it. The highlight moves to it, and the list stays where it is.
- Shuffle the playlist. The rows slide into their new order.
Reset in the title bar stops playback and puts the playlist back in order.
Built with
useAnimate: the playhead, as an animation of a numberuseAnimeTimeline: the artist line and total time on each track changeuseScrambleText: the track titleuseDraggable: the seek and volume thumbsuseAnimatable: the level meters<AnimeTransitionGroup>: the playlist shuffle<AnimeTransition>: the cover crossfade and the play and pause iconsspringfrom#nanime/easings, andshuffleandrandomfrom#nanime/utils
Playhead. There is no audio element. A usePlayer composable keeps
clock = reactive({ time: 0 }) and animates it with
useAnimate: time goes from 0 to the
track's length in seconds, over that many seconds, with ease: 'linear'
and autoplay: false. Play and pause call the animation's play() and
pause(). Seeking calls seek() with the time in milliseconds, which also
works while paused. onComplete moves to the next track. The params are a
getter that reads the current track, so a new track rebuilds the animation
with its own length.
Sliders. Both sliders are one component. The thumb gets
useDraggable with container set to the
rail, y: false and velocityMultiplier: 0, so it stays on the rail and
stops where it is let go instead of sliding on. The rail is the thumb's
width longer than the track it draws, so the thumb's centre lines up with
both ends. The value is the draggable's x divided by that range. While
the value changes from outside, for example as the playhead moves,
setX(value * range, true) moves the thumb without firing the drag
callbacks. onDrag reports the value as it moves and onRelease reports
the final one. The seek bar only seeks on release, and shows the dragged
time until then.
Track change. The cover is keyed on the track inside an
<AnimeTransition>. Both covers sit
absolute inset-0 in the same box, and the transition runs the old one's
leave and the new one's enter together, so they crossfade over 400ms.
useScrambleText watches the track's
title and scrambles the heading into each new one. The heading renders the
first title with v-once, so Vue leaves its text to the scramble after
that. The artist line and the total time keep their elements, so a
useAnimeTimeline built once with
autoplay: false holds their fades, and a watcher on the track's id calls
restart(). They fade in at 150ms and 250ms, so a track change runs in
order rather than all at once.
Level meters. Sixteen bars read their heights from one reactive object
animated by useAnimatable, one property
per bar. While playing, an interval sets each bar to a new random height
every 180ms, scaled by the volume. An animatable starts each move from
where the bar is, so the bars never jump. When paused, every bar goes back
to a low resting height.
Playlist. The playlist keeps all six tracks in place and marks the
current one, so picking a track only moves the highlight. A queue that
took the picked track out and put the old one back at the end would add a
leave, a move and an enter to every track change. The list is an
<AnimeTransitionGroup>, and Shuffle replaces
it with a new order that the group moves the rows into over 350ms. The
move has no stagger. The group staggers every element inside it, not only
the rows, so a stagger would make each row's text trail behind its row.
Using the nanime Nuxt module (AnimeJS v4 for Vue, with Tailwind CSS v4 and
Nuxt UI), build a music player in Vue 3 single-file components, with no
real audio. useAnimate, useAnimeTimeline, useScrambleText, useDraggable,
useAnimatable, <AnimeTransition> and <AnimeTransitionGroup> are
auto-imported in components. In a plain .ts composable, import useAnimate
from '#nanime/composables'. Import spring from '#nanime/easings', and
shuffle and random from '#nanime/utils'. Use strict TypeScript with
no `any` and no `as` casts. Ease names are AnimeJS v4 strings such as
'out(3)'. Keep motion subtle: ease out for things already on screen, and a
small spring overshoot only for things that pop in.
Data: six tracks { id, title, artist, album, duration (seconds, all
different), icon, cover (a gradient class per track) }. A usePlayer() composable holds playlist (all six
tracks), currentId, playing, volume (0 to 1) and clock = reactive({ time:
0 }). current is a computed that finds currentId in the playlist.
Playhead: playback = useAnimate(clock, () => ({ time: [0,
current.value.duration], duration: current.value.duration * 1000, ease:
'linear', autoplay: false, onComplete: next })). The getter rebuilds the
animation for each new track. Watch currentId and call playback.seek(0),
then playback.play() if playing. play() and pause() set playing and call
the animation's play() and pause(). seek(fraction) calls
playback.seek(fraction * duration * 1000). next() and previous() step
currentId through the playlist, wrapping at the ends, and previous()
restarts the track instead when more than 3 seconds in. playTrack(track)
sets currentId and plays, or toggles play when it is already current.
shufflePlaylist() replaces the playlist with shuffle([...playlist]). The
playlist never changes on a track change, only the highlight does.
Slider component (used for seek and volume): props value (0 to 1) and label;
emits input while dragging and change on release. Markup: a rail div
"relative h-4 w-full" (template ref "rail"), a track "absolute inset-x-2
top-1/2 h-1" with a fill whose width is the value in %, and a thumb
"absolute top-0 left-0 size-4 rounded-full touch-none" (ref "thumb").
draggable = useDraggable(thumb, { container: rail, y: false,
velocityMultiplier: 0, onGrab, onDrag, onRelease, onResize: measure }).
range = rail width - thumb width, measured on mount and on resize. The value
is draggable.x / range, clamped to 0..1. While not dragging, watch the value
prop and call draggable.setX(value * range, true), which moves the thumb
without firing callbacks. While dragging, show the dragged value.
Now playing: a cover, an <h2 ref="title">, a meta line "artist · album",
level meters, the seek slider with current and total time, previous /
play-pause / next buttons, the volume slider and a row of disabled buttons
(like, repeat, lyrics, cast).
- Cover: a "relative size-40 overflow-hidden rounded-2xl" box holding an
<AnimeTransition> with enter { opacity: [0, 1], duration: 400, ease:
'inOut(2)' } and leave { opacity: 0, duration: 400, ease: 'inOut(2)' }.
Inside it, a div keyed on the track id, "absolute inset-0", with the
track's gradient and icon. Both covers are stacked during the change, so
they crossfade.
- Title: render the first title with v-once so Vue never touches its text
again, and call useScrambleText(title, {}, () => ({ text: track.title,
settleDuration: 300, revealRate: 40 })). It scrambles into each new title.
- Track change: trackChange = useAnimeTimeline({ autoplay: false, defaults:
{ ease: 'out(3)', duration: 300 } }), then .add(meta, { opacity: [0, 1] },
150).add(total, { opacity: [0, 1] }, 250), where total is the total-time
label. Watch the track id and call trackChange.restart(). Things run in
order rather than all at once, since a track change happens often.
- Seek: show the dragged time while dragging, and call seek only on change.
- Play button: the icon inside <AnimeTransition mode="out-in"> keyed on
playing, enter { opacity: [0, 1], scale: [0.6, 1], ease: spring({ bounce:
0.2, duration: 250 }) }.
Level meters: 16 bars. levels = reactive(Record<string, number>) with one
key per bar, levelsTo = useAnimatable(levels, one { duration: 180, ease:
'out(2)' } per key). While playing, every 180ms call levelsTo[bar]?.(random(
0.15, 1, 2) * (0.25 + 0.75 * volume)) for each bar. When paused, send each
bar to 0.08. Draw each bar with transform: scaleY(level) and origin-bottom.
Playlist: all tracks as buttons inside <AnimeTransitionGroup tag="ul"
class="relative" :move-animation="{ duration: 350, ease: 'out(3)' }">
keyed by id. The current row gets a primary background and title colour,
with a colour transition. Clicking a row plays it. A Shuffle button
shuffles the playlist, and the group moves the rows into the new order.
Give the move no stagger: the group staggers every element inside it, so
a row's text would trail its row.