Project: A Documentation Site

Sidebar navigation, an on this page menu, code blocks that scroll, a search box and deep links that survive a sticky header. The hardest layout to get right.

The brief

Build a documentation site: a persistent sidebar of sections, a page level table of contents, code samples, and deep links that work.

It is the layout with the most competing demands - three navigation regions, long content, and a reading order that must still make sense on a phone.

The layout

<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>
  <a href="#doc-nav" class="skip-link">Skip to documentation menu</a>

  <header class="docs-header">
    <a href="/" class="logo"><img src="/logo.svg" alt="Riverside Docs home" width="140" height="32"></a>

    <search>
      <form action="/search" method="get" role="search">
        <label for="docs-search" class="visually-hidden">Search the documentation</label>
        <input type="search" id="docs-search" name="q" placeholder="Search">
        <button type="submit">Search</button>
      </form>
    </search>

    <button type="button" class="nav-toggle" aria-expanded="false" aria-controls="doc-nav">
      Menu
    </button>
  </header>

  <div class="docs-layout">
    <nav id="doc-nav" aria-label="Documentation" class="sidebar">
      <h2 class="visually-hidden">Documentation sections</h2>

      <h3>Getting started</h3>
      <ul>
        <li><a href="/docs/install">Installation</a></li>
        <li><a href="/docs/quick-start">Quick start</a></li>
      </ul>

      <h3>Guides</h3>
      <ul>
        <li><a href="/docs/forms" aria-current="page">Forms</a></li>
        <li><a href="/docs/tables">Tables</a></li>
      </ul>
    </nav>

    <main id="main-content" tabindex="-1">
      <nav aria-label="Breadcrumb">
        <ol>
          <li><a href="/docs">Docs</a></li>
          <li><a href="/docs/guides">Guides</a></li>
          <li><a href="/docs/forms" aria-current="page">Forms</a></li>
        </ol>
      </nav>

      <article>
        <h1>Forms</h1>
        <p class="lead">How to build a form that works for everyone.</p>

        <h2 id="labels">Labels</h2>
        <p>...</p>

        <pre><code class="language-html">&lt;label for="email"&gt;Email&lt;/label&gt;
&lt;input type="email" id="email" name="email"&gt;</code></pre>

        <h2 id="validation">Validation</h2>
        <p>...</p>

        <footer class="page-nav">
          <a href="/docs/quick-start" rel="prev">Previous: Quick start</a>
          <a href="/docs/tables" rel="next">Next: Tables</a>
        </footer>
      </article>
    </main>

    <nav aria-labelledby="toc-heading" class="toc">
      <h2 id="toc-heading">On this page</h2>
      <ol>
        <li><a href="#labels">Labels</a></li>
        <li><a href="#validation">Validation</a></li>
      </ol>
    </nav>
  </div>
</body>

The decisions

With three navigation regions, one skip link is not enough. Offering both skip to content and skip to the documentation menu covers the two things a returning reader actually wants.

Every nav is labelled

Four navigation landmarks on one page - main, documentation, breadcrumb and table of contents - and without labels a screen reader announces four identical navigation regions.

Main comes before the table of contents in the source

On a phone the layout collapses to a single column in source order. Putting the table of contents after the content means a reader is not made to scroll past a list of links to reach the article. CSS grid puts it back on the right for wide screens.

.docs-layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem;
  max-width: 90rem;
  margin-inline: auto;
  padding-inline: 1rem;
}

@media (min-width: 64rem) {
  .docs-layout {
    grid-template-columns: 16rem minmax(0, 1fr) 14rem;
    grid-template-areas: "sidebar main toc";
  }
  .sidebar { grid-area: sidebar; }
  main     { grid-area: main; }
  .toc     { grid-area: toc; }
}

@media (min-width: 64rem) {
  .sidebar, .toc {
    position: sticky;
    top: 5rem;
    align-self: start;
    max-height: calc(100vh - 6rem);
    overflow-y: auto;
  }
}

Note minmax(0, 1fr) on the main column. Without it, a long unbroken line in a code block forces the whole grid wider than the viewport.

:is(h1, h2, h3, h4)[id] {
  scroll-margin-top: 5rem;
}

Without this, following a table of contents link lands the heading underneath the fixed header and the reader sees the wrong section.

Code blocks scroll inside themselves

pre {
  overflow-x: auto;
  padding: 1rem;
  background: #f8fafc;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  tab-size: 2;
}

pre code { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; }

A wide code block with no scroll container makes the entire page scroll sideways on a phone, which is one of the most common documentation site failures.

A copy button on code blocks

document.querySelectorAll("pre > code").forEach((code) => {
  const pre = code.parentElement;
  const button = document.createElement("button");

  button.type = "button";
  button.className = "copy";
  button.textContent = "Copy";
  button.setAttribute("aria-label", "Copy code to clipboard");

  button.addEventListener("click", async () => {
    await navigator.clipboard.writeText(code.textContent);
    button.textContent = "Copied";
    setTimeout(() => { button.textContent = "Copy"; }, 1800);
  });

  pre.append(button);
});

The button announces its state change through its own text, which a screen reader reports when it is activated.

Highlighting the current section

const headings = document.querySelectorAll("main h2[id]");
const links    = document.querySelectorAll(".toc a");

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return;
    const id = entry.target.id;
    links.forEach((link) => {
      link.setAttribute("aria-current",
        link.getAttribute("href") === `#${id}` ? "true" : "false");
    });
  });
}, { rootMargin: "-20% 0px -70% 0px" });

headings.forEach((h) => observer.observe(h));

Escaping code samples

Documentation shows markup, so every sample must be entity escaped:

<!-- this renders as a real input -->
<pre><code><input type="email"></code></pre>

<!-- this shows the markup -->
<pre><code>&lt;input type="email"&gt;</code></pre>

Escape the ampersand first when doing it by hand, or everything after it double escapes.

A documentation search does not need a server. A generated JSON index and a client side search is enough for a few hundred pages:

<search>
  <form action="/search" method="get" role="search">
    <label for="q" class="visually-hidden">Search the documentation</label>
    <input type="search" id="q" name="q"
           autocomplete="off"
           aria-describedby="search-hint">
    <small id="search-hint">Press / to focus</small>
    <button type="submit">Search</button>
  </form>
</search>

<div id="search-results" aria-live="polite"></div>

Keeping the real form with a real action means search still works if the script fails.

Checking your work

  1. Validate every page.
  2. Tab through: two skip links, then header, then sidebar, then content.
  3. Follow every table of contents link and confirm the heading is not under the header.
  4. Check a wide code block at 320 pixels; the block should scroll, not the page.
  5. Confirm all four navigation landmarks are labelled distinctly.
  6. Test the copy buttons with a keyboard.

Extensions

  • Add a version switcher with correct canonical tags per version.
  • Add syntax highlighting, keeping the text selectable.
  • Add a dark mode with a stored preference applied before first paint.
  • Add TechArticle structured data.
  • Add a keyboard shortcut to focus search, ignoring key presses inside fields.

Practice

  1. Build the three column layout and confirm the source order is content before table of contents.
  2. Add scroll-margin-top and test every fragment link under a sticky header.
  3. Add copy buttons and use them with the keyboard alone.
  4. Test the whole layout at 320 pixels and list what needed changing.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.