---
title: Build DOM for hidden components only when opened
description: >-
  Filter panels, cart drawers, and dialogs add thousands of DOM nodes even when
  closed, slowing every interaction. Build their DOM only when the user first
  opens them to reduce style recalculation cost and improve INP.
source_url:
  html: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/lazy-dom-rendering
  md: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/lazy-dom-rendering.md
api_name: liquid
---

# Build DOM for hidden components only when opened

Defer creating DOM nodes for initially hidden components until the user first requests them, rather than rendering them on page load.

***

## Why

Components that are initially hidden, such as filter panels, sub-filter facets, cart drawers, dialogs, and wishlist overlays, still add DOM nodes that the browser must account for during every style recalculation. When filter facets add 3,000 to 3,500 nodes to a collection page DOM, every user interaction on that page incurs higher presentation delay because the browser recalculates styles across all nodes before painting the response. This is true even when the filter panel is closed.

This DOM cost compounds when the same pattern exists across multiple components. The goal is to reduce the initial node count so that style recalculations triggered by any interaction are cheaper.

***

## How

### Render-on-demand from a `<template>` tag

Store the component's HTML in a `<template>` element. Template content sits in a separate document fragment and isn't part of the live DOM, so it contributes zero nodes to style recalculation. On first interaction, inject the content:

```javascript
const placeholder = document.querySelector("#filter-panel-placeholder");
const template = document.querySelector("#filter-panel-template");


toggleButton.addEventListener("click", async () => {
  // Test `children.length`, not `hasChildNodes()`: a placeholder written across
  // multiple lines holds a whitespace text node and always has child nodes.
  if (placeholder.children.length === 0) {
    const shell = template.content.cloneNode(true);
    placeholder.appendChild(shell);
    placeholder.hidden = false;


    const sections = placeholder.querySelectorAll(".facet-group");
    // For large components (1,000+ nodes), yield between chunks. Every group is
    // initialized either way.
    const yieldBetweenGroups = sections.length > 5;


    for (const section of sections) {
      if (yieldBetweenGroups) {
        // `globalThis.` is required: a bare `scheduler?.yield()` throws a
        // ReferenceError in browsers that don't define the global at all.
        await (globalThis.scheduler?.yield?.() ??
          new Promise((r) => setTimeout(r, 0)));
      }
      initializeFacetGroup(section);
    }
  } else {
    placeholder.hidden = !placeholder.hidden;
  }
});
```

For small components (under 200 nodes), the synchronous insertion is sufficient. For large components with independent sub-sections like facet groups, yielding between sections keeps the first-open interaction responsive.

### Render-on-demand from a `data-` attribute

When the HTML is generated server-side but shouldn't be in the live DOM initially, store it as a string in a `data-` attribute. The attribute doesn't add DOM nodes:

```liquid
{%- capture filter_panel_html -%}
  {%- for filter in collection.filters -%}
    <div class="facet-group">
      <h3>{{ filter.label }}</h3>
      {%- for value in filter.values -%}
        <label>
          <input type="checkbox" name="{{ value.param_name }}" value="{{ value.value }}">
          {{ value.label }}
        </label>
      {%- endfor -%}
    </div>
  {%- endfor -%}
{%- endcapture -%}


<div id="filter-panel-placeholder" data-html="{{ filter_panel_html | escape }}" hidden></div>
```

```javascript
const placeholder = document.querySelector("#filter-panel-placeholder");


toggleButton.addEventListener("click", () => {
  if (placeholder.dataset.html) {
    placeholder.innerHTML = placeholder.dataset.html;
    placeholder.removeAttribute("data-html");
    placeholder.hidden = false;
  } else {
    placeholder.hidden = !placeholder.hidden;
  }
});
```

Guard on `placeholder.dataset.html`, not on `placeholder.hasChildNodes()`. A placeholder `<div>` written across multiple lines contains a whitespace text node, so `hasChildNodes()` returns `true` on the first click and the panel never gets injected.

### Fetch-on-demand via the Section Rendering API

For Liquid-backed theme sections like cart drawers, fetch the section HTML on first interaction instead of rendering it on page load:

```javascript
let cartDrawerLoaded = false;


cartIcon.addEventListener("click", async () => {
  if (!cartDrawerLoaded) {
    const response = await fetch(
      `${window.location.pathname}?sections=cart-drawer`
    );
    const data = await response.json();
    document.querySelector("#cart-drawer-placeholder").innerHTML =
      data["cart-drawer"];
    cartDrawerLoaded = true;
  }
  openCartDrawer();
});
```

This pattern applies only to Liquid-backed theme sections. For JS-generated DOM like filter facets built by third-party services, use the `<template>` or `data-` attribute patterns.

***

## Examples

A collection page with 24 product cards and a fully rendered filter panel on load might have 8,000+ DOM nodes. Deferring the filter panel can reduce initial load to 4,500 to 5,000 nodes, reducing style recalculation cost on every card click, filter toggle, and add-to-cart interaction.

***

## Testing

Measure DOM node count before and after in the DevTools console:

```javascript
document.querySelectorAll("*").length;
```

Run this on initial page load before opening any components. A reduction of 2,000+ nodes on a collection page is a meaningful improvement. Follow up by measuring INP presentation delay in RUM data or using a DevTools trace with interaction recording.

***

## References

* [HTML `<template>` element, MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template)
* [Section Rendering API](https://shopify.dev/docs/api/ajax/section-rendering)
* [Reduce mega-menu DOM overhead](https://shopify.dev/docs/storefronts/themes/best-practices/performance/reduce-mega-menu-dom-overhead)
* [Load JavaScript on user interaction](https://shopify.dev/docs/storefronts/themes/best-practices/performance/load-javascript-on-user-interaction)

***
