CSS Tutorial – A Perfect Table of Contents With HTML + CSS


Earlier this year, I self-published an ebook called Understanding JavaScript Promises (free for download). Even though I didn’t have any intention of turning it into a print book, enough people reached out inquiring about a print version that I decided to self-publish that as well .I thought it would be an easy exercise using HTML and CSS to generate a PDF and then send it off to the printer. What I didn’t realize was that I didn’t have an answer to an important part of a print book: the table of contents.

The makeup of a table of contents

At its core, a table of contents is fairly simple. Each line represents a part of a book or webpage and indicates where you can find that content. Typically, the lines contain three parts:

  1. The title of the chapter or section
  2. Leaders (i.e. those dots, dashes, or lines) that visually connect the title to the page number
  3. The page number

A table of contents is easy to generate inside of word processing tools like Microsoft Word or Google Docs, but because my content was in Markdown and then transformed into HTML, that wasn’t a good option for me. I wanted something automated that would work with HTML to generate the table of contents in a format that was suitable for print. I also wanted each line to be a link so it could be used in webpages and PDFs to navigate around the document. I also wanted dot leaders between the title and page number.

And so I began researching.

I came across two excellent blog posts on creating a table of contents with HTML and CSS. The first was “Build a Table of Contents from your HTML” by Julie Blanc. Julie worked on PagedJS, a polyfill for missing paged media features in web browsers that properly formats documents for print. I started with Julie’s example, but found that it didn’t quite work for me. Next, I found Christoph Grabo’s “Responsive TOC leader lines with CSS” post, which introduced the concept of using CSS Grid (as opposed to Julie’s float-based approach) to make alignment easier. Once again, though, his approach wasn’t quite right for my purposes.

After reading these two posts, though, I felt I had a good enough understanding of the layout issues to embark on my own. I used pieces from both blog posts as well as adding some new HTML and CSS concepts into the approach to come up with a result I’m happy with.

Choosing the correct markup

When deciding on the correct markup for a table of contents, I thought primarily about the correct semantics. Fundamentally, a table of contents is about a title (chapter or subsection) being tied to a page number, almost like a key-value pair. That led me to two options:

  • One option is to use a table (<table>) with one column for the title and one column for the page.
  • Then there’s the often unused and forgotten definition list (<dl>) element. It also acts as a key-value map. So, once again, the relationship between the title and the page number would be obvious.

Either of these seemed like good options until I realized that they really only work for single-level tables of contents, namely, only if I wanted to have a table of contents with just chapter names. If I wanted to show subsections in the table of contents, though, I didn’t have any good options. Table elements aren’t great for hierarchical data, and while definition lists can technically be nested, the semantics didn’t seem correct. So, I went back to the drawing board.

I decided to build off of Julie’s approach and use a list; however, I opted for an ordered list (<ol>) instead of an unordered list (<ul>). I think an ordered list is more appropriate in this case. A table of contents represents a list of chapters and subheadings in the order in which they appear in the content. The order matters and shouldn’t get lost in the markup.

Unfortunately, using an ordered list means losing the semantic relationship between the title and the page number, so my next step was to re-establish that relationship within each list item. The easiest way to solve this is to simply insert the word “page” before the page number. That way, the relationship of the number relative to the text is clear, even without any other visual distinction.

HTML

<!-- role=list necessary because WebKit removes list semantic when list-style-type: none -->
<ol class="toc-list" role="list">

    <li>
        <a href="#Introduction">
            <span class="title">Introduction<span class="leaders" aria-hidden="true"></span></span> <span
                data-href="#Introduction" class="page"><span class="visually-hidden">Page&nbsp;</span>5</span>
        </a>
        <ol role="list">

            <li>
                <a href="#Introduction-About-This-Book">
                    <span class="title">About This Book<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Introduction-About-This-Book" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>5</span>
                </a>
            </li>

            <li>
                <a href="#Introduction-Acknowledgments">
                    <span class="title">Acknowledgments<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Introduction-Acknowledgments" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>8</span>
                </a>
            </li>

            <li>
                <a href="#Introduction-About-the-Author">
                    <span class="title">About the Author<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Introduction-About-the-Author" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>8</span>
                </a>
            </li>

            <li>
                <a href="#Introduction-Disclaimer">
                    <span class="title">Disclaimer<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Introduction-Disclaimer" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>8</span>
                </a>
            </li>

        </ol>
    </li>
    <li>
        <a href="#Promise-Basics">
            <span class="title">1. Promise Basics<span class="leaders" aria-hidden="true"></span></span>
            <span data-href="#Promise-Basics" class="page"><span class="visually-hidden">Page&nbsp;</span>9</span>
        </a>
        <ol role="list">

            <li>
                <a href="#Promise-Basics-The-Promise-Lifecycle">
                    <span class="title">The Promise Lifecycle<span class="leaders" aria-hidden="true"></span></span>
                    <span data-href="#Promise-Basics-The-Promise-Lifecycle" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>17</span>
                </a>
            </li>

            <li>
                <a href="#Promise-Basics-Creating-New-Unsettled-Promises">
                    <span class="title">Creating New (Unsettled) Promises<span class="leaders"
                            aria-hidden="true"></span></span> <span
                        data-href="#Promise-Basics-Creating-New-Unsettled-Promises" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>21</span>
                </a>
            </li>

            <li>
                <a href="#Promise-Basics-Creating-Settled-Promises">
                    <span class="title">Creating Settled Promises<span class="leaders" aria-hidden="true"></span></span>
                    <span data-href="#Promise-Basics-Creating-Settled-Promises" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>24</span>
                </a>
            </li>

            <li>
                <a href="#Promise-Basics-Summary">
                    <span class="title">Summary<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Promise-Basics-Summary" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>27</span>
                </a>
            </li>

        </ol>
    </li>
    <li>
        <a href="#Chaining-Promises">
            <span class="title">2. Chaining Promises<span class="leaders" aria-hidden="true"></span></span>
            <span data-href="#Chaining-Promises" class="page"><span class="visually-hidden">Page&nbsp;</span>28</span>
        </a>
        <ol role="list">

            <li>
                <a href="#Chaining-Promises-Catching-Errors">
                    <span class="title">Catching Errors<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Chaining-Promises-Catching-Errors" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>30</span>
                </a>
            </li>

            <li>
                <a href="#Chaining-Promises-Using-finally-in-Promise-Chains">
                    <span class="title">Using finally() in Promise Chains<span class="leaders"
                            aria-hidden="true"></span></span> <span
                        data-href="#Chaining-Promises-Using-finally-in-Promise-Chains" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>34</span>
                </a>
            </li>

            <li>
                <a href="#Chaining-Promises-Returning-Values-in-Promise-Chains">
                    <span class="title">Returning Values in Promise Chains<span class="leaders"
                            aria-hidden="true"></span></span> <span
                        data-href="#Chaining-Promises-Returning-Values-in-Promise-Chains" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>35</span>
                </a>
            </li>

            <li>
                <a href="#Chaining-Promises-Returning-Promises-in-Promise-Chains">
                    <span class="title">Returning Promises in Promise Chains<span class="leaders"
                            aria-hidden="true"></span></span> <span
                        data-href="#Chaining-Promises-Returning-Promises-in-Promise-Chains" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>42</span>
                </a>
            </li>

            <li>
                <a href="#Chaining-Promises-Summary">
                    <span class="title">Summary<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Chaining-Promises-Summary" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>43</span>
                </a>
            </li>

        </ol>
    </li>
    <li>
        <a href="#Working-with-Multiple-Promises">
            <span class="title">3. Working with Multiple Promises<span class="leaders" aria-hidden="true"></span></span>
            <span data-href="#Working-with-Multiple-Promises" class="page"><span
                    class="visually-hidden">Page&nbsp;</span>43</span>
        </a>
        <ol role="list">

            <li>
                <a href="#Working-with-Multiple-Promises-The-Promiseall-Method">
                    <span class="title">The Promise.all() Method<span class="leaders" aria-hidden="true"></span></span>
                    <span data-href="#Working-with-Multiple-Promises-The-Promiseall-Method" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>51</span>
                </a>
            </li>

            <li>
                <a href="#Working-with-Multiple-Promises-The-PromiseallSettled-Method">
                    <span class="title">The Promise.allSettled() Method<span class="leaders"
                            aria-hidden="true"></span></span> <span
                        data-href="#Working-with-Multiple-Promises-The-PromiseallSettled-Method" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>57</span>
                </a>
            </li>

            <li>
                <a href="#Working-with-Multiple-Promises-The-Promiseany-Method">
                    <span class="title">The Promise.any() Method<span class="leaders" aria-hidden="true"></span></span>
                    <span data-href="#Working-with-Multiple-Promises-The-Promiseany-Method" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>61</span>
                </a>
            </li>

            <li>
                <a href="#Working-with-Multiple-Promises-The-Promiserace-Method">
                    <span class="title">The Promise.race() Method<span class="leaders" aria-hidden="true"></span></span>
                    <span data-href="#Working-with-Multiple-Promises-The-Promiserace-Method" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>65</span>
                </a>
            </li>

            <li>
                <a href="#Working-with-Multiple-Promises-Summary">
                    <span class="title">Summary<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Working-with-Multiple-Promises-Summary" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>67</span>
                </a>
            </li>

        </ol>
    </li>
    <li>
        <a href="#Async-Functions-and-Await-Expressions">
            <span class="title">4. Async Functions and Await Expressions<span class="leaders"
                    aria-hidden="true"></span></span>
            <span data-href="#Async-Functions-and-Await-Expressions" class="page"><span
                    class="visually-hidden">Page&nbsp;</span>67</span>
        </a>
        <ol role="list">

            <li>
                <a href="#Async-Functions-and-Await-Expressions-Defining-Async-Functions">
                    <span class="title">Defining Async Functions<span class="leaders" aria-hidden="true"></span></span>
                    <span data-href="#Async-Functions-and-Await-Expressions-Defining-Async-Functions" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>69</span>
                </a>
            </li>

            <li>
                <a href="#Async-Functions-and-Await-Expressions-What-Makes-Async-Functions-Different">
                    <span class="title">What Makes Async Functions Different<span class="leaders"
                            aria-hidden="true"></span></span> <span
                        data-href="#Async-Functions-and-Await-Expressions-What-Makes-Async-Functions-Different"
                        class="page"><span class="visually-hidden">Page&nbsp;</span>81</span>
                </a>
            </li>

            <li>
                <a href="#Async-Functions-and-Await-Expressions-Summary">
                    <span class="title">Summary<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Async-Functions-and-Await-Expressions-Summary" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>83</span>
                </a>
            </li>

        </ol>
    </li>
    <li>
        <a href="#Unhandled-Rejection-Tracking">
            <span class="title">5. Unhandled Rejection Tracking<span class="leaders" aria-hidden="true"></span></span>
            <span data-href="#Unhandled-Rejection-Tracking" class="page"><span
                    class="visually-hidden">Page&nbsp;</span>83</span>
        </a>
        <ol role="list">

            <li>
                <a href="#Unhandled-Rejection-Tracking-Detecting-Unhandled-Rejections">
                    <span class="title">Detecting Unhandled Rejections<span class="leaders"
                            aria-hidden="true"></span></span> <span
                        data-href="#Unhandled-Rejection-Tracking-Detecting-Unhandled-Rejections" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>85</span>
                </a>
            </li>

            <li>
                <a href="#Unhandled-Rejection-Tracking-Web-Browser-Unhandled-Rejection-Tracking">
                    <span class="title">Web Browser Unhandled Rejection Tracking<span class="leaders"
                            aria-hidden="true"></span></span> <span
                        data-href="#Unhandled-Rejection-Tracking-Web-Browser-Unhandled-Rejection-Tracking"
                        class="page"><span class="visually-hidden">Page&nbsp;</span>90</span>
                </a>
            </li>

            <li>
                <a href="#Unhandled-Rejection-Tracking-Nodejs-Unhandled-Rejection-Tracking">
                    <span class="title">Node.js Unhandled Rejection Tracking<span class="leaders"
                            aria-hidden="true"></span></span> <span
                        data-href="#Unhandled-Rejection-Tracking-Nodejs-Unhandled-Rejection-Tracking" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>94</span>
                </a>
            </li>

            <li>
                <a href="#Unhandled-Rejection-Tracking-Summary">
                    <span class="title">Summary<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Unhandled-Rejection-Tracking-Summary" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>95</span>
                </a>
            </li>

        </ol>
    </li>
    <li>
        <a href="#Final-Thoughts">
            <span class="title">Final Thoughts<span class="leaders" aria-hidden="true"></span></span>
            <span data-href="#Final-Thoughts" class="page"><span class="visually-hidden">Page&nbsp;</span>96</span>
        </a>
        <ol role="list">

            <li>
                <a href="#Final-Thoughts-Download-the-Extras">
                    <span class="title">Download the Extras<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Final-Thoughts-Download-the-Extras" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>96</span>
                </a>
            </li>

            <li>
                <a href="#Final-Thoughts-Support-the-Author">
                    <span class="title">Support the Author<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Final-Thoughts-Support-the-Author" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>96</span>
                </a>
            </li>

            <li>
                <a href="#Final-Thoughts-Help-and-Support">
                    <span class="title">Help and Support<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Final-Thoughts-Help-and-Support" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>97</span>
                </a>
            </li>

            <li>
                <a href="#Final-Thoughts-Follow-the-Author">
                    <span class="title">Follow the Author<span class="leaders" aria-hidden="true"></span></span> <span
                        data-href="#Final-Thoughts-Follow-the-Author" class="page"><span
                            class="visually-hidden">Page&nbsp;</span>102</span>
                </a>
            </li>

        </ol>
    </li>
</ol>

CSS

@import url("https://fonts.googleapis.com/css2?family=Literata");

* {
    font-family: "Literata";
}


.visually-hidden {
    clip: rect(0 0 0 0);
    clip-path: inset(100%);
    height: 1px;
    overflow: hidden;
    position: absolute;
    width: 1px;
    white-space: nowrap;
}

.toc-list, .toc-list ol {
  list-style-type: none;
}

.toc-list {
  padding: 0;
}

.toc-list ol {
  padding-inline-start: 2ch;
}

.toc-list > li > a {
  font-weight: bold;
  margin-block-start: 1em;
}

.toc-list li > a {
    text-decoration: none;
    display: grid;
    grid-template-columns: auto max-content;
    align-items: end;
}

.toc-list li > a > .title {
    position: relative;
    overflow: hidden;
}

.toc-list li > a .leaders::after {
    position: absolute;
    padding-inline-start: .25ch;
    content: " . . . . . . . . . . . . . . . . . . . "
        ". . . . . . . . . . . . . . . . . . . . . . . "
        ". . . . . . . . . . . . . . . . . . . . . . . "
        ". . . . . . . . . . . . . . . . . . . . . . . "
        ". . . . . . . . . . . . . . . . . . . . . . . "
        ". . . . . . . . . . . . . . . . . . . . . . . "
        ". . . . . . . . . . . . . . . . . . . . . . . ";
    text-align: right;
}

.toc-list li > a > .page {
    min-width: 2ch;
    font-variant-numeric: tabular-nums;
    text-align: right;
}

Conclusion

Creating a table of contents with nothing but HTML and CSS was more of a challenge than I expected, but I’m very happy with the result. Not only is this approach flexible enough to accommodate chapters and subsections, but it handles sub-subsections nicely without updating the CSS. The overall approach works on web pages where you want to link to the various locations of content, as well as PDFs where you want the table of contents to link to different pages. And of course, it also looks great in print if you’re ever inclined to use it in a brochure or book.

I’d like to thank Julie Blanc and Christoph Grabo for their excellent blog posts on creating a table of contents, as both of those were invaluable when I was getting started. I’d also like to thank Sara Soueidan for her accessibility feedback as I worked on this project.

Nandemo Webtools

Leave a Reply