Design Lounge
Install

Nº 594 of 605

Scroll-morphing playlist header

A 500px playlist header whose 260px cover and 104px title shrink into a 72px sticky bar on one registered --p driven by a CSS scroll timeline.

Preview

Open full size
1280 × 800 · 15.0 KB · 204 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 "Scroll-morphing playlist header" (piece scroll-morphing-header) 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

193 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 top of a playlist page in a web music player (“Slow Burn, Vol. 3” by Lowfield Radio). The header is a 500px burnt-sienna block with a 260px drawn cover, a 104px Syne ExtraBold title, a description and meta line. As the page scrolls the first 428px, the cover and title travel and shrink into a 72px sticky bar (44px cover, 22px title) while the ground darkens from rust to near-black, the copy fades out and a compact “Now playing” chip with a 44px play button fades in on the right. The sticky track-list column header docks directly under the bar. One registered custom property, --p (0 → 1), drives every part of the morph; a CSS scroll timeline animates it, and a few lines of JS write the same --p where scroll timelines are missing. The detail worth copying: the header is position: sticky with top: calc(72px - 500px), so it never leaves the flow, and its bottom 72px simply become the bar.

Reference behaviour

  1. Initial frame (scrollY 0, 1280×800): rust header fills y 0–500. Top row at y 24: two 40px round back/forward buttons, a 300×40 search field (“Search songs, people, radio”), and a 40px “NF” avatar at the right. Cover 260×260 at (48, 160) with a 4px radius and a soft drop shadow. To its right at x 340: “Public playlist” (13px/600) at y 184, the title “Slow Burn” (104px/800) at y 212, a 15px two-line description, and the meta line “Lowfield Radio · 4,812 saves · 20 songs, 1 h 30 min”.
  2. Below the header (dark ground): an action row with a 60px orange pause/play button, shuffle, save (pressed, filled orange), more, and a 36px outlined “Follow Lowfield” pill. Then a sticky column header (#, Title, Album, Date added, Time) and 20 track rows, 56px each. Row 1 “Ashfall Lullaby” is playing: orange title and a three-bar equaliser in the number column.
  3. Scroll 0 → 428px: --p goes 0 → 1 linearly with scroll.
    • Cover: translateY(p × 282px) scale(1 − p × 0.8308) from its top-left corner; it lands as a 44px square at (48, 442). Radius 4 → 22px, shadow 24/60px → 4/10px.
    • Title: translate(p × −232px, p × 241px) scale(1 − p × 0.7885); it lands at 22px visual size at (108, 453), vertically centred in the bar.
    • Kicker and description: opacity 1 − 4p (gone by p = 0.25); description also lifts 24px.
    • Ground: color-mix(in oklab, #8f3a1c, #1f1513 p×100%); a 1px bottom hairline fades in to 14% cream.
    • Compact chip (“Now: Ashfall Lullaby” + 44px play button): opacity 5p − 4 (appears over the last 20%).
  4. From 428px on the header is stuck: only its bottom 72px show. The track-list column header sticks at top: 72px beneath it.
  5. Scrolling back reverses the morph exactly.
  6. The big play button, the compact play button and clicking or pressing Enter/Space on a row all share one player state: clicking the current row toggles pause; clicking another row makes it current and playing. Paused pauses the equaliser bars; icons swap between pause and play and labels between “Pause” and “Play”. The compact chip’s text follows the current track.
  7. Shuffle and Save toggle aria-pressed; pressed icons turn full ink (save fills orange).
  8. The compact play button is unfocusable (tabindex=-1) until the bar is fully compact.

Structure

1280 × 800, document scrolls
┌───────────────────────────────────────────────────────────────────────┐ y 0
│ (‹)(›) [ Search songs, people, radio      ]                     (NF)  │ 24
│ ┌──────────┐  Public playlist                                        │ 184
│ │  COVER   │  Slow Burn                                 104/800      │ 212
│ │  260²    │  description 15px, max 560                              │ 336
│ │ (48,160) │  Lowfield Radio · 4,812 saves · 20 songs, 1 h 30 min    │
│ └──────────┘                                                         │
├────────────── bar region (bottom 72px of the header) ────────────────┤ 428
│ [44] Slow Burn 22px                         Now: Ashfall …  ( ▮▮ 44) │
└───────────────────────────────────────────────────────────────────────┘ 500
  ( ▮▮ 60 )  ⤨  ♥  •••  ( Follow Lowfield )                  padding 28 48 20
  #   TITLE                          ALBUM          DATE ADDED      TIME   sticky top 72
  ▮▮  [art] Ashfall Lullaby          Low Sun Sessions  2 Oct 2026   4:12   56px rows
  • <header class="hero">: position: sticky; top: calc(var(--bar) - var(--hero)); height: 500px; overflow: hidden. Children are absolutely positioned: .top row, .cover, .kick, <h1 class="title">, .desc, .mini.
  • <main>: .acts row of buttons, then <section class="list" aria-label="Tracks"> with .thead (aria-hidden, sticky) and #rows (role="list") of .tr (role="listitem", tabindex="0").
  • Track grid: 40px minmax(0,1.4fr) minmax(0,1fr) 140px 64px, gap 16px, padding 0 16px inside a list padded 0 32px.
  • Each row’s 40px art is CSS only: a radial-gradient disc over a band stripe over a pale ground, coloured per row from a hue.

Tokens

@property --p { syntax: '<number>'; inherits: true; initial-value: 0; }
:root {
  /* colour */
  --bg: #141011;         /* page */
  --row: #1d1718;        /* row hover */
  --line: #2c2324;       /* column header rule */
  --ink: #f6ebdd;        /* primary text */
  --dim: #ad9d90;        /* secondary text, idle icons */
  --acc: #f07a3a;        /* play buttons, playing title, eq */
  --acc-ink: #1a0d06;    /* icon on accent */
  --rust: #8f3a1c;       /* header ground at p = 0 */
  --rust-deep: #1f1513;  /* header ground at p = 1 */
  --desc: #f3dfcf;  --meta: #f0d6c2;
  --cream: #efe3cf; --sun: #e2582b; --band: #1b1512;   /* cover art */
  --pill-line: #8a7a6f;  /* Follow outline */

  --syne: "Syne", system-ui, sans-serif;

  /* geometry: desktop */
  --hero: 500px; --bar: 72px;
  --cl: 48px; --ct: 160px; --cs: 260px; --cy: 282px; --ck: .1692;      /* cover: left, top, size, travel, end scale */
  --tl: 340px; --tt: 212px; --tf: 104px; --tx: -232px; --ty: 241px; --tk: .2115; /* title */
  --row-h: 56px; --thead-h: 40px; --play: 60px; --play-mini: 44px;
  --r-cover: 4px; --r-cover-end: 22px; --r-row: 6px;

  /* motion */
  --ease: cubic-bezier(.2, .7, .2, 1);
  --t-press: 160ms; --t-eq: 900ms;
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
TitleSyne104px (46px < 760)8001−0.04emsentence
Title, compactSyne22px visual (18px < 760), via scale8001−0.04emsentence
KickerSyne13px6001.4+0.04emsentence
DescriptionSyne15px5001.40sentence
MetaSyne14px600 / 5001.40sentence
Column headerSyne12px6001.4+0.06emUPPERCASE
Track titleSyne15px6001.40sentence, ellipsis
Artist, album, date, timeSyne13–14px5001.40tabular numbers in #
PillSyne13px60010sentence

Motion

ElementTriggerPropertyFrom → ToDuration / rangeEasing
.heroscroll--p0 → 1scroll(root), range 0 → 428pxlinear (scroll-linked)
.cover--ptransform, radius, shadowsee behaviour 3follows --pn/a
.title--ptranslate, scale(0,0) 1 → (−232, 241) 0.2115follows --pn/a
.kick, .desc--popacity, translateY1 → 0 by p 0.25follows --pn/a
.hero ground--pbackground#8f3a1c → #1f1513 (oklab mix)follows --pn/a
.mini--popacity0 until p 0.8 → 1 at p 1follows --pn/a
.playhover / activescale1 → 1.05 / 0.96160ms--ease
.eq iplayingscaleY0.25 ↔ 1, alternate, 3 bars offset −300ms900ms loop--ease

Reduced motion: the timeline animation keeps running but with animation-timing-function: steps(1, jump-end), so the header is fully expanded until 428px and fully compact after: two still states, no tween. The JS fallback rounds --p the same way. Equaliser bars stop at 70% height; the press scale is removed.

States

  • Expanded (p = 0): every header element visible; compact chip invisible and not clickable.
  • Compact (p = 1): .hero.compact class added by JS; chip clickable and its button focusable.
  • Playing / paused: pause icon M7 5h3.5v14H7zM13.5 5H17v14h-3.5z vs play M8 5.5v13l11-6.5z; aria-label “Pause”/“Play”; eq bars run or freeze.
  • Current row: orange title, eq in place of the number, aria-current="true".
  • Row hover / focus-visible: --row background, 6px radius.
  • Ghost icon pressed: full ink; save also fills orange.
  • Focus-visible: 2px --acc outline, 3px offset, on every button, row and the search field.
  • Search field: the <input> itself carries the pill: 40px tall, 20px radius, 1px #e2c8b4 border (5:1 on the rust ground), transparent fill, 44px left padding for the absolutely placed magnifier.

Accessibility

  • The title is the page’s only <h1>; it stays real text throughout (scaled with transform, never swapped for a second copy), so screen readers read one title.
  • The cover is decorative (aria-hidden); the meta line carries the facts.
  • Back, forward, shuffle, save and more are <button>s with aria-label; shuffle and save carry aria-pressed.
  • Rows are focusable list items with an aria-label of “Title by Artist, m:ss”; Enter and Space play/pause.
  • Contrast: ink on rust 6.4:1, description 5.8:1, meta 5.4:1; dim on page 7.2:1 and on row hover 6.7:1; icon on accent 6.8:1; the Follow outline is 4.6:1 against the page.
  • Hit targets: 40px round buttons, 60px and 44px play buttons, 56px rows.

Responsive rules

  • ≥ 1280: as specified.
  • 1024–1279: same geometry; the title still fits (it ends near x 1000).
  • 768–1023: same; the Album column shrinks first (minmax(0, 1fr) with ellipsis).
  • < 760: hero 468px, bar 64px. Cover 196px at (16, 76), ends 40px at (16, 416): --cy: 340px; --ck: .2041. Title 46px at (16, 292), ends 18px at (68, 427): --tx: 52px; --ty: 135px; --tk: .3913. Search and kicker hide; description sits under the title. Grid becomes 28px 1fr 48px (album and date hide). The chip hides its “Now” text and keeps the 44px button.
  • Nothing scrolls sideways at 390px.

Acceptance checklist

Always

  • The header is sticky with top: bar − hero; nothing is cloned or swapped between the big and compact states.
  • One registered --p (0 → 1 over hero − bar px of scroll) drives cover, title, fades, ground and chip.
  • Cover and title transform from their top-left corners and land exactly inside the bar, vertically centred.
  • Copy is fully transparent by p 0.25, so it never collides with the travelling title.
  • The compact play button is not focusable or clickable until p = 1.
  • The column header sticks directly under the bar.
  • Without scroll-timeline support, JS writes --p from scrollY and the result is identical.
  • Reduced motion gives two still states with no tween.
  • Focus rings visible on all controls; nothing scrolls sideways at 390px.

This demo

  • Desktop: 500px header, 72px bar, 260 → 44px cover, 104 → 22px title, rust #8f3a1c → #1f1513.
  • Title “Slow Burn”, meta “Lowfield Radio · 4,812 saves · 20 songs, 1 h 30 min”.
  • 20 rows; “Ashfall Lullaby” by Moro Quartet is playing on load.
  • Play buttons are #f07a3a with a #1a0d06 icon.

Implementation notes

One number, many properties. Register --p so the keyframe can animate it, then let every element read it in calc():

@property --p { syntax: '<number>'; inherits: true; initial-value: 0; }
.hero { position: sticky; top: calc(var(--bar) - var(--hero)); height: var(--hero);
  background: color-mix(in oklab, var(--rust), var(--rust-deep) calc(var(--p) * 100%));
  animation: morph linear both; animation-timeline: scroll(root);
  animation-range: 0 calc(var(--hero) - var(--bar)); }
@keyframes morph { from { --p: 0 } to { --p: 1 } }
.cover { transform-origin: 0 0;
  transform: translateY(calc(var(--p) * var(--cy))) scale(calc(1 - var(--p) * (1 - var(--ck)))); }

Work out the end positions from the bar, not by eye. For a top-left origin: travel = end − start, scale = endSize / startSize. Cover: 442 − 160 = 282px, 44 / 260 = 0.1692. Title: bar centre 464 − 11 = 453; 453 − 212 = 241px down, 108 − 340 = −232px across, 22 / 104 = 0.2115. Put each set in custom properties per breakpoint so the CSS never changes.

Fallback and reduced motion share one function:

const span = -parseFloat(getComputedStyle(hero).top);     // 428
let p = Math.min(1, Math.max(0, scrollY / span));
if (reduce) p = p >= 1 ? 1 : 0;
if (!CSS.supports('animation-timeline: scroll()')) hero.style.setProperty('--p', p);
hero.classList.toggle('compact', scrollY >= span - 1);

Common mistakes: animating font-size, width or top (layout on every frame; transform the element instead); forgetting white-space: nowrap on the title, so it rewraps mid-scale; using position: fixed for the bar, which needs a spacer and breaks anchor offsets; and leaving the compact button clickable while it is still transparent.

Details

Palette
Type
Syne
Motion
Rich motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Haunted Boulder City

Pin the screen and let scroll turn the pages. Only the line you are on gets to shout.

All sources