Design Lounge
Install

Nº 469 of 520

Vinyl now-playing widget

A 70s now-playing widget in small and medium sizes: CSS-only album art, a record that spins while playing, a scrubber, and equaliser bars.

Preview

Open full size
1280 × 800 · 12.8 KB · 216 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 "Vinyl now-playing widget" (piece widget-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

237 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

A home-screen style music widget for a fictional player, “Turntide”, in two sizes. The page is cream with horizontal 70s stripes. The widget is espresso brown with a cream fat-italic display face and a mono for everything else. In the medium size, a square album sleeve sits on the left with a black record sliding out of it; the record spins while music plays and stops dead on pause. The right side has a four-bar equaliser, the title, the artist, a scrubber, times, and three round controls. The small size is a 212px square: the art fills it, the title and a play button sit on a dark fade at the bottom, and a 3px line shows progress. The detail worth copying: all three album covers are pure CSS gradients, swapped by one class on the widget, so the piece ships with no images.

Reference behaviour

  1. First frame: medium size, playing. Track “Monsoon Radio” by The Marigold Hours, album “Low Sun Over Patan”, 3:48 long, at 1:14. The record spins (3.2s per turn). The four equaliser bars bounce. The size switch above reads Small | Medium.
  2. Every second while playing, position advances one second: the scrubber thumb and orange fill move, elapsed counts up, remaining (-m:ss) counts down.
  3. Play/pause (48px orange circle): toggles. On pause the record and the bars freeze where they are (animation-play-state: paused), the label above the title reads “Paused”, the icon becomes a triangle, the button label becomes “Play”.
  4. Next: loads the next track at 0:00 and swaps the cover by class. Previous: if more than 3 seconds in, restarts the track; otherwise loads the previous track. Tracks loop.
  5. Dragging the scrubber or pressing arrow keys on it seeks. While dragging, the clock does not move the thumb.
  6. When a track ends it loads the next one automatically and a polite live region announces “Late Tram by Nilo Sato”.
  7. Hovering the sleeve (medium only) slides the record 12px further out of the sleeve over 420ms.
  8. Small: the widget animates from 580 × 212 to 212 × 212 over 420ms expo out. The record fades out, the cover goes edge to edge, the medium body hides, and the mini overlay shows title, a small equaliser with the artist, a 44px play button, and the 3px progress line. Medium reverses it.
  9. Reduced motion: no spin, no bouncing bars (they sit still at 70% height while playing), the size change is instant. The clock still runs.

Structure

1280 × 800 · body grid, centred, padding 32px 16px · stack gap 28px
               ( SMALL | MEDIUM )                 segmented switch, 44px tall
┌──────────────────────────────────────────────────────────────┐
│ ┌──────────┐◖)                ▮▮▮▮ NOW PLAYING              │ medium 580 × 212
│ │  cover   │ record  ◖)       Monsoon Radio (28px display)   │ padding 20px, r 28px
│ │ 172×172  │ 160px            The Marigold Hours             │ grid 240px | 1fr, gap 20px
│ │Low Sun…  │◖)                ━━━━━━━●────────────────       │ scrubber 24px hit, 4px track
│ └──────────┘                  1:14                 -2:34     │
│                                ⏮   (⏸)   ⏭          Turntide │ 40px, 48px, 40px
└──────────────────────────────────────────────────────────────┘

small: 212 × 212, padding 0
┌────────────────┐
│  cover, full   │
│                │
│░Copper Bus  (⏸)│ fade from 0% to 92% espresso, 44px top padding
│░▮▮▮ Kesar & …  │
│░━━━━━━─────────│ 3px progress at bottom 6px
└────────────────┘
  • Segmented control: div[role=group][aria-label="Widget size"] with two button[aria-pressed].
  • Widget: section[aria-label="Turntide now playing"] with data-size="medium|small", class playing when playing, and a track class t1|t2|t3.
  • .sleeve (aria-hidden): .rec (the record, absolute, top 6px, left 80px, 160px) under .art (absolute, inset 0, r 10px). The art carries the album name in the display face, bottom-left.
  • .body: top line (equaliser + state word), p.title, p.artist, .scrub (input[type=range] + times row), .ctrls (prev, play/pause, next, wordmark).
  • .mini: shown only in small. It repeats title, artist, play/pause, and a decorative progress bar.
  • One p[aria-live=polite], visually hidden.

Tokens

:root {
  --page: #e9dcc0;     /* cream page */
  --stripe: #e2d2b1;   /* 4px stripes every 26px */
  --card: #2b1d14;     /* espresso widget */
  --card-2: #3a281c;   /* scrubber track, button hover */
  --ink: #f3e7cf;      /* title, icons on orange */
  --ink-2: #d2c1a2;    /* artist, idle icons */
  --ink-3: #a8957a;    /* labels, times, wordmark */
  --orange: #d9541e;   /* accent: play button, scrubber fill, record label, focus */
  --mustard: #e3a92b;  /* equaliser, selected size */
  --olive: #7b7a33;    /* cover 2 */
  --vinyl: #141010;

  --disp: "Shrikhand", Georgia, serif;
  --mono: "Space Mono", ui-monospace, monospace;

  --medium-w: 580px; --medium-h: 212px; --small: 212px;
  --pad: 20px; --r-widget: 28px; --r-cover: 10px;
  --cover: 172px; --record: 160px; --record-left: 80px;
  --shadow: 0 2px 0 #1a110b, 0 30px 50px -30px rgba(43, 29, 20, .7);

  --std: cubic-bezier(.2, .7, .2, 1);
  --expo: cubic-bezier(.16, 1, .3, 1);
  --t-size: 420ms;
  --t-spin: 3.2s;   /* linear, one turn */
}

Covers, set as --art on the widget class:

.t1 { --art: radial-gradient(circle at 50% 118%, #f3e7cf 0 14%, #e3a92b 14% 26%, #d9541e 26% 38%,
        #a5371a 38% 50%, #5a2a17 50% 62%, transparent 62%), linear-gradient(#efd9a8, #efd9a8); --ac: #2b1d14; }
.t2 { --art: radial-gradient(circle at 70% 30%, #e3a92b 0 18%, transparent 18.5%),
        repeating-linear-gradient(90deg, #7b7a33 0 18px, #5f5e26 18px 36px); --ac: #f3e7cf; }
.t3 { --art: repeating-radial-gradient(circle at 30% 70%, #f3e7cf 0 8px, #d9541e 8px 16px, #2b1d14 16px 24px); --ac: #f3e7cf; }

Typography

RoleFamilySizeWeightLine-heightTrackingCase
TitleShrikhand28px4001.050Title, ellipsis
Cover album nameShrikhand19px4001-0.01emTitle, colour --ac
Mini titleShrikhand18px4001.050Title, ellipsis
WordmarkShrikhand14px40010Title
State labelSpace Mono10px7001.40.16emUPPER
ArtistSpace Mono13px4001.40Title, ellipsis
TimesSpace Mono11px4001.40tabular
Size switchSpace Mono12px70010.08emUPPER

Shrikhand has one weight. Do not fake bold it. It is the only display face; everything else is Space Mono.

Motion

ElementTriggerPropertyFrom → ToDurationEasingReduced motion
Recordplayingrotate0 → 360°, loop3.2slinearstatic
Recordpauseanimation-play-staterunning → pausedinstant——
Equaliser bar 1–4playingscaleY.25 → 1 → .45, alternate900 / 620 / 1100 / 740ms, delays 0 / −200 / −500 / −300ms--stdstatic at .7
Widgetsize switchwidth, height580×212 ↔ 212×212420ms--expoinstant
Recordsize switchopacity1 ↔ 0240ms--stdinstant
Recordsleeve hoverleft80 → 92px420ms--expoinstant
Icon buttonpressscale1 → .92120ms--stdsame
Scrubber thumbdragscale1 → 1.25120ms--stdsame
Segmentselectbackground, colourtransparent → mustard200ms--stdinstant

Linear is right for the spin: a record turns at constant speed. Every UI move uses --std or --expo.

States

  • Playing: .playing on the widget; record spinning, bars moving, pause icon, label “Now playing”.
  • Paused: record and bars frozen mid-motion, play icon, label “Paused”.
  • Medium / small: data-size on the widget; the matching segment is mustard with espresso text and aria-pressed="true".
  • Icon button hover: background --card-2, icon --ink. Play button hover #e8662f.
  • Focus-visible: 2px orange outline, 3px offset (0 on the range input).
  • Seeking: while the range is being dragged, the clock does not overwrite it.
  • Track change: cover class, title, artist, album name, and times update at once; position 0:00.
  • Empty: a real product shows “Nothing playing” in the title slot with a flat --card-2 cover and disabled controls. Not shown in the demo.

Accessibility

  • The widget is a section labelled “Turntide now playing”. The sleeve is decorative (aria-hidden).
  • Play/pause is a button whose aria-label flips between “Play” and “Pause”. Both play buttons (medium and mini) stay in sync.
  • Previous and next have labels “Previous track” and “Next track”.
  • The scrubber is a native input[type=range] with aria-label="Seek" and aria-valuetext="1:14 of 3:48", updated each second. Arrow keys seek by 1s, Page Up/Down by 10% (native).
  • The size switch is a group of two toggle buttons with aria-pressed.
  • A polite live region names the new track on next, previous, or auto-advance. It does not announce the clock.
  • Hit targets: 40px for prev/next, 48px play (44px in small), 24px-tall scrubber hit area, 36px segments inside a 44px pill.
  • Contrast: --ink on --card 13:1. --ink-3 on --card 5.6:1. --ink on --orange 3.6:1 is icon-only (≥ 3:1 for UI graphics).

Responsive rules

  • ≥ 640: medium is 580 × 212, small 212 × 212, centred.
  • < 540: medium becomes a column: width 100%, height auto, padding 16px, cover 150px, record 140px at left 70px, title 24px. Small stays 212px.
  • The widget’s width is min(580px, 100%), so it never causes horizontal scroll at 375px.
  • Long titles and artists ellipsis on one line in both sizes; they never wrap.
  • If shown on a phone home screen, small maps to a 2 × 2 widget and medium to 4 × 2.

Acceptance checklist

Always

  • Two sizes from one element, switched with a data-size attribute and a 420ms width/height transition.
  • The record spins only while playing, and pause freezes it in place rather than resetting.
  • Equaliser bars use different durations and negative delays so they never move in step.
  • Covers are CSS gradients, swapped by one class; no image files.
  • Scrubber is a native range with a filled track and aria-valuetext in m:ss.
  • Previous restarts the track if more than 3s in.
  • Both play buttons share one state and one label.
  • Track changes are announced politely; the clock is not.
  • Hit targets ≥ 40px for buttons.
  • Reduced motion: no spin, still bars, instant resize.

This demo

  • Turntide, tracks Monsoon Radio (3:48), Copper Bus (4:12), Late Tram (3:05).
  • Opens at 1:14 of Monsoon Radio, playing, medium.
  • Widget #2b1d14, r 28px; page #e9dcc0 with #e2d2b1 stripes 4px every 26px.
  • Play button 48px #d9541e; equaliser #e3a92b.
  • Shrikhand for title and cover text; Space Mono for the rest.

Implementation notes

Pause by freezing, not removing. Removing the animation snaps the record back to 0°. animation-play-state holds the frame:

.rec { animation: spin 3.2s linear infinite; animation-play-state: paused; }
.w.playing .rec { animation-play-state: running; }
.eq span { transform-origin: bottom; animation: eq 900ms var(--std) infinite alternate; animation-play-state: paused; }
.w.playing .eq span { animation-play-state: running; }
@keyframes eq { 0% { transform: scaleY(.25) } 50% { transform: scaleY(1) } 100% { transform: scaleY(.45) } }

A filled range track with one custom property. Write the percentage from JS; the track gradient reads it:

input[type=range] { -webkit-appearance: none; appearance: none; height: 24px; background: transparent; --v: 0%; }
input[type=range]::-webkit-slider-runnable-track {
  height: 4px; border-radius: 2px;
  background: linear-gradient(90deg, var(--orange) var(--v), var(--card-2) var(--v)); }
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; width: 14px; height: 14px; margin-top: -5px;
  border-radius: 50%; background: var(--ink); border: 3px solid var(--orange); }

Set --v on the widget too, so the small size’s 3px bar reads the same number.

Don’t fight the user’s drag. The 1s clock must skip writing seek.value while the thumb is held:

seek.addEventListener('input', () => { dragging = true; pos = +seek.value; paint(); });
seek.addEventListener('change', () => { dragging = false; });
setInterval(() => { if (!playing || dragging) return; pos++; pos >= dur ? next() : paint(); }, 1000);

The record is a radial gradient for the label and spindle hole plus a repeating-radial-gradient of 1px lighter rings every 3px for grooves, with a faint conic highlight in ::after. The highlight spins with the record; that is acceptable at this size.

Common mistakes:

  • A glossy gradient play button. The button is flat orange.
  • Spinning the cover instead of the record.
  • Letting the title wrap to two lines in medium; the scrubber gets pushed out of the widget.
  • Using ease for the resize. Use expo out so the small square settles.
  • Duplicating state for the mini play button instead of sharing one setPlaying().
  • Purple or neon equaliser bars. They are mustard.

Rebuild order:

  1. Page stripes, segmented switch.
  2. Medium shell, sleeve with cover and record.
  3. Body: label, title, artist, range, times, controls.
  4. Track data, load(), paint(), the 1s clock.
  5. Play/pause with frozen animations; equaliser.
  6. Small size overlay and the resize transition.
  7. Live region, reduced motion, the column layout under 540px.

Details

Palette
Type
Shrikhand · Space Mono
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Icon Museum

A control. It studies this site for quiet, not for a show.

All sources

On the shelves