CheckoutWorks Shopify Partner

We fix Shopify bugs at a flat $49 per fix. Most fixes within 24 hours.

Fix My Bug →

How We Added a Blog Table of Contents to Shopify Horizon Without an App

·Johnny Chen Founder & Shopify Developer at CheckoutWorks
Shopify blog table of contents shown in desktop and mobile layouts without an app.

You can add an automatic table of contents to a Shopify Horizon blog without an app, but Horizon does not include a setting that creates one for you. The practical options are manual anchor links, a dedicated app, or a small theme customization.

For our storefront, we built a dedicated Horizon theme block that reads the H2 headings already in each article. Liquid renders the visible navigation with the page, while a few lines of simple JavaScript connect each link to its section. There is no external library, app subscription, or separate menu to maintain.

You are looking at the live result. This guide includes the complete block code, the small section registration it requires, installation steps, and the limitations to understand before adding it to a theme.

What does the finished table of contents do?

On a wider screen, the table of contents stays in a left column while the article keeps its original reading width. On a smaller screen, the layout becomes a single column and places the navigation above the content. The links also leave enough scroll offset to keep each heading visible below Horizon’s sticky header.

Shopify Horizon blog post with a sticky table of contents beside the article on desktop

On desktop, the table of contents stays beside the article while the main text keeps a comfortable reading width.

Shopify Horizon blog table of contents displayed above the article on a mobile screen

On mobile, the table of contents moves above the article and remains easy to scan.

What should you know before installing it?

This version is designed for Shopify Horizon and uses Horizon’s theme block architecture, layout variables, and sticky-header behavior. The underlying idea can be adapted to other themes, but the code below should not be presented as a universal Shopify theme component.

Before editing your theme:

  • Duplicate the theme and make the change in the unpublished copy.
  • Confirm that the article body uses plain <h2>...</h2> elements for its main sections.
  • Keep Horizon’s original article content block in the theme, but use only one content block in each article template. Adding both blocks to the same template would output the article twice.
  • Test several articles on desktop and mobile before publishing the theme.

The installation changes theme code. If you are not comfortable reviewing and testing Liquid, CSS, and JavaScript, keep the work in a duplicate theme until a developer has checked it.

“Without an app” does not mean “without JavaScript.” The component uses a few lines of simple JavaScript to add destination IDs to the rendered H2 headings. The article and table of contents remain readable if that script does not run, but the section links need it to jump to their destinations.

How do you install the complete Horizon block?

Step 1: Duplicate your theme

From Shopify Admin, go to Online Store → Themes. Open the actions menu for your current theme and select Duplicate. Use that unpublished copy for the remaining steps.

Step 2: Create the new theme block

Open Edit code for the duplicate theme. In the blocks directory, create a new Liquid file named:

_blog-post-content-with-toc.liquid

Step 3: Paste the complete block code

The block below contains its Liquid, markup, responsive styles, JavaScript, and Theme Editor schema in one file. Expand it, copy everything inside the code area, paste it into the new block file, and save.

Code preview — expand below for the complete block to copy.

{% comment %} Modified by CheckoutWorks.dev {% endcomment %}
{% assign article_sections = article.content | split: '<h2>' %}

<blog-post-table-of-contents
  class="article-with-toc"
  ...
Show full codeCollapse code
{% comment %} Modified by CheckoutWorks.dev {% endcomment %}
{% assign article_sections = article.content | split: '<h2>' %}

<blog-post-table-of-contents
  class="article-with-toc"
  style="
    {% if block.settings.text_color != blank %}
      --color: {{ block.settings.text_color }};
      --color-rgb: {{ block.settings.text_color.rgb }};
    {% endif %}
  "
  {{ block.shopify_attributes }}
>
  <nav
    class="article-toc"
    aria-labelledby="ArticleTocTitle-{{ block.id }}"
  >
    <p
      class="article-toc__title"
      id="ArticleTocTitle-{{ block.id }}"
    >
      {{ block.settings.heading | escape }}
    </p>
    <ul class="article-toc__list">
      {%- for article_section in article_sections offset: 1 -%}
        {%- assign heading = article_section | split: '</h2>' | first -%}
        {%- assign heading_text = heading | strip_html | strip -%}
        {%- assign heading_slug = heading_text | handleize -%}
        {%- if heading_slug == blank -%}
          {%- assign heading_slug = 'section' -%}
        {%- endif -%}
        {%- assign heading_id = heading_slug | append: '-' | append: forloop.index -%}
        <li>
          <a href="#{{ heading_id }}">
            {{- heading_text -}}
          </a>
        </li>
      {%- endfor -%}
    </ul>
  </nav>

  <div class="blog-post-content rte">
    <rte-formatter>
      {{ article.content }}
    </rte-formatter>
  </div>
</blog-post-table-of-contents>

{% stylesheet %}
  .article-with-toc {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    width: 100%;
    max-width: var(--normal-content-width);
    margin-inline: auto;
    gap: var(--gap-xl);
    color: var(--color, inherit);
  }

  .article-toc {
    padding: var(--padding-xl);
    border: 1px solid rgb(var(--color-foreground-rgb) / var(--opacity-10));
    border-radius: var(--style-border-radius-sm);
    background-color: rgb(var(--color-foreground-rgb) / var(--opacity-5));
    font-size: 0.9375rem;
    line-height: 1.4;
  }

  .article-toc__title {
    margin: 0 0 var(--margin-sm);
    font-weight: 600;
  }

  .article-toc__list {
    display: grid;
    gap: 0.6rem;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .article-toc__list a,
  .blog-post-content a {
    color: currentcolor;
  }

  .article-toc__list a {
    text-decoration: none;
  }

  .article-toc__list a:hover {
    text-decoration: underline;
  }


  .blog-post-content {
    width: 100%;
    max-width: var(--normal-content-width);
    margin-inline: auto;
  }

  .blog-post-content h2 {
    scroll-margin-block-start: calc(var(--header-height, 0px) + var(--padding-xl));
  }


  .blog-post-content blockquote {
    margin-block: 2em;
  }

  @media screen and (min-width: 990px) {
    .article-with-toc {
      grid-template-columns: minmax(16rem, 20rem) minmax(0, var(--normal-content-width));
      max-width: 75rem;
      column-gap: clamp(2rem, 4vw, 4rem);
      align-items: start;
    }

    .article-toc {
      position: sticky;
      top: calc(var(--header-height, 0px) + var(--padding-xl));
      transition: top var(--animation-speed) var(--animation-easing);
    }

    body:has(#header-component[sticky='scroll-up'][data-sticky-state='idle']) .article-toc {
      top: var(--padding-xl);
    }

    .blog-post-content {
      grid-column: 2;
    }
  }
{% endstylesheet %}

{% javascript %}
  class BlogPostTableOfContents extends HTMLElement {
    connectedCallback() {
      if (this.dataset.ready !== undefined) return;

      const headings = this.querySelectorAll('.blog-post-content h2');
      const tocLinks = this.querySelectorAll('.article-toc__list a');

      headings.forEach((heading, index) => {
        const target = tocLinks[index]?.getAttribute('href');

        if (target?.startsWith('#')) heading.id = target.slice(1);
      });

      this.dataset.ready = '';
    }
  }

  if (!customElements.get('blog-post-table-of-contents')) {
    customElements.define('blog-post-table-of-contents', BlogPostTableOfContents);
  }
{% endjavascript %}

{% schema %}
{
  "name": "Content with TOC",
  "settings": [
    {
      "type": "text",
      "id": "heading",
      "label": "t:settings.heading",
      "default": "In this article"
    },
    {
      "type": "color",
      "id": "text_color",
      "label": "t:settings.text_color",
      "placeholder": "t:settings.default"
    }
  ],
  "presets": [
    {
      "name": "Content with TOC",
      "category": "t:categories.blog_post"
    }
  ]
}
{% endschema %}

Step 4: Register the block with the Blog post section

In Edit code, open sections/main-blog-post.liquid. Find the blocks array inside the section schema and add the private TOC block alongside Horizon’s original article content block:

{
  "type": "_blog-post-content-with-toc"
}

Keep the existing _blog-post-content entry. Registering the private block here makes it available only inside Horizon’s Blog post section instead of every section that accepts public theme blocks.

Step 5: Replace the existing article content block

Open the duplicate theme in the Theme Editor and preview a blog post. In the Blog post section, remove the existing Content block. Add Content with TOC, then set its heading to In this article.

The replacement matters because this custom block outputs article.content itself. If the original content block remains in the template, the article body can appear twice.

Step 6: Test before publishing

Preview more than one article and check:

  • Every H2 appears once in the table of contents.
  • Each link moves to the correct section.
  • Headings remain visible below the sticky header.
  • The table of contents stays beside the article on desktop.
  • It moves above the article on mobile.
  • Images, lists, tables, blockquotes, code samples, and H3 headings remain in their original order.
  • Short articles still look reasonable with the navigation present.

Publish the duplicate theme only after the affected blog templates and representative articles have passed those checks.

How does the automatic table of contents work?

The implementation has three small parts, each with a narrow responsibility.

  1. Liquid reads the H2 headings. The block splits the article HTML at each plain H2 opening tag and uses the text before the closing tag as the navigation label.
  2. Liquid renders the visible navigation. Shopify returns a real navigation landmark, list, and anchor links as part of the page HTML.
  3. A few lines of JavaScript connect the destinations. The custom element finds the rendered H2 elements and assigns the matching IDs.

The article body is still output directly through {{ article.content }}. The block does not split the article apart and reconstruct it. Paragraphs, images, lists, tables, blockquotes, code samples, and H3 headings remain in the order stored by Shopify.

There is no click handler, scroll listener, active-section tracker, or third-party library. The browser follows ordinary anchor links. JavaScript only adds the matching destination IDs, while CSS controls the responsive layout, sticky position, and header offset.

Why is the component lightweight and easy to maintain?

The main article and visible navigation are rendered by Shopify with Liquid and HTML. The JavaScript does not build the article, fetch content, or run a scroll-tracking system. It performs one heading query followed by one short loop.

The component makes no request for an external library. Its Liquid, markup, responsive CSS, JavaScript, and Theme Editor schema stay together in one block. The parent Blog post section needs only one registration entry, so the implementation remains easy to locate during a theme update.

It also does not require a second table of contents to be updated whenever an article changes. Adding, renaming, or removing a compatible H2 makes the same change in the navigation the next time Shopify renders the article.

An app can still be the right choice when it manages a broader publishing workflow, supports several article layouts, or gives nontechnical staff more editorial controls. Custom code is not automatically better. For our narrower requirement—read headings, show links, and connect sections—the small native component kept the ongoing maintenance proportional to the feature.

What keeps the customization manageable?

The component follows one content rule: main section headings use plain <h2>...</h2> markup. Our publishing guidelines already use H2 for those sections, so adding, changing, or removing an H2 makes the same change in the navigation.

Before adopting the approach, we checked our blog export. All 59 H2 headings across nine articles followed the expected plain format. Those articles could use the component without adding a separate menu or rewriting their bodies.

Keeping the Liquid, styles, JavaScript, and schema in one new block also limits the custom surface that needs attention during a Horizon update. The theme’s original article content block remains untouched, so the customization can be removed by switching the template back to the original block and removing the TOC block registration.

That makes the feature easier to identify and test, but not update-proof. A future Horizon release can change layout variables, header behavior, block APIs, or the surrounding article structure. Review the custom block whenever those parts of the theme change.

Does a table of contents improve Shopify SEO?

A table of contents is not a proven direct ranking factor, and adding one does not turn every section into a separate indexed page. A URL fragment such as #does-a-table-of-contents-improve-shopify-seo-6 still points to a location within the same article.

The stronger SEO work happens before the navigation is generated: the article needs to answer a useful question, and its headings need to describe the sections clearly. Google’s SEO Starter Guide emphasizes useful, well-organized content rather than a special table-of-contents formula.

The navigation still has practical value. It helps readers scan a longer article, decide whether it contains the answer they need, and move directly to that section. The W3C guidance on in-page navigation also describes a table of contents as a useful page outline and recommends a navigation landmark with links to the relevant headings.

What limitations should you accept?

  • H2 markup must stay predictable. The extraction expects a plain H2 opening tag without a class, inline style, or manually added ID.
  • Only H2 headings appear. H3 headings remain in the article but are not added to the navigation.
  • The template controls where it appears. Every article using that template receives the block, including short posts that might not need it.
  • JavaScript completes the jumps. Without JavaScript, the article and visible directory remain readable, but the links do not have matching destinations.
  • Section IDs use the heading text and article order. Renaming or reordering an H2 can change its fragment.
  • The implementation is Horizon-specific. Other themes may use different content wrappers, spacing variables, header selectors, and block structures.

Each fragment combines a handleized heading with its position, such as #what-limitations-should-you-accept-7. The readable text makes copied links easier to understand, while the numeric suffix keeps duplicate headings unique. Renaming or reordering an H2 can still change its fragment, so author-defined IDs remain the better option when stable external links to individual sections are required.

When should a Shopify blog use a table of contents?

A table of contents is most useful when a reader is likely to look for one part of an article rather than read every paragraph in order. Good candidates include:

  • Multi-step Shopify tutorials.
  • Troubleshooting guides with several possible causes.
  • Theme or app comparisons.
  • Migration and theme update guides.
  • Articles with separate setup, implementation, testing, and limitation sections.

A short post with one or two sections may not need another navigation element. For longer Horizon articles, this approach provides a useful overview while keeping the operational cost proportional to the feature: one content rule, one focused block, one section registration, and a few lines of JavaScript that do one job.

Get Shopify help _

Need a blog table of contents?

We can build it into your theme.

$99 USD

ONE CUSTOM FEATURE · 30-DAY GUARANTEE

Add a table of contents