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
Use it
Free to use · credit appreciated1 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.
<!-- Design Lounge Nº 456 · "Tilted stat card stack" · www.designlounge.live -->
# Tilted stat card stack
> **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 ││ │
… 213 more lines. Copy or download for the full brief. Loading the source…Brief
253 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
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
- 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.
- The wave under “interview” draws left to right on load, 800ms, 300ms delay.
- The section is
100vh + 3 × 80vhtall. Its inner panel isposition: sticky; top: 0; height: 100vh. One step is0.8 × innerHeightof scroll. - Progress
p = clamp((scrollY − section.offsetTop) / step, 0, 3). - For card
i ≥ 1, localt = clamp(p − (i − 1), 0, 1). The card’s translateY is(1 − t) × 0.75 × cardHeight, plus a queue offsetclamp((i − 1) − p, 0, 3) × (0.75 × cardHeight + 200)that keeps later cards out of sight below the pin. - 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. - 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. - When
tpasses 0.97, the card’s number counts up from 0 to its value in 700ms, quartic ease-out, formatted with thousands commas. Iftdrops back under 0.3, the count is re-armed for the next landing. - 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.
- The counter and the dots follow
round(p). The current dot stretches from a 10px circle to a 28×10 pill in ink. - 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. - 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
navbar (brand link + four links) and a separatea.quotepill. The pill holds a 40px ochre circle with a chevron. - Section:
section#statslabelled by itsh2. Inside,.pinis the sticky grid. - Left column:
p.eyebrow,h2withspan.wavycontaining the inline SVG wave,p.lede, and.steps(counter,ol.dotsof buttons,button.next). - Right column:
.stagewithrole="list", fourarticle.cardwithrole="listitem"and anaria-labelthat reads the full stat (“2,380 interviews this year”). - Card:
header(label + 48px icon disc),.num,ppinned to the bottom withmargin-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:
| Card | Fill | Text | Icon disc |
|---|---|---|---|
| 1 Languages | --surface | --teal | teal disc, white glyph |
| 2 Interviews | --ochre | --ink | white disc, ink glyph |
| 3 Brief to session | --teal | #fff | white disc, teal glyph |
| 4 Returning clients | --brick | #fff | white disc, brick glyph |
Typography
One family, Schibsted Grotesk, at very different sizes. The size jump is the design.
| Role | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|
| Headline h2 | 76px | 500 | 0.98 | −0.05em | Sentence |
| Stat number | 112px | 500 | 1 | −0.055em | — |
| Stat suffix (days, %) | 44px | 500 | 1 | −0.03em | lower |
| CTA band h3 | 88px | 500 | 0.95 | −0.05em | Sentence |
| Card sentence | 17px | 400 | 1.4 | 0 | Sentence |
| Lede | 16px | 400 | 1.5 | 0 | Sentence |
| Nav brand | 19px | 600 | 1 | −0.02em | — |
| Nav link | 15px | 500 | 1 | 0 | — |
| Eyebrow, card label | 13px | 600 | 1 | 0.06–0.08em | UPPER |
| Counter | 14px | 600 | 1 | 0.02em | tabular |
Use lining figures on the stat number, not tabular. Tabular figures make the comma in “2,380” a full figure wide.
Motion
| Thing | Trigger | Property | From → To | Duration / easing | Reduced motion |
|---|---|---|---|---|---|
| Wave underline | load | stroke-dashoffset | 1 → 0 (pathLength 1) | 800ms --expo, 300ms delay | drawn from the start |
| Card i rise | scroll | translateY | 0.75 × cardH → 0 | scroll-linked over one step | same, user-driven |
| Card i tilt | scroll | rotate | rest + entry → rest | scroll-linked | rotation 0 always |
| Card count-up | t > 0.97 | number text | 0 → value | 700ms, quartic out | final value, no tween |
| Covered card dim | p − i > 0.6 | opacity of number + sentence | 1 → 0.35 | 240ms --ease | no transition |
| Current dot | step change | width, radius, colour | 10 circle → 28×10 pill | 240ms --ease | instant |
| Quote chevron | hover | translateX | 0 → 3px | 180ms --ease | none |
| Dot / Next | click | window scroll | — | smooth | behavior: 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--linecircles 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
sectionwitharia-labelledbypointing to theh2. - The stage is a list. Each card’s
aria-labelreads the full stat so a screen reader does not hear “2” “3” “8” “0” mid-count. - The dots are real buttons inside an
ollabelled “Stats”. Each has anaria-label(“Stat 2: interviews”) andaria-currenton 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
#1f7a6dis about 5.2:1, white on#c2452dabout 5.0:1,#13202bon#e3a21aabout 7.4:1,#465462on#e8eef2about 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
100vhand the section is100vh + (n − 1) × 0.8 × 100vhfor 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 × peekbelow 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
- 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.
- 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; }
- The count-up writes
innerHTMLso the suffix stays in its smallersmallelement:
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
topinstead oftransform. 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: hiddenon the pin. Tilted cards then create horizontal scroll on phones.
Details
- Palette
- Type
- Schibsted Grotesk
- Motion
- Rich motion
- Build
- One session
- Tags
Studied
MindMarketOne path ties the page together. Cards sit beside it, and the numbers pile up as you scroll.
All sources