Music Player
Installation
npx shadcn@latest add https://uibrik.com/r/music-player.jsonInstall the packages
npm install motion react-use-measure torphAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Set up squircle corners
Needs
@squircle/core,@squircle/tailwindcssand a few lines of CSS. See squircle setup.Copy the source into your project
components/uibrik/music-player/music-player.tsx"use client"; import AnimatedTime from "./AnimatedTime"; import CoverStack from "./CoverStack"; import ProgressRing from "./ProgressRing"; import NextTrackIcon from "./icons/NextTrackIcon"; import PlayPauseIcon from "./icons/PlayPauseIcon"; import PreviousTrackIcon from "./icons/PreviousTrackIcon"; import { animate, useMotionValue } from "motion/react"; import { useEffect, useRef, useState } from "react"; import useMeasure from "react-use-measure"; import { TextMorph } from "torph/react"; export type Track = { title: string; artist: string; cover: string; duration: string; }; // Made-up tracks, ordered so each title and artist shares over half its // letters (case-sensitive, as TextMorph matches them) with the next one, // wrapping round, so most letters slide rather than fade. Covers are Unsplash // photos (Unsplash License: free for commercial use, no attribution required), // cropped square: // Siren Song unsplash.com/photos/oRJsHUlm63I // Inner Lights unsplash.com/photos/qdexC1ISxmw // Stars Align unsplash.com/photos/FzURx0rFhUk // Late Signal unsplash.com/photos/ggDOpGichOo // Silent Snow unsplash.com/photos/yX1HXelXmlQ export const SAMPLE_TRACKS: Track[] = [ { title: "Siren Song", artist: "Sela Moran", cover: "https://uibrik.com/registry/music-player/siren-song.jpg", duration: "1:11", }, { title: "Inner Lights", artist: "Reid Sloan", cover: "https://uibrik.com/registry/music-player/inner-lights.jpg", duration: "1:52", }, { title: "Stars Align", artist: "Mae Rollins", cover: "https://uibrik.com/registry/music-player/stars-align.jpg", duration: "1:47", }, { title: "Late Signal", artist: "Ines Moreau", cover: "https://uibrik.com/registry/music-player/late-signal.jpg", duration: "1:00", }, { title: "Silent Snow", artist: "Teo Marlen", cover: "https://uibrik.com/registry/music-player/silent-snow.jpg", duration: "1:26", }, ]; // TextMorph morphs letter by letter only while the text has no ordinary space; // with one it switches to whole words and only morphs words that look alike, // so "Neon Rain" -> "Silver Ridge" would just crossfade. A four-per-em space // (U+2005) is about as wide as a normal one but doesn't trigger that, so // multi-word names keep sliding their shared letters. const letters = (text: string) => text.replace(/ /g, "\u2005"); const toSeconds = (duration: string) => { const [minutes, seconds] = duration.split(":").map(Number); return minutes * 60 + seconds; }; const format = (seconds: number) => { const total = Math.floor(seconds); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; }; export type MusicPlayerProps = { tracks?: Track[]; /** Ring stroke width in px. */ ringWidth?: number; /** Gap between the card and the ring in px. */ ringGap?: number; /** Seconds the cover stack takes to slide. */ coverDuration?: number; /** Milliseconds the title and artist take to morph. */ morphDuration?: number; }; export function MusicPlayer({ tracks = SAMPLE_TRACKS, ringWidth = 4, ringGap = 6, coverDuration = 0.8, morphDuration = 500, }: MusicPlayerProps) { const [index, setIndex] = useState(0); const [playing, setPlaying] = useState(false); const [elapsed, setElapsed] = useState(0); const [cardRef, bounds] = useMeasure(); const track = tracks[index]; const total = toSeconds(track.duration); // The ring runs off a MotionValue so playback can drive it per frame and a // track change can tween it back to zero instead of snapping. const progress = useMotionValue(0); const [rewinding, setRewinding] = useState(false); const frame = useRef(0); const elapsedRef = useRef(0); const seek = (seconds: number) => { elapsedRef.current = seconds; setElapsed(seconds); }; // A track change starts the clock over and, if the ring has filled at all, // holds playback while the effect below rewinds it. const changeTrack = (step: 1 | -1) => { seek(0); if (progress.get() !== 0) setRewinding(true); setIndex((i) => (i + step + tracks.length) % tracks.length); }; const next = () => changeTrack(1); const previous = () => changeTrack(-1); // On a track change, rewind the ring back to the start before the new track // begins filling it. useEffect(() => { if (progress.get() === 0) return; const controls = animate(progress, 0, { duration: 0.6, ease: [0.4, 0, 0.2, 1], onComplete: () => setRewinding(false), }); return () => { controls.stop(); setRewinding(false); }; }, [index]); useEffect(() => { if (!playing || rewinding) return; let last = performance.now(); const tick = (now: number) => { const value = Math.min(elapsedRef.current + (now - last) / 1000, total); last = now; seek(value); progress.set(value / total); if (value >= total) { setPlaying(false); return; } frame.current = requestAnimationFrame(tick); }; frame.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame.current); }, [playing, total, index, rewinding]); return ( <div className="relative"> <ProgressRing width={bounds.width} height={bounds.height} progress={progress} strokeWidth={ringWidth} inset={ringGap} radius={32 + ringGap} /> <div ref={cardRef} className="relative w-[336px] p-4 squircle-mask squircle-4xl bg-muted flex items-center gap-x-4 overflow-hidden" > <CoverStack tracks={tracks} index={index} duration={coverDuration} /> <div className="flex flex-col min-w-0 flex-1"> <div className="flex flex-col tracking-[-1%] leading-none overflow-hidden"> <TextMorph as="h1" duration={morphDuration} ease="cubic-bezier(0.19, 1, 0.22, 1)" className="text-xl font-bold text-neutral-900 dark:text-neutral-50" > {letters(track.title)} </TextMorph> <TextMorph as="p" duration={morphDuration} ease="cubic-bezier(0.19, 1, 0.22, 1)" className="text-md text-neutral-600 dark:text-neutral-400" > {letters(track.artist)} </TextMorph> </div> <p className="text-xs font-medium tracking-tight text-neutral-500 dark:text-neutral-400 mt-4 mb-2.5"> <AnimatedTime value={`${format(elapsed)}/${track.duration}`} /> </p> <div className="flex items-center justify-between w-40"> <button type="button" onClick={previous} className="flex items-center justify-center bg-neutral-200 text-neutral-700 p-2 squircle-mask squircle-2xl cursor-pointer transition duration-200 ease-out hover:bg-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:hover:text-neutral-50 active:scale-95" > <PreviousTrackIcon /> </button> <button type="button" onClick={() => setPlaying((p) => !p)} aria-label={playing ? "Pause" : "Play"} className="flex items-center justify-center bg-neutral-200 text-neutral-700 p-2 squircle-mask squircle-2xl cursor-pointer transition duration-200 ease-out hover:bg-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:hover:text-neutral-50 active:scale-95" > <PlayPauseIcon playing={playing} /> </button> <button type="button" onClick={next} className="flex items-center justify-center bg-neutral-200 text-neutral-700 p-2 squircle-mask squircle-2xl cursor-pointer transition duration-200 ease-out hover:bg-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700 dark:hover:text-neutral-50 active:scale-95" > <NextTrackIcon /> </button> </div> </div> </div> </div> ); }
Adds the files to
components/uibrik/music-player and installs motion, react-use-measure and torph. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/music-player.Props
MusicPlayer- ringWidth
- Type number
- 4
- Progress ring stroke width.
- ringGap
- Type number
- 6
- Gap between the card and the ring.
- coverDuration
- Type number
- 0.8
- How long the cover stack takes to slide.
- morphDuration
- Type number
- 500
- How long the title and artist take to morph.
- tracks
- Type Track[]
- SAMPLE_TRACKS
- Title, artist, cover image and m:ss duration per track.