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
Use it
Free to use · credit appreciated1 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.
<!-- Design Lounge Nº 603 · "Vinyl record player" · www.designlounge.live -->
# Vinyl record player
> **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 │
└─────────────────────────────────────────────────────────────┴────────────────────────┘
… 185 more lines. Copy or download for the full brief. Loading the source…Brief
225 linesBuild 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
- 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.
- 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-labelbecome Play. - Press play: the arm swings from −2° to its tracking angle over 900ms and the disc resumes.
- 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°. - 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.
- 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.
- 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⅓”.
- Next on A5 crosses to B1; previous more than 3s into a track restarts it, otherwise steps back, crossing from B1 to A5.
- 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.
- 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 grid760px 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 / 760on phones), so the whole turntable scales as one drawing..sleeve360 × 360 at (40, 210), rotated −3°:.sun(64% circle),.sea(bottom 46%, striped), band name top left, italic title bottom left..platter480 × 480 at (210, 160):.disc(grooves and.label, rotates) and.sheen(reflection, does not rotate) both inset 10..base88px disc at the pivot,.restcradle at (688, 470), and the arm<svg>80 × 380 at (660, 110) withtransform-origin: 50% 10.53%(the pivot at 40, 40 in its viewBox).
<section class="side" aria-labelledby="ttl">: eyebrow,h1with the second word on its own indented line, artistp, 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
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
| Album title | Instrument Serif, second word italic | 112px (clamp 64 to 112) | 400 | 0.88 | −0.02em | title |
| Sleeve title | Instrument Serif italic | 38u | 400 | 1 | 0 | title |
| Label title | Instrument Serif italic | 24u | 400 | 1 | 0 | title |
| Artist | Outfit | 20px | 500 | 1.45 | 0 | title |
| Now-playing track | Outfit | 17px | 600 | 1.45 | 0 | title |
| Body, rows | Outfit | 15px | 400 | 1.45 | 0 | title |
| Meta, times, durations | Outfit, tabular-nums | 13px | 400 to 500 | 1.45 | 0 | sentence |
| Eyebrow | Outfit | 12px | 500 | 1 | +0.2em | UPPERCASE |
| Sleeve band, label lines | Outfit | 13u / 8u | 600 | 1 | +0.2em to +0.24em | UPPERCASE |
Motion
| Element | Trigger | Property | From → To | Duration | Easing | Reduced motion |
|---|---|---|---|---|---|---|
.disc | playing | rotate | 0 → 360°, infinite | 1800ms per turn | linear (constant rpm) | still, no rotation |
.disc | pause | animation-play-state | running → paused | instant | none | still |
| Arm | play / pause | rotate | −2° ↔ 8° to 28° | 900ms | --expo | instant |
| Arm | playback | rotate | about +0.004° per 250ms tick | inherits 900ms | --expo | instant |
| Arm | scrub, row jump | rotate | current → new groove | 300ms | --expo | instant |
| Arm | side change | rotate | groove → rest, 700ms wait, → new groove | 900ms each | --expo | instant |
| EQ bars | current row while playing | scaleY | 0.4 → 1, alternate | 900ms, offsets −0.3s and −0.6s | --ease | frozen at 0.4 |
| Play button | press | scale | 1 → 0.94 | 160ms | --ease | none |
| Scrub thumb | held | scale | 1 → 1.25 | 120ms | --ease | none |
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
--trackto--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-hioutline, 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
buttonwitharia-pressedand anaria-labelthat switches between “Pause” and “Play”. Previous and next have labels. - The scrubber is a native
input type="range"witharia-label="Position in track"andaria-valuetextlike “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/tabpanelwitharia-selected; Left and Right arrows switch sides. - Contrast: all text on
--bgis ≥ 6.3:1; coral row text#FF9479is 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 atclamp(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
- Tags