Design Lounge
Install

Nº 598 of 605

Scroll ring carousel

Ten film posters on a 3D ring that turns 36 degrees per card with scroll, drag or arrow keys, snaps to the front card and swaps the 76px title below.

Preview

Open full size
1280 × 800 · 14.8 KB · 198 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 ring carousel" (piece scroll-ring-carousel) 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

206 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 competition page of a fictional festival, Northlight Film Week (6 to 15 November 2026, Harbour Cinema, Kessel). Ten posters stand on a 3D ring, each rotated 36° around a vertical axis 470px from its face. The page section is pinned and 46vh of scroll turns the ring by one card; you can also drag the ring sideways, flick it, press Left/Right/Home/End, use the round step buttons or click a side poster. However it moves, the ring settles on a card, the front card is lit at full brightness while its neighbours fall off with the cosine of their angle, and a details row under the ring swaps to the front film’s title (76px Big Shoulders ExtraBold), director, runtime and screenings. The detail worth copying: scroll position is the only state. Drag, keys and snapping all write scrollTo, and one paint function turns the scroll position into the ring angle, card lighting and the details row, so the inputs can never disagree.

Reference behaviour

  1. Initial frame (1280×800, scrollY 0): fixed 60px top bar (“NORTHLIGHT” in ink and “FILM WEEK” in amber, 22px/900 caps; the dates in dim 16px; Competition (current, amber), Shorts, Passes). A dim hint at (40, 84): “Competition · Scroll, drag or use arrow keys”. The ring centred in the space above the details row: front poster “The Salt Year” (280×400, red ground, sun-and-stripes motif) at full brightness, “Last Bus to Ostra” and “Glasshouse” either side at 81% facing, two more cards at the edges at 31%. A faint amber floor glow under the ring. Details row: kicker “Competition · Hungary”, title “THE SALT YEAR”, a three-row fact list (Director Ilse Varga, Runtime 104 min, Screens Fri 7 Nov 19:30 · Sun 9 Nov 16:00), and on the right an amber “Book seats” pill, step buttons and the counter “01 / 10”.
  2. Scroll: the section is 100vh + 9 × 46vh tall with a sticky stage. Fractional index f = progress × 9; ring angle −f × 36°. Cards light continuously as they pass the front.
  3. Snap: 160ms after the last scroll event, if the stage is fully pinned and f is more than 0.02 from a whole card, the page smooth-scrolls to the nearest card.
  4. Drag (mouse, pen or touch, horizontal): every 220px of pointer travel is one card; the page scrolls instantly to match while dragging. On release it smooth-scrolls to round(f − vx × 1.2) where vx is the last pointer velocity in px/ms, so a flick carries one or two extra cards. A drag of more than 6px suppresses the click that follows.
  5. Keys while the stage has focus: Right = next, Left = previous, Home = first, End = last. Each smooth-scrolls to that card’s scroll position.
  6. Click a side poster: the ring turns to it. Step buttons do the same as the arrow keys. Ends clamp (no wrap), counter shows “NN / 10”.
  7. When the front card changes, the details row, the counter, the Book seats label and a polite live region (“Film 6 of 10: Night Shift at Kessel, Mirek Dvorak”) update, and the front card gets a 1px 40%-ink ring.
  8. Poster type (country · runtime, and the title) is shown only on the front card; every other poster shows its art only, so dimmed type is never rendered as text.
  9. “Book seats” changes to “Seats held” for 1.6s on click.
  10. After the pin, a “Festival pass, 22 screenings” band follows.

Structure

1280 × 800
┌──────────────────────────────────────────────────────────────────────┐
│ NORTHLIGHT FILM WEEK  6 to 15 November …     COMPETITION SHORTS PASSES│ 60 fixed
├──────────────────────────────────────────────────────────────────────┤
│ COMPETITION · SCROLL, DRAG OR USE ARROW KEYS                          │ stage sticky 100vh
│          ▕▏┌──────┐ ┌──────────┐ ┌──────┐▕▏                           │ grid rows 1fr / 200px
│          ▕▏│ −1   │ │  FRONT   │ │ +1   │▕▏   ring: 280 × 400        │
│          ▕▏│ 81%  │ │ 280×400  │ │ 81%  │▕▏   radius 470, 36°/card   │
│          ▕▏└──────┘ └──────────┘ └──────┘▕▏   perspective 1700px     │
│                 ░░░░ floor glow ░░░░                                  │
│ COMPETITION · HUNGARY        DIRECTOR  Ilse Varga        (BOOK SEATS) │
│ THE SALT YEAR  76px          RUNTIME   104 min          (‹) 01/10 (›) │
│                              SCREENS   Fri 7 Nov 19:30 · …            │
└──────────────────────────────────────────────────────────────────────┘
  • <header class="top"> fixed with brand link, date span and <nav aria-label="Festival">.
  • <section class="reel"> (tall) with a visually hidden <h1>; .stage is position: sticky; top: 0; height: 100vh; padding-top: 60px; display: grid; grid-template-rows: 1fr auto, tabindex="0", role="region", aria-roledescription="carousel", aria-label, touch-action: pan-y.
  • .scene (perspective: 1700px; perspective-origin: 50% 45%) → .ring (transform-style: preserve-3d) → ten .card (aria-hidden) → .poster with an SVG motif in the middle 50% and HTML type (<small>, <h3>).
  • .info 3-column grid 1.3fr 1fr auto, 200px tall: kicker + <h2>; <dl class="facts">; .ctl with the Book link, prev/next buttons and counter.
  • <p class="sr" aria-live="polite"> for announcements.

Tokens

:root {
  /* colour */
  --bg: #0d0c0b;        /* page */
  --panel: #171513;
  --line: #2b2825;      /* bar rule, section rule */
  --ink: #f2ebe0;       /* text */
  --dim: #a59d90;       /* labels, dates, hint */
  --acc: #f2a33a;       /* sodium amber: brand half, current nav, CTA, kicker */
  --acc-hover: #f7b55a;
  --acc-ink: #1b1206;
  --step-line: #6f685e; /* round step buttons */
  /* poster grounds / inks */
  --p1: #b23a24 / #f4e6cf;  --p2: #1f4d45 / #e8efe0;  --p3: #e3b341 / #1b1712;
  --p4: #22303f / #f1e3c8;  --p5: #ede3d0 / #a92f1c;  --p6: #141619 / #f0a33a;
  --p7: #5f6b33 / #f3efdc;  --p8: #3b1f2b / #f0d8c8;  --p9: #d9774a / #1d140f;
  --p10: #2e5b8a / #f2ece0;  /* write these as two tokens each in real code */

  --font: "Big Shoulders Display", system-ui, sans-serif;

  /* ring */
  --cw: 280px; --ch: 400px; --r: 470px; --step: 36deg;   /* 360 / 10 */
  --perspective: 1700px;
  --scroll-per-card: 46vh;
  --px-per-card: 220px;     /* drag */
  --flick: 1.2;             /* cards per px/ms of release velocity */
  --dim-floor: .32;         /* brightness of a card facing sideways */

  /* motion */
  --ease: cubic-bezier(.2, .7, .2, 1);
  --t-settle: 160ms;        /* idle time before snapping */
  --t-held: 1600ms;
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
Front titleBig Shoulders Display76px (54px < 820)8000.9−0.01emUPPERCASE, balanced wrap
Poster titleBig Shoulders Display52px (36px)9000.86−0.01emUPPERCASE
Poster metaBig Shoulders Display13px (11px)7001.4+0.14emUPPERCASE
BrandBig Shoulders Display22px9001+0.06emUPPERCASE
NavBig Shoulders Display16px7001+0.08emUPPERCASE
KickerBig Shoulders Display14px7001.4+0.16emUPPERCASE
Fact labelBig Shoulders Display14px5001.4+0.1emUPPERCASE
Fact value, bodyBig Shoulders Display17–18px5001.40sentence
CTABig Shoulders Display18px8001+0.08emUPPERCASE

One condensed family, nine weights: the hierarchy is weight and size, not a second face.

Motion

ElementTriggerPropertyFrom → ToDurationEasing
.ringscroll positionrotateY−f × 36°scroll-linkedn/a
.cardscroll positionfilter: brightness, saturate0.32 + 0.68·max(0, cos(d·36°)); saturate 0.4 + 0.6·…scroll-linkedn/a
pageidle 160ms, drag release, key, step, clickscroll positionto nearest / target cardbrowser smooth scrollbrowser
Poster typecard is not the front cardvisibilityvisible → hiddeninstantn/a
Details rowfront index changestextswapinstantn/a
Book seatsclicklabel“Book seats” → “Seats held” → back1600msn/a

d is the signed card distance from the front, wrapped into −5…5.

Reduced motion: the ring only paints whole cards (round(f)), so it steps 36° at a time instead of sweeping, and every programmatic scroll is instant (behavior: 'auto'). Scroll, drag and keys still work; nothing glides.

States

  • Front card: brightness 1, 1px rgba(242,235,224,.4) ring, type visible.
  • Neighbour (±1): brightness ≈ 0.87, art only.
  • Far (±2): brightness ≈ 0.53, art only. Cards facing away are hidden by backface-visibility: hidden.
  • Dragging: cursor grabbing; no snapping until release.
  • Step button hover: ring from #6f685e to ink. CTA hover: #f7b55a.
  • Focus-visible: 2px amber outline, 3px offset; the stage itself shows a 2px inset amber ring.
  • Ends: at card 1 Left does nothing; at card 10 Right does nothing.

Accessibility

  • The stage is a focusable region with aria-roledescription="carousel" and a label that names the keys. Posters are aria-hidden because the details row holds the same information as real text, read in a sensible order.
  • Visually hidden text (the <h1> and the live region) uses 1px, clip-path: inset(50%) and opacity: 0; it stays in the accessibility tree.
  • The live region announces “Film N of 10: Title, Director” when the front card changes.
  • Step buttons have aria-label “Previous film” / “Next film”; the Book link’s aria-label names the film.
  • touch-action: pan-y lets phones scroll vertically through the pin while horizontal drags turn the ring.
  • Contrast: ink on page 16.5:1; dim 7.3:1; CTA text 8.9:1; every poster ink on its ground is at least 4.8:1 (lowest: #f4e6cf on #b23a24; olive #f3efdc on #5f6b33 is 5.0:1). Step rings #6f685e are 3.5:1.
  • Hit targets: step buttons 44px, CTA 48px tall (44px on phones).

Responsive rules

  • ≥ 1280: as specified.
  • 1024–1279: same ring; the details grid columns shrink and the title wraps to two balanced lines inside the fixed 200px row.
  • 820–1023: same; perspective unchanged.
  • < 820: cards 190×272, radius 310px, perspective 1100px. Top bar keeps only the brand. Details stack: kicker, 54px title, facts, then a row with the step buttons and counter on the left and Book seats on the right. Side cards are cut by the screen edge and show art only.
  • Nothing scrolls sideways at 390px; the stage clips the ring.

Acceptance checklist

Always

  • Scroll position is the single source of truth; drag, keys, buttons and clicks all call scrollTo.
  • Ring step is 360 / n degrees and radius at least (w / 2) / tan(180° / n) plus a gap.
  • Card lighting follows cos of the angular distance, floored at 0.32 brightness.
  • The ring snaps to a whole card 160ms after scrolling stops, only while pinned.
  • Drag maps a fixed px per card and a flick adds cards from release velocity; a drag never triggers a click.
  • Left, Right, Home and End work when the stage has focus; ends clamp.
  • The details row and a polite live region update when the front card changes.
  • Poster text is rendered only on the front card (dimmed cards carry no text).
  • Reduced motion steps whole cards with instant scrolling.
  • Nothing scrolls sideways at 390px.

This demo

  • Ten films from “The Salt Year” (Ilse Varga, Hungary, 104 min) to “Last Bus to Ostra” (Jonas Kiel, Denmark, 85 min).
  • Cards 280×400, radius 470px, perspective 1700px, 46vh of scroll per card.
  • Amber #f2a33a used only for the brand half, current nav item, kicker, CTA and focus.

Implementation notes

Build the ring once; rotate one element.

.ring { position: absolute; left: 50%; top: 50%; width: var(--cw); height: var(--ch);
  margin: calc(var(--ch) / -2) 0 0 calc(var(--cw) / -2); transform-style: preserve-3d;
  transform: translateZ(calc(var(--r) * -1)) rotateY(var(--a)); }
.card { position: absolute; inset: 0; backface-visibility: hidden;
  transform: rotateY(calc(var(--i) * 36deg)) translateZ(var(--r));
  filter: brightness(calc(.32 + var(--f, 0) * .68)) saturate(calc(.4 + var(--f, 0) * .6)); }

The translateZ(-r) on the ring puts the front card back at z = 0, so it renders at exactly 280×400.

One paint from scroll:

const fNow = () => clamp(0, (scrollY - top0()) / span() * (N - 1), N - 1);
const paint = (f) => {
  const v = reduce ? Math.round(f) : f;
  ring.style.setProperty('--a', -v * 36 + 'deg');
  cards.forEach((c, k) => { let d = k - v; d -= N * Math.round(d / N);
    c.style.setProperty('--f', Math.max(0, Math.cos(d * Math.PI / 5))); });
  show(Math.round(f));                    // details row, counter, live region
};

Drag writes scroll, release snaps with momentum:

const f = clamp(0, sf - (e.clientX - sx) / 220, N - 1);
scrollTo({ top: top0() + span() * f / (N - 1), behavior: 'auto' });
// on pointerup
go(Math.round(fNow() - vx * 1.2));   // vx in px/ms, sign flips because dragging left moves forward

Common mistakes: keeping a separate “current index” that drag and scroll update independently (they drift); snapping while the user is still scrolling (wait 160ms of quiet); dimming with opacity (it shows the backs of other cards through the front one, and the far cards’ text fails contrast); and forgetting touch-action: pan-y, which makes phones either refuse to scroll or refuse to drag.

Details

Palette
Type
Big Shoulders Display
Motion
Rich motion
Build
Multi-step
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