Skip to content
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

Playing from playlistLate Night Drive

Low Tide

Mira Okafor · Harbour Lights

1:043:28
  1. And the harbour lights came on
  2. one by one along the pier,
  3. you said the tide would bring us back
  4. 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.