Skip to main content Accessibility Feedback


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

Source Code


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.



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).
    levels="h4, h5, h6"
    heading="On This Page..."


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');

The Web Component

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

     * Instantiate the Web Component
    constructor () {

        // Get parent class properties

        // 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


     * 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 =
                ${Array.from(this.headings).map((heading, index) => {

                    // Add an ID if one is missing

                    // 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 +=
                            <a href="#${}">${heading.innerHTML.trim()}</a>`;

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

                    return html;


     * Create an ID for a heading if one does not exist
     * @param  {Node} heading The heading element
    createID (heading) {
        if ( return; = `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 '';