Design Lounge
Install

Nº 551 of 605

Kinetic scroll type hero

A pinned race-timing hero whose 184px variable headline trades width and weight line by line as you scroll, while the product line below holds still.

Preview

Open full size
1280 × 800 · 9.3 KB · 150 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 "Kinetic scroll type hero" (piece hero-kinetic-scroll-type) 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

182 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 site for Pacekeeper, an invented chip-timing company for road races. The headline “Split every second.” is set in Anybody, a variable font with a width axis from 50 to 150, at up to 184px across three lines. The hero is pinned for the first 560px of scroll, and over that distance the three lines trade places in width and weight: the wide black first line goes condensed and light, the condensed light second line goes wide and black, the third line narrows. The pointer adds weight to whichever line it is nearest. The product sentence, buttons and race clock beneath a hairline never move, so the page stays readable while the type performs.

Reference behaviour

  1. Initial state (scroll 0): “SPLIT” at width 150%, weight 820, flush left; “EVERY” at width 62%, weight 300, indented 14vw; “SECOND.” at width 112%, weight 880, right aligned, with the full stop in lime #C8F25A.
  2. Scroll the page: the hero sticks under the 64px nav for 560px. A registered custom property --s runs 0 → 1 over that range (CSS scroll-driven animation on the root scroller).
  3. As --s rises: line 1 width 150 → 55, weight 820 → 300, moves right 0 → 30vw. Line 2 width 62 → 150, weight 300 → 860, moves left 14vw → 0. Line 3 width 112 → 52, weight 880 → 680, moves left 0 → 4vw. Scrolling back reverses exactly.
  4. After 560px the pin releases and a results table (“Lisbon Half, leaders at 15 km”, five rows) scrolls up.
  5. Pointer over the hero: each line gets --pw = 1 − (vertical distance from pointer to line centre ÷ 1.4 × line height), clamped at 0. Line 1 gains up to 80 weight, line 2 up to 500, line 3 loses up to 300. --pw is a registered number, so it eases over 420ms expo out.
  6. Pointer leaves: all --pw return to 0 over 420ms.
  7. The race clock “01:12:44.3” counts up in tenths (100ms interval) to show the product is live; the meta row dot pulses opacity 1 → 0.35 on a 1.6s cycle.
  8. No scroll-timeline support: a passive scroll listener writes --s = min(1, scrollY / 560) in one rAF. Reduced motion: no scroll animation, no pointer response, no clock ticking, no pulse; the headline rests in its first-frame state.

Structure

1280 × 800
┌──────────────────────────────────────────────────────────────────────┐
│ ◷ Pacekeeper          Timing  Live results  Race directors  Pricing [Book a demo] │ nav 64, sticky
├──────────────────────────────────────────────────────────────────────┤
│ • LIVE NOW: LISBON HALF, 11,402 ON COURSE        CHIP TIMING SINCE 2014 │ meta 12px caps
│ SPLIT                               (184px, wdth 150, wght 820)       │
│            EVERY                    (wdth 62, wght 300, +14vw)         │
│                         SECOND▪     (wdth 112, wght 880, right)        │
│                                                                       │
│ ───────────────────────────────────────────────────────────────────── │ hairline
│ product sentence 16/1.55   [Book a demo →] [See Lisbon Half results]   RACE CLOCK │
│ max 440px                                                  01:12:44.3 │
└──────────────────────────────────────────────────────────────────────┘
.pin is 100vh − 64 + 560px tall; .hero inside it is sticky at top 64, height 100vh − 64 (min 736)
padding 28 / 48 / 36
  • <nav aria-label="Primary"> sticky, logo link, <ul> of four links, small lime button.
  • <main> → .pin (div) → <section class="hero" aria-labelledby="h">: .meta row, <h1 id="h" aria-label="Split every second."> holding three aria-hidden span.ln lines, .product grid (paragraph, button row, clock).
  • <section class="results"> with h2 and a five-row <table>.

Tokens

:root {
  --bg: #0e1013;        /* page */
  --bg-2: #16191d;      /* results band */
  --line: #2a2e34;      /* hairlines */
  --ink: #f1efe8;       /* headline, strong text */
  --ink-2: #a9a79f;     /* body, meta */
  --ink-3: #8a8880;     /* ghost button ring */
  --accent: #c8f25a;    /* full stop, live dot, primary button */
  --accent-ink: #141a05;
  --font: "Anybody", system-ui, sans-serif;   /* wdth 50..150, wght 100..900 */
  --fs-display: clamp(64px, 13.6vw, 184px);
  --fs-clock: 34px; --fs-h2: 40px; --fs-lede: 16px; --fs-body: 15px; --fs-nav: 14px; --fs-meta: 12px; --fs-label: 11px;
  --space: 4px 8px 10px 16px 20px 28px 40px 48px 72px;
  --r-btn: 4px;
  --ease: cubic-bezier(.2, .7, .2, 1);
  --expo: cubic-bezier(.16, 1, .3, 1);
  --t-micro: 160ms;
  --t-weight: 420ms;
  --pin-range: 560px;
}
@property --s  { syntax: "<number>"; inherits: true;  initial-value: 0; }
@property --pw { syntax: "<number>"; inherits: false; initial-value: 0; }

Typography

RoleFamilySizeWidthWeightLine-heightTrackingCase
Headline line 1Anybody184px150% → 55%820 → 300 (+80 pointer)0.84-0.01emupper
Headline line 2Anybody184px62% → 150%300 → 860 (+500 pointer)0.84-0.01emupper
Headline line 3Anybody184px112% → 52%880 → 680 (−300 pointer)0.84-0.01emupper
LogoAnybody17px125%80010.01em
Results h2Anybody40px120%8001.1-0.01emupper
Race clockAnybody34px80%6001.1-0.01emtabular
Product sentenceAnybody16px100%400, lead clause 6001.550
ButtonsAnybody14px110%70010.01em
Meta, labelsAnybody12px / 11px100%6001.40.1emupper

One family. The width axis does the work a second family would normally do: the clock is condensed, the logo and h2 are extended.

Motion

ThingTriggerPropertyFrom → toDurationEasing
Line 1scroll 0–560pxfont-stretch, font-weight, translateX150%/820/0 → 55%/300/30vwscroll-linkedlinear (scroll is the clock)
Line 2samesame62%/300/14vw → 150%/860/0scroll-linkedlinear
Line 3samesame112%/880/0 → 52%/680/−4vwscroll-linkedlinear
Pointer weightpointermove on hero--pw per line0 → up to 1420msexpo out
Live dotalwaysopacity1 → .35 → 11.6s loopstandard
Clockalwaystext+0.1s100ms intervalnone
Buttonshover / activebrightness / translateY1 → 1.08 / 0 → 1px160msstandard

Reduced motion removes all of the above; the first frame is complete on its own.

States

  • Scroll 0: first-frame layout above.
  • Pinned, mid scroll: lines interpolated; the product row unchanged.
  • Released: hero scrolls away at --s = 1; results table follows.
  • Pointer near a line: that line’s weight shifts by its --pw amount.
  • Primary button hover: brightness 1.08. Ghost button: transparent with a 1.5px --ink-3 ring (5.4:1 on --bg); hover ring turns --ink.
  • Focus-visible: 2px lime outline, 3px offset.

Accessibility

  • The h1 carries aria-label="Split every second."; the three line spans are aria-hidden, so a screen reader hears one sentence, not three fragments.
  • The clock is aria-live="off" so it never chatters.
  • Contrast: --ink on --bg 16.9:1; --ink-2 on --bg 8.1:1; --accent-ink on lime 14:1.
  • Keyboard: logo, four nav links, Book a demo (nav), Book a demo, See Lisbon Half results. The animation is decoration and needs no control.
  • Reduced motion stops the scroll effect entirely, not just its easing.

Responsive rules

  • ≥1280: as drawn, 48px gutters.
  • 1024: display size follows 13.6vw (≈139px); same pin.
  • <900 (phone at 390): nav links hide; gutters 16px; the pin is removed (.pin height auto, hero position relative) so phones scroll normally while --s still runs over the first 560px; headline clamp(48px, 17vw, 120px); product row stacks to one column with the clock left aligned; results table hides the Club column. No sideways scroll: the hero has overflow:hidden and body overflow-x:hidden.

Acceptance checklist

Always

  • The headline is one h1 with an aria-label; visual lines are aria-hidden spans.
  • Width and weight are animated with font-stretch and font-weight driven by one registered number, not by swapping fonts.
  • The hero is pinned for exactly the animation range, so the whole effect is seen before the section leaves.
  • The product sentence and buttons do not move, scale or change weight at any point.
  • @supports (animation-timeline: scroll()) gates the CSS path; a rAF scroll listener covers the rest.
  • Reduced motion leaves the headline static, in its first-frame state, with no pointer response.
  • Ghost button ring is at least 3:1 against the page.
  • No horizontal scroll at 390 wide; long lines are clipped by the hero, not the page.

This demo

  • Headline is “Split every second.” with a lime full stop, 184px at 1280.
  • Line 1 goes 150%/820 → 55%/300 and moves right 30vw over 560px of scroll.
  • Line 2 goes 62%/300 → 150%/860 and slides from 14vw to 0.
  • The race clock reads “01:12:44.3” at load and counts tenths.
  • Results table lists Inês Varela first at 00:47:12.

Implementation notes

One number drives everything. Register --s, animate it on the root scroll timeline, and derive axis values with calc():

@property --s { syntax: "<number>"; inherits: true; initial-value: 0; }
@keyframes squeeze { to { --s: 1; } }
@supports (animation-timeline: scroll()) {
  h1 { animation: squeeze linear both; animation-timeline: scroll(root block); animation-range: 0 560px; }
}
.l1 { font-stretch: calc((150 - var(--s) * 95) * 1%);
      font-weight: calc(820 - var(--s) * 520 + var(--pw) * 80);
      transform: translateX(calc(var(--s) * 30vw)); }

Pin for the range. The wrapper is taller than the hero by exactly the animation range:

.pin  { height: calc(100vh - 64px + 560px); }
.hero { position: sticky; top: 64px; height: calc(100vh - 64px); overflow: hidden; }

Pointer weight eases for free. Because --pw is a registered <number>, a transition on it interpolates the weight; JS only writes the target:

lines.forEach((l) => {
  const b = l.getBoundingClientRect();
  const d = Math.abs(e.clientY - (b.top + b.height / 2)) / (b.height * 1.4);
  l.style.setProperty('--pw', Math.max(0, 1 - d).toFixed(3));
});

Common mistakes: loading the font without the wdth axis (family=Anybody:wdth,wght@50..150,100..900), so stretch does nothing; animating font-variation-settings strings, which cannot be combined from two sources; letting the product copy sit inside the animated element; using outline text for a light line, which fails contrast checks.

Details

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

Studied

Lama Lama

A tiny nav bar can carry the voice. Change its one line for each section.

All sources