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

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.

Example in Horizon:
-
slideshow.liquid— only accepts_slide -
layered-slideshow.liquid— accepts only_layered-slide -
header-announcements.liquid— accepts only_announcement -
marquee.liquid—text,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.

Example in Horizon:
-
hero.liquid—@theme,text,button,logoetc. -
footer.liquid—_divider,@app,button,menu,textetc. -
collection-list.liquid—@theme,@app,text,icon,imageetc.
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— Passesclosest.productfrom the parent to the nested dynamic block -
blocks/_collection-card.liquid— Inheritsclosest.collectionfrom 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— Passesclosest.productto the media gallery and product details -
featured-product.liquid— Passessection.settings.productasclosest.product -
collection-list.liquid— Passesclosest.collectionto 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-cardin a product list loop -
main-blog.liquid— Repeatedly calls_blog-post-cardin 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.

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
-
content_for 'blocks'(plural) is a dynamic block expansion. You can control the scope of acceptance by specifying@theme/@app/ individual type in theblocksarray of the schema. -
content_for "block"(singular) is a static block. It requirestypeandid. It can be called anywhere in Liquid, in a conditional or loop. - You can override the default setting by matching
idin the static block presets and specifyingstatic: true. It will be automatically added with the default value even if you do not include it in the presets. - 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. - 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.