Teseria
All elements

Now playing bar

A mini player whose every size, corner and position is calc() on one number, --npb-p, from 0 (bar) to 1 (full player). The number is a hand-rolled spring, or your finger: drag the bar up and it follows, then flies to whichever end it was thrown towards, keeping the throw's velocity.

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

NowPlayingBar.tsx428 lines
"use client";
// NowPlayingBar: a mini player whose every size, corner and position is calc() on one number, --npb-p, from 0 (bar)
// to 1 (full player). The number is a hand-rolled spring, or your finger: drag the bar up and it follows, then
// flies to whichever end it was thrown towards, keeping the throw's velocity. Past either end it gives a little.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, PointerEvent, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Pause, Play, SkipBack, SkipForward } from "lucide-react";

export type NowPlayingBarCue = "unfold" | "fold" | "enable" | "disable" | "next" | "back" | "detent" | "grab" | "place";

export type NowPlayingTrack = {
  /** Track title. */
  title: string;
  /** Artist. */
  by: string;
  /** Cover image URL. */
  art: string;
  /** Length in seconds. */
  len: number;
};

export type NowPlayingBarProps = {
  /** Queue, in order. The defaults use Teseria's demo art (/art/*.svg exists only in Teseria); pass your own covers. */
  tracks?: NowPlayingTrack[];
  /** Full player showing (controlled). */
  open?: boolean;
  /** Full player showing at mount (uncontrolled). */
  defaultOpen?: boolean;
  /** Called when the bar settles open or closed. */
  onOpenChange?: (open: boolean) => void;
  /** Playing (controlled). */
  playing?: boolean;
  /** Playing at mount (uncontrolled). */
  defaultPlaying?: boolean;
  /** Called when play or pause is pressed. */
  onPlayingChange?: (playing: boolean) => void;
  /** Index of the track on show (controlled). */
  index?: number;
  /** Index at mount (uncontrolled). */
  defaultIndex?: number;
  /** Called when the track changes, by skip or when one ends. */
  onIndexChange?: (index: number) => void;
  /** Seconds into the first track at mount. */
  startAt?: number;
  /** Called with the new position in seconds when the user seeks. */
  onSeek?: (seconds: number) => void;
  /** Sound hook: "unfold" / "fold" on open / close, "enable" / "disable" play and pause, "next" / "back" on skip, "detent" on arrow-key seek, "grab" on drag or scrub start, "place" on scrub end. */
  onSound?: (cue: NowPlayingBarCue) => void;
  className?: string;
  style?: CSSProperties;
};

export const DEFAULT_NOW_PLAYING_TRACKS: NowPlayingTrack[] = [
  { title: "Night Swim", by: "Pale Rooms", art: "/art/lagoon.svg", len: 204 },
  { title: "Salt Air", by: "Dune Choir", art: "/art/dunes.svg", len: 187 },
];

const RANGE = 116; // px of height between bar and player, the drag scale for p

const EASE = [0.22, 1, 0.36, 1] as const;
const EXIT = [0.4, 0, 1, 1] as const;
const MORPH = { type: "spring", stiffness: 340, damping: 30, mass: 0.8 } as const;
const NONE = { duration: 0 } as const;

/** Content swap inside a springing container: in a beat after the shell starts, out faster. */
const SWAP = {
  initial: { opacity: 0, scale: 0.94, filter: "blur(6px)" },
  animate: {
    opacity: 1,
    scale: 1,
    filter: "blur(0px)",
    transition: { opacity: { duration: 0.16, delay: 0.08 }, default: { duration: 0.34, ease: EASE, delay: 0.06 } },
    transitionEnd: { filter: "none" },
  },
  exit: {
    opacity: 0,
    scale: 0.94,
    filter: "blur(6px)",
    transition: { opacity: { duration: 0.12 }, default: { duration: 0.16, ease: EXIT } },
  },
};
const STILL = { initial: { opacity: 0 }, animate: { opacity: 1, transition: NONE }, exit: { opacity: 0, transition: NONE } };
const swap = (still: boolean) => (still ? STILL : SWAP);

const cn = (...a: (string | false | undefined)[]) => a.filter(Boolean).join(" ");
const clamp = (n: number, a: number, b: number) => Math.min(b, Math.max(a, n));
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
const calm = () => typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;

function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (v: T) => void) {
  const [inner, setInner] = useState(defaultValue);
  const v = value !== undefined ? value : inner;
  const set = (n: T) => {
    if (value === undefined) setInner(n);
    onChange?.(n);
  };
  return [v, set] as const;
}

/** One rolling digit. Spare cells at both ends so 9→0 rolls forward and 0→9 rolls back, then settles unseen. */
const rollY = (i: number) => `translateY(${-i * 1.2}em)`;
function Roll({ d, base = 10 }: { d: number; base?: number }) {
  const strip = useRef<HTMLSpanElement>(null);
  const prev = useRef(d);
  useLayoutEffect(() => {
    const p = prev.current;
    prev.current = d;
    const el = strip.current;
    if (!el || p === d || calm()) return;
    const to = p === base - 1 && d === 0 ? base + 1 : p === 0 && d === base - 1 ? 0 : d + 1;
    el.getAnimations().forEach((a) => a.cancel());
    el.animate(
      [{ transform: rollY(p + 1), filter: "blur(0px)" }, { filter: "blur(0.9px)", offset: 0.4 }, { transform: rollY(to), filter: "blur(0px)" }],
      { duration: 480, easing: "cubic-bezier(.22,1,.36,1)" },
    );
  }, [d, base]);
  return (
    <span className="npb-roll">
      <span ref={strip} className="npb-roll-strip" style={{ transform: rollY(d + 1) }}>
        {[base - 1, ...Array.from({ length: base }, (_, i) => i), 0].map((n, i) => (
          <span key={i}>{n}</span>
        ))}
      </span>
    </span>
  );
}

function Clock({ s, className }: { s: number; className?: string }) {
  const m = Math.floor(s / 60);
  const r = s % 60;
  return (
    <span className={cn("npb-num", className)}>
      <span className="npb-sr">{mmss(s)}</span>
      <span className="npb-num-v" aria-hidden>
        {m >= 10 && <Roll d={Math.floor(m / 10) % 10} />}
        <Roll d={m % 10} />
        <span>:</span>
        <Roll d={Math.floor(r / 10)} base={6} />
        <Roll d={r % 10} />
      </span>
    </span>
  );
}

/** Icon or label swap in place: blur out, blur in. */
function Swap({ k, still, children, className }: { k: string; still: boolean; children: ReactNode; className?: string }) {
  return (
    <AnimatePresence initial={false} mode="popLayout">
      <motion.span key={k} className={cn("npb-swap", className)} {...swap(still)}>
        {children}
      </motion.span>
    </AnimatePresence>
  );
}

/** Seek bar: drag, tap or arrow keys. Owns its drag value so the times follow the finger. */
function Scrub({ at, len, onSeek, off, className, cue }: { at: number; len: number; onSeek: (s: number) => void; off?: boolean; className?: string; cue: (c: "detent" | "grab" | "place") => void }) {
  const track = useRef<HTMLDivElement>(null);
  const lastTock = useRef(0);
  const [drag, setDrag] = useState<number | null>(null);
  const v = clamp(drag ?? at, 0, len);
  const pick = (x: number) => {
    const r = track.current!.getBoundingClientRect();
    return Math.round(clamp((x - r.left) / r.width, 0, 1) * len);
  };
  const key = (e: KeyboardEvent) => {
    const step: Record<string, number> = { ArrowRight: 5, ArrowUp: 5, ArrowLeft: -5, ArrowDown: -5, Home: -len, End: len };
    if (step[e.key] === undefined) return;
    e.preventDefault();
    e.stopPropagation();
    const now = performance.now();
    if (now - lastTock.current >= 60) {
      lastTock.current = now;
      cue("detent");
    }
    onSeek(clamp(at + step[e.key], 0, len));
  };
  return (
    <div className={cn("npb-scrub", className)} aria-hidden={off || undefined}>
      <div
        ref={track}
        className="npb-scrub-hit"
        role="slider"
        tabIndex={off ? -1 : 0}
        aria-label="Seek"
        aria-valuemin={0}
        aria-valuemax={len}
        aria-valuenow={v}
        aria-valuetext={mmss(v)}
        data-drag={drag !== null || undefined}
        style={{ "--npb-f": len ? v / len : 0 } as CSSProperties}
        onPointerDown={(e) => {
          if (off || e.button !== 0) return;
          e.stopPropagation();
          e.currentTarget.setPointerCapture(e.pointerId);
          setDrag(pick(e.clientX));
          cue("grab");
        }}
        onPointerMove={(e) => drag !== null && setDrag(pick(e.clientX))}
        onPointerUp={(e) => {
          if (drag === null) return;
          onSeek(pick(e.clientX));
          setDrag(null);
          cue("place");
        }}
        onPointerCancel={() => setDrag(null)}
        onKeyDown={key}
      >
        <span className="npb-scrub-rail">
          <span className="npb-scrub-fill" />
        </span>
        <span className="npb-scrub-knob" />
      </div>
      <div className="npb-times" aria-hidden>
        <Clock s={v} />
        <span>
          −<Clock s={len - v} />
        </span>
      </div>
    </div>
  );
}

export function NowPlayingBar({
  tracks = DEFAULT_NOW_PLAYING_TRACKS,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  playing,
  defaultPlaying = true,
  onPlayingChange,
  index,
  defaultIndex = 0,
  onIndexChange,
  startAt = 48,
  onSeek,
  onSound,
  className,
  style,
}: NowPlayingBarProps) {
  const still = !!useReducedMotion();
  const root = useRef<HTMLDivElement>(null);
  const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);
  const p0 = useRef(open ? 1 : 0).current;
  const sim = useRef({ p: p0, v: 0, to: p0, raf: 0, last: 0 });
  const drag = useRef<{ y: number; p: number; t: number; v: number; moved: boolean } | null>(null);
  const [on, setOn] = useControllable(playing, defaultPlaying, onPlayingChange);
  const [rawI, setI] = useControllable(index, defaultIndex, onIndexChange);
  const [at, setAt] = useState(startAt);
  const n = tracks.length;
  const i = n ? ((rawI % n) + n) % n : 0;
  const t = tracks[i] as NowPlayingTrack | undefined;
  const len = t?.len ?? 0;
  const live = useRef({ still, onSound });
  live.current = { still, onSound };

  useEffect(() => {
    if (!on || !n) return;
    const id = setInterval(() => setAt((a) => a + 1), 1000);
    return () => clearInterval(id);
  }, [on, n]);
  useEffect(() => {
    if (!n || at < len) return;
    setI((i + 1) % n);
    setAt(0);
  }, [at, len]);
  useEffect(() => {
    const s = sim.current;
    return () => {
      cancelAnimationFrame(s.raf);
      s.raf = 0;
      s.last = 0;
    };
  }, []);

  const write = () => root.current?.style.setProperty("--npb-p", sim.current.p.toFixed(4));
  // MORPH as a hand-rolled spring (k 340, c 30, m .8) so a drag can hand its velocity straight over
  const step = (now: number) => {
    const s = sim.current;
    const dt = s.last ? clamp((now - s.last) / 1000, 0, 1 / 30) : 1 / 60;
    s.last = now;
    s.v += ((-340 * (s.p - s.to) - 30 * s.v) / 0.8) * dt;
    s.p += s.v * dt;
    if (Math.abs(s.p - s.to) < 0.0004 && Math.abs(s.v) < 0.004) {
      s.p = s.to;
      s.v = 0;
      s.raf = 0;
      s.last = 0;
      write();
      return;
    }
    write();
    s.raf = requestAnimationFrame(step);
  };
  const run = (to: 0 | 1, v = 0) => {
    const s = sim.current;
    s.to = to;
    s.v = v;
    if (live.current.still) {
      s.p = to;
      s.v = 0;
      write();
      return;
    }
    if (!s.raf) s.raf = requestAnimationFrame(step);
  };
  const go = (to: 0 | 1, v = 0) => {
    if (to !== sim.current.to) onSound?.(to ? "unfold" : "fold");
    setOpen(to === 1);
    run(to, v);
  };
  // a controlled `open` that changes from outside springs the bar the same way
  useEffect(() => {
    const to = open ? 1 : 0;
    if (to !== sim.current.to) run(to);
  }, [open]);

  const down = (e: PointerEvent<HTMLDivElement>) => {
    if (e.button !== 0 || (e.target as HTMLElement).closest(".npb-ctl, .npb-scrub")) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    const s = sim.current;
    cancelAnimationFrame(s.raf);
    s.raf = 0;
    s.last = 0;
    drag.current = { y: e.clientY, p: s.p, t: e.timeStamp, v: 0, moved: false };
  };
  const move = (e: PointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d) return;
    const dy = e.clientY - d.y;
    if (!d.moved) {
      if (Math.abs(dy) < 4) return;
      d.moved = true;
      onSound?.("grab");
    }
    const raw = d.p - dy / RANGE;
    // past either end the bar gives a little and no more
    const p = raw > 1 ? 1 + 0.07 * Math.tanh((raw - 1) / 0.07) : raw < 0 ? 0.07 * Math.tanh(raw / 0.07) : raw;
    const s = sim.current;
    d.v = d.v + (((p - s.p) / Math.max(1, e.timeStamp - d.t)) * 1000 - d.v) * 0.5;
    d.t = e.timeStamp;
    s.p = p;
    write();
  };
  const up = () => {
    const d = drag.current;
    drag.current = null;
    if (!d) return;
    if (!d.moved) return go(sim.current.to ? 0 : 1);
    go(sim.current.p + d.v * 0.14 > 0.5 ? 1 : 0, d.v);
  };
  const skip = (dir: number) => {
    onSound?.(dir > 0 ? "next" : "back");
    setI((i + dir + n) % n);
    setAt(0);
  };
  if (!t) return <div className={cn("npb-root", className)} style={style} />;

  return (
    <div className={cn("npb-root", className)} style={style}>
      <div
        ref={root}
        className="npb-np"
        style={{ "--npb-p": p0 } as CSSProperties}
        data-open={open || undefined}
        onPointerDown={down}
        onPointerMove={move}
        onPointerUp={up}
        onPointerCancel={up}
        onKeyDown={(e) => {
          if (e.key !== "Escape" || !open) return;
          e.stopPropagation();
          go(0);
          e.currentTarget.querySelector<HTMLButtonElement>(".npb-hit")?.focus();
        }}
      >
        {/* pointer taps are handled by the drag; this button is for the keyboard (detail 0) */}
        <button
          type="button"
          className="npb-hit"
          aria-expanded={open}
          aria-label={open ? "Collapse player" : `Now playing ${t.title} by ${t.by}. Expand player`}
          onClick={(e) => e.detail === 0 && go(open ? 0 : 1)}
        />
        <AnimatePresence initial={false}>
          <motion.img key={`a${i}`} className="npb-np-art" src={t.art} alt="" draggable={false} {...swap(still)} />
        </AnimatePresence>
        <AnimatePresence initial={false}>
          <motion.span key={`t${i}`} className="npb-np-text" {...swap(still)}>
            <b>{t.title}</b>
            <small>{t.by}</small>
          </motion.span>
        </AnimatePresence>
        <button
          type="button"
          className="npb-ctl npb-np-play"
          aria-label={on ? "Pause" : "Play"}
          onClick={() => {
            onSound?.(on ? "disable" : "enable");
            setOn(!on);
          }}
        >
          <Swap k={on ? "pause" : "play"} still={still}>
            {on ? <Pause size={16} fill="currentColor" strokeWidth={0} /> : <Play size={16} fill="currentColor" strokeWidth={0} />}
          </Swap>
        </button>
        <button type="button" className="npb-ctl npb-np-skip npb-np-prev" aria-label="Previous track" tabIndex={open ? 0 : -1} onClick={() => skip(-1)}>
          <SkipBack size={17} fill="currentColor" />
        </button>
        <button type="button" className="npb-ctl npb-np-skip npb-np-next" aria-label="Next track" tabIndex={open ? 0 : -1} onClick={() => skip(1)}>
          <SkipForward size={17} fill="currentColor" />
        </button>
        <Scrub
          className="npb-np-scrub"
          at={at}
          len={len}
          onSeek={(s) => {
            setAt(s);
            onSeek?.(s);
          }}
          off={!open}
          cue={(c) => onSound?.(c)}
        />
      </div>
    </div>
  );
}
NowPlayingBar.css295 lines
/* NowPlayingBar. Prefix npb-. */
.npb-root,
.npb-root * {
  box-sizing: border-box;
}
.npb-root {
  position: relative;
  flex: none;
  margin: 0 auto;
  width: 300px;
  max-width: 100%;
  min-height: 220px;
  font-family: var(--ts-font, system-ui, sans-serif);
  font-size: 13px;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--ts-fg);
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}
:where(.npb-root) button {
  font: inherit;
  letter-spacing: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  transition:
    scale 0.22s cubic-bezier(.22, 1, .36, 1),
    background-color 0.16s cubic-bezier(.22, 1, .36, 1),
    color 0.16s cubic-bezier(.22, 1, .36, 1),
    opacity 0.16s cubic-bezier(.22, 1, .36, 1),
    box-shadow 0.16s cubic-bezier(.22, 1, .36, 1);
}
:where(.npb-root) button:active {
  scale: 0.96;
}
.npb-root :focus-visible {
  outline: 2px solid var(--ts-focus, #3446eb);
  outline-offset: 2px;
}
.npb-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.npb-swap {
  display: inline-grid;
  place-items: center;
  white-space: nowrap;
}

.npb-root {
  --npb-pill: #0b0b0c;
  --npb-on: #fff;
  --npb-on-2: rgba(255, 255, 255, 0.56);
  --npb-on-3: rgba(255, 255, 255, 0.14);
  --npb-on-4: rgba(255, 255, 255, 0.22);
}
[data-theme="dark"] .npb-root {
  --npb-pill: #000;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .npb-root {
    --npb-pill: #000;
  }
}
.npb-np {
  background: var(--npb-pill);
  color: var(--npb-on);
}
.npb-num {
  display: inline-flex;
  font-variant-numeric: tabular-nums;
}
.npb-num-v {
  display: inline-flex;
}
.npb-roll {
  display: block;
  height: 1.2em;
  clip-path: inset(0 -0.1em);
}
.npb-roll-strip {
  display: flex;
  flex-direction: column;
}
.npb-roll-strip > span {
  display: block;
  height: 1.2em;
  line-height: 1.2em;
  text-align: center;
}
.npb-hit {
  position: absolute;
  inset: 0;
  z-index: 0;
  border-radius: inherit;
}
.npb-hit:active {
  scale: none;
}
.npb-root .npb-hit:focus-visible {
  outline: none;
  box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.72);
}
.npb-np :focus-visible {
  outline-color: #fff;
}
.npb-ctl {
  position: relative;
  flex: none;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: var(--npb-on-3);
  color: var(--npb-on);
}
.npb-ctl:hover {
  background: var(--npb-on-4);
}
.npb-ctl[aria-pressed="true"] {
  background: var(--npb-on);
  color: var(--npb-pill);
}
.npb-scrub-hit {
  --npb-f: 0;
  position: relative;
  height: 14px;
  display: flex;
  align-items: center;
  cursor: pointer;
  touch-action: none;
}
.npb-scrub-rail {
  position: relative;
  width: 100%;
  height: 4px;
  border-radius: 4px;
  background: var(--npb-on-4);
  overflow: hidden;
}
.npb-scrub-fill {
  position: absolute;
  inset: 0;
  background: var(--npb-on);
  transform-origin: 0 50%;
  scale: var(--npb-f) 1;
  transition: scale 1s linear;
}
.npb-scrub-knob {
  position: absolute;
  top: 50%;
  left: calc(var(--npb-f) * 100%);
  width: 10px;
  height: 10px;
  margin: -5px 0 0 -5px;
  border-radius: 50%;
  background: var(--npb-on);
  scale: 0;
  transition:
    scale 0.22s cubic-bezier(.22, 1, .36, 1),
    left 1s linear;
}
.npb-scrub-hit:hover .npb-scrub-knob,
.npb-scrub-hit:focus-visible .npb-scrub-knob,
.npb-scrub-hit[data-drag] .npb-scrub-knob {
  scale: 1;
}
.npb-scrub-hit[data-drag] .npb-scrub-fill {
  transition: none;
}
.npb-scrub-hit[data-drag] .npb-scrub-knob {
  transition: scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.npb-scrub-hit:focus-visible {
  outline-offset: 0;
  border-radius: 4px;
}
.npb-times {
  display: flex;
  justify-content: space-between;
  margin-top: 1px;
  font-size: 12px;
  color: var(--npb-on-2);
}
.npb-np {
  --npb-p: 0;
  position: absolute;
  left: 50%;
  bottom: 24px; /* open player centred */
  translate: -50% 0;
  width: calc(248px + 36px * var(--npb-p));
  height: calc(56px + 116px * var(--npb-p));
  border-radius: calc(28px + 2px * var(--npb-p));
  overflow: hidden;
  isolation: isolate;
  touch-action: none;
  cursor: grab;
}
.npb-np:active {
  cursor: grabbing;
}
.npb-np > :not(.npb-hit) {
  position: absolute;
  z-index: 1;
}
.npb-np-art {
  left: calc(8px + 8px * var(--npb-p));
  top: calc(8px + 8px * var(--npb-p));
  width: calc(40px + 56px * var(--npb-p));
  height: calc(40px + 56px * var(--npb-p));
  border-radius: calc(20px - 6px * var(--npb-p));
  object-fit: cover;
  pointer-events: none;
}
.npb-np-text {
  left: calc(58px + 66px * var(--npb-p));
  right: calc(52px - 36px * var(--npb-p));
  top: calc(10px + 10px * var(--npb-p));
  display: flex;
  flex-direction: column;
  gap: calc(2px + 3px * var(--npb-p));
  pointer-events: none;
  white-space: nowrap;
}
.npb-np-text b {
  font-size: calc(13px + 3px * var(--npb-p));
  font-weight: 500;
  letter-spacing: -0.015em;
  overflow: hidden;
  text-overflow: ellipsis;
}
.npb-np-text small {
  font-size: calc(12px + 1px * var(--npb-p));
  color: var(--npb-on-2);
  overflow: hidden;
  text-overflow: ellipsis;
}
.npb-np-play {
  left: calc(202px - 38px * var(--npb-p));
  top: calc(10px + 60px * var(--npb-p));
  width: calc(36px + 4px * var(--npb-p));
  height: calc(36px + 4px * var(--npb-p));
  background: var(--npb-on);
  color: var(--npb-pill);
}
.npb-np-play:hover {
  background: rgba(255, 255, 255, 0.86);
}
.npb-np-skip {
  top: 74px;
  width: 32px;
  height: 32px;
  background: none;
  opacity: clamp(0, (var(--npb-p) - 0.55) * 2.2, 1);
  scale: calc(0.7 + 0.3 * var(--npb-p));
  filter: blur(calc((1 - var(--npb-p)) * 4px));
}
.npb-np-skip:active {
  scale: 0.96;
}
.npb-np-prev {
  left: 124px;
}
.npb-np-next {
  left: 212px;
}
.npb-np:not([data-open]) :is(.npb-np-skip, .npb-np-scrub) {
  pointer-events: none;
}
.npb-np-scrub {
  left: calc(58px - 42px * var(--npb-p));
  right: calc(52px - 36px * var(--npb-p));
  top: calc(39px + 83px * var(--npb-p));
}
.npb-np .npb-scrub-rail {
  height: calc(2px + 2px * var(--npb-p));
}
.npb-np .npb-times {
  opacity: clamp(0, (var(--npb-p) - 0.6) * 2.5, 1);
}
@media (prefers-reduced-motion: reduce) {
  .npb-scrub-fill,
  .npb-scrub-knob {
      transition: none;
    }
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { NowPlayingBar } from "./NowPlayingBar";
import "./NowPlayingBar.css";

export default function Demo() {
  return <NowPlayingBar />;
}