All Taste components
Animated screens
Music player
A now-playing screen where the record spins, the waveform moves and the lyrics follow the song.
spinning disc · live waveform · scrubbing progress · lyric highlight
1:043:28
- And the harbour lights came on
- one by one along the pier,
- you said the tide would bring us back
- so I waited for you here
/* globals.css
@keyframes disc-spin { to { transform: rotate(1turn); } }
@keyframes wave-bar { from { transform: scaleY(.35); } }
*/
"use client";
import { useEffect, useState } from "react";
import { ChevronDown, Ellipsis, Heart, Pause, Play, Repeat, Shuffle, SkipBack, SkipForward } from "lucide-react";
import { cn } from "@/lib/utils";
const DURATION = 208; // 3:28
// Lyric lines with the second they start.
const lyrics = [
{ at: 64, text: "And the harbour lights came on" },
{ at: 71, text: "one by one along the pier," },
{ at: 79, text: "you said the tide would bring us back" },
{ at: 86, text: "so I waited for you here" },
];
const bars = Array.from({ length: 40 }, (_, i) => ({
h: 8 + Math.round(22 * Math.abs(Math.sin(i * 0.62) * Math.cos(i * 0.21 + 0.4))),
d: 0.8 + ((i * 37) % 7) / 10,
}));
const clock = (s: number) => Math.floor(s / 60) + ":" + String(Math.floor(s % 60)).padStart(2, "0");
export default function NowPlayingScreen() {
const [playing, setPlaying] = useState(true);
const [at, setAt] = useState(64);
const [liked, setLiked] = useState(true);
useEffect(() => {
if (!playing) return;
const id = setInterval(() => setAt((s) => (s + 1) % DURATION), 1000);
return () => clearInterval(id);
}, [playing]);
const line = lyrics.reduce((cur, l, i) => (at >= l.at ? i : cur), -1);
const paused = playing ? "" : "[animation-play-state:paused]";
return (
<div className="mx-auto flex min-h-dvh max-w-md flex-col bg-[linear-gradient(to_bottom,var(--tst-primary-soft),var(--tst-bg)_55%)] px-4 pb-[max(env(safe-area-inset-bottom),24px)] font-sans text-fg">
<header className="flex h-12 items-center">
<button type="button" aria-label="Close player" className="grid size-11 place-items-center rounded-full hover:bg-surface-2"><ChevronDown className="size-[22px]" /></button>
<p className="flex-1 text-center text-[15px] font-semibold leading-5"><span className="block text-xs font-normal text-fg-muted">Playing from playlist</span>Late Night Drive</p>
<button type="button" aria-label="More" className="grid size-11 place-items-center rounded-full hover:bg-surface-2"><Ellipsis className="size-[22px]" /></button>
</header>
{/* Record: the disc turns while playing; the sheen stays still. */}
<div aria-hidden className="relative mx-auto mt-1.5 size-44 rounded-full after:pointer-events-none after:absolute after:inset-0 after:rounded-full after:bg-[conic-gradient(from_300deg,transparent_0_10%,rgb(255_255_255/.14)_14%,transparent_20%_60%,rgb(255_255_255/.1)_64%,transparent_70%)]">
<div className={cn("absolute inset-0 rounded-full bg-[repeating-radial-gradient(circle,var(--tst-neutral-900)_0_1.5px,var(--tst-neutral-800)_1.5px_3px)] shadow-[0_20px_40px_-18px_rgb(0_0_0/.55)] animate-[disc-spin_8s_linear_infinite] motion-reduce:animate-none", paused)}>
<span className="absolute inset-[29%] rounded-full bg-[conic-gradient(from_20deg,var(--tst-brand-300),var(--tst-brand-600),var(--tst-brand-800),var(--tst-warning),var(--tst-brand-300))]" />
<span className="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-neutral-950" />
</div>
</div>
<div className="mt-3.5 flex items-center gap-2">
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold">Low Tide</h1>
<p className="truncate text-xs text-fg-muted">Mira Okafor · Harbour Lights</p>
</div>
<button type="button" aria-pressed={liked} aria-label="Like" onClick={() => setLiked(!liked)}
className={cn("grid size-11 place-items-center rounded-full transition active:scale-90", liked ? "text-danger" : "text-fg-muted")}>
<Heart className={cn("size-[22px]", liked && "fill-current")} />
</button>
</div>
<div aria-hidden className="mt-3 flex h-8 items-center justify-between">
{bars.map((b, i) => (
<i key={i} className={cn("w-[3px] rounded-full", i / bars.length < at / DURATION ? "bg-primary" : "bg-primary/35",
playing && "animate-[wave-bar_1s_ease-in-out_infinite_alternate] motion-reduce:animate-none")}
style={{ height: b.h, animationDuration: b.d + "s", animationDelay: -i * 0.13 + "s" }} />
))}
</div>
<input type="range" min={0} max={DURATION} value={at} onChange={(e) => setAt(Number(e.target.value))} aria-label="Position" aria-valuetext={clock(at) + " of " + clock(DURATION)}
className="mt-3.5 h-4 w-full cursor-pointer accent-[var(--tst-fg)]" />
<div className="flex justify-between text-xs tabular-nums text-fg-muted"><span>{clock(at)}</span><span>{clock(DURATION)}</span></div>
<ol aria-label="Lyrics" className="mt-3.5 grid gap-0.5">
{lyrics.map((l, i) => (
<li key={l.at} className={cn("truncate text-sm font-semibold transition-colors duration-500 ease-taste", i === line ? "text-fg" : "text-fg-subtle/60")}>{l.text}</li>
))}
</ol>
<div className="mt-auto flex items-center justify-between px-1 pt-3">
<button type="button" aria-label="Shuffle" className="grid size-11 place-items-center text-fg-muted"><Shuffle className="size-5" /></button>
<button type="button" aria-label="Previous" onClick={() => setAt(0)} className="grid size-11 place-items-center"><SkipBack className="size-6" /></button>
<button type="button" aria-label={playing ? "Pause" : "Play"} onClick={() => setPlaying(!playing)}
className="grid size-[60px] place-items-center rounded-full bg-primary text-primary-fg transition active:scale-95">
{playing ? <Pause className="size-6 fill-current" /> : <Play className="size-6 fill-current" />}
</button>
<button type="button" aria-label="Next" className="grid size-11 place-items-center"><SkipForward className="size-6" /></button>
<button type="button" aria-label="Repeat" className="grid size-11 place-items-center text-fg-muted"><Repeat className="size-5" /></button>
</div>
</div>
);
}
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.