Skip to main content Accessibility Feedback

<table-of-contents>

Create a multi-level table of contents from the page headings.

Source Code

Usage

Include a <table-of-contents> element in your HTML.

By default, it will automatically generate a list of anchor links to all of the h2 through h6 heading elements. If a heading doesn’t have an ID, it will generate one.

<table-of-contents></table-of-contents>

Options

The <table-of-contents> component can be customized with a handful of attributes…

  • levels - Change which heading levels are used (default: h2, h3, h4, h5, h6)
  • list-type - Adjust the list type used for the table of contents (default: ul).
  • heading - The label to use for the heading (default: Table of Contents).
  • heading-level - The h* level to use for heading (default: h2).
  • content - Limit the headings used to a specific element by passing in a selector string for the content wrapper (ex. #main).
<table-of-contents 
    levels="h4, h5, h6"
    list-type="ol"
    heading="On This Page..."
    heading-level="h3"
    content="#sidebar"
></table-of-contents>

Methods

If the content of the HTML changes, you can dynamically render an updated table of contents by running the render() method on the <table-of-contents> element that you want to update.

let toc = document.querySelector('table-of-contents');
toc.render();

The Web Component

customElements.define('table-of-contents', class extends HTMLElement {

    /**
     * Instantiate the Web Component
     */
    constructor () {

        // Get parent class properties
        super();

        // Define properties
        this.content = this.getAttribute('content') || document;
        this.levels = this.getAttribute('levels') || 'h2, h3, h4, h5, h6';
        this.heading = this.getAttribute('heading') || 'Table of Contents';
        this.headingLevel = this.getAttribute('heading-level') || 'h2';
        this.listType = this.getAttribute('list-type') || 'ul';

        // Get the headings
        // If none are found, don't render a list
        this.headings = this.content.querySelectorAll(this.levels);
        if (!this.headings.length) return;

        // Render the list
        this.render();

    }

    /**
     * Inject the table of contents into the DOM
     */
    render () {

        // Track the current heading level
        let level = this.headings[0].tagName.slice(1);
        let startingLevel = level;

        // Cache the number of headings
        let len = this.headings.length - 1;

        // Inject the HTML into the DOM
        this.innerHTML =
            `<${this.headingLevel}>${this.heading}</${this.headingLevel}>
            <${this.listType}>
                ${Array.from(this.headings).map((heading, index) => {

                    // Add an ID if one is missing
                    this.createID(heading);

                    // Check the heading level vs. the current list
                    let currentLevel = heading.tagName.slice(1);
                    let levelDifference = currentLevel - level;
                    level = currentLevel;
                    let html = this.getStartingHTML(levelDifference, index);

                    // Generate the HTML
                    html +=
                        `<li>
                            <a href="#${heading.id}">${heading.innerHTML.trim()}</a>`;

                    // If the last item, close it all out
                    if (index === len) {
                        html += this.getOutdent(Math.abs(startingLevel - currentLevel));
                    }

                    return html;

                }).join('')}
            </${this.listType}>`;
    }

    /**
     * Create an ID for a heading if one does not exist
     * @param  {Node} heading The heading element
     */
    createID (heading) {
        if (heading.id.length) return;
        heading.id = `toc_${crypto.randomUUID()}`;
    }

    /**
     * Get the HTML to indent a list a specific number of levels
     * @param  {Integer} count The number of times to indent the list
     * @return {String}        The HTML
     */
    getIndent (count) {
        let html = '';
        for (let i = 0; i < count; i++) {
            html += `<${this.listType}>`;
        }
        return html;
    }

    /**
     * Get the HTML to close an indented list a specific number of levels
     * @param  {Integer} count The number of times to "outdent" the list
     * @return {String}        The HTML
     */
    getOutdent (count) {
        let html = '';
        for (let i = 0; i < count; i++) {
            html += `</${this.listType}></li>`;
        }
        return html;
    }

    /**
     * Get the HTML string to start a new list of headings
     * @param  {Integer} diff  The number of levels in or out from the current level the list is
     * @param  {Integer} index The index of the heading in the "headings" NodeList
     * @return {String}        The HTML
     */
    getStartingHTML (diff, index) {

        // If indenting
        if (diff > 0) {
            return this.getIndent(diff);
        }

        // If outdenting
        if (diff < 0) {
            return this.getOutdent(Math.abs(diff));
        }

        // If it's not the first item and there's no difference
        if (index && !diff) {
            return '</li>';
        }

        return '';

    }

});

Find this useful? You can support my work by purchasing an annual membership.