How to Style a “Table of Contents”

A plain list of links does not deserve your long article. You want a Table of Contents that guides the eye, reveals depth, and responds to scroll and focus. By the end of this tutorial you will style a clean, numbered Table of Contents (TOC) with circles for top-level items, subtle connectors for nested items, sticky behavior, and tasteful hover effects. You will also learn how to add triangle markers and prepare states for the currently active section.

Why Styling a Table of Contents Matters

A strong TOC sets the reading rhythm. It reduces cognitive load by showing structure and priority. A skim-friendly TOC also shortens the distance between a reader and the information they need. Good styling does more than decorate; it communicates hierarchy, click targets, and state. CSS gives you enough tools to shape the TOC without shipping extra JavaScript. Sticky positioning, counters, and pseudo-elements cover most needs, while custom properties keep the theming manageable.

Prerequisites

You will work with semantic HTML, counters, pseudo-elements, and a few modern layout primitives. The code targets evergreen browsers.

  • Basic HTML
  • CSS custom properties
  • CSS pseudo-elements (::before / ::after)

Step 1: The HTML Structure

Start with a two-column layout. The TOC sits in an aside with a nav landmark for screen readers. The list uses nested ordered lists to reflect heading depth. Each heading in the article has an id so the links can jump to it. This is the complete HTML you will style.

<!-- HTML -->
<div class="layout">
  <aside class="toc" aria-label="Table of contents">
    <header class="toc__header">
      <h2 class="toc__title">Contents</h2>
    </header>

    <nav class="toc__nav" role="navigation">
      <ol class="toc__list">
        <li>
          <a href="#intro">Introduction</a>
        </li>
        <li>
          <a href="#setup">Setup</a>
          <ol>
            <li><a href="#setup-ids">Anchor IDs</a></li>
            <li><a href="#setup-structure">Heading Structure</a></li>
          </ol>
        </li>
        <li>
          <a href="#styling">Styling</a>
          <ol>
            <li><a href="#numbers">Numbering</a></li>
            <li><a href="#markers">Markers</a></li>
          </ol>
        </li>
        <li>
          <a href="#accessibility">Accessibility</a>
        </li>
        <li>
          <a href="#faq">FAQ</a>
        </li>
      </ol>
    </nav>
  </aside>

  <main class="article">
    <h2 id="intro">Introduction</h2>
    <p>Sample copy that explains what the article covers, enough to scroll and test the TOC behavior.</p>

    <h2 id="setup">Setup</h2>
    <p>Guidance on preparing headings and anchors.</p>

    <h3 id="setup-ids">Anchor IDs</h3>
    <p>Create stable IDs that match your link href attributes.</p>

    <h3 id="setup-structure">Heading Structure</h3>
    <p>Use h2 for primary sections, h3 for subsections, and so on.</p>

    <h2 id="styling">Styling</h2>
    <p>High-level choices for list layout, spacing, and color.</p>

    <h3 id="numbers">Numbering</h3>
    <p>Use CSS counters to number sections consistently.</p>

    <h3 id="markers">Markers</h3>
    <p>Add shapes and connectors that reinforce hierarchy and state.</p>

    <h2 id="accessibility">Accessibility</h2>
    <p>Landmarks, focus states, and reduced motion support.</p>

    <h2 id="faq">FAQ</h2>
    <p>Address common questions about styling and structure.</p>
  </main>
</div>

Step 2: The Basic CSS & Styling

Set up color variables, type scale, layout, and a sticky TOC column. The TOC will have a subtle panel look with rounded corners and a soft border. The article column gets comfortable line-length and spacing. This foundation keeps the next steps focused on the TOC details.

/* CSS */
:root{
  --bg: #0b0d12;
  --panel: #0f131a;
  --panel-border: #1c2330;
  --fg: #e6ecf1;
  --muted: #a9b5c1;
  --accent: #6dd6ff;
  --accent-ink: #0b2733;
  --link: #cde9ff;
  --hot: #ffc857;
}

*{box-sizing:border-box}

html,body{height:100%}

body{
  margin:0;
  background:var(--bg);
  color:var(--fg);
  font:16px/1.6 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}

.layout{
  display:grid;
  grid-template-columns: 300px 1fr;
  gap: 2rem;
  max-width: 1100px;
  margin: 0 auto;
  padding: 2rem;
}

.article{
  min-width:0;
}

.article h2,
.article h3{
  scroll-margin-top: 96px; /* room for sticky bars if your site uses one */
}

.toc{
  position: sticky;
  top: 2rem;
  align-self: start;
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 14px;
  padding: 1rem 1rem 1.25rem;
  max-height: calc(100vh - 4rem);
  overflow: auto;
}

.toc__title{
  margin:0 0 .75rem 0;
  font-size: 0.95rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

.toc a{
  color: var(--link);
  text-decoration: none;
}

.toc a:hover,
.toc a:focus{
  color: #fff;
  outline: none;
}

@media (max-width: 900px){
  .layout{
    grid-template-columns: 1fr;
  }
  .toc{
    position: relative;
    top: auto;
    max-height: none;
  }
}

Advanced Tip: Keep colors and radii in custom properties. When you theme a docs site, you update the TOC in one place and keep contrast tight across all states.

Step 3: Building the Numbered TOC

Now you will remove default markers, add counters, and draw clear hierarchy. Top-level items will get numbered badges shaped as circles. Nested items will get a vertical connector and a small dot marker. The link remains the clickable element to preserve expected keyboard behavior.

/* CSS */
.toc__list{
  list-style: none;
  margin: 0;
  padding: 0;
  counter-reset: section;
}

/* Top-level items: numbered circles */
.toc__list > li{
  counter-increment: section;
  position: relative;
  padding-left: 2.5rem;
  margin: .35rem 0;
}

.toc__list > li::before{
  content: counter(section);
  position: absolute;
  left: 0;
  top: 0.05rem;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: 9999px; /* circle badge */
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 700;
  font-size: 0.9rem;
  display: grid;
  place-items: center;
  box-shadow: 0 0 0 2px rgba(0,0,0,0.2) inset;
}

/* Top-level link styling */
.toc__list > li > a{
  display: block;
  padding: .25rem .5rem;
  border-radius: 8px;
  transition: background-color .2s, color .2s;
}

.toc__list > li > a:hover,
.toc__list > li > a:focus-visible{
  background: rgba(255,255,255,0.06);
}

/* Nested lists */
.toc__list ol{
  list-style: none;
  margin: .4rem 0 .6rem 1.25rem;
  padding-left: 1rem;
  border-left: 1px solid rgba(255,255,255,0.08);
}

/* Nested items: small dot markers */
.toc__list ol li{
  position: relative;
  padding-left: 1rem;
  margin: .25rem 0;
}

.toc__list ol li::before{
  content: "";
  position: absolute;
  left: -0.6rem;
  top: 0.75rem;
  width: .35rem;
  height: .35rem;
  border-radius: 50%;
  background: var(--muted);
}

/* Nested links */
.toc__list ol a{
  display: block;
  padding: .2rem .25rem;
  border-radius: 6px;
  color: var(--muted);
  transition: color .2s, background-color .2s;
}
.toc__list ol a:hover,
.toc__list ol a:focus-visible{
  color: #fff;
  background: rgba(255,255,255,0.05);
}

/* Active link convention (set server-side or via JS): */
.toc a[aria-current="true"]{
  color: #fff;
  background: rgba(109,214,255,0.12);
  box-shadow: 0 0 0 1px rgba(109,214,255,0.3) inset;
  border-radius: 8px;
}

How This Works (Code Breakdown)

The ordered list resets a counter named section at the root. Each top-level li increases that counter and exposes the value in the ::before pseudo-element. That pseudo-element becomes the circular badge. The large border-radius creates the circle, a pattern you will find in many places. If you want a deeper refresher on shape mechanics, see how to make a circle with CSS, since the badge uses the same principle.

The link is a block-level target with padding so it is easy to click. Transitions cover color and background to keep the hover feel responsive without heavy motion. Nested lists gain a border-left to draw a spine. Each nested item uses a small dot drawn with a ::before pseudo-element. This dot keeps the layout readable even for dense subtopics.

For an active state, the CSS watches for aria-current=”true” on the link. You can set that server-side for static sites or apply it on scroll with a few lines of JavaScript. Styling the active link with a subtle backdrop and outline helps the eye track position when the page is long.

Step 4: Triangle Markers and Visual Cues

Circles handle numbering; now add directional markers. A tiny triangle helps guide the eye toward the content column and hints that the link moves focus to the right. You will draw the triangle using borders on a pseudo-element. You can also swap the orientation for different states.

/* CSS */
.toc a{
  position: relative;
}

/* Hidden by default, shown on hover/focus/active */
.toc a::after{
  content: "";
  position: absolute;
  right: .4rem;
  top: 50%;
  transform: translateY(-50%) translateX(4px);
  border-left: 6px solid var(--hot);            /* the triangle body */
  border-top: 4px solid transparent;            /* triangle edges */
  border-bottom: 4px solid transparent;
  opacity: 0;
  transition: transform .2s, opacity .2s;
}

/* Reveal on interaction or active state */
.toc a:hover::after,
.toc a:focus-visible::after,
.toc a[aria-current="true"]::after{
  opacity: 1;
  transform: translateY(-50%) translateX(0);
}

/* If you prefer a down-pointing triangle for nested lists: */
.toc__list ol a::after{
  right: .25rem;
  border-left: 0;
  border-top: 6px solid var(--muted); /* down arrow head */
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  transform: translateY(-10%) scale(0.95);
  opacity: 0;
}
.toc__list ol a:hover::after,
.toc__list ol a:focus-visible::after{
  opacity: 1;
  transform: translateY(-10%) scale(1);
}

How This Works (Code Breakdown)

The right-pointing marker is a classic CSS triangle built entirely with borders. The only visible border is on the left side; the top and bottom borders are transparent to form the tip. If you want a deeper walkthrough, check this primer on creating a right-pointing triangle with CSS. For nested items you can rotate the concept and use a down-pointing marker; the same trick applies, and this pattern mirrors the approach in a triangle-down shape tutorial.

The ::after element starts hidden for every link. On hover, focus-visible, or when aria-current=”true” is present, it fades and slides into place. This reads as an aim indicator without stealing attention from the text. The placement differs for nested links so the triangle does not collide with the left-side connectors.

Advanced Techniques: Animations & Hover Effects

Small motion polish can improve clarity. Add a soft underline that grows from left to right and a gentle pulse to the number badge when a user focuses a top-level item. Respect reduced motion preferences so your TOC behaves well for everyone.

/* CSS */
/* Link underline grow */
.toc a{
  background-image: linear-gradient(currentColor, currentColor);
  background-repeat: no-repeat;
  background-size: 0 2px;
  background-position: 0 100%;
  transition: background-size .25s ease;
}

.toc a:hover,
.toc a:focus-visible{
  background-size: 100% 2px;
}

/* Badge pulse on focus */
@keyframes toc-badge-pulse{
  0%{ box-shadow: 0 0 0 0 rgba(109,214,255,0.0); transform: scale(1); }
  60%{ box-shadow: 0 0 0 6px rgba(109,214,255,0.15); transform: scale(1.02); }
  100%{ box-shadow: 0 0 0 0 rgba(109,214,255,0.0); transform: scale(1); }
}

.toc__list > li:focus-within::before{
  animation: toc-badge-pulse 800ms ease;
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce){
  .toc a, .toc a::after{
    transition: none;
  }
  .toc__list > li:focus-within::before{
    animation: none;
  }
}

Accessibility & Performance

Great styling fails if it harms navigation or reading. A TOC must be friendly to keyboards, screen readers, and all levels of motion sensitivity, and it must render fast.

Accessibility

Use a nav landmark and an aria-label to help assistive tech announce the TOC. The links are the interactive units, not the markers, so do not add tabindex to pseudo-elements. Ensure a visible focus state on links by using the hover style for :focus-visible and a background highlight for the active section. If your site uses a fixed header, add scroll-margin-top to the target headings so links do not hide the title behind the header. If you rely on motion, gate it behind prefers-reduced-motion media queries so users can opt out. For active section styling, set aria-current=”true” as the user scrolls; it improves context for screen readers and matches the visual state.

Performance

This TOC is light on layout cost. Pseudo-elements render quickly and avoid extra DOM nodes. Counters are computed during layout with negligible overhead. Position: sticky is cheap relative to scripts that recalc positions during scroll. Transitions cover color and background-size, not expensive properties like width or box-shadow spreads during continuous animations. Keep shadows subtle and infrequent, as in the one-time badge pulse on focus. Avoid heavy paint effects inside a scroll container and prefer opacity, transform, and color changes for smoother interaction.

Ship a TOC That Pulls Its Weight

You built a numbered Table of Contents with clear hierarchy, circles for top-level items, connectors for nested items, and responsive states that respect keyboard and motion preferences. The triangle markers give you an extra nudge of direction without new assets or scripts. With these patterns, you can theme, extend, and ship a TOC that serves long-form content with confidence.

Leave a Comment