Design Lounge
Install

Nº 456 of 520

Tilted stat card stack

A pinned stats section: big headline left, four coloured stat cards that rise tilted, land on a 44px peek, count up, and pile as you scroll.

Preview

Open full size
1280 × 800 · 14.4 KB · 235 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Tilted stat card stack" (piece stats-tilted-card-stack) 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

253 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

Studied from mindmarket.com: the “few numbers behind the insights” block, where a sticky headline stays put while big coloured stat cards slide up one by one and pile on top of each other. This version is the stats section of a fictional user-research recruiting service, Fieldwise. The section pins for three scroll steps. On the left: an eyebrow, a 76px headline with one word underlined by a hand-drawn ochre wave, a short lede, and a step counter with dots and a Next button. On the right: four 420×480 cards (white, ochre, teal, brick). Each new card rises from below tilted 7–9°, settles to a small resting tilt of 1–2°, and lands 44px lower than the one before, so every earlier card’s label row still shows like a tab. When a card lands, its number counts up. The detail worth copying: the cards are driven by one scroll progress value, each card takes a one-unit slice of it, and the next card is always peeking tilted at the bottom of the first frame, so the viewer knows to scroll.

Reference behaviour

  1. First frame (scrollY 0): floating white nav bar 16px from the top, headline “The numbers behind every interview we run” with a wave under “interview”, counter “01 / 04”, and the white Languages card (41) fully in place. The ochre card peeks tilted about 11° from below the stage, its top edge roughly 300px below the white card’s top.
  2. The wave under “interview” draws left to right on load, 800ms, 300ms delay.
  3. The section is 100vh + 3 × 80vh tall. Its inner panel is position: sticky; top: 0; height: 100vh. One step is 0.8 × innerHeight of scroll.
  4. Progress p = clamp((scrollY − section.offsetTop) / step, 0, 3).
  5. For card i ≥ 1, local t = clamp(p − (i − 1), 0, 1). The card’s translateY is (1 − t) × 0.75 × cardHeight, plus a queue offset clamp((i − 1) − p, 0, 3) × (0.75 × cardHeight + 200) that keeps later cards out of sight below the pin.
  6. Rotation is restingTilt[i] + (1 − t) × entryTilt[i], with resting [0, −2, 1.5, −1] degrees and entry [0, 9, −8, 7] degrees. Rotation origin is bottom centre.
  7. Each card’s resting top is i × 44px, so the finished pile shows a 44px strip of each covered card: its uppercase label and icon disc.
  8. When t passes 0.97, the card’s number counts up from 0 to its value in 700ms, quartic ease-out, formatted with thousands commas. If t drops back under 0.3, the count is re-armed for the next landing.
  9. A card that is covered by more than 0.6 of the next card’s travel dims its number and sentence to 35% opacity. The label row stays at full strength.
  10. The counter and the dots follow round(p). The current dot stretches from a 10px circle to a 28×10 pill in ink.
  11. Clicking a dot smooth-scrolls to section.offsetTop + i × step. Next goes to the next step, and from step 4 to the following CTA band.
  12. After the pin releases, an ochre CTA band follows: “Ready when your users are” at 88px with a Book a call pill.

Structure

1280 × 800, page scrolls
┌──────────────────────────────────────────────────────────────────┐
│ 16px ┌ nav bar 56px, white, radius 14 ─────────────┐ ┌ Book a call ┐│
│      │ ~ Fieldwise      Recruiting Moderation …    │ │         (>) ││
│      └──────────────────────────────────────────────┘ └────────────┘│
│ pad 120 top / 96 sides                                              │
│ BY THE NUMBERS                               ┌ stage 420 × 612 ──┐ │
│ The numbers                                  │┌ card 0 white ───┐│ │
│ behind every                                 ││LANGUAGES    (o) ││ │
│ interview we run       (76px)                ││ 41   (112px)    ││ │
│ ~~~~~~~~~ ochre wave                         ││                 ││ │
│ lede 300px wide                              ││ sentence        ││ │
│                                              │└╱card 1 tilted╲──┘│ │
│ 01 / 04  ━ • • •   [ Next ↓ ]                │ ╱ peeking      ╲  │ │
└──────────────────────────────────────────────────────────────────┘
grid: 1fr | 420px, gap 64px. Stage height = 480 + 3 × 44.
  • Nav: a fixed row with a nav bar (brand link + four links) and a separate a.quote pill. The pill holds a 40px ochre circle with a chevron.
  • Section: section#stats labelled by its h2. Inside, .pin is the sticky grid.
  • Left column: p.eyebrow, h2 with span.wavy containing the inline SVG wave, p.lede, and .steps (counter, ol.dots of buttons, button.next).
  • Right column: .stage with role="list", four article.card with role="listitem" and an aria-label that reads the full stat (“2,380 interviews this year”).
  • Card: header (label + 48px icon disc), .num, p pinned to the bottom with margin-top: auto.
  • After: section#book, ochre, centred CTA.
  • A visually hidden aria-live="polite" paragraph announces the current stat.

Tokens

:root {
  /* colour */
  --bg: #e8eef2;        /* page, pale steel */
  --surface: #ffffff;   /* nav, first card, icon discs */
  --ink: #13202b;       /* headline, body, current dot */
  --ink-2: #465462;     /* lede, eyebrow, nav links */
  --line: #cfd9e0;      /* inactive dots */
  --ochre: #e3a21a;     /* wave, card 2, CTA band, quote circle */
  --teal: #1f7a6d;      /* card 1 text, card 3 fill */
  --brick: #c2452d;     /* card 4 fill */
  --focus: #13202b;

  /* type */
  --sans: "Schibsted Grotesk", system-ui, sans-serif;

  /* shape */
  --r-card: 32px;
  --r-pill: 14px;
  --peek: 44px;
  --card-h: 480px;
  --stage-w: 420px;

  /* motion */
  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);
  --expo: cubic-bezier(0.16, 1, 0.3, 1);
  --t-micro: 160ms;
  --t-layout: 240ms;
  --t-draw: 800ms;
  --t-count: 700ms;
}

Card colour pairs:

CardFillTextIcon disc
1 Languages--surface--tealteal disc, white glyph
2 Interviews--ochre--inkwhite disc, ink glyph
3 Brief to session--teal#fffwhite disc, teal glyph
4 Returning clients--brick#fffwhite disc, brick glyph

Typography

One family, Schibsted Grotesk, at very different sizes. The size jump is the design.

RoleSizeWeightLine-heightTrackingCase
Headline h276px5000.98−0.05emSentence
Stat number112px5001−0.055em—
Stat suffix (days, %)44px5001−0.03emlower
CTA band h388px5000.95−0.05emSentence
Card sentence17px4001.40Sentence
Lede16px4001.50Sentence
Nav brand19px6001−0.02em—
Nav link15px50010—
Eyebrow, card label13px60010.06–0.08emUPPER
Counter14px60010.02emtabular

Use lining figures on the stat number, not tabular. Tabular figures make the comma in “2,380” a full figure wide.

Motion

ThingTriggerPropertyFrom → ToDuration / easingReduced motion
Wave underlineloadstroke-dashoffset1 → 0 (pathLength 1)800ms --expo, 300ms delaydrawn from the start
Card i risescrolltranslateY0.75 × cardH → 0scroll-linked over one stepsame, user-driven
Card i tiltscrollrotaterest + entry → restscroll-linkedrotation 0 always
Card count-upt > 0.97number text0 → value700ms, quartic outfinal value, no tween
Covered card dimp − i > 0.6opacity of number + sentence1 → 0.35240ms --easeno transition
Current dotstep changewidth, radius, colour10 circle → 28×10 pill240ms --easeinstant
Quote chevronhovertranslateX0 → 3px180ms --easenone
Dot / Nextclickwindow scroll—smoothbehavior: auto

Scroll work runs in one requestAnimationFrame per scroll burst. Do not tween the transforms with CSS transitions on top of the scroll mapping: it lags the finger.

States

  • Nav link hover: colour --ink-2 → --ink.
  • Quote pill hover: chevron circle nudges 3px right.
  • Next hover: fills ink, text turns white.
  • Dot current: aria-current="true", 28×10 ink pill. Others are 10px --line circles inside 40px hit targets.
  • Card resting: full opacity.
  • Card covered: number and sentence 35% opacity, label row unchanged.
  • Card landing: number tweens up once per landing.
  • Focus-visible: 2px ink outline, 3px offset, 8px radius on every link and button.
  • Empty, error, loading: not used. The figures are static content.

Accessibility

  • The section is a section with aria-labelledby pointing to the h2.
  • The stage is a list. Each card’s aria-label reads the full stat so a screen reader does not hear “2” “3” “8” “0” mid-count.
  • The dots are real buttons inside an ol labelled “Stats”. Each has an aria-label (“Stat 2: interviews”) and aria-current on the active one.
  • Next is a button with a visible word. Icons are aria-hidden.
  • A polite live region announces the card label when the step changes.
  • Tab order: brand, nav links, Book a call, dots 1–4, Next, then the CTA band. Cards are not focusable; the controls drive them.
  • Contrast: white on #1f7a6d is about 5.2:1, white on #c2452d about 5.0:1, #13202b on #e3a21a about 7.4:1, #465462 on #e8eef2 about 6.7:1.
  • Hit targets: dots 40×40 (32×40 under 900px), Next 44px tall, quote pill 56px.

Responsive rules

  • ≥1280: as specified, 96px side padding, 76px headline.
  • 1024–1100: side padding 48px, gap 40px, headline 60px, the four nav links hide; the brand and the pill stay.
  • 768–900 and below: single column. Headline 38px, lede hidden, steps row under the headline with 8px gaps. Stage becomes width: 100%; max-width: 340px, centred. Cards 340px tall, peek 34px, numbers 76px with 30px suffix, sentences 15px.
  • <640: same as above; the quote pill drops its words and keeps the 40px chevron circle. Check there is no horizontal scroll at 375px: rotated cards stay inside the pin’s overflow: hidden.
  • Never shrink the peek below 32px: the label row must stay readable in the pile.

Acceptance checklist

Always

  • The pinned panel is exactly 100vh and the section is 100vh + (n − 1) × 0.8 × 100vh for n cards.
  • One progress value drives every card. Card i uses the slice [i − 1, i].
  • In the first frame the first card is in place and the second card is visibly peeking and tilted.
  • Each landed card sits i × peek below the first, so every covered label row stays visible.
  • Rotation origin is bottom centre. Resting tilts are within ±2°.
  • A number counts up once each time its card lands, and reads its final value under reduced motion.
  • Dots and Next scroll to the matching step. The current dot has aria-current.
  • Covered cards dim their number and sentence, not their label.
  • No horizontal scroll at 375px wide.
  • Focus is visible on every control.

This demo

  • Headline “The numbers behind every interview we run”, 76px, with an ochre wave under “interview”.
  • Cards in order: 41 Languages (white/teal), 2,380 Interviews this year (ochre/ink), 6 days Brief to first session (teal/white), 93% Clients who return (brick/white).
  • Card 420×480, radius 32, peek 44px, stage 420×612.
  • Entry tilts 9, −8, 7 degrees; resting tilts −2, 1.5, −1 degrees.
  • Counter reads “01 / 04” at the top and “04 / 04” when the pile is complete.

Implementation notes

  1. The scroll mapping. Keep it to one function and one rAF:
const step = () => innerHeight * 0.8;
function render() {
  const p = clamp((scrollY - section.offsetTop) / step(), 0, cards.length - 1);
  const rise = cards[0].offsetHeight * 0.75;
  cards.forEach((c, i) => {
    if (!i) return;
    const t = clamp(p - (i - 1), 0, 1);
    const queued = clamp((i - 1) - p, 0, 3) * (rise + 200);
    const r = reduce ? 0 : tilt[i] + (1 - t) * enter[i];
    c.style.transform = `translateY(${(1 - t) * rise + queued}px) rotate(${r}deg)`;
    if (t > 0.97 && !landed[i]) { landed[i] = true; countUp(c); }
    if (t < 0.3) landed[i] = false;
  });
}

The queued term is what stops cards 3 and 4 sitting right behind card 2 in the first frame. Without it, all three peek in the same spot.

  1. The wave underline is a single path with pathLength="1", so the draw needs no measuring:
<span class="wavy">interview<svg viewBox="0 0 100 16" preserveAspectRatio="none" aria-hidden="true">
  <path pathLength="1" d="M0 8 Q 6.25 0 12.5 8 T 25 8 T 37.5 8 T 50 8 T 62.5 8 T 75 8 T 87.5 8 T 100 8"/></svg></span>
.wavy { position: relative; display: inline-block; }
.wavy svg { position: absolute; left: -2px; bottom: -14px; width: calc(100% + 4px); height: 16px; overflow: visible; }
.wavy path { fill: none; stroke: var(--ochre); stroke-width: 5; stroke-linecap: round;
  stroke-dasharray: 1; stroke-dashoffset: 1; animation: draw .8s var(--expo) .3s forwards; }
  1. The count-up writes innerHTML so the suffix stays in its smaller small element:
function countUp(card) {
  const el = card.querySelector('.num'), to = +el.dataset.to;
  const tail = el.dataset.suffix ? `<small>${el.dataset.suffix}</small>` : '';
  if (reduce) { el.innerHTML = to.toLocaleString('en-US') + tail; return; }
  const t0 = performance.now();
  requestAnimationFrame(function tick(now) {
    const k = Math.min(1, (now - t0) / 700), e = 1 - (1 - k) ** 4;
    el.innerHTML = Math.round(to * e).toLocaleString('en-US') + tail;
    if (k < 1) requestAnimationFrame(tick);
  });
}

Common mistakes:

  • Stacking the cards with no peek. The pile then looks like one card and the earlier stats vanish.
  • Tilting around the centre. A centre origin swings the top corners into the headline column; bottom centre keeps the motion low.
  • Animating top instead of transform. Use transforms only.
  • Starting with no card peeking. The section then reads as a static card and nobody scrolls.
  • Using four random brand colours. Keep one white card, one warm, one cool, one hot, and repeat the page accent (ochre) on the wave and the CTA.
  • Making the cards focusable buttons. They do nothing on click; the dots and Next are the controls.
  • Forgetting overflow: hidden on the pin. Tilted cards then create horizontal scroll on phones.

Details

Palette
Type
Schibsted Grotesk
Motion
Rich motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

MindMarket

One path ties the page together. Cards sit beside it, and the numbers pile up as you scroll.

All sources