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
npm i framer-motion lucide-react
2. Files
"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. 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;
}
}
/*
* 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
import { NowPlayingBar } from "./NowPlayingBar";
import "./NowPlayingBar.css";
export default function Demo() {
return <NowPlayingBar />;
}