Open demo

CSS code

HTML
<div class="doc">
  <nav class="toc" aria-labelledby="toc-h">
    <span class="toc-h" id="toc-h">On this page</span>
    <ul>
      <li><a href="#s1" aria-current="true">Getting started</a></li>
      <li><a href="#s2">Installing</a></li>
      <li><a href="#s3">Configuration</a></li>
      <li><a href="#s4">Deploying</a></li>
      <li><a href="#s5">Troubleshooting</a></li>
    </ul>
  </nav>
  <article>
    <section id="s1"><h2>Getting started</h2>
      <p>Scroll the page and watch the marker in the contents follow along. It moves when a section passes a line
        30% of the way down the viewport, which is roughly where a reader's eye sits.</p>
      <p>Headings get <code>scroll-margin-top</code> so a jump from the contents does not tuck them against the top edge.</p></section>
    <section id="s2"><h2>Installing</h2>
      <p>One command, then a restart. Nothing here needs a build step or a plugin: the contents are a list of anchor
        links plus a short script.</p>
      <p>If JavaScript never runs, the list is still a set of working anchor links. The highlight is an enhancement,
        not the feature.</p></section>
    <section id="s3"><h2>Configuration</h2>
      <p>Every option has a sensible default. The ones worth changing on day one are the region, the
        retention window and who is allowed to invite people.</p>
      <p>Configuration lives in one file so it can be reviewed like any other change.</p></section>
    <section id="s4"><h2>Deploying</h2>
      <p>Deploys are atomic: the new version is built, checked and swapped in. A failed check leaves
        the previous version serving, so a bad build is never visible.</p>
      <p>Roll back by redeploying the previous tag.</p></section>
    <section id="s5"><h2>Troubleshooting</h2>
      <p>Almost everything that goes wrong on a first install is a cache. Clear it, then check the
        logs before changing any configuration.</p>
      <p>If the problem survives both, the status page will say so.</p></section>
  </article>
</div>
CSS
/* Sticky "On this page" contents beside an article. The link for the section being read gets aria-current="true",
   which also draws the marker. Without JavaScript it is still a list of working anchor links. */
.doc{--toc-accent:#4f46e5;--toc-ink:#101828;--toc-muted:#667085;--toc-line:#e4e7ec;
  display:grid;grid-template-columns:230px 1fr;gap:44px;max-width:940px;margin:0 auto;padding:44px 30px 90px}
.toc{position:sticky;top:30px;align-self:start}
.toc-h{display:block;margin:0 0 13px;font-size:.71rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:#5d6679}
.toc ul{margin:0;padding:0;list-style:none}
.toc a{display:block;padding:7px 0 7px 14px;border-left:2px solid var(--toc-line);font-size:.87rem;color:var(--toc-muted);text-decoration:none;
  transition:color .18s,border-color .18s}
.toc a:hover{color:var(--toc-ink)}
.toc a[aria-current]{color:var(--toc-accent);border-color:var(--toc-accent);font-weight:700}
.toc a:focus-visible{outline:2px solid var(--toc-accent);outline-offset:2px}
.doc article h2{margin:0 0 12px;font-size:1.35rem;letter-spacing:-.02em;scroll-margin-top:30px}
.doc article section{margin-bottom:58px}
.doc article p{margin:0 0 12px;color:#475467;line-height:1.75;font-size:.93rem}
@media (max-width:760px){.doc{grid-template-columns:1fr}.toc{position:static}}
@media (prefers-reduced-motion:reduce){.toc a{transition:none}}
JavaScript
// Scroll spy: the current section is the last one whose top has passed 30% of the viewport height,
// or the last section once the page is scrolled to the bottom (short final sections never reach the line).
// A click on a contents link marks that link straight away.
document.querySelectorAll('.toc').forEach(toc => {
  const links = [...toc.querySelectorAll('a[href^="#"]')];
  const sections = links.map(a => document.getElementById(a.hash.slice(1))).filter(Boolean);
  if (!sections.length) return;
  let locked = false, unlock;
  const mark = id => links.forEach(a => a.hash === '#' + id ? a.setAttribute('aria-current', 'true') : a.removeAttribute('aria-current'));
  const pick = () => {
    if (locked) return;
    let current = sections[0];
    for (const s of sections) if (s.getBoundingClientRect().top <= innerHeight * 0.3) current = s;
    if (scrollY > 0 && innerHeight + scrollY >= document.documentElement.scrollHeight - 2) current = sections.at(-1);
    mark(current.id);
  };
  let queued = false;
  addEventListener('scroll', () => {
    if (locked) { clearTimeout(unlock); unlock = setTimeout(() => { locked = false; }, 150); return; }
    if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; pick(); }); }
  }, { passive: true });
  addEventListener('resize', pick);
  toc.addEventListener('click', e => {
    const a = e.target.closest('a[href^="#"]');
    if (!a) return;
    mark(a.hash.slice(1));
    locked = true; clearTimeout(unlock); unlock = setTimeout(() => { locked = false; }, 150);
  });
  pick();
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS, Bootstrap 5.3.8 JS bundle

HTML
<div class="doc" data-bs-spy="scroll" data-bs-root-margin="0px 0px -70%" data-bs-target="#toc">
  <nav class="toc" id="toc" aria-labelledby="toc-h">
    <span class="toc-h" id="toc-h">On this page</span>
    <ul class="nav flex-column">
      <li class="nav-item"><a class="nav-link active" href="#s1" aria-current="true">Getting started</a></li>
      <li class="nav-item"><a class="nav-link" href="#s2">Installing</a></li>
      <li class="nav-item"><a class="nav-link" href="#s3">Configuration</a></li>
      <li class="nav-item"><a class="nav-link" href="#s4">Deploying</a></li>
      <li class="nav-item"><a class="nav-link" href="#s5">Troubleshooting</a></li>
    </ul>
  </nav>
  <article>
    <section id="s1"><h2>Getting started</h2>
      <p>Scroll the page and watch the marker in the contents follow along. It moves when a section passes a line
        30% of the way down the viewport, which is roughly where a reader's eye sits.</p>
      <p>Headings get <code>scroll-margin-top</code> so a jump from the contents does not tuck them against the top edge.</p></section>
    <section id="s2"><h2>Installing</h2>
      <p>One command, then a restart. Nothing here needs a build step or a plugin: the contents are a list of anchor
        links plus a short script.</p>
      <p>If JavaScript never runs, the list is still a set of working anchor links. The highlight is an enhancement,
        not the feature.</p></section>
    <section id="s3"><h2>Configuration</h2>
      <p>Every option has a sensible default. The ones worth changing on day one are the region, the
        retention window and who is allowed to invite people.</p>
      <p>Configuration lives in one file so it can be reviewed like any other change.</p></section>
    <section id="s4"><h2>Deploying</h2>
      <p>Deploys are atomic: the new version is built, checked and swapped in. A failed check leaves
        the previous version serving, so a bad build is never visible.</p>
      <p>Roll back by redeploying the previous tag.</p></section>
    <section id="s5"><h2>Troubleshooting</h2>
      <p>Almost everything that goes wrong on a first install is a cache. Clear it, then check the
        logs before changing any configuration.</p>
      <p>If the problem survives both, the status page will say so.</p></section>
  </article>
</div>
CSS
/* Sticky contents driven by Bootstrap's Scrollspy plugin (data-bs-spy on the layout, data-bs-target on the .nav).
   Scrollspy sets .active on the link for the section being read; a short script mirrors it to aria-current="true". */
.doc{--toc-accent:#4f46e5;--toc-ink:#101828;--toc-muted:#667085;--toc-line:#e4e7ec;
  display:grid;grid-template-columns:230px 1fr;gap:44px;max-width:940px;margin:0 auto;padding:44px 30px 90px}
.toc{position:sticky;top:30px;align-self:start}
.toc-h{display:block;margin:0 0 13px;font-size:.71rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:#5d6679}
.toc .nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:7px;--bs-nav-link-font-size:.87rem;--bs-nav-link-color:var(--toc-muted);--bs-nav-link-hover-color:var(--toc-ink)}
.toc .nav-link{padding-left:14px;border-left:2px solid var(--toc-line);line-height:normal;transition:color .18s,border-color .18s}
.toc .nav-link.active,.toc .nav-link[aria-current]{color:var(--toc-accent);border-color:var(--toc-accent);font-weight:700}
.toc .nav-link:focus-visible{outline:2px solid var(--toc-accent);outline-offset:2px;box-shadow:none}
.doc article h2{margin:0 0 12px;font-size:1.35rem;font-weight:700;letter-spacing:-.02em;line-height:1.2;scroll-margin-top:30px}
.doc article section{margin-bottom:58px}
.doc article p{margin:0 0 12px;color:#475467;line-height:1.75;font-size:.93rem}
.doc article code{color:inherit}
@media (max-width:760px){.doc{grid-template-columns:1fr}.toc{position:static}}
@media (prefers-reduced-motion:reduce){.toc .nav-link{transition:none}}
JavaScript
// Scrollspy only toggles .active; mirror it to aria-current so screen readers hear which section is current.
document.querySelectorAll('[data-bs-spy="scroll"]').forEach(spy => spy.addEventListener('activate.bs.scrollspy', e => {
  const toc = document.querySelector(spy.dataset.bsTarget);
  toc.querySelectorAll('.nav-link').forEach(a => a === e.relatedTarget ? a.setAttribute('aria-current', 'true') : a.removeAttribute('aria-current'));
}));

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="mx-auto grid max-w-[940px] grid-cols-[230px_1fr] gap-11 px-[30px] pt-11 pb-[90px] max-[760px]:grid-cols-1">
  <nav data-toc class="sticky top-[30px] self-start max-[760px]:static" aria-labelledby="toc-h">
    <span class="mb-[13px] block text-[.71rem] font-bold tracking-[.09em] text-[#5d6679] uppercase" id="toc-h">On this page</span>
    <ul>
      <li><a class="block border-l-2 border-[#e4e7ec] py-[7px] pl-3.5 text-[.87rem] text-[#667085] transition-[color,background-color,border-color] duration-[180ms] hover:text-[#101828] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] aria-[current=true]:border-[#4f46e5] aria-[current=true]:font-bold aria-[current=true]:text-[#4f46e5] motion-reduce:transition-none" href="#s1" aria-current="true">Getting started</a></li>
      <li><a class="block border-l-2 border-[#e4e7ec] py-[7px] pl-3.5 text-[.87rem] text-[#667085] transition-[color,background-color,border-color] duration-[180ms] hover:text-[#101828] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] aria-[current=true]:border-[#4f46e5] aria-[current=true]:font-bold aria-[current=true]:text-[#4f46e5] motion-reduce:transition-none" href="#s2">Installing</a></li>
      <li><a class="block border-l-2 border-[#e4e7ec] py-[7px] pl-3.5 text-[.87rem] text-[#667085] transition-[color,background-color,border-color] duration-[180ms] hover:text-[#101828] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] aria-[current=true]:border-[#4f46e5] aria-[current=true]:font-bold aria-[current=true]:text-[#4f46e5] motion-reduce:transition-none" href="#s3">Configuration</a></li>
      <li><a class="block border-l-2 border-[#e4e7ec] py-[7px] pl-3.5 text-[.87rem] text-[#667085] transition-[color,background-color,border-color] duration-[180ms] hover:text-[#101828] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] aria-[current=true]:border-[#4f46e5] aria-[current=true]:font-bold aria-[current=true]:text-[#4f46e5] motion-reduce:transition-none" href="#s4">Deploying</a></li>
      <li><a class="block border-l-2 border-[#e4e7ec] py-[7px] pl-3.5 text-[.87rem] text-[#667085] transition-[color,background-color,border-color] duration-[180ms] hover:text-[#101828] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] aria-[current=true]:border-[#4f46e5] aria-[current=true]:font-bold aria-[current=true]:text-[#4f46e5] motion-reduce:transition-none" href="#s5">Troubleshooting</a></li>
    </ul>
  </nav>
  <article>
    <section class="mb-[58px]" id="s1"><h2 class="mb-3 scroll-mt-[30px] text-[1.35rem] font-bold tracking-[-.02em]">Getting started</h2>
      <p class="mb-3 text-[.93rem] leading-[1.75] text-[#475467]">Scroll the page and watch the marker in the contents follow along. It moves when a section passes a line
        30% of the way down the viewport, which is roughly where a reader's eye sits.</p>
      <p class="mb-3 text-[.93rem] leading-[1.75] text-[#475467]">Headings get <code>scroll-margin-top</code> so a jump from the contents does not tuck them against the top edge.</p></section>
    <section class="mb-[58px]" id="s2"><h2 class="mb-3 scroll-mt-[30px] text-[1.35rem] font-bold tracking-[-.02em]">Installing</h2>
      <p class="mb-3 text-[.93rem] leading-[1.75] text-[#475467]">One command, then a restart. Nothing here needs a build step or a plugin: the contents are a list of anchor
        links plus a short script.</p>
      <p class="mb-3 text-[.93rem] leading-[1.75] text-[#475467]">If JavaScript never runs, the list is still a set of working anchor links. The highlight is an enhancement,
        not the feature.</p></section>
    <section class="mb-[58px]" id="s3"><h2 class="mb-3 scroll-mt-[30px] text-[1.35rem] font-bold tracking-[-.02em]">Configuration</h2>
      <p class="mb-3 text-[.93rem] leading-[1.75] text-[#475467]">Every option has a sensible default. The ones worth changing on day one are the region, the
        retention window and who is allowed to invite people.</p>
      <p class="mb-3 text-[.93rem] leading-[1.75] text-[#475467]">Configuration lives in one file so it can be reviewed like any other change.</p></section>
    <section class="mb-[58px]" id="s4"><h2 class="mb-3 scroll-mt-[30px] text-[1.35rem] font-bold tracking-[-.02em]">Deploying</h2>
      <p class="mb-3 text-[.93rem] leading-[1.75] text-[#475467]">Deploys are atomic: the new version is built, checked and swapped in. A failed check leaves
        the previous version serving, so a bad build is never visible.</p>
      <p class="mb-3 text-[.93rem] leading-[1.75] text-[#475467]">Roll back by redeploying the previous tag.</p></section>
    <section class="mb-[58px]" id="s5"><h2 class="mb-3 scroll-mt-[30px] text-[1.35rem] font-bold tracking-[-.02em]">Troubleshooting</h2>
      <p class="mb-3 text-[.93rem] leading-[1.75] text-[#475467]">Almost everything that goes wrong on a first install is a cache. Clear it, then check the
        logs before changing any configuration.</p>
      <p class="mb-3 text-[.93rem] leading-[1.75] text-[#475467]">If the problem survives both, the status page will say so.</p></section>
  </article>
</div>
JavaScript
// Scroll spy: the current section is the last one whose top has passed 30% of the viewport height,
// or the last section once the page is scrolled to the bottom (short final sections never reach the line).
// A click on a contents link marks that link straight away.
document.querySelectorAll('[data-toc]').forEach(toc => {
  const links = [...toc.querySelectorAll('a[href^="#"]')];
  const sections = links.map(a => document.getElementById(a.hash.slice(1))).filter(Boolean);
  if (!sections.length) return;
  let locked = false, unlock;
  const mark = id => links.forEach(a => a.hash === '#' + id ? a.setAttribute('aria-current', 'true') : a.removeAttribute('aria-current'));
  const pick = () => {
    if (locked) return;
    let current = sections[0];
    for (const s of sections) if (s.getBoundingClientRect().top <= innerHeight * 0.3) current = s;
    if (scrollY > 0 && innerHeight + scrollY >= document.documentElement.scrollHeight - 2) current = sections.at(-1);
    mark(current.id);
  };
  let queued = false;
  addEventListener('scroll', () => {
    if (locked) { clearTimeout(unlock); unlock = setTimeout(() => { locked = false; }, 150); return; }
    if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; pick(); }); }
  }, { passive: true });
  addEventListener('resize', pick);
  toc.addEventListener('click', e => {
    const a = e.target.closest('a[href^="#"]');
    if (!a) return;
    mark(a.hash.slice(1));
    locked = true; clearTimeout(unlock); unlock = setTimeout(() => { locked = false; }, 150);
  });
  pick();
});

About this set

An “On this page” contents list that stays beside a long article and highlights the section being read. The layout is a two-column grid: a 230px sticky column for the contents and the article beside it. The current link turns indigo and bold with an indigo left border; the others sit on a light grey rail. Under 760px the contents move above the article and stop sticking. Use it for documentation, long guides, policies and changelogs. The links are ordinary anchors to the section ids, so the list works with JavaScript switched off, and headings have scroll-margin-top so a jump does not pin them to the top edge. A short script marks the current link with aria-current=”true”: as you scroll it picks the last section whose top has passed a line 30% down the viewport, and at the very bottom of the page it picks the last section, so a short final section still gets highlighted. Clicking a link marks it straight away. The Bootstrap version lets the Scrollspy plugin set .active and mirrors that to aria-current.

What’s included

  • Sticky contents column beside the article, stacked above it under 760px
  • Current section marked with aria-current="true" and an indigo rail
  • Plain anchor links that work without JavaScript
  • Last section highlighted when the page reaches the bottom
  • Bootstrap version uses the Scrollspy plugin

Accessibility

The contents are a list of links in a nav labelled by its visible "On this page" heading, and the link for the section in view carries aria-current="true". Links show an indigo :focus-visible outline, headings have scroll-margin-top for anchor jumps, and the colour transitions switch off under prefers-reduced-motion.

Customise it

Change --toc-accent, --toc-ink, --toc-muted and --toc-line on .doc, and the 230px column and 30px sticky offset. Move the trigger line by editing innerHeight * 0.3 in the script (data-bs-root-margin in Bootstrap). In Tailwind edit the aria-[current=true]: utilities.