CSS-First UI Patterns · Page structure · 18

Table of Contents & Anchor Links

A table of contents that stays in view, smooth jumps to each section, a highlight where you land — and the one CSS line that stops headings from disappearing under a fixed header. No scrolling script required.

JavaScript
0 KB
Built with
position: sticky, scroll-margin-top, :target
Browsers
All current browsers (Chrome, Edge, Firefox, Safari)
Updated
How do I stop anchor links from hiding under a fixed header?

Add html { scroll-padding-top: 5rem; } — or scroll-margin-top on the targets — using the height of your header plus a little space. The browser then stops scrolling that far below the top, so the heading stays visible. Add scroll-behavior: smooth for animated jumps. No JavaScript needed.

Live demo

A short guide to building a website for China. Click an entry in the contents — the text scrolls smoothly to that section, leaves a little space above the heading and highlights it. The contents list stays in view while you scroll. Each section has a link back to the contents.

Live demo 0 KB JavaScript Click · Tab · Enter

How the table of contents works

  1. Anchor links to ids

    Each entry links to #ui-sec-fonts and so on; each section has the matching id. Browsers have jumped to such targets since the early days of the web.

  2. position: sticky keeps it in view

    The contents list scrolls normally until it reaches the top, then stays there while the text moves on. align-self: start stops the grid from stretching it.

  3. scroll-margin-top leaves space

    Without it, the heading lands flush at the top edge — or under a fixed header. scroll-margin-top on the target, or scroll-padding-top on the scrolling box, moves the stopping point down.

  4. :target highlights where you landed

    The section in the URL's #… part matches :target, so its heading gets a soft background. Useful when the section is short and you can't tell which one you jumped to.

  5. Smooth, but not for everyone

    scroll-behavior: smooth animates the jump — inside prefers-reduced-motion: no-preference, so visitors who prefer less motion jump instantly.

  6. Back to the contents

    A small link at the end of each section returns to the list — handy on phones, where the list sits above the text instead of beside it.

Copy the HTML and CSS

Exactly the code running in the demo. In the demo, the guide scrolls inside its own box; on a normal page, drop the box's height and overflow and let the page scroll. All class names start with ui-.

table-of-contents.html
<div class="ui-toc-demo" tabindex="0" aria-label="Demo guide: building a website for China">
  <div class="ui-toc-layout">
    <nav class="ui-toc" aria-labelledby="ui-toc-title">
      <p class="ui-toc__title" id="ui-toc-title">On this page</p>
      <ol>
        <li><a href="#ui-sec-fonts">Fonts</a></li>
        <li><a href="#ui-sec-hosting">Hosting</a></li>
        <li><a href="#ui-sec-urls">URLs</a></li>
        <li><a href="#ui-sec-forms">Forms</a></li>
      </ol>
    </nav>

    <div class="ui-toc-content">
      <section class="ui-toc-section" id="ui-sec-fonts" aria-labelledby="ui-h-fonts">
        <h3 id="ui-h-fonts">Fonts</h3>
        <p>Google Fonts is not reliably reachable from mainland China. Use the fonts Chinese devices already have: PingFang SC on Apple, Microsoft YaHei on Windows, Noto Sans SC on Android.</p>
        <p>Need a brand font? Cut it down to the characters you use. <a href="/tools/chinese-font-subset/">Font subset generator</a></p>
        <a class="ui-toc-back" href="#ui-toc-title">↑ Contents</a>
      </section>
      <section class="ui-toc-section" id="ui-sec-hosting" aria-labelledby="ui-h-hosting">
        <h3 id="ui-h-hosting">Hosting</h3>
        <p>A server far from China can be slow or unreliable for visitors inside it. Hosting in mainland China requires an ICP filing for the domain.</p>
        <p>Not sure what your company needs? <a href="/tools/cn-market-entry/">Market entry checker</a></p>
        <a class="ui-toc-back" href="#ui-toc-title">↑ Contents</a>
      </section>
      <section class="ui-toc-section" id="ui-sec-urls" aria-labelledby="ui-h-urls">
        <h3 id="ui-h-urls">URLs</h3>
        <p>Use Pinyin in URLs, not Chinese characters — those turn into long percent-encoded strings when a link is copied.</p>
        <p>Turn titles into slugs in seconds. <a href="/tools/pinyin-slug-generator/">Pinyin slug generator</a></p>
        <a class="ui-toc-back" href="#ui-toc-title">↑ Contents</a>
      </section>
      <section class="ui-toc-section" id="ui-sec-forms" aria-labelledby="ui-h-forms">
        <h3 id="ui-h-forms">Forms</h3>
        <p>Ask for a mobile number first — 11 digits, optional +86 — and accept that some visitors type full-width digits.</p>
        <p>Get a ready-made pattern. <a href="/tools/chinese-phone-number-validator/">Phone number validator</a></p>
        <a class="ui-toc-back" href="#ui-toc-title">↑ Contents</a>
      </section>
    </div>
  </div>
</div>
table-of-contents.css
/* Table of contents with anchor links — no JavaScript.
   A sticky contents list, smooth scrolling, a little space above
   each target, and a highlight on the section you jumped to. */
.ui-toc-demo {
  --ui-text: #1b1f3b;
  --ui-muted: #4b5070;
  --ui-accent: #4f46e5;
  --ui-mark: #fff4c2;
  container-type: inline-size;
  height: 26rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: 12px;
  background: #fff;
  color: var(--ui-muted);
  font: 400 0.9375rem/1.65 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.ui-toc-demo:focus-visible {
  outline: 2px solid var(--ui-accent);
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ui-toc-demo {
    scroll-behavior: smooth;
  }
}
.ui-toc-demo a:focus-visible {
  outline: 2px solid var(--ui-accent);
  outline-offset: 2px;
  border-radius: 3px;
}
.ui-toc-layout {
  display: grid;
  grid-template-columns: 11rem 1fr;
  gap: 2rem;
  padding: 1.5rem;
}

/* The contents list sticks while the text scrolls */
.ui-toc {
  position: sticky;
  top: 1.5rem;
  align-self: start;
}
.ui-toc__title {
  margin: 0 0 0.5rem;
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ui-text);
}
.ui-toc ol {
  margin: 0;
  padding: 0 0 0 1.1rem;
}
.ui-toc li {
  margin: 0 0 0.35rem;
}
.ui-toc a {
  color: var(--ui-accent);
  text-decoration: none;
}
.ui-toc a:hover {
  text-decoration: underline;
}

/* Sections: space above each target when you jump to it */
.ui-toc-section {
  margin: 0 0 2rem;
  padding: 0;
  scroll-margin-top: 1.5rem;
}
.ui-toc-section h3 {
  margin: 0 0 0.5rem;
  padding: 0.1rem 0.4rem;
  margin-left: -0.4rem;
  border-radius: 6px;
  font-size: 1.125rem;
  color: var(--ui-text);
  transition: background-color 0.3s;
}
.ui-toc-section p {
  margin: 0 0 0.75rem;
}
.ui-toc-section p a {
  color: var(--ui-accent);
  font-weight: 600;
}

/* The section you jumped to is highlighted */
.ui-toc-section:target h3 {
  background: var(--ui-mark);
}

.ui-toc-back {
  font-size: 0.8125rem;
  color: var(--ui-muted);
}

/* Narrow: contents on top, not sticky */
@container (max-width: 36rem) {
  .ui-toc-layout {
    grid-template-columns: 1fr;
    gap: 1.25rem;
  }
  .ui-toc {
    position: static;
  }
}

The fixed-header fix for any page

If your site has a fixed or sticky header, every anchor link — table of contents, “jump to” links, links from other pages to #section — lands with the heading hidden behind it. These lines fix all of them at once. This site does the same: try the “Jump to” links on the overview page — each section stops just below our fixed header.

fixed-header-offset.css
/* Anchor links under a fixed header: one line for the whole page.
   Use the height of your header plus a little air. */
html {
  scroll-padding-top: 5.5rem;
}

/* Or per element, if only some targets need it */
h2[id],
section[id] {
  scroll-margin-top: 5.5rem;
}

/* Smooth jumps — only for visitors who haven't asked for less motion */
@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }
}

Tables of contents and SEO

Clear sections, clear answers. Headings with ids and a contents list show search engines and AI assistants how a long page is organised. That makes it easier to match one section to one question — and Google sometimes shows links that jump straight to a section in its results.

Build the contents in the HTML. Many plugins create the table of contents with JavaScript after the page loads, from the headings they find. Crawlers that don't run the script get no contents and no anchor links. Generate it on the server, or write it by hand.

Readable anchors. #hosting is easier to share than #section-3. On Chinese pages, use short Pinyin or English ids — not Chinese characters, which turn into long percent-encoded strings in copied links.

Accessibility and browser support

SituationWithout JSDetails
KeyboardWorksNormal links; after a jump, the next Tab continues inside the section you landed in.
Screen readersWorksA labelled navigation (“On this page”) with an ordered list of links; sections are labelled by their headings.
Fixed header offsetAllscroll-margin-top and scroll-padding-top work in all current browsers.
Reduced motionRespectedSmooth scrolling only when no reduced motion is requested.
Highlight the section you're readingNeeds JSMarking the current entry while scrolling (“scrollspy”) needs a few lines with IntersectionObserver. The highlight after a jump works without.

Questions about tables of contents and anchor links

Why do my anchor links scroll the heading under the header?

The browser aligns the target with the top of the window, where your fixed header sits. Add scroll-padding-top to html, or scroll-margin-top to the targets, with your header's height.

scroll-margin-top or scroll-padding-top?

scroll-padding-top on the scrolling element (usually html) fixes every anchor at once. scroll-margin-top on individual targets lets you set different values.

How do I make a sticky table of contents?

position: sticky; top: 1.5rem on the contents list, and align-self: start if it sits in a grid or flex layout. Make sure no parent has overflow: hidden, which stops sticky from working.

Is smooth scrolling possible without JavaScript?

Yes: scroll-behavior: smooth. Wrap it in @media (prefers-reduced-motion: no-preference).

Is a table of contents good for SEO?

It helps search engines and readers understand long pages, and its anchor links can appear as jump links in results — as long as it is in the HTML, not built by a script.

This FAQ is built without JavaScript — see the accordion pattern.

Long-form content for China

Want your guides to rank on Baidu and be quoted by Chinese AI?

Well-structured content in native Chinese is what Baidu ranks and AI assistants cite. We plan, write and optimise it — and run Chinese GEO and Baidu SEO for Western companies.

CSS-First UI Patterns are written and maintained by Jademond Digital, a Chinese digital marketing agency founded in Guangzhou in 2012. We build and audit websites for Baidu, Google and AI search — which is why we care what works without JavaScript. About us →