shopify-blocks-invocation-patterns-en

Three call patterns organized by analyzing all 39 sections of Horizon

What's changed in Horizon?

"Horizon" is a new theme released in Shopify's Summer '25 Edition. Not only is its appearance different, but the internal structure of the theme - in other words, the architecture itself - is quite different from previous themes.

At the heart of this are Theme Blocks . In previous themes (such as Dawn and Rise), blocks were defined locally within a section and could not be moved outside of that section. Layouts also had a flat, single-level structure and could not be nested.

This is where theme blocks have made a big change. Blocks are placed in the /blocks folder as independent Liquid files, and can be reused across sections. Furthermore, blocks can be nested up to eight levels deep (excluding the section level), allowing for a great deal of freedom in creating layouts in the theme editor ( official documentation: Theme blocks ).

Difference between section blocks and theme blocks

  • Section block (previous) : Local definition within a section. Can only be used within that section, cannot be nested.
  • Theme Blocks (Horizon) : Placed as independent files in the /blocks folder. Reusable across sections, supports up to 8 levels of nesting (excluding section levels).

At Shopify's Edition Conference, this design philosophy was described as "anything anywhere" - the ability to place any component anywhere. When you actually try out Horizon's theme editor, you'll see that it offers a completely different degree of freedom than traditional themes. While only a few themes still use this architecture, it's clear that Shopify is putting effort into this direction.

However, behind this freedom lies the complexity of the code structure. As of v3.3.1, the Horizon theme has 39 sections and 94 blocks , and there are multiple patterns for calling the blocks. When I tried to customize it, the first thing I wondered was, "How is this block being called?"

In this article, I have tried to organize the block calling patterns in my own way based on all the Horizon section code and the official documentation . I hope this will be helpful for those who are also following the Horizon code. (Also, if you notice any differences, I would appreciate it if you could point them out.)

About the classification of this article

The three classifications used in this article - "dynamic blocks," "static blocks," and "section blocks (traditional method)" - are not terms used in the official Shopify documentation, but are a personal classification organized by the author while reading through all of Horizon's section code. The official documentation uses the classifications theme blocks / section blocks / app blocks, and does not classify them by call pattern. Please note that this article uses its own classification, prioritizing ease of understanding.

Three categories of blocking calls

There are three main ways to call a block:

classification Liquid Syntax Features
A. Dynamic Blocks {% content_for 'blocks' %} (plural) Merchants can freely add, delete, and rearrange items
B. Static Blocks {% content_for "block", type: "...", id: "..." %} (singular) Theme developers have fixed the layout using Liquid. Cannot be deleted or rearranged.
C. Section Block (conventional method) {% for block in section.blocks %} Local definition within a section. Cannot be used with theme blocks.

Below we will look at each pattern in more detail.

Prerequisites: What is a schema?

Before getting into the main topic, let's briefly review the term "schema," which appears frequently in this article.

In Shopify themes, a JSON-formatted definition is written at the end of each section file ( /sections/○○.liquid ) using the {% schema %} tag. This is the "schema." It acts like a blueprint that determines the behavior of the section, such as the section name, the types of blocks it accepts, and the settings that appear in the theme editor.

 // sections/example-section.liquid の末尾

{%- schema -%}
 { 
"name" : "Section name" ,
 "blocks" : [ ← Which blocks to accept
 { "type" : "@theme" }
 ],
 "settings" : [ ... ] ← Settings displayed in the theme editor
 }
 {%- endschema -%}

The same mechanism applies to Horizon theme blocks. You can also write a schema in a block file ( /blocks/○○.liquid ), and you define whether the block accepts child blocks here. In other words, sections and blocks have in common the fact that they "declare the block's acceptance rules in the schema."

From here on, we will look at how the calling pattern changes depending on how blocks array in this schema is written.

A: Dynamic blocks — content_for 'blocks'

This method allows merchants to freely add, delete, and rearrange blocks using the theme editor. The types of blocks accepted are controlled by the blocks array in the schema.

Pattern A-1: ​​Accept all theme blocks

Specifying @theme will cause all public theme blocks (without the underscore prefix) in /blocks/ folder to appear in the block picker.

 {%- content_for 'blocks' -%}

 {%- schema -%}
 {
 "name" : "Section" , 
"blocks" : [{ "type" : "@theme" }, { "type" : "@app" }]
 }
 {%- endschema -%}

The theme editor's block picker displays all published theme blocks, organized by category.

The block picker for the @theme section, showing all public theme blocks by category: Custom, Collection, Form, Footer, etc.

Example in Horizon:

  • _blocks.liquid@theme , @app , _divider
  • section.liquid , main-page.liquid , main-404.liquid , password.liquid — Same as above

Tip: Adding @app also allows app blocks to be accepted. Also, adding private blocks like _divider with @theme allows them to be displayed at the top of the picker as recommended blocks. This mechanism is explained in detail in "Pattern A-4: Adding @theme + Recommended Blocks" below.

Pattern A-2: Accept only specific theme blocks (specify public blocks)

If you explicitly specify a particular block type in the schema, only that block will appear in the picker, but because the block itself exists in /blocks/ with a regular filename, it will also appear in pickers in other sections with @theme .

 {%- content_for 'blocks' -%}
 
{%- schema -%}
 {
 "name" : "Slideshow" ,
 "blocks" : [{ "type" : "slide" }]
 }
 {%- endschema -%}

Note: In the Horizon theme, this "public block name specification" pattern is not used alone, and is mostly implemented in private blocks (A-3).

Pattern A-3: Accept only private blocks

A private block is made private by prefixing the block filename with an underscore ( _ ). Private blocks will not appear in the block picker of sections with @theme , and will only be available in sections you explicitly specify.

 {%- content_for 'blocks' -%}

 {%- schema -%}
 { 
"name" : "Slideshow" ,
 "blocks" : [{ "type" : "_slide" }]
 }
 {%- endschema -%}

In the block picker of the Slideshow section, only the private block "Slide" is available as an option.

The block picker in the Slideshow section, showing only the private block "Slides"

Example in Horizon:

  • slideshow.liquid — only accepts _slide
  • layered-slideshow.liquid — accepts only _layered-slide
  • header-announcements.liquid — accepts only _announcement
  • marquee.liquidtext , icon , logo , _divider (mixed public and private)

Tip: This is ideal for sections where you want to enforce a specific structure, such as slideshows or announcement bars. Merchants can add, remove, and reorder blocks, but the choices displayed in the picker will be limited.

Pattern A-4: @theme + recommended blocks

While accepting all theme blocks with @theme , if you add specific blocks, they will be displayed as "recommended" at the top of the picker. Other blocks can be selected from "Show all".

 {%- content_for 'blocks' -%}

 {%- schema -%}
 { 
"name" : "Hero" ,
 "blocks" : [
 { "type" : "@theme" },
 { "type" : "text" },
 { "type" : "button" },
 { "type" : "_marquee" }
 ]
 }
 {%- endschema -%}

The hero section block picker displays recommended blocks (groups, spacers, text, buttons, etc.) by category at the top, and you can access all theme blocks by clicking "Show all" at the bottom.

The hero section block picker, with recommended blocks listed by category at the top and a "Show all" link at the bottom.

Example in Horizon:

  • hero.liquid@theme , text , button , logo etc.
  • footer.liquid_divider , @app , button , menu , text etc.
  • collection-list.liquid@theme , @app , text , icon , image etc.

Pattern A-5: Contextual dynamic blocks

This is a pattern where you pass a resource to content_for 'blocks' so that the child block can access it with closest .

 {%- content_for 'blocks' , closest.product: product -%}

Example in Horizon:

  • blocks/_product-card.liquid — Passes closest.product from the parent to the nested dynamic block
  • blocks/_collection-card.liquid — Inherits closest.collection from its parent to the nested dynamic block.

Tip: In Horizon, this pattern is used within block files , not sections, to pass resources received from the parent to the child block when the block itself expands a nested dynamic block.

Supported resource types:

resource syntax
Product closest.product: <ProductDrop>
Collection closest.collection: <CollectionDrop>
Article closest.article: <ArticleDrop>
Blog closest.blog: <BlogDrop>
Page closest.page: <PageDrop>
Metaobject closest.metaobject.<definition_type>: <MetaobjectDrop>

B: Static Block — content_for "block" (singular)

Theme developers fix the placement of these items within Liquid. Merchants can change the settings and hide them, but cannot delete, rearrange, or duplicate them .

Basic Syntax

 {%- content_for "block" , type: "<type>" , id: "<id>" -%} 
Parameters explanation
type Theme block type name in /blocks/ folder
id (required) A string ID unique within the parent section/block. Set by the developer (not auto-generated by Shopify).

Comparison with dynamic blocks

Static Blocks Dynamic Blocks
Hide/Customize Possible Possible
Sort (D&D) Not possible Possible
Delete/duplicate Not possible Possible
Conditional Rendering Possible Not possible
Rendering in a for loop Possible Not possible
max_blocks limit Not counted be counted

Pattern B-1: Basic Static Block

This is the simplest pattern, placing a specific block in a fixed position.

 {%- content_for "block" , type: "_cart-title" , id: "cart-page-title" -%}
 {%- content_for "block" , type: "_cart-products" , id: "cart-page-items" -%}
 {%- content_for "block" , type: "_cart-summary" , id: "cart-page-summary" -%}

Example in Horizon:

  • main-cart.liquid — Cart page title, product list, and summary are fixed in place
  • featured-blog-posts.liquid — fixed position title block
  • search-header.liquid — Fixed positioning of header and search input

Pattern B-2: Static block passing context (closest)

This pattern passes resource data to a static block and makes it accessible via closest within the block.

 {%- content_for 'block' ,
 type: '_product-media-gallery' ,
 id: 'media-gallery' ,
 closest.product: closest.product
 -%}

Example in Horizon:

  • product-information.liquid — Passes closest.product to the media gallery and product details
  • featured-product.liquid — Passes section.settings.product as closest.product
  • collection-list.liquid — Passes closest.collection to the collection card

Pattern B-3: Static block in a for loop

Call the static block inside a loop, passing a different resource for each iteration. The ID remains the same, but a different context applies for each loop iteration.

 {%- for product in collection.products -%} 
{%- content_for 'block' ,
 type: '_product-card' ,
 id: 'product-card' ,
 closest.product: product
 -%}
 {%- endfor -%}

Example in Horizon:

  • main-collection.liquid — Repeatedly calling _product-card in a product list loop
  • main-blog.liquid — Repeatedly calls _blog-post-card in a loop through posts
  • product-recommendations.liquid — product recommendations loop

Note: The ID does not need to be unique across the entire route section, just unique to its immediate parent . Any theme-check warnings can be suppressed with {% # theme-check-disable UniqueStaticBlockId %} .

Pattern B-4: Static block passing additional parameters

In addition to closest , you can pass any data as a parameter.

{% comment %} variant パラメータで表示モードを切り替え {% endcomment %}
{%- content_for 'block', type: '_header-menu', id: 'header-menu', variant: 'desktop' -%}
{%- content_for 'block', type: '_header-menu', id: 'header-menu', variant: 'mobile' -%}

{% comment %} テキストを直接渡す {% endcomment %} 
{%- content_for 'block' , id: 'heading' , type: '_heading' , text: heading_text -%}

 {% comment %} force_empty controls the display of empty states {% endcomment %}
 {%- content_for 'block' , id: 'cart-page-title' , type: '_cart-title' , force_empty: true -%}

List of parameters observed in Horizon:

Parameters Purpose Use Section
closest.product Passing product resources product-information, main-collection, etc.
closest.collection Passing collection resources collection-list, collection-links etc.
closest.article Passing article resources featured-blog-posts, main-blog
variant Specifying display variations header (desktop/mobile/navigation_bar)
text Direct text transfer search-header
results / results_size Resources to filter and number of items main-collection, search-results
index / current Loop index and current selection collection-links
force_empty Force empty state rendering main-cart

Pattern B-5: Conditional Rendering

When a static block is called within a Liquid conditional branch, it will be indicated with a dotted eye icon in the theme editor sidebar.

 {%- unless cart.empty? -%}
 {%- content_for 'block' , id: 'cart-page-summary' , type: '_cart-summary' -%}
 {%- endunless -%}

Horizon example: main-cart.liquid — Show summary only if cart is not empty

Below is the actual theme editor screen for the cart page: You can see that the "Summary" block in the left sidebar has a gear icon and is conditionally displayed.

Theme editor for the cart page, showing the conditional rendering icon on the Summary block in the sidebar

Important supplement to B: Integration with presets

Static blocks are called by specifying type and id in Liquid, but if you use the same id in presets and write static: true and a setting value , the default settings will be applied when the section is first added.

{% comment %} Liquid側: 静的ブロックの呼び出し {% endcomment %}
{%- content_for "block", type: "collapsible-row-summary", id: "collapsible-row" -%}

{%- schema -%}
{
  "name": "Collapsible row",
  "blocks": [{"type": "@theme"}, {"type": "@app"}],
  "presets": [
    { 
"name" : "Collapsible row" ,
 "blocks" : [
 {
 "type" : "collapsible-row-summary" ,
 "static" : true ,
 "id" : "collapsible-row" , ← Match the id on the Liquid side
 "settings" : {
 "summary" : "Collapsible row" ← default setting
 }
 }
 ]
 }
 ]
 }
 {%- endschema -%} 

Behavior when not included in presets

Static blocks are automatically added with default settings when adding a section/block, even if they are not explicitly specified in the preset. Including them in presets is only necessary if you want to override the default settings.

They are flagged as static: true in the JSON data and are not included in block_order array (the order is determined by the order they appear in the Liquid code).

A + B mixed pattern

In the Horizon theme, the most common pattern is to combine dynamic and static blocks within a single section.

Pattern AB-1: Static block (fixed part) + Dynamic block (free part)

 {% comment %} 固定: タイトル{% endcomment %}
 {%- content_for 'block' , id: 'blog-post-title' , type: 'text' -%}

 {% comment %} 固定: アイキャッチ画像{% endcomment %} 
{%- content_for 'block' , id: 'blog-post-featured-image' , type: '_blog-post-featured-image' -%}

 {% comment %} fixed: body {% endcomment %}
 {%- content_for 'block' , id: 'blog-post-content' , type: '_blog-post-content' -%}

 {% comment %} Optional: Merchant-added blocks {% endcomment %}
 {%- content_for 'blocks' -%}

Example in Horizon:

  • main-blog-post.liquid — The blog post structure is static, but additional content is dynamic
  • product-information.liquid — media gallery and product details are static, additional blocks ( @app ) are dynamic
  • main-cart.liquid — cart structure is static, additional blocks are dynamic

Pattern AB-2: section.blocks iteration + dynamic blocks

The pattern is to get the number of blocks using section.blocks to build the UI, and then actually render the blocks using content_for 'blocks' .

{% comment %} section.blocksでタブボタンを生成 {% endcomment %}
<div role="tablist">
  {%- for block in section.blocks -%}
    <button role="tab" id="Tab-{{ block.id }}"></button>
  {%- endfor -%}
</div>
 
{% comment %} The actual content is expanded as a dynamic block {% endcomment %}
 <div class = "panels" >
 {%- content_for 'blocks' -%}
 </div>

Example in Horizon:

  • layered-slideshow.liquid — Generates tab buttons and expands the panel with dynamic blocks
  • slideshow.liquid — Gets the number of slides and generates the controls

Pattern AB-3: capture + content_for 'blocks'

This is a pattern in which the output of a dynamic block is captured, processed, and then rendered.

 {%- capture slides -%}
 {%- content_for 'blocks' -%}
 {%- endcapture -%}

 {%- render 'slideshow' , slides: slides, slide_count: section.blocks.size -%}

Example in Horizon:

  • slideshow.liquid — Captures a slide and passes it to the snippet
  • product-information.liquid — Captures media gallery and product details and places them conditionally
  • main-cart.liquid — Captures additional blocks and uses them in both the regular and empty templates.

C: Section block (traditional method)

This method defines blocks directly within the section schema and processes section.blocks in a loop. It cannot be used in conjunction with theme blocks.

 {%- for block in section.blocks -%}
 {%- case block.type -%}
 {%- when "heading" -%}
 <h1> {{ block.settings.heading }} </h1> 
{%- endcase -%}
 {%- endfor -%}

 {%- schema -%}
 {
 "name" : "Example section" ,
 "blocks" : [
 {
 "type" : "heading" ,
 "name" : "Heading" ,
 "settings" : [
 {
 "type" : "text" ,
 "id" : "heading" ,
 "label" : "Heading" , 
"default" : "Hello, world!"
 }
 ]
 }
 ]
 }
 {%- endschema -%}

Limitations:

  • Can only be used within the section in which it is defined (cannot be reused)
  • Only one level (nesting not allowed)
  • Cannot be used in the same section as the theme block ( @theme / @app )

Use in Horizon: The Horizon theme fully uses theme blocks (A/B), and does not use section blocks (C). It seems that A/B will become the standard for creating themes in the future.

Pattern Cheat Sheet (All Horizon Theme Sections)

Here is a list of which block call patterns each section of the Horizon theme uses:

section Dynamic Blocks Static Blocks schema blocks
_blocks.liquid blocks - @theme , @app , _divider
carousel.liquid - group / _carousel-content (defined in presets)
collection-links.liquid - _collection-link (loop) (defined in presets)
collection-list.liquid blocks _collection-card (loop) @theme , @app , text , icon etc.
featured-blog-posts.liquid - _featured-blog-posts-title , _featured-blog-posts-card (loop) _featured-blog-posts-*
featured-product.liquid - _media-without-appearance , _featured-product (defined in presets)
featured-product-information.liquid blocks _featured-product-information-carousel , _product-details @app
footer.liquid blocks - _divider , @app , various public blocks
footer-utilities.liquid blocks - footer-copyright etc.
header.liquid - _header-logo , _header-menu (multiple variants) (no presets)
header-announcements.liquid blocks - _announcement
hero.liquid blocks - @theme , text , button etc.
layered-slideshow.liquid blocks - _layered-slide
main-404.liquid blocks - @theme , @app , _divider
main-blog-post.liquid blocks text , _blog-post-featured-image , _blog-post-content @theme , @app
main-blog.liquid blocks _blog-post-card (Loop) @theme , @app , text etc.
main-cart.liquid blocks _cart-title , _cart-products , _cart-summary @theme , @app , text etc.
main-collection.liquid - filters , _product-card (Loop) (no presets)
main-collection-list.liquid blocks _collection-card (loop) @theme , @app , text etc.
main-page.liquid blocks - @theme , @app , _divider
marquee.liquid blocks - text , icon , logo , _divider
media-with-content.liquid - _media-without-appearance , _content-without-appearance (defined in presets)
password.liquid blocks - @theme , @app , _divider
product-hotspots.liquid blocks text (heading) _hotspot-product
product-information.liquid blocks _product-media-gallery , _product-details @app
product-list.liquid blocks _product-list-content , _product-card (Loop) @theme , @app , _divider
product-recommendations.liquid blocks _product-card (LOOP) @theme , @app , text etc.
search-header.liquid - _heading , _search-input (no presets)
search-results.liquid - filters , _product-card (Loop) (no presets)
section.liquid blocks - @theme , @app , _divider
slideshow.liquid blocks (capture) - _slide

summary

  1. content_for 'blocks' (plural) is a dynamic block expansion. You can control the scope of acceptance by specifying @theme / @app / individual type in the blocks array of the schema.
  2. content_for "block" (singular) is a static block. It requires type and id . It can be called anywhere in Liquid, in a conditional or loop.
  3. You can override the default setting by matching id in the static block presets and specifying static: true . It will be automatically added with the default value even if you do not include it in the presets.
  4. By passing optional additional parameters ( closest.product , variant , text , force_empty , etc.), you can achieve different display and behavior even for the same block type.
  5. In the Horizon theme, the A+B mixed pattern is the most common, with fixed structures being realized with static blocks and free areas being realized with dynamic blocks.
Back to blog