Design Lounge
Install

Nº 042 of 520

Now playing

A full-screen player: CSS-gradient album art with SVG grain, the same art blurred as the backdrop, a scrubber with a fat drag thumb, a path-morphing play/pause and time-synced lyrics.

Preview

Open full size
390 × 844 · 10.8 KB · 121 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Now playing" (piece ios-now-playing) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

212 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

The now-playing sheet of “Marrow”, a music app. There is no image: the 326px album art is a layered CSS gradient (a warm orange-to-plum diagonal with a gold radial at the top-left) with an inline SVG feTurbulence grain blended in soft-light at 28%. The same gradient, blurred 48px and darkened, fills the screen behind everything so the UI sits in the record’s colour. Below the art: title and artist, a scrubber whose 6px thumb grows to 18px while dragging and whose track thickens, a play/pause button whose SVG path morphs between the two glyphs over 280ms, a thumbless volume slider, and four lines of lyrics that highlight and scroll as playback time passes. Pausing shrinks the art to 86%; playing grows it back with a spring.

Reference behaviour

  1. Initial state: playing. Art at scale 1 with a deep shadow; “Copper Wire / Marrow · Low Tide Radio”; scrubber at 1:08 of 3:34 (31.8%); remaining time “−2:26”; the second lyric line is highlighted, the first dimmed; volume at 62%.
  2. Time advances in real time via requestAnimationFrame only while playing (no loop when paused). The scrubber, both time labels and aria-valuenow update every frame.
  3. Lyric lines have timestamps (60, 66, 73, 80 s). The latest line whose time ≤ current is on (full ink); earlier lines are past (38% ink at 50% opacity); later lines are 38% ink at 70% opacity. When the current line index ≥ 2 the block translates up by (index − 1) × 23px so the current line stays second from the top. Colour and position transition over 280ms.
  4. Tap play/pause: the glyph morphs (two bars ↔ two triangle halves) over 280ms cubic-bezier(.32,.72,0,1); aria-label and aria-pressed flip; the art scales to 0.86 (paused) or 1 (playing) over 520ms with the same spring, and its shadow follows.
  5. Press on the scrubber: the track grows from 6px to 10px and the thumb from 6px to 18px with a shadow, over 160ms; the position jumps to the pointer. Drag: position follows the pointer (clamped to 0–214 s); time labels update live; playback resumes from the released position. Release: track and thumb return to their rest sizes.
  6. With the scrubber focused, ArrowLeft/ArrowRight nudge ±5 s.
  7. Drag the volume slider: the filled portion follows (--v), no visible thumb.
  8. At 3:34 playback stops and the button shows the play glyph; tapping it resumes from the end (the demo does not loop).
  9. “Previous”, “Next” and “More” are present with press states but do not change the track.

Structure

390 × 844
┌────────────────────────────────────────┐
│ (54 status)  blurred art fills bg      │
│ 60         ▬ grabber 36×5              │
│    ┌────────────────────────────┐      │
│    │ art 326×326, r14      (◎)  │      │  gradient + grain
│    │                            │      │
│    │ MARROW                     │      │
│    └────────────────────────────┘      │
│  Copper Wire (22/600)            (…)   │
│  Marrow · Low Tide Radio (16, 66%)     │
│  ━━━━━━━━●───────────────────────      │  scrubber 36 tall, track 6
│  1:08                          −2:26   │
│        ◀◀     (  ▌▌  72  )    ▶▶       │
│  🔈 ━━━━━━━━━━━━━━━───────── 🔊        │  volume (icons are SVG)
│ ───────────────────────────────────────│ hairline
│  Salt on the window, the kettle… (past)│  lyrics, 90px window
│  Copper wire humming a song…    (on)   │
│  The tide keeps its hours…             │
│                                   44   │
└────────────────────────────────────────┘
  • .bgart (aria-hidden): absolute, inset:-40px, the art gradient with filter: blur(48px) saturate(140%), opacity .85, scale(1.1); ::after darkens top→bottom (15% → 70% of --bg).
  • .screen: flex column, padding: 60px 32px 44px, class playing toggled.
  • .art[aria-label]: ::before gradient stack, an inline <svg class="grain"> (feTurbulence rect, aria-hidden), a wordmark <b> bottom-left and a ring glyph top-right.
  • .meta: <h1> + <p> and a 40px glass “More” <button>.
  • .scrub[role=slider tabindex=0]: 36px tall hit area, .bar → .fill (width --pct) and .thumb (left --pct).
  • .times, .ctls (Previous / Play / Next), .vol (<input type=range> between two SVGs), .lyrics (<p data-t> × 4).

Tokens

:root {
  /* album art gradient */
  --art-a: #f2683b;  --art-b: #8e1f4a;  --art-c: #2a1230;  --art-d: #f7b955;
  --art-gradient: radial-gradient(120% 90% at 20% 15%, var(--art-d), transparent 45%),
                  linear-gradient(150deg, var(--art-a), var(--art-b) 58%, var(--art-c));
  --grain-opacity: .28;                    /* feTurbulence baseFrequency .9, 2 octaves, soft-light */

  /* surface + text (on the blurred art) */
  --bg: #1a0f16;
  --ink: #fff5ee;
  --ink-2: rgba(255,245,238,.66);          /* artist line */
  --ink-3: rgba(255,245,238,.38);          /* times, upcoming lyrics, volume icons */
  --track: rgba(255,245,238,.22);          /* scrubber + volume track, grabber */
  --accent: #ffb28a;                       /* focus rings only */
  --glass: rgba(255,245,238,.10);  --glass-line: rgba(255,245,238,.16);

  /* type */
  --font: "Familjen Grotesk", system-ui, -apple-system, sans-serif;

  /* geometry */
  --art: 326px;  --r-art: 14px;  --art-paused: .86;
  --art-shadow: 0 24px 60px rgba(0,0,0,.45), 0 2px 6px rgba(0,0,0,.3);
  --track-h: 6px;  --track-h-drag: 10px;  --thumb: 6px;  --thumb-drag: 18px;
  --play: 72px;  --skip-icon: 34px;  --play-icon: 40px;  --ctl-gap: 40px;
  --lyric-line: 23px;  --lyric-window: 90px;
  --duration: 214;                         /* seconds */

  /* motion */
  --t-micro: 160ms;
  --t-morph: 280ms;
  --t-art: 520ms;
  --spring: cubic-bezier(.32, .72, 0, 1);
  --ease: cubic-bezier(.2, .7, .2, 1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
Song titleFamiljen Grotesk22px6001.2−0.015emsentence
Artist lineFamiljen Grotesk16px4001.40sentence, --ink-2
WordmarkFamiljen Grotesk13px7001+0.18emUPPERCASE, 85% ink
TimesFamiljen Grotesk12px5001.40tabular-nums, U+2212 for remaining
LyricsFamiljen Grotesk17px5001.350sentence

Motion

ElementTriggerPropertyFrom → ToDurationEasingNotes
.artpause / playtransform, box-shadowscale 1 ↔ .86520ms--spring
#pp pathpause / playdbars ↔ triangle halves280ms--springCSS d: path(); same command count
.barscrub pressheight6px ↔ 10px160ms--ease
.thumbscrub presswidth, height, margin6px ↔ 18px160ms--easeshadow appears at 18px
.fill, .thumbplaybackwidth / leftfollows --pct0—set per frame
.lyrics pline changecolor, transformdim ↔ ink; translateY(−(i−1)·23px)280ms--ease
.ctl:activetransform1 → scale(.9)160ms--ease
.iconhoverbackground10% → 18% ink160ms--ease

Reduced motion: all transitions 1ms (the glyph swaps instantly, art snaps between sizes, lyrics jump). Playback timing is unaffected.

States

  • Playing: .screen.playing, art at scale 1, #play[aria-pressed=true][aria-label=Pause].
  • Paused: art at .86, play glyph, no rAF loop running.
  • Scrubbing: .scrub.drag, fat thumb, thick track; time labels update but the rAF does not overwrite t.
  • Ended: paused at 214 s; remaining reads “−0:00”.
  • Lyric line: .on (ink), .past (38% ink at 50% opacity), upcoming (38% ink at 70% opacity).
  • Focus-visible: scrubber → 2px --accent ring on the track; controls → 2px ring with 4px offset; More → 2px ring, 2px offset; volume → 2px outline, 2px offset.

Accessibility

  • The scrubber is role="slider" with aria-valuemin/max/now in seconds and aria-valuetext like “1:08 of 3:34”; ArrowLeft/Right move 5 s. Its hit area is 36px tall although the track is 6px.
  • Play/pause is one <button> whose aria-label flips between “Pause” and “Play” and whose aria-pressed reflects playing.
  • Volume is a native <input type="range" aria-label="Volume">; the thumb is hidden visually but keyboard and touch still work because the input is 28px tall.
  • The art has aria-label naming the album; the backdrop is aria-hidden.
  • Lyrics are aria-live="off" (they change every few seconds; announcing them would be noise).
  • Contrast: --ink on the darkest backdrop region ≥ 12:1; --ink-2 ≥ 7:1; --ink-3 is only used for 12px times and dimmed lyrics that are not the current line.
  • Hit targets: play 72px, skips 34px icons inside 44px buttons, More 40px, scrubber 36px tall.

Responsive rules

  • 360 wide: art 296px (min(326px, 100vw − 64px)); everything else unchanged.
  • ≥ 430 wide: keep the art at 326px and centre the column at 390px.
  • Short viewports (< 760px): art 260px, lyrics window 66px (two lines).
  • Tablet: art 420px on the left, controls and lyrics in a 360px column on the right; the blurred backdrop remains full-bleed.

Acceptance checklist

  • Album art is a 326px square with radius 14px, the two-layer gradient from the tokens, and an inline SVG feTurbulence grain at 28% soft-light; no raster image or external URL is loaded.
  • The backdrop is the same gradient blurred 48px, saturated 140%, scaled 1.1 with a top-to-bottom darkening overlay.
  • Playback time advances only while playing and the rAF loop stops when paused or ended.
  • The scrubber thumb is 6px at rest and 18px while dragging; the track is 6px → 10px; both transition in 160ms.
  • Dragging the scrubber sets the position to the pointer (clamped) and updates both time labels live; the remaining time uses U+2212.
  • Play/pause morphs the SVG path via the CSS d property over 280ms; both paths have identical command sequences.
  • Pausing scales the art to 0.86 over 520ms cubic-bezier(.32,.72,0,1); playing restores it.
  • Lyrics highlight the latest line whose timestamp ≤ current time and translate up by (index − 1) × 23px once the index is ≥ 2.
  • Scrubber has role="slider" with live aria-valuenow/aria-valuetext and responds to arrow keys.
  • Volume is a native range input, 28px tall hit area, thumb hidden, fill drawn from --v.
  • Every control shows a visible focus ring; Reduced motion collapses all transitions to 1ms.

Implementation notes

Path morph with the CSS d property. Attribute changes do not transition; the CSS property does, provided both paths have the same commands in the same order. Use two subpaths of M L L L Z for both glyphs (the second triangle half repeats a point):

const PAUSE = "M6 4L10 4L10 20L6 20ZM14 4L18 4L18 20L14 20Z";
const PLAY  = "M7 4L13 8L13 16L7 20ZM13 8L19 12L19 12L13 16Z";
function setPlaying(p) {
  playing = p; last = 0;
  pp.style.d = `path("${p ? PAUSE : PLAY}")`; pp.setAttribute('d', p ? PAUSE : PLAY);   // attribute is the fallback
  play.setAttribute('aria-label', p ? 'Pause' : 'Play'); play.setAttribute('aria-pressed', String(p));
  screen.classList.toggle('playing', p);
  if (p) requestAnimationFrame(tick);
}
.ctl.play path { transition: d 280ms cubic-bezier(.32,.72,0,1); }

Grain without an image, as an inline SVG filter layered over the gradient (inline SVG needs no xmlns, and nothing is fetched):

<div class="art">
  <svg class="grain" aria-hidden="true">
    <filter id="grain"><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" stitchTiles="stitch"/></filter>
    <rect width="100%" height="100%" filter="url(#grain)"/>
  </svg>
</div>
.grain { position: absolute; inset: 0; width: 100%; height: 100%; opacity: .28; mix-blend-mode: soft-light; pointer-events: none; }

Frame loop that stops itself — advance by real elapsed time and return early when paused:

function tick(now) {
  if (!playing) return;
  if (last) t = Math.min(DUR, t + (now - last) / 1000); last = now;
  if (!drag) render();
  if (t >= DUR) { setPlaying(false); return; }
  requestAnimationFrame(tick);
}

Common mistakes: morphing between paths with different command counts (the browser snaps); using setInterval for time; letting the rAF loop overwrite the scrub position mid-drag (guard with drag); a 0-height range thumb without giving the input height (unreachable by touch); overflow:hidden on the screen clipping the art’s shadow.

Details

Palette
Type
Familjen Grotesk
Motion
Rich motion
Build
Multi-step
Designed bySusan AcharyaKathmandu

Studied

Dragone

A show with a way in. Spectacle, then the ask.

All sources