Design Lounge
Install

Nº 603 of 605

Vinyl record player

A music player built around a turntable: the disc spins while playing, the tonearm swings on and tracks inward across the side, with a drag scrubber and A/B tracklist.

Preview

Open full size
1280 × 800 · 14.3 KB · 162 lines

Use it

Free to use · credit appreciated

1 Once, in your project · Node 22.20+

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Vinyl record player" (piece vinyl-record-player) 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

225 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 now-playing view for a music or listening-room product, built around a turntable instead of a square of album art. On the left, the LP “Salt Hours” by the invented band Marrow & Lune sits on a platter, half slid out of a CSS-drawn sleeve (a coral sun over striped teal sea). On the right, a 112px Instrument Serif title, transport controls, a drag scrubber and a Side A / Side B tracklist. The detail worth copying: the tonearm is a progress indicator. On play it swings from its rest at −2° onto the outer groove at 8°, and as the side plays it creeps inward to 28°, so the arm’s angle is always the share of the side already played. Pause lifts it back to rest; the record’s light reflection never turns with the disc.

Reference behaviour

  1. Initial state: playing. Side A, track 2 “Salt Hours”, at 1:12 of 4:12. The disc spins at 33⅓ rpm (one turn per 1.8s), the arm rests on the record at about 12°, the eyebrow reads “Now spinning · LP · 2026” with a coral dot and a 4px coral halo, and the current row shows three animated equaliser bars in place of its “A2” number.
  2. Press the 64px coral pause button: the disc stops where it is (animation paused, not reset), the arm swings back to −2° over 900ms with an expo-out curve, the eyebrow reads “Arm lifted” with a grey dot, the equaliser bars freeze at 40% height, and the button’s icon and aria-label become Play.
  3. Press play: the arm swings from −2° to its tracking angle over 900ms and the disc resumes.
  4. While playing, position advances 0.25s every 250ms. The arm angle is 8 + 20 × (time played on this side ÷ side length) degrees, so A1 at 0:00 sits at 8° and the end of A5 at 28°.
  5. Drag the scrubber: the time, the coral fill and the arm angle follow the thumb live, with the arm on a 300ms transition so it glides rather than snaps. The thumb grows to 1.25× while held.
  6. Click a row on the visible side: that track starts at 0:00 and the arm moves to its groove over 300ms. If the player was paused it starts playing.
  7. Click a row on the other side: the arm lifts to rest, waits 700ms (the record is “flipped”), then drops onto the new side’s groove and the label text changes to “SIDE B · 33⅓”.
  8. Next on A5 crosses to B1; previous more than 3s into a track restarts it, otherwise steps back, crossing from B1 to A5.
  9. When A5 ends the player stops, the arm lifts, and a coral “Flip to side B” button appears at the right end of the tab row. Pressing it shows side B and starts B1.
  10. The Side A / Side B tabs only change which list is shown; the current row stays marked on whichever side is playing.

Structure

1280 × 800
┌───────────────────────── stage 760 × 800 ──────────────────┬──── 520 ───────────────┐
│                                         ( base 88 ) pivot   │ ● NOW SPINNING · LP    │
│          ┌──────────────────────┐   700,150  │              │ Salt          112px    │
│ ┌────────│      platter 480     │            │ arm 80×380   │    Hours               │
│ │ sleeve │   ┌──────────────┐   │           /               │ Marrow & Lune  20px    │
│ │ 360²   │   │ label 150 ●  │   │         /                 │ label line     13px    │
│ │ sun    │   └──────────────┘   │       ▫ headshell         │ (|<) ( || 64 ) (>|) np │
│ │ sea    │   centre 450,400     │   ▭ rest 688,470          │ 1:13 ━━━━━●─────── 4:12│
│ └────────│                      │                           │ Side A  Side B         │
│          └──────────────────────┘                           │ A1 Low Water     3:48  │
│                                                             │ ▮▮▮ Salt Hours   4:12  │
│                                                             │ … 5 rows × 38px        │
└─────────────────────────────────────────────────────────────┴────────────────────────┘
  • <main class="app">: CSS grid 760px 1fr, full height.
  • .stage (aria-hidden="true", decorative): all children absolutely placed in a 760 × 800 coordinate space multiplied by --u (1px on desktop, 100vw / 760 on phones), so the whole turntable scales as one drawing.
    • .sleeve 360 × 360 at (40, 210), rotated −3°: .sun (64% circle), .sea (bottom 46%, striped), band name top left, italic title bottom left.
    • .platter 480 × 480 at (210, 160): .disc (grooves and .label, rotates) and .sheen (reflection, does not rotate) both inset 10.
    • .base 88px disc at the pivot, .rest cradle at (688, 470), and the arm <svg> 80 × 380 at (660, 110) with transform-origin: 50% 10.53% (the pivot at 40, 40 in its viewBox).
  • <section class="side" aria-labelledby="ttl">: eyebrow, h1 with the second word on its own indented line, artist p, transport row (button × 3 plus a live “now playing” block), scrubber row (input type="range" between two time spans), role="tablist" with two tabs and the flip button, and <ol role="tabpanel"> of track buttons.

Tokens

:root {
  /* surfaces */
  --bg: #0f1d1e;        /* deep sea-black page */
  --bg2: #142728;       /* row hover */
  --line: #28403f;      /* tab rule */
  --track: #5f7672;     /* scrubber track and outline buttons, 3.4:1 on bg */
  /* ink */
  --ink: #f3e9dc;       /* 15:1 on bg */
  --ink2: #b9c4bd;      /* secondary, 9.6:1 */
  --ink3: #93a39d;      /* eyebrow, track numbers, 6.3:1 */
  /* brand */
  --coral: #f2785c;     /* play button, label, progress fill */
  --coral-hi: #ff9479;  /* current row text, flip button, focus ring */
  --on-coral: #2a0f08;  /* icons and label text on coral, 7.4:1 */
  /* sleeve and record */
  --paper: #ecdcc4;
  --sea: #1f4e4f;
  --sea2: #2c6a69;
  --vinyl: #121212;
  --groove: #1d1d1d;
  --platter: #081010;
  --arm: #d9d4cc;
  /* type */
  --serif: "Instrument Serif", Georgia, serif;
  --sans: "Outfit", system-ui, sans-serif;
  --fs-title: 112px; --fs-artist: 20px; --fs-np: 17px; --fs-body: 15px; --fs-meta: 13px; --fs-eyebrow: 12px;
  /* geometry */
  --u: 1px;             /* stage unit: 760 units across */
  --stage-w: 760; --platter: 480; --label: 150; --sleeve: 360;
  --arm-rest: -2deg; --arm-start: 8deg; --arm-end: 28deg;
  --play: 64px; --ctl: 44px; --row-h: 38px; --tab-h: 40px; --scrub-h: 6px; --thumb: 18px;
  --r-row: 8px; --r-pill: 999px;
  --shadow-stage: 0 30px 60px rgba(0,0,0,.55);
  /* motion */
  --rev: 1.8s;          /* 33⅓ rpm */
  --t-arm: 900ms;       /* swing on and off */
  --t-track: 300ms;     /* arm glide while scrubbing or jumping */
  --t-flip: 700ms;      /* pause between sides */
  --t-eq: 900ms;
  --ease: cubic-bezier(.2,.7,.2,1);
  --expo: cubic-bezier(.16,1,.3,1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
Album titleInstrument Serif, second word italic112px (clamp 64 to 112)4000.88−0.02emtitle
Sleeve titleInstrument Serif italic38u40010title
Label titleInstrument Serif italic24u40010title
ArtistOutfit20px5001.450title
Now-playing trackOutfit17px6001.450title
Body, rowsOutfit15px4001.450title
Meta, times, durationsOutfit, tabular-nums13px400 to 5001.450sentence
EyebrowOutfit12px5001+0.2emUPPERCASE
Sleeve band, label linesOutfit13u / 8u6001+0.2em to +0.24emUPPERCASE

Motion

ElementTriggerPropertyFrom → ToDurationEasingReduced motion
.discplayingrotate0 → 360°, infinite1800ms per turnlinear (constant rpm)still, no rotation
.discpauseanimation-play-staterunning → pausedinstantnonestill
Armplay / pauserotate−2° ↔ 8° to 28°900ms--expoinstant
Armplaybackrotateabout +0.004° per 250ms tickinherits 900ms--expoinstant
Armscrub, row jumprotatecurrent → new groove300ms--expoinstant
Armside changerotategroove → rest, 700ms wait, → new groove900ms each--expoinstant
EQ barscurrent row while playingscaleY0.4 → 1, alternate900ms, offsets −0.3s and −0.6s--easefrozen at 0.4
Play buttonpressscale1 → 0.94160ms--easenone
Scrub thumbheldscale1 → 1.25120ms--easenone

The disc’s spin is the one linear motion on purpose: a record turns at a constant speed. Every UI move uses the curves above.

States

  • Playing: coral eyebrow dot with 0 0 0 4px rgba(242,120,92,.2) halo, pause icon, arm on the record, disc turning, EQ bars moving.
  • Paused (“Arm lifted”): grey dot, play icon, arm at −2° over the rest cradle, disc frozen at its current angle.
  • Side finished: paused state plus the “Flip to side B” button (coral-hi text, 600).
  • Current row: text --coral-hi; its number is replaced by three 3 × 12px bars.
  • Row hover: background --bg2, 8px radius.
  • Outline controls hover: border from --track to --ink.
  • Play hover: --coral-hi; active scale 0.94.
  • Selected tab: full ink and a 2px coral underline; other tab --ink2.
  • Focus-visible: 2px --coral-hi outline, 3px offset, on every button, tab and the range input.

Accessibility

  • The stage is decorative (aria-hidden="true"); all information it shows (side, track, progress) is also in text on the right.
  • Play is one toggle button with aria-pressed and an aria-label that switches between “Pause” and “Play”. Previous and next have labels.
  • The scrubber is a native input type="range" with aria-label="Position in track" and aria-valuetext like “1:13 of 4:12”; arrow keys step one second, Page Up/Down step ten (native).
  • The now-playing block is aria-live="polite", so track changes are announced, but the clock is not inside it (it would announce four times a second).
  • Tabs use role="tablist"/tab/tabpanel with aria-selected; Left and Right arrows switch sides.
  • Contrast: all text on --bg is ≥ 6.3:1; coral row text #FF9479 is 7.3:1; label text on coral is 7.4:1. Outline controls and the scrub track are ≥ 3:1 against the page.
  • Targets: transport 44px and 64px, rows 38px full width, tabs 40px.

Responsive rules

  • ≥ 1280: as specified, stage 760px, column 520px.
  • 1024 to 1279: column narrows; the title clamps down toward 64px (clamp(64px, 9vw, 112px)).
  • 901 to 1023: same two columns; the tracklist durations stay right aligned.
  • ≤ 900: one column. The stage keeps its drawing and scales with --u: calc(100vw / 760), trimmed to 680 units tall; the column follows with 20px gutters and the title at clamp(56px, 17vw, 88px). The page scrolls vertically.
  • At 360 wide the stage is 360 × 322; nothing in the transport row wraps because the now-playing text ellipsises.

Acceptance checklist

Always

  • The disc turns only while playing and stops in place on pause (play-state paused, not reset).
  • The reflection layer sits above the grooves and does not rotate.
  • Play swings the arm on over 900ms; pause swings it back to rest.
  • The arm angle maps linearly from 8° to 28° across the whole side, including while scrubbing.
  • Changing sides lifts the arm, waits 700ms, then drops it on the new side.
  • The scrubber is a native range input with aria-valuetext, and its fill follows the value.
  • Tabs switch the visible side without changing what is playing.
  • Reduced motion: no spin, no EQ animation, the arm jumps; every state is still shown.
  • No horizontal scroll at 390px; the turntable scales as one drawing.

This demo

  • “Salt Hours” by Marrow & Lune, Hollow Reed Records HRR-031, ten tracks, five a side.
  • Starts playing Side A track 2 at 1:12 of 4:12.
  • Label reads “MARROW & LUNE / Salt Hours / SIDE A · 33⅓ / HRR-031” around a light spindle.
  • “Flip to side B” appears when A5 ends.

Implementation notes

The arm is a rotated SVG with its origin on the pivot. Draw the arm in its own 80 × 380 viewBox with the pivot at (40, 40), then set the origin in percentages so it survives scaling. Drive the angle with one custom property:

.arm { position: absolute; left: calc(660 * var(--u)); top: calc(110 * var(--u));
  width: calc(80 * var(--u)); height: calc(380 * var(--u));
  transform-origin: 50% 10.53%;            /* 40/80, 40/380 */
  transform: rotate(var(--a, -2deg));
  transition: transform 900ms cubic-bezier(.16,1,.3,1); }
.arm.track { transition-duration: 300ms; }  /* while scrubbing or jumping */
function setArm() {
  const played = sideTracks.slice(0, track).reduce((a, t) => a + t.len, 0) + time;
  arm.style.setProperty('--a', playing ? (8 + 20 * played / sideLength).toFixed(2) + 'deg' : '-2deg');
}

Find the start and end angles from the geometry, not by eye: with the pivot at (700, 150), arm length 328 and the record centred at (450, 400), 8° lands the stylus 217 units from centre (outer groove) and 28° lands it 100 units out (just outside the 75-unit label).

Spin with CSS, pause with play-state. Toggling the animation itself snaps the disc back to 0°:

.disc { background: repeating-radial-gradient(circle, #121212 0 2px, #1d1d1d 2px 3px);
  animation: spin 1.8s linear infinite; animation-play-state: paused; }
.playing .disc { animation-play-state: running; }
.sheen { background: conic-gradient(from 20deg, transparent 0 8%, rgba(255,240,220,.09) 13%,
  transparent 20% 52%, rgba(255,240,220,.07) 60%, transparent 68%);
  mask: radial-gradient(circle, transparent 34%, #000 35%); }
@keyframes spin { to { transform: rotate(360deg); } }

Range fill without a second element. Paint the played part as a background image sized by a custom property, and keep a solid background-color on the input itself so the track keeps its 3:1 edge:

input[type=range] { appearance: none; height: 6px; border-radius: 3px;
  background-color: #5f7672; background-image: linear-gradient(#f2785c, #f2785c);
  background-size: var(--p, 0%) 100%; background-repeat: no-repeat; }

Common mistakes: rotating the reflection with the disc (it reads as a spinning picture, not a record); swinging the arm with ease so it lands with no settle; driving playback with requestAnimationFrame and updating the live region every frame; letting the label text sit over the spindle hole.

Details

Palette
Type
Instrument Serif · Outfit
Motion
Rich motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Bermawy

Let one toy be the real control. Keep the rest plain and on paper.

All sources