All Components

Free Etch Component

Table of Contents

A floating “On this page” menu for Etch. It builds itself from your page headings, highlights the section you are reading, and turns into a Contents button on mobile. The CSS and JS ship with the component, so there is nothing else to load.

Freecopy, paste, done

Table of Contents: live preview

Documentation

Overview

Adds a floating “On this page” menu to long pages. It reads the headings on your page and builds the list for you, so there’s nothing to type and nothing to keep in sync when you edit your content.

On desktop it sits on the right edge of the screen as a stack of small lines, one per heading. Hover over it and the full list slides open. On phones it turns into a small Contents button at the bottom of the screen that opens a sheet. The CSS and JS ship with the component, so there is nothing else to load.

1. Import Component

  1. Click Copy JSON above and paste it into any Etch page (Cmd/Ctrl + V on the canvas).
  2. Place it right before your article content, after the title and intro. It floats on screen no matter where you put it, but keyboard and screen reader users reach it in structure order. Putting it first lets them use it to jump ahead instead of finding it after the whole article.
  3. Make sure your content has headings (H2 and H3 by default) inside the <main> element. If your content lives somewhere else, change the Content Selector property.
  4. Adjust the component properties in the sidebar.

Tip: Drop it into your blog post or docs template once and every post gets its own table of contents automatically.

2. How It Works

Where to Place It

The menu looks the same wherever it sits in the structure panel, but placement still matters for accessibility. Keyboard users tab through the page in structure order, and screen readers read it in that order too. Put the component just before your content so they reach it first. It only adds one tab stop, since the links stay hidden until the menu is opened. Avoid placing it inside your site header, and keep it out of wrappers that use transform, filter or backdrop-filter, since those stop it from floating.

Builds Itself From Your Headings

Every heading it finds becomes a link. If a heading doesn’t have an ID yet, one is created from the heading text (for example, “Gear you actually need” becomes #gear-you-actually-need). Headings you already gave an ID keep it.

Shows Where You Are

As the reader scrolls, the current section is highlighted and a small marker slides down the rail to follow along. Inside the open panel or mobile sheet, a soft highlight sits behind the current section’s link and glides to the next one as you read. Hover over (or tab to) another link and the highlight slides over to it, then returns to where you are when you move away. Short sections near the bottom of the page still get their turn, even if they can’t scroll all the way to the top.

Desktop Rail, Mobile Pill

Above the breakpoint (768px by default) you get the slim rail on the right. Below it, you get a Contents pill at the bottom of the screen with a ring that fills up as you scroll. Tap it and the list opens as a sheet. You can also lock it to one style with the Mode property.

Only Shows Up When It’s Useful

If a page has fewer headings than the Minimum Headings setting (2 by default), the menu hides itself. It also fades away when your content scrolls out of view, like when the reader reaches the footer.

Stays in Sync

If your headings change after the page loads (filters, tabs, content loaded in later, or editing in the builder), the list rebuilds itself. Hidden headings and empty headings are skipped.

Smooth Jumps With Room for Your Header

Clicking a link smoothly scrolls to that heading. If you have a sticky header, set Scroll Offset so the heading doesn’t end up hidden underneath it.

Handles Messy Outlines

If you skip a level (an H2 followed by an H4), the H4 still indents just one step, so the list always looks tidy.

Accessibility

The menu is a proper navigation landmark named after its title, so screen reader users can jump straight to it. The current section is announced, Esc closes the menu, and after clicking a link, keyboard focus moves to that heading so the next Tab continues from there. On desktop, a short hover delay keeps the panel from flashing open when your cursor just passes by, and clicking the rail pins it open.

Reduced Motion

Users with prefers-reduced-motion turned on get instant jumps and no sliding or staggered animations. The link highlight still follows along, it just moves instantly instead of gliding.

Print

The menu is hidden when the page is printed.

3. Component Properties

Each instance has a set of component properties that control its behavior.

PropertyDescriptionAccepted ValueRequired
TOC ClassAdds classes to the .c-toc root. Useful for setting CSS variables per instance.CSS classesNo
Content SelectorWhere to look for headings. If it matches more than one element, the headings are combined in page order. Default: main.CSS selector (e.g. main, .post-content)No
Heading LevelsWhich headings to include. Default: h2, h3.Text, like h2, h3, 2,3 or a range like h2-h4No
Exclude SelectorHeadings that match this, or sit inside something that matches it, are left out. Adding the data-toc-ignore attribute to a heading (or its wrapper) always works too. Default: empty.CSS selector (e.g. .sidebar, .card)No
Minimum HeadingsHides the menu when the page has fewer headings than this. Default: 2.NumberNo
TitleThe label at the top of the panel. It also names the menu for screen readers. Leave it empty to hide the visible label. Default: On This Page.TextNo
NumberedNumbers the links like an outline: 1, 1.1, 1.2, 2. Default: Off.ToggleNo
ModeHow the menu is shown. Responsive uses the rail on desktop and the pill on mobile. Default: Responsive.Select: Responsive, Rail only, Pill onlyNo
Breakpoint (px)Screens narrower than this get the mobile pill. Set it to 0 to keep the rail at every size. Only shown when Mode is Responsive. Default: 768.Number (px)No
Show MarkerShows the sliding marker on the rail and the progress ring on the mobile pill. Hidden when Mode is Pill only. Default: On.ToggleNo
Scroll OffsetHow much space to leave above a heading after jumping to it. Use this if a sticky header covers your headings. A plain number is treated as pixels. Leave it empty to use your site’s existing scroll-margin-top. Default: empty.Number or CSS length (e.g. 80, 5rem, var(--header-height))No
Update URL HashAdds the heading’s link (like #the-recipe) to the address bar when a link is clicked, so readers can copy and share it. It replaces the current history entry, so the back button isn’t cluttered. Default: Off.ToggleNo

For colors, sizes, spacing and animation timing, see the Customization page. For scripting, see the JavaScript API page.

Customization

This page covers everything you can change beyond the component properties, without touching the JS. Most sites only need to change a color or two.

Available CSS Variables

These CSS variables control how the menu looks. Set them on the .c-toc root (inline, in a class added with the TOC Class property, or globally on :root). The defaults use your ACSS colors and spacing when they’re available, so the menu matches your site out of the box.

Position and layering

VariableDefaultDescription
--toc-insetvar(--space-m, 1rem)Distance from the edge of the screen.
--toc-z-index50Stacking order. Raise it if a sticky header or popup sits on top of the menu.
--toc-offset(not set)Space left above a heading after jumping to it. Same as the Scroll Offset property, but you can change it per breakpoint with a media query. The property wins if both are set.

The panel

VariableDefaultDescription
--toc-panel-width256pxWidth of the desktop panel.
--toc-panel-bgvar(--white, #fff)Panel background. Also used for the mobile pill and sheet.
--toc-panel-bordervar(--neutral-light, #eeeef0)Panel border color.
--toc-panel-radiusvar(--radius-s, 12px)Panel corner roundness. The link highlight corners adjust to match.
--toc-panel-shadow0 4px 16px rgb(0 0 0 / 0.08)Panel shadow.
--toc-panel-paddingvar(--space-xs)Space inside the panel.
--toc-scrimrgb(0 0 0 / 0.2)The dark overlay behind the mobile sheet.
VariableDefaultDescription
--toc-font-sizevar(--text-s, 0.8125rem)Text size of the list.
--toc-link-colorvar(--neutral-ultra-dark, #5c5c68)Link color.
--toc-link-active-colorvar(--black, #0a0a0a)Link color on hover and for the current section. Also used for the title.
--toc-link-hover-bgvar(--neutral-ultra-light, #f4f4f7)Background behind a link on hover. The sliding highlight uses it too, so hover and the current section always match.
--toc-active-bgtransparentExtra background on the current section’s link itself, painted on top of the sliding highlight. Usually you’ll want --toc-indicator-bg instead.
--toc-indicator-bgvar(--toc-link-hover-bg)Color of the sliding highlight behind the current (or hovered) link.
--toc-indicator-radiusvar(--toc-link-radius)Corner roundness of the sliding highlight. Matches the link corners by default.
--toc-link-padding-block0.3125remSpace above and below each link. Taller on touch screens automatically.

Desktop rail and marker

VariableDefaultDescription
--toc-line-colorYour neutral color at 70%Color of the small lines on the rail.
--toc-line-active-colorvar(--black, #0a0a0a)Color of the line for the current section.
--toc-line-width16pxLength of a top-level line. Nested headings get shorter lines so they read as indented.
--toc-line-step5pxHow much shorter each nested level’s line gets.
--toc-line-thickness2pxThickness of the lines and the marker.
--toc-line-gap8pxSpace between lines.
--toc-marker-colorvar(--neutral-dark, #5c5c68)Color of the sliding marker. The mobile progress ring uses it too.
--toc-marker-widthLine width + 6pxLength of the sliding marker.

Mobile pill

The pill sizes itself from its font size, so changing --toc-pill-font-size scales the whole thing. The rest are optional fine-tuning.

VariableDefaultDescription
--toc-pill-font-size(not set)Text size of the pill. Everything else in the pill scales with it.
--toc-pill-padding-block / --toc-pill-padding-inline(not set)Space inside the pill.
--toc-pill-gap(not set)Space between the ring, label and arrow.
--toc-ring-size / --toc-pill-icon-size(not set)Size of the progress ring and the arrow icon.
--toc-ring-colorMarker colorColor of the filled part of the progress ring.
--toc-ring-track-colorPanel border colorColor of the empty part of the progress ring.
--toc-ring-stroke2.5Thickness of the ring. Unitless, up to about 4.

Animation timing

VariableDefaultDescription
--toc-duration160msSpeed of small effects like hover colors.
--toc-easingcubic-bezier(0.2, 0, 0, 1)Easing curve for those small effects and the sliding highlight.
--toc-indicator-duration240msHow long the link highlight takes to glide from one link to the next.
--toc-reveal-duration400msHow long the menu takes to fade in when your content scrolls into view.

Example:

<nav
  class="c-toc"
  style="
    --toc-panel-width: 300px;
    --toc-link-active-color: var(--primary);
    --toc-marker-color: var(--primary);
    --toc-indicator-bg: var(--primary-ultra-light);
  "
>
  ...
</nav>

Working with a sticky header

If your header stays stuck to the top of the screen, it can cover the heading you just jumped to. There are three ways to fix it. Pick one.

  • Scroll Offset property: the easiest. Type your header height, like 80 or var(--header-height).
  • --toc-offset variable: same result, but you can use a different value on mobile with a media query.
  • Your own CSS: if your site already sets scroll-margin-top on headings or scroll-padding-top on the page, leave the offset empty and the menu respects it.

Leaving headings out

Some headings shouldn’t be in the menu, like the ones in a sidebar, a newsletter box or a related posts section. You have two options:

  • Add the data-toc-ignore attribute to the heading, or to any element wrapped around it.
  • Put a selector in the Exclude Selector property, like .sidebar, .newsletter.

Styling the parts

Useful selectors if you want to go beyond the variables:

.c-toc { /* the whole menu */ }
.c-toc__rail { /* desktop rail button */ }
.c-toc__marker { /* sliding marker on the rail */ }
.c-toc__line { /* one rail line per heading */ }
.c-toc__pill { /* mobile Contents button */ }
.c-toc__panel { /* the panel / mobile sheet */ }
.c-toc__title { /* "On This Page" label */ }
.c-toc__close { /* close button in the mobile sheet */ }
.c-toc__list { /* the list of links */ }
.c-toc__list::before { /* sliding highlight behind the current link */ }
.c-toc__link { /* every link */ }
.c-toc__link--sub { /* links for nested headings */ }
.c-toc__link[aria-current] { /* the current section */ }

State attributes

The menu adds these attributes to the .c-toc root, so you can style it based on what it’s doing.

  • [data-state="open"] / [data-state="closed"]: whether the panel is open.
  • [data-mode="desktop"] / [data-mode="mobile"]: whether it’s showing the rail or the pill.
  • [data-in-view]: your content is on screen, so the menu is visible.
  • [hidden]: the page doesn’t have enough headings, so the menu is hidden.

Without JavaScript

The menu is built by the script, so without JavaScript it simply doesn’t show. Your headings and content are untouched.

Common recipes

.c-toc {
  --toc-link-active-color: var(--primary);
  --toc-line-active-color: var(--primary);
  --toc-marker-color: var(--primary);
}

Dark panel

.c-toc {
  --toc-panel-bg: #111;
  --toc-panel-border: #222;
  --toc-link-color: #aaa;
  --toc-link-active-color: #fff;
  --toc-link-hover-bg: #1c1c1c;
}

Different header offset on mobile

Leave the Scroll Offset property empty and use the variable instead:

.c-toc { --toc-offset: 100px; }

@media (max-width: 767px) {
  .c-toc { --toc-offset: 70px; }
}

Only include one part of the page

Set Content Selector to the wrapper around your article, like .post-content. The menu only lists headings inside it and only shows while that part of the page is on screen.

JavaScript API

Most sites don’t need any of this. The menu sets itself up on every .c-toc on the page as soon as the page loads. Reach for the JavaScript API when you want to open or close the menu from your own button, jump to a section from code, rebuild the list after a big content change, or remove the menu cleanly.

Getting an instance

Once it’s set up, each menu has its instance attached as _toc:

const toc = document.querySelector(".c-toc")._toc;
toc.open();

If you’d rather grab them all at once, TableOfContents.init() returns every instance:

const [toc] = TableOfContents.init(".c-toc");
if (toc) toc.scrollTo("the-recipe");

Or create one directly on an element you already have:

const nav = document.querySelector(".my-toc");
const toc = new TableOfContents(nav);

Instance methods

MethodWhat it doesNotes
open()Opens the panel (or the sheet on mobile) and keeps it open.Stays open until close(), Esc, a click outside, or a link click.
close()Closes the panel.Safe to call when it’s already closed.
toggle()Opens it if it’s closed, closes it if it’s open.
scrollTo(id)Smoothly scrolls to a heading and marks it as the current section.Pass the heading’s ID without the #, like "the-recipe". Also updates the URL if Update URL Hash is on, and moves keyboard focus to the heading. Jumps instantly for reduced-motion users.
refresh()Re-reads all the settings and rebuilds the list.You rarely need this. The list already rebuilds itself when headings change or when the settings attributes change.
destroy()Removes all listeners, timers and observers and clears out the list.Call before removing the menu from the page for a clean teardown.

Instance properties

PropertyTypeDescription
rootElementThe .c-toc element.
isOpenbooleantrue while the panel is open.
entriesArrayOne item per heading in the list. Each has id, text, level (2 for an H2), depth (how far it’s indented), heading (the heading element) and link (its link in the menu).
activeObject | nullThe entry for the section the reader is currently in, or null before the first heading.

Settings as attributes

Each component property is written to the .c-toc element as a data- attribute. Change one with JavaScript and the menu updates on its own, no need to call refresh().

AttributeProperty
data-contentContent Selector
data-headingsHeading Levels
data-excludeExclude Selector
data-minMinimum Headings
data-titleTitle
data-numberedNumbered
data-layoutMode (responsive, rail or pill)
data-breakpointBreakpoint (px)
data-markerShow Marker
data-offsetScroll Offset
data-update-hashUpdate URL Hash

Static methods

MethodDescription
TableOfContents.init(selector)Sets up every matching menu on the page and returns an array of instances. Menus that are already set up return their existing instance instead of being set up twice, so it’s safe to call again. Defaults to .c-toc. This is what runs automatically on page load.

Examples

Open the menu from your own button

const toc = document.querySelector(".c-toc")._toc;

document.querySelector("#show-contents").addEventListener("click", () => {
  toc.open();
});

Jump to a section from code

document.querySelector(".c-toc")._toc.scrollTo("common-mistakes");

Know when the menu opens or closes
There are no custom events. Watch the data-state attribute instead:

const nav = document.querySelector(".c-toc");

new MutationObserver(() => {
  console.log("menu is", nav.dataset.state); // "open" or "closed"
}).observe(nav, { attributes: true, attributeFilter: ["data-state"] });

Show which section the reader is in

const toc = document.querySelector(".c-toc")._toc;

window.addEventListener("scroll", () => {
  document.querySelector("#current-section").textContent =
    toc.active ? toc.active.text : "";
}, { passive: true });

Switch to the pill layout from code
Works when Mode is set to Responsive, since that outputs both the rail and the pill.

document.querySelector(".c-toc").dataset.layout = "pill";

Set up a menu added after page load

// after dropping new .c-toc markup into the page
TableOfContents.init(".new-section .c-toc");

Clean teardown before removing it

const nav = document.querySelector(".c-toc");
nav._toc.destroy();
nav.remove();

Saved you an afternoon? Pass it forward.

Free, and staying that way

I build these on nights and weekends and give them away. If one earned a spot in your build, a coffee or a paid component keeps the next free one coming.

Buy me a coffee

A quick tip, any amount. It goes straight into the next free component.

Grab a paid component

One-time price, lifetime updates, unlimited sites.

Browse all components

Thanks for building with me, Nick