Design Lounge
Install

Nº 333 of 520

Paper docs with scrollspy TOC

An editorial docs layout: 240px grouped nav, a 68ch serif-headed article, and a 200px On this page list with scrollspy and a red progress line.

Preview

Open full size
1280 × 800 · 14.2 KB · 254 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 "Paper docs with scrollspy TOC" (piece sidebar-docs-toc) 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

264 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. When a kit is locked, map colours onto the kit tokens. Keep the three column widths, the 68ch measure and the scrollspy line.

What it is

A documentation page for Larkspur, a fictional background job runner. The page is “Retries and backoff”. Three columns sit on warm paper: a 240px left nav with four groups and the current page marked in red, a centre article capped at 68ch with serif headings and sans body, and a 200px “On this page” list on the right. As you read, the list marks the heading in view and a 2px red line grows down its left edge to show how far you are. A small mono readout under the list says how much you have read. It should feel like a printed manual, not a dashboard. The detail worth copying is the progress line drawn on the TOC rail itself, so position and progress are one mark.

docs-three-column is the neutral white version with a top bar and a copy button. This piece is the paper one with no top bar.

Reference behaviour

  1. First frame: article at the top. “Retries and backoff” in the left nav has aria-current="page", a 2px red left rule, weight 600 and a soft red wash fading to the right. In the TOC, “How a retry is scheduled” is current. The progress line is 0% tall. The readout says “0% read”.
  2. Scroll the article: the TOC item for the last heading whose top has passed 120px below the scroller’s top becomes current. It turns --ink, weight 500, and gets a 5px red dot on the rail.
  3. While scrolling, the red line’s height equals scrollTop / (scrollHeight - clientHeight) as a percentage of the list height. The readout updates to the rounded percent.
  4. At the very bottom of the article, the last TOC item becomes current even if its heading has not reached the 120px line.
  5. Click a TOC item: the article scrolls so the heading sits 32px below the top. Use smooth scrolling. Under reduced motion, jump with no animation. Focus moves to the heading with preventScroll, so screen readers land there.
  6. Click “Back to top”: same behaviour, target is the h1.
  7. Hover a left nav link or TOC link: text goes from muted to --ink over 160ms. No underline, no background.
  8. Hover a pager card at the foot: its border turns --ink.
  9. Only the centre column scrolls. The left nav and the TOC stay put.

Structure

1280 × 800
┌──── 240 ────┬───────────────────── 1fr ──────────────────────┬──── 200 ────┐
│ Larkspur v4.2│        Jobs / Retries and backoff              │             │
│─────────────│        Retries and backoff        (h1 46px)    │ ON THIS PAGE│
│ START HERE  │        A job that fails is not finished…       │ │ How a re… │
│  Introduction│        (lede, serif 20px)                     │ ┃•How a re… │
│  Install     │        7 min read · Updated 28 September 2026 │ │   What co…│
│  Your first… │        ─────────────────────────────────────  │ │ Backoff…  │
│ JOBS        │        How a retry is scheduled   (h2 28px)    │ │ Idempot…  │
│  Defining…   │        prose 15/1.65 …                        │ │ When ret… │
│  Scheduling  │        What counts as a failure  (h3 15px)    │ │   Alertin…│
│▌Retries and…│        • …                                     │ │ Limits    │
│  Timeouts    │        ┌ code block, paper tint ─────────────┐ │ 0% read   │
│  Concurrency │        └──────────────────────────────────────┘ │ Back to top│
│ OPERATIONS  │        table …                                 │             │
│ REFERENCE   │        (scrolls)                               │             │
└─────────────┴────────── article max 68ch, padding 48 40 96 ───┴─────────────┘
  • body is a grid: grid-template-columns: 240px minmax(0, 1fr) 200px, height: 100%, overflow: hidden.
  • Left: nav aria-label="Documentation". Brand row, then four groups. Each group is an h2 label plus a ul with aria-labelledby on the label. Own scroll if it overflows. border-right: 1px solid --line.
  • Centre: a div scroller (overflow: auto, --sheet background) holding one article. The article has max-width: 68ch, margin: 0 auto, padding 48px 40px 96px.
  • Article order: breadcrumb p, h1, lede p, meta row, then sections. Each section is an h2 with an id, prose, and sometimes an h3, a pre, a table or a note.
  • Foot of the article: a two-column pager, Previous and Next.
  • Right: aside labelled by its “On this page” label. Padding 56px 24px 32px 0. Inside: a positioned wrapper holding the 1px track, the 2px fill and an ol of links. h3 links are indented 12px more. Then the readout and Back to top.

Tokens

:root {
  --paper: #f6f1e7;        /* page ground, side columns */
  --sheet: #fbf8f1;        /* article column */
  --code: #efe8da;         /* code blocks and inline code */
  --ink: #1f1b16;          /* headings and body */
  --ink-2: #4a443a;        /* nav links, lede */
  --ink-3: #6e6658;        /* labels, meta, idle TOC */
  --line: #e3d9c7;         /* column rules, table rules */
  --line-2: #d4c8b2;       /* TOC track, pager border */
  --accent: #b8321f;       /* current page rule, TOC dot and fill, note rule */
  --accent-soft: #f3e2d9;  /* current page wash */
  --string: #5b6b2f;       /* string colour in code */
  --focus: #b8321f;

  --serif: "Newsreader", Georgia, serif;
  --sans: "IBM Plex Sans", system-ui, sans-serif;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;

  --measure: 68ch;
  --col-nav: 240px;
  --col-toc: 200px;
  --spy-line: 120px;
  --anchor-gap: 32px;

  --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
  --space-5: 20px; --space-6: 24px; --space-10: 40px; --space-12: 48px;
  --r-code: 4px; --r-inline: 3px;

  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);
  --fast: 160ms;
}

The mono is a system stack on purpose. Two Google families is the limit, and the serif and sans carry the page.

Typography

RoleFamilySize / line-heightWeightTrackingColour
BrandNewsreader22px / 1600-0.01em--ink
Versionmono12px4000--ink-3
Nav group labelIBM Plex Sans11px / 1.46000.08em, uppercase--ink-3
Nav linkIBM Plex Sans14px / 1.45400, current 6000--ink-2, current --ink
BreadcrumbIBM Plex Sans13px4000--ink-3
h1Newsreader, opsz 7246px / 1.05600-0.02em--ink
LedeNewsreader20px / 1.55000--ink-2
h2Newsreader, opsz 3628px / 1.2600-0.01em--ink
h3IBM Plex Sans15px / 1.46000--ink
BodyIBM Plex Sans15px / 1.654000--ink
Code blockmono13px / 1.64000--ink
Inline codemono13.5px4000--ink on --code
Table headIBM Plex Sans12px6000.06em, uppercase--ink-3
TOC labelIBM Plex Sans11px6000.08em, uppercase--ink-3
TOC linkIBM Plex Sans13px / 1.4, sub 12.5px400, current 5000--ink-3, current --ink
Readoutmono12px4000--ink-3

Headings are serif. Everything you scan or click is sans. Code is mono. Do not set the nav or the TOC in the serif.

Motion

ThingTriggerPropertyFrom → toDurationEasingReduced motion
TOC click scrollclickscrollTopcurrent → heading − 32pxbrowser smoothbrowserbehavior: 'auto', instant
Progress fillscrollheight0% → 100%follows scroll, one update per framenonesame, it is not an animation
TOC currentscrollcolour, weight, dotidle → current160ms colour--easeinstant
Link hoverhovercolourmuted → --ink160ms--easeinstant
Pager hoverhoverborder-colour--line-2 → --ink160ms--easeinstant

Batch scroll work in requestAnimationFrame. Do not put a CSS transition on the fill height. It lags behind the thumb.

States

  • Left nav link resting: --ink-2, 2px transparent left border.
  • Left nav current: aria-current="page", --ink, weight 600, 2px --accent left border, background linear-gradient(90deg, --accent-soft, transparent 80%).
  • TOC idle: --ink-3.
  • TOC current: aria-current="location", --ink, weight 500, a 5px --accent dot centred on the 1px track.
  • Progress fill: 2px wide, --accent, from the top of the rail.
  • Focus-visible: 2px --focus outline, offset 2px, 2px radius. In the left nav, offset -2px so the ring is not clipped by the column edge.
  • Note block: 2px --accent left rule, --paper background, 12px 16px padding, label “Note.” in the accent.
  • Empty page (no h2): hide the TOC column content and keep the 200px column so the article does not jump.
  • Loading: not used. Docs are static.

Accessibility

  • Three landmarks: nav “Documentation”, article inside the main scroller, aside “On this page”.
  • One h1 per page. Section headings are h2, subsections h3. The TOC mirrors that nesting with the indent.
  • Left nav uses aria-current="page". The TOC uses aria-current="location". Do not mix them up.
  • TOC links are real href="#id" links. They work with JavaScript off.
  • After a TOC jump, move focus to the heading. Give the heading tabindex="-1" and call focus({ preventScroll: true }).
  • The progress line and the readout are aria-hidden. They repeat what the scrollbar already says.
  • Contrast: #1f1b16 on #fbf8f1 is about 16:1. #6e6658 on #f6f1e7 is about 5:1. #b8321f on #fbf8f1 is about 5.6:1.
  • Keep the body at 15px with 1.65 line-height. Keep the measure at 68ch. Do not stretch prose across the column.
  • TOC and nav links are at least 28px tall on desktop. On touch layouts they become 44px.

Responsive rules

  • At 1280 and wider: 240 / 1fr / 200. The article stays at 68ch and centres in the middle column.
  • At 1024 to 1279: keep all three columns. The article padding drops to 32px on the sides.
  • Below 1024: the left nav becomes a drawer.
    • Add a 52px top bar to the article column with the brand and a 40px menu button. The button has aria-expanded and aria-controls.
    • The drawer is 280px wide, slides in from the left over 240ms with cubic-bezier(0.2, 0.7, 0.2, 1), over a scrim of rgba(31, 27, 22, .4).
    • Trap focus in the drawer. Escape and a scrim tap close it and return focus to the menu button.
    • The current page stays marked inside the drawer.
    • Reduced motion: no slide.
  • Below 1024, the TOC also leaves its column. Move it to a collapsible “On this page” block under the meta row, a details element, closed by default. Keep the scrollspy off in this mode. Show the progress as a 2px red line fixed to the top of the article column instead.
  • Below 640: article padding 24px 20px 64px. h1 drops to 34px. h2 drops to 24px. Code blocks scroll sideways inside themselves. The page never scrolls sideways.

Acceptance checklist

Always

  • Three columns at desktop: 240px, minmax(0, 1fr), 200px. Only the centre scrolls.
  • Article measure is 68ch, centred.
  • Headings are serif. Body, nav and TOC are sans. Code is mono.
  • Exactly one left nav link has aria-current="page" with a 2px accent rule.
  • The TOC marks one item with aria-current="location", using a 120px line from the scroller top.
  • At the scroll bottom, the last TOC item is current.
  • The progress fill height tracks scroll progress, 0% to 100%.
  • TOC click scrolls to 32px above the heading, smooth, and instant under reduced motion.
  • Focus moves to the target heading after a jump.
  • Focus rings are visible on every link.
  • Below 1024 the nav is a drawer and the TOC is a collapsible block.
  • No horizontal scroll at any width.

This demo

  • Product is Larkspur v4.2. Page is “Retries and backoff” in the Jobs group.
  • Groups are Start here, Jobs, Operations, Reference.
  • TOC has five h2 items and two h3 items: What counts as a failure, Alerting on dead jobs.
  • Paper #f6f1e7, sheet #fbf8f1, accent #b8321f.
  • Fonts are Newsreader and IBM Plex Sans.

Implementation notes

The scrollspy is a loop over headings on scroll, not an IntersectionObserver. An observer fires on enter and leave, so short sections between two long ones get skipped. The loop is cheap with fewer than 30 headings.

const LINE = 120;
function update() {
  const top = scroller.getBoundingClientRect().top;
  let idx = 0;
  heads.forEach((h, i) => { if (h.getBoundingClientRect().top - top <= LINE) idx = i; });
  const max = scroller.scrollHeight - scroller.clientHeight;
  if (max > 0 && scroller.scrollTop >= max - 2) idx = heads.length - 1;
  links.forEach((a, i) => i === idx
    ? a.setAttribute('aria-current', 'location')
    : a.removeAttribute('aria-current'));
  const p = max > 0 ? scroller.scrollTop / max : 1;
  fill.style.height = (p * 100).toFixed(1) + '%';
  pct.textContent = Math.round(p * 100) + '% read';
}
scroller.addEventListener('scroll', () => requestAnimationFrame(update), { passive: true });

Smooth scroll that respects the setting. Read the media query at click time, not once at load.

const reduce = matchMedia('(prefers-reduced-motion: reduce)');
function go(id) {
  const el = document.getElementById(id);
  const y = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top
    + scroller.scrollTop - 32;
  scroller.scrollTo({ top: Math.max(0, y), behavior: reduce.matches ? 'auto' : 'smooth' });
  el.setAttribute('tabindex', '-1');
  el.focus({ preventScroll: true });
}

The rail. The track and the fill live in a positioned wrapper next to the ol, not inside it. An ol may only hold li.

.rail { position: relative; }
.track, .fill { position: absolute; left: 0; top: 0; width: 1px; background: var(--line-2); }
.track { bottom: 0; }
.fill { width: 2px; left: -.5px; background: var(--accent); height: 0; }
.toc a[aria-current="location"]::before {
  content: ""; position: absolute; left: -2px; width: 5px; height: 5px;
  margin-top: 6px; border-radius: 50%; background: var(--accent);
}

Common mistakes:

  • Scrolling the whole window and making the side columns position: sticky. That works, but then the scroller is window. Pick one and measure against it.
  • Letting prose run the full column width. Cap at 68ch.
  • A blue link colour. The only colour is the red accent.
  • Using the accent for every link. Links in prose stay ink with an underline.
  • Pure white #fff for the article. Use --sheet.
  • Code blocks in a dark theme on a paper page. Keep them on --code with ink text.
  • Highlighting the first heading only when it is in view. At the top of the page, the first heading is current.
  • A CSS transition on the fill height. It trails the scroll.
  • Drawing a second progress bar at the top of the page on desktop. The rail is the progress.

Rebuild order:

  1. Set the three column grid and make only the centre scroll.
  2. Build the left nav with groups and the current page rule.
  3. Set the article measure, the serif headings and the body type.
  4. Add the TOC list from the h2 and h3 ids.
  5. Add the rail, the fill and the readout.
  6. Wire the scrollspy and the click scroll.
  7. Check reduced motion and focus after a jump.
  8. Add the drawer and the collapsible TOC below 1024.

Details

Palette
Type
Newsreader · IBM Plex Sans
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Tailwind CSS docs

Put each instruction next to its code. Draw the grid with thin lines and let the empty sides carry a faint hatch.

All sources

On the shelves