NS Design System
v3.0.0 · 257 tokens

108Post layout & TOC

TOC rail | article | share rail. Three columns is a claim that both rails earn their width, so they are narrow, sticky, and the first things dropped — the share rail below 80rem, the TOC folding into a disclosure below 64rem. The article column never widens to fill the space they leave: the measure is the point of the page. The TOC is a real nav of real anchor links that works with JS off; the scroll-spy only adds aria-current.

Use it for

  • Any post longer than about four screens
  • Documentation pages with the same shape

Not for

  • A short post — a TOC with three entries is chrome
  • Three levels of heading in the outline. Two is the limit; a page needing three needs splitting

The three-column page

Live — scroll the demo and watch the TOC mark the section you are in. The outline here is hand-authored; pass data-toc-from="#post-body" instead and assets/js/toc.js builds it from the article's own h2/h3.

The limit is the transaction

Governor limits are counted per transaction, not per record. A trigger that runs one query per record does not use one query — it uses as many queries as the data load has rows, and the platform stops it at 101.

NoteThe 101 is not a typo. The hundredth query succeeds; the hundred-and-first is what raises the exception.

Counting the wrong thing

The instinct is to count records. The platform counts statements, and the difference is the entire lesson.

The fix is a pattern

Collect the ids first, query once, put the results in a map, then loop. It is three lines longer and it does not care whether the load is one record or two hundred.

Watch outA map keyed on a field that is not unique silently drops records. Key on the id unless you can prove otherwise.

Testing it honestly

A test that inserts one record proves nothing about the case that broke. Insert two hundred.

TipPut the 200 in a constant your factory reads. When the limit changes, one edit fixes every test.
markup
<div class="ns-post" style="padding-block:var(--space-6)">
  <nav class="ns-post__rail ns-toc" aria-label="On this page">
    <p class="ns-toc__title">On this page</p>
    <a class="ns-toc__link" href="#s-limits" aria-current="true">The limit is the transaction</a>
    <a class="ns-toc__link ns-toc__link--sub" href="#s-count">Counting the wrong thing</a>
    <a class="ns-toc__link" href="#s-fix">The fix is a pattern</a>
    <a class="ns-toc__link" href="#s-test">Testing it honestly</a>
  </nav>

  <article class="ns-post__body ns-prose" id="post-body">
    <h2 id="s-limits">The limit is the transaction</h2>
    <p>Governor limits are counted per transaction, not per record. A trigger that runs one query per record does not use one query — it uses as many queries as the data load has rows, and the platform stops it at 101.</p>
    <div class="ns-callout">
      <i class="ph ph-info" aria-hidden="true"></i>
      <div><span class="ns-callout__label">Note</span>The 101 is not a typo. The hundredth query succeeds; the hundred-and-first is what raises the exception.</div>
    </div>
    <h3 id="s-count">Counting the wrong thing</h3>
    <p>The instinct is to count records. The platform counts statements, and the difference is the entire lesson.</p>
    <h2 id="s-fix">The fix is a pattern</h2>
    <p>Collect the ids first, query once, put the results in a map, then loop. It is three lines longer and it does not care whether the load is one record or two hundred.</p>
    <div class="ns-callout ns-callout--warn">
      <i class="ph ph-warning" aria-hidden="true"></i>
      <div><span class="ns-callout__label">Watch out</span>A map keyed on a field that is not unique silently drops records. Key on the id unless you can prove otherwise.</div>
    </div>
    <h2 id="s-test">Testing it honestly</h2>
    <p>A test that inserts one record proves nothing about the case that broke. Insert two hundred.</p>
    <div class="ns-callout ns-callout--tip">
      <i class="ph ph-lightbulb" aria-hidden="true"></i>
      <div><span class="ns-callout__label">Tip</span>Put the 200 in a constant your factory reads. When the limit changes, one edit fixes every test.</div>
    </div>
  </article>

  <aside class="ns-post__aside ns-share ns-share--rail">
    <span class="ns-share__label">Share</span>
    <button type="button" class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--sm" aria-label="Copy link"><i class="ph ph-link-simple" aria-hidden="true"></i></button>
    <button type="button" class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--sm" aria-label="Share on LinkedIn"><i class="ph ph-linkedin-logo" aria-hidden="true"></i></button>
    <button type="button" class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--sm" aria-label="Share on X"><i class="ph ph-x-logo" aria-hidden="true"></i></button>
    <button type="button" class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--sm" aria-label="Save for later"><i class="ph ph-bookmark-simple" aria-hidden="true"></i></button>
  </aside>
</div>

Collapsed TOC

Below 64rem the rail has nowhere to stick, so the outline folds into a native <details> above the text. Same links, same markup, no second implementation.

markup
<details class="ns-toc ns-toc--collapsible" style="max-inline-size:26rem;inline-size:100%">
  <summary>On this page <i class="ph ph-caret-down" aria-hidden="true"></i></summary>
  <a class="ns-toc__link" href="#s-limits">The limit is the transaction</a>
  <a class="ns-toc__link ns-toc__link--sub" href="#s-count">Counting the wrong thing</a>
  <a class="ns-toc__link" href="#s-fix">The fix is a pattern</a>
</details>

Callouts

The aside inside prose. A 3px leading edge and a tint — never a heavy full box, which stops the eye at the top of the paragraph instead of carrying it through. The WORD in the label is the signal and the colour only agrees with it: a warning that is only orange is not a warning to a monochromat.

NoteContext the reader needs but did not ask for.
TipSomething that makes the task easier, which they can safely skip.
Watch outA mistake that is easy to make and annoying to undo.
Don'tA mistake that loses data. Reserve this one — four danger callouts in a post means none of them is read.
markup
<div style="display:grid;gap:var(--space-3);max-inline-size:34rem;inline-size:100%">
  <div class="ns-callout"><i class="ph ph-info" aria-hidden="true"></i><div><span class="ns-callout__label">Note</span>Context the reader needs but did not ask for.</div></div>
  <div class="ns-callout ns-callout--tip"><i class="ph ph-lightbulb" aria-hidden="true"></i><div><span class="ns-callout__label">Tip</span>Something that makes the task easier, which they can safely skip.</div></div>
  <div class="ns-callout ns-callout--warn"><i class="ph ph-warning" aria-hidden="true"></i><div><span class="ns-callout__label">Watch out</span>A mistake that is easy to make and annoying to undo.</div></div>
  <div class="ns-callout ns-callout--danger"><i class="ph ph-warning-circle" aria-hidden="true"></i><div><span class="ns-callout__label">Don't</span>A mistake that loses data. Reserve this one — four danger callouts in a post means none of them is read.</div></div>
</div>

Accessibility contract

  • The TOC is a <nav> with an accessible name, containing ordinary anchor links — it works, and is announced, with JavaScript off
  • The current section is marked with aria-current, the same attribute the navbar and the lesson row use
  • Headings without ids get one derived from their text, because an anchor link to nothing is worse than no anchor link
  • The share rail's buttons are icon-only and every one carries an aria-label
  • Reading progress (.ns-lprogress--article) is a role="progressbar" with a live aria-valuenow