Skip to main content

Liquid Tags

Tags are the programming logic of Liquid templates. They create control flow, loops, and output dynamic content.

{% if user %}
Hello {{ user.name }}!
{% endif %}
Standard Liquid

OQO extends Liquid, the templating language created by Shopify. All standard Liquid tags work in OQO themes, plus the custom tags documented below.


Collections (Shopify-style)

Posts are accessed via the global collections object using Shopify-style for loops. This is the primary way to display posts in themes.

Basic Usage

{% for post in collections.featured_news %}
<article>
<h3>{{ post.title }}</h3>
<p>{{ post.excerpt }}</p>
<a href="{{ post | post_url }}">Read more</a>
</article>
{% endfor %}

With Limit

{% for post in collections.sports limit: 6 %}
<article>{{ post.title }}</article>
{% endfor %}

Dynamic Collection from Settings

{%- assign collection_slug = section.settings.collection -%}
{% for post in collections[collection_slug] limit: section.settings.count %}
<article>
<img src="{{ post.image | image_url: 'medium' }}" alt="{{ post.image.alt }}">
<h3>{{ post.title }}</h3>
</article>
{% endfor %}

Special Collections

<!-- All approved posts (recent first) -->
{% for post in collections.all_posts limit: 10 %}
{{ post.title }}
{% endfor %}

<!-- All tags -->
{% for tag in collections.all_tags %}
<a href="{{ tag | tag_url }}">{{ tag.name }}</a>
{% endfor %}

Collection Metadata

Access collection properties directly:

{% assign current = collections.sports %}
{% if current %}
<header class="collection-header">
{% if current.image %}
<img src="{{ current.image | image_url: 'hero' }}" alt="{{ current.title }}">
{% endif %}
<h1>{{ current.title }}</h1>
<p>{{ current.description }}</p>
<span>{{ current.posts_count }} {{ current.posts_count | pluralize_word: 'article' }}</span>
</header>
{% endif %}

Collection Properties:

PropertyTypeDescription
titlestringCollection name
handlestringURL-safe slug
descriptionstringCollection description
imageImageDropOptional hero image
posts_countintegerTotal posts in collection
postsarrayPosts in this collection

post

Resolves and renders a single post. The tag accepts an optional identifier -- a quoted slug, a variable reference, or nothing (to resolve from the current URL).

From URL Parameters (default)

When no argument is provided, the tag resolves the post from the current URL parameters. This is the most common usage for post detail pages:

{% post %}
{% if found %}
<article>
<h1>{{ post.title }}</h1>
{{ post.content_html }}
</article>
{% else %}
<p>Post not found</p>
{% endif %}
{% endpost %}

By Slug (literal string)

Pass a quoted slug to resolve a specific post:

{% post 'welcome-to-our-blog' %}
{% if found %}
<article>
<h1>{{ post.title }}</h1>
{{ post.content_html }}
</article>
{% endif %}
{% endpost %}

From Section Settings (variable)

Pass a variable reference to resolve from a section setting. When the setting contains a PostDrop (from a post setting type), the tag uses it directly. When it contains a slug string, it looks up the post by slug:

{% post section.settings.selected_post %}
{% if found %}
<article>
<h1>{{ post.title }}</h1>
{{ post.content_html }}
</article>
{% endif %}
{% endpost %}
Fallback Behavior

When a variable resolves to nil or blank, the tag falls back to URL parameter resolution. This means a section can work both as a "selected post" display and as a URL-driven post page.

Block Variables

Inside the block, these variables are available:

VariableTypeDescription
postPostDropThe resolved post (nil if not found)
foundbooleanTrue if post was resolved successfully

Complete Example

{% post %}
{% if found %}
<article>
{% if post.image %}
<img src="{{ post.image | image_url: 'hero' }}" alt="{{ post.image.alt }}">
{% endif %}

<h1>{{ post.title }}</h1>
<time>{{ post.published_at | date_format: 'long' }}</time>

{% if post.author %}
<span>By {{ post.author.name }}</span>
{% endif %}

<div class="post-content">
{{ post.content_html }}
</div>

{% unless post.tags.blank? %}
<div class="post-tags">
{% for tag in post.tags %}
<a href="{{ tag | tag_url }}">{{ tag.name }}</a>
{% endfor %}
</div>
{% endunless %}
</article>
{% else %}
<div class="not-found">
<h2>Post not found</h2>
<p>The post you are looking for does not exist.</p>
</div>
{% endif %}
{% endpost %}

section_wrapper

Wraps section content with proper data attributes for the visual editor and live preview.

{% section_wrapper 'section' class: 'my-class' style: section_style data-css-vars: 'bgcolor,overlay' %}
<h2>Content here</h2>
{% endsection_wrapper %}

Output:

<section class="gz-section gz-hero-banner my-class"
data-section-id="abc123"
data-section-type="hero/banner"
data-section-wrapper="true"
data-css-vars="bgcolor,overlay"
style="--bgcolor: #1F2937;">
<h2>Content here</h2>
</section>

Parameters:

ParameterDescription
First argumentHTML tag: 'section', 'div', 'nav', 'header', 'footer', etc.
classCSS classes to add
styleInline styles (use with CSS variables)
idElement ID
data-css-varsComma-separated settings for live CSS variable updates
data-controllerStimulus controller(s) to attach
Any data-*Custom data attributes

Auto-Added Classes:

  • gz-section - Common class for all sections
  • gz-{type} - Section-type class (e.g., gz-hero-banner)

block_wrapper

Wraps block content for editor selection and live preview.

{% for block in section.blocks %}
{% block_wrapper block %}
{% if block.type == 'feature' %}
<div class="feature">
<h3>{{ block.settings.title }}</h3>
<p>{{ block.settings.description }}</p>
</div>
{% endif %}
{% endblock_wrapper %}
{% endfor %}

With custom element and classes:

{% block_wrapper block tag: 'li' class: 'nav-item' %}
<a href="{{ block.settings.link | link_href }}">
{{ block.settings.link | link_text }}
</a>
{% endblock_wrapper %}

For nested blocks (children):

{% for child in block.children %}
{% block_wrapper child source: 'child' %}
{{ child.settings.text }}
{% endblock_wrapper %}
{% endfor %}

content_for_layout

Outputs page sections with optional filtering by section settings. Used in layout files to create multi-column layouts.

Basic Usage

<!-- Output all sections (in layout.liquid) -->
{% content_for_layout %}

Filter by Setting

<!-- Multi-column layout -->
<div class="grid-3col">
<div class="col-left">
{% content_for_layout column: 'left_col' %}
</div>
<div class="col-center">
{% content_for_layout column: 'center_col' %}
</div>
<div class="col-right">
{% content_for_layout column: 'right_col' %}
</div>
</div>

Parameters:

Any key-value pair filters sections by matching section.settings[key] == value.

ParameterTypeDescription
(any)string/booleanFilter by section setting value

Section Schema for Column Support:

{
"settings": [
{
"type": "select",
"id": "column",
"label": "Column",
"default": "center_col",
"options": [
{ "value": "left_col", "label": "Left Column" },
{ "value": "center_col", "label": "Center Column" },
{ "value": "right_col", "label": "Right Column" }
]
}
]
}

paginate

Wraps a collection with pagination context.

{% paginate collections.all_posts by 12 %}
{% for post in paginate.collection %}
<article>
<img src="{{ post.image | image_url: 'medium' }}" alt="">
<h3>{{ post.title }}</h3>
</article>
{% endfor %}

<nav class="pagination">
{% if paginate.previous %}
<a href="{{ paginate.previous.url }}">&laquo; Previous</a>
{% endif %}

{% for part in paginate.parts %}
{% if part.is_link %}
<a href="{{ part.url }}">{{ part.title }}</a>
{% else %}
<span class="current">{{ part.title }}</span>
{% endif %}
{% endfor %}

{% if paginate.next %}
<a href="{{ paginate.next.url }}">Next &raquo;</a>
{% endif %}
</nav>
{% endpaginate %}

Paginate Object:

PropertyTypeDescription
page_sizeintegerItems per page
current_pageintegerCurrent page number
current_offsetintegerOffset for query
itemsintegerTotal items count
pagesintegerTotal pages
collectionarrayCurrent page's items
previousobjectPrevious page link (or nil)
nextobjectNext page link (or nil)
partsarrayPage number links

Part Object:

PropertyTypeDescription
titlestringPage number or ellipsis
urlstringLink URL
is_linkbooleanTrue if clickable

render

Include a snippet (partial template) with local variables.

<!-- Simple include -->
{% render 'card' %}

<!-- With variables -->
{% render 'post-card', post: post, show_excerpt: true %}

<!-- Loop over collection -->
{% render 'item' for items as item %}

<!-- Pass variable with auto-name -->
{% render 'post-card' with post %}

Important: Variables must be passed explicitly - snippets don't inherit parent scope.

Snippet Locations (searched in order):

  1. themes/{theme}/snippets/{name}.liquid
  2. themes/{theme}/partials/{name}.liquid
  3. themes/default/snippets/{name}.liquid (fallback)

Loop Variables (for for syntax):

VariableDescription
forloop.index1-based index
forloop.index00-based index
forloop.firstTrue on first iteration
forloop.lastTrue on last iteration
forloop.lengthCollection size

theme_css

Smart CSS loading with live preview support.

<!-- In your layout's <head> -->
{% theme_css %}

Output (Production):

<link rel="stylesheet" href="/themes/default/assets/theme-abc123.css">

Output (Preview):

<link rel="stylesheet" href="/themes/default/assets/theme-abc123.css">
<style id="theme-variables">:root { --theme-primary-color: #ff0000; }</style>
<script>/* Live update listener */</script>

theme_javascript

Smart JavaScript loading with visual editor support.

<!-- At end of layout body -->
{% theme_javascript %}

Outputs the theme's JavaScript plus, in preview mode, the visual editor script for hover overlays and edit buttons.


member_form

Block tag that generates an authentication form with the correct action URL, method, and CSRF token. Handles all the security plumbing so you only need to provide the form fields.

{% member_form "login" %}
<input type="email" name="email" placeholder="Email">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
{% endmember_form %}

Output:

<form action="/member/login" method="post" class="member-form member-form--login">
<input type="hidden" name="authenticity_token" value="...">
<input type="email" name="email" placeholder="Email">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
</form>

Form Types:

TypeAction URLDescription
login/member/loginSign in with email + password
register/member/registerCreate a new member account
logout/member/logoutSign out the current member
forgot_password/member/password/newRequest a password reset email
reset_password/member/password/editSet a new password (from reset link)

Block Variables:

Inside the block, a form variable is available:

VariableTypeDescription
form.errorsstringError message from the last form submission (if any)
form.return_tostringURL to redirect to after authentication (if set)

Example: Login Form with Error Handling

{% member_form "login" %}
{% if form.errors %}
<div class="form-error">{{ form.errors }}</div>
{% endif %}

<label for="email">Email</label>
<input type="email" name="email" id="email" required>

<label for="password">Password</label>
<input type="password" name="password" id="password" required>

<button type="submit">Sign In</button>

<a href="{{ routes.member_password_path }}">Forgot password?</a>
<p>Don't have an account? <a href="{{ routes.member_register_path }}">Register</a></p>
{% endmember_form %}

Example: Registration Form

{% member_form "register" %}
{% if form.errors %}
<div class="form-error">{{ form.errors }}</div>
{% endif %}

<input type="text" name="first_name" placeholder="First Name">
<input type="text" name="last_name" placeholder="Last Name">
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required>

<button type="submit">Create Account</button>
{% endmember_form %}

Example: Logout Button

{% member_form "logout" %}
<button type="submit">Sign Out</button>
{% endmember_form %}

CSS Classes:

Each form has consistent classes for styling:

  • .member-form -- Base class for all member forms
  • .member-form--{type} -- Type-specific class (e.g., .member-form--login, .member-form--register)

member / guest

Conditional block tags that render content based on whether a member is signed in.

{% member %} renders its content only when a member is logged in. {% guest %} renders its content only when no member is logged in. They are complementary tags.

{% member %}
<p>Welcome back, {{ member.display_name }}!</p>
{% member_form "logout" %}
<button type="submit">Sign Out</button>
{% endmember_form %}
{% endmember %}

{% guest %}
<a href="{{ routes.member_login_path }}">Sign In</a>
<a href="{{ routes.member_register_path }}">Create Account</a>
{% endguest %}

Example: Header Auth Navigation

<nav class="auth-nav">
{% member %}
{% if member.avatar_url %}
<img src="{{ member.avatar_url }}" alt="" class="avatar">
{% endif %}
<span>{{ member.display_name }}</span>
{% member_form "logout" %}
<button type="submit" class="btn-link">Sign Out</button>
{% endmember_form %}
{% endmember %}

{% guest %}
<a href="{{ routes.member_login_path }}">Sign In</a>
<a href="{{ routes.member_register_path }}">Join</a>
{% endguest %}
</nav>
tip

These tags are cleaner than using {% if member_logged_in %} conditionals. They read naturally and make templates easier to understand.


login_form

Block tag that renders an OAuth social login form for a specific provider. Handles all security plumbing (POST form, CSRF token, state parameter) while giving theme designers full control over the button appearance.

If the provider is not configured by the site admin, it renders an HTML comment instead.

Basic Usage

{% login_form "google" %}{% endlogin_form %}
{% login_form "github" %}{% endlogin_form %}

When the block body is empty, a default button is rendered with the provider's brand icon and label.

Output (when configured):

<form action="/users/auth/google_oauth2" method="post" class="login-form login-form--google">
<input type="hidden" name="authenticity_token" value="...">
<input type="hidden" name="state" value="...">
<button type="submit" class="login-form-btn login-form-btn--google" data-provider="google">
<svg class="login-form-icon">...</svg>
<span>Continue with Google</span>
</button>
</form>

Output (when NOT configured):

<!-- google login not configured -->

Custom Button

Use the block body to provide your own button markup. A provider variable is available inside the block:

VariableTypeDescription
provider.namestringProvider key ("google", "github", etc.)
provider.labelstringDisplay name ("Google", "GitHub", etc.)
provider.iconstringBrand SVG icon markup
{% login_form "google" %}
<button type="submit" class="my-social-btn">
{{ provider.icon }}
<span>Sign in with {{ provider.label }}</span>
</button>
{% endlogin_form %}

Member OAuth

Add for: "member" to target member authentication routes instead of the default user routes:

{% login_form "google" for: "member" %}{% endlogin_form %}

This changes the form action from /users/auth/google_oauth2 to /member/auth/google_oauth2.

Available Providers

ProviderTagDisplay Label
Google{% login_form "google" %}Continue with Google
Facebook{% login_form "facebook" %}Continue with Facebook
Apple{% login_form "apple" %}Continue with Apple
Twitter/X{% login_form "twitter" %}Continue with X
GitHub{% login_form "github" %}Continue with GitHub
LinkedIn{% login_form "linkedin" %}Continue with LinkedIn

CSS Classes

Each form and button has consistent classes for styling:

  • .login-form -- Base class for all login forms
  • .login-form--{provider} -- Provider-specific form class
  • .login-form-btn -- Default button class
  • .login-form-btn--{provider} -- Provider-specific button class
  • .login-form-icon -- SVG icon class

Example: Login Page with Social + Email

<div class="login-page">
<h2>Sign In</h2>

<div class="social-login">
{% login_form "google" for: "member" %}{% endlogin_form %}
{% login_form "github" for: "member" %}{% endlogin_form %}
{% login_form "facebook" for: "member" %}{% endlogin_form %}
</div>

<div class="divider">or</div>

{% member_form "login" %}
{% if form.errors %}
<div class="form-error">{{ form.errors }}</div>
{% endif %}
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Sign In with Email</button>
{% endmember_form %}
</div>
Configuration Required

OAuth providers must be configured by the site administrator at Admin > Settings > OAuth before they appear. Unconfigured providers render as HTML comments, so you can safely include all providers in your theme -- only the configured ones will display.


theme_asset

Load CSS, JS, or image assets from the theme.

{% theme_asset 'main.css' %}
{% theme_asset 'theme.js' %}
{% theme_asset 'logo.png' %}

Output:

<link rel="stylesheet" href="/themes/default/assets/main-abc123.css">
<script src="/themes/default/assets/theme-def456.js"></script>
/themes/default/assets/logo.png

Options:

OptionDescription
type: 'css'Force CSS output
type: 'js'Force JS output
preload: 'true'Add preload for CSS
defer: 'false'Disable defer for JS
async: 'true'Add async for JS

showcase

Render items in visual layout modes (mondrian, masonry, grid). Useful for displaying collections or tags as visual tiles.

{% showcase layout: 'mondrian' collection: collections.featured columns: 4 %}
<article style="{{ tile.style }}">
<a href="{{ item | collection_url }}"
style="background-image: url('{{ item.image | image_url: 'medium' }}')">
<h3>{{ item.title }}</h3>
</a>
</article>
{% endshowcase %}

Parameters:

ParameterTypeDefaultDescription
layoutstring'mondrian''mondrian', 'masonry', 'grid', 'full_width'
collectionobject-Array of items to display
columnsinteger4Number of columns
row_heightinteger160Row height in pixels
height_scalefloat1.5Height multiplier
overlay_opacitystring'0.45'Overlay opacity
draw_borderbooleanfalseDraw borders
show_descriptionbooleanfalseShow item descriptions
show_post_countbooleanfalseShow post count

Block Variables:

VariableTypeDescription
itemDropCurrent item (CollectionDrop, TagDrop, or PostDrop)
tileTileDropTile info (style, width, height, variant)
forloopHashStandard loop object

schema

Define section settings. Must be at the end of the section file.

{% schema %}
{
"name": "Hero Banner",
"category": "hero",
"settings": [
{
"type": "text",
"id": "title",
"label": "Title",
"default": "Welcome"
},
{
"type": "image",
"id": "background_image",
"label": "Background Image"
},
{
"type": "checkbox",
"id": "show_overlay",
"label": "Show Overlay",
"default": true
}
],
"blocks": [
{
"type": "button",
"name": "Button",
"settings": [
{
"type": "link",
"id": "link",
"label": "Button Link"
}
]
}
]
}
{% endschema %}

See Settings System for full schema documentation.


style

Defines per-section CSS that gets extracted, compiled, and served as a separate CSS file. This is the Shopify-style approach where section CSS lives alongside the section template in the same .liquid file.

The {% style %} block does not render inline in the HTML output. Instead, OQO's CSS compiler extracts these blocks and pre-compiles them into separate CSS files that are loaded via {% theme_css %}.

Basic Usage

{% style %}
[data-section-wrapper][data-section-id="{{ section.id }}"] {
--section-bgcolor: {{ section.settings.bgcolor }};
background-color: var(--section-bgcolor);
}
{% endstyle %}

With Conditional Settings

Use Liquid conditionals inside {% style %} blocks to apply CSS variables only when a setting has a value:

{% style %}
[data-section-wrapper][data-section-id="{{ section.id }}"] {
{% if section.settings.custom_colors and section.settings.bgcolor != blank %}
--section-bgcolor: {{ section.settings.bgcolor | color_alpha_to_rgba }};
{% else %}
--section-bgcolor: var(--theme-background-color);
{% endif %}

{% if section.settings.custom_colors and section.settings.text_color != blank %}
--section-text-color: {{ section.settings.text_color | color_alpha_to_rgba }};
{% else %}
--section-text-color: var(--theme-text-color);
{% endif %}

background-color: var(--section-bgcolor);
color: var(--section-text-color);
}
{% endstyle %}

How It Works

  1. You write a {% style %}...{% endstyle %} block in your section's .liquid file
  2. The CSS compiler extracts the block and processes it as a Liquid template with section.id and section.settings available
  3. The compiled CSS is written to a separate file and loaded automatically
  4. In preview mode, the block is re-rendered on the fly when settings change, enabling live hot-reload of CSS variables

Rules

RuleWhy
Use [data-section-wrapper][data-section-id="{{ section.id }}"] as the selectorScopes CSS to the specific section instance
Stick to CSS variables and simple property rulesBlocks are re-rendered on every settings change and must be lightweight
Use --section-* prefix for your CSS variablesDistinguishes section-level variables from --theme-* global variables
Put animations, hover effects, and complex CSS in the theme stylesheetStatic styles do not need hot-reload and belong in the main CSS

Complete Section Example

A section template with HTML, a {% style %} block, and a {% schema %}:

{% section_wrapper 'section' class: 'hero-banner' %}
<div class="hero-content">
<h1>{{ section.settings.title }}</h1>
<p>{{ section.settings.subtitle }}</p>
</div>
{% endsection_wrapper %}

{% style %}
[data-section-wrapper][data-section-id="{{ section.id }}"] {
{% if section.settings.bgcolor != blank %}
--section-bgcolor: {{ section.settings.bgcolor | color_alpha_to_rgba }};
{% else %}
--section-bgcolor: var(--theme-background-color);
{% endif %}

background-color: var(--section-bgcolor);
min-height: 400px;
display: flex;
align-items: center;
}
{% endstyle %}

{% schema %}
{
"name": "Hero Banner",
"settings": [
{ "type": "text", "id": "title", "label": "Title" },
{ "type": "text", "id": "subtitle", "label": "Subtitle" },
{ "type": "color_alpha", "id": "bgcolor", "label": "Background Color" }
]
}
{% endschema %}

zone

Declares a layout zone where sections can be placed. Zones are the building blocks of layout templates -- they define the regions (header, footer, sidebar, etc.) where site-wide sections live.

The admin UI automatically discovers zones by parsing your layout template. No configuration needed beyond adding the tag.

Basic Usage

{% zone 'header' %}{% endzone %}

<main>
{% content_for_layout %}
</main>

{% zone 'footer' %}{% endzone %}

With Default Content

Default content is shown when no sections have been assigned to the zone:

{% zone 'sidebar' %}
<aside class="default-sidebar">
<p>Add sections to customize this sidebar</p>
</aside>
{% endzone %}

With Wrapper Options

When you provide wrapper options, the zone's content is wrapped in an HTML element:

{% zone 'header' tag: 'header' class: 'site-header' %}{% endzone %}
{% zone 'footer' tag: 'footer' id: 'site-footer' %}{% endzone %}
{% zone 'sidebar' tag: 'aside' class: 'sidebar-area' %}{% endzone %}

Without options, section HTML is output directly with no wrapper element.

Parameters

ParameterTypeDescription
First argumentstringZone name (required)
tagstringWrapper HTML element: div, header, footer, aside, nav, section, main, article
classstringCSS classes for wrapper
idstringHTML id for wrapper

Zone Naming

Use any name you want. Names automatically become human-readable labels in the admin UI:

Zone NameAdmin Label
headerHeader
left_sidebarLeft Sidebar
above_foldAbove Fold
main_navigationMain Navigation

Complete Layout Example

<!DOCTYPE html>
<html lang="{{ request.locale | default: 'en' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page.title | default: settings.site_name }}</title>
{% theme_css %}
</head>
<body class="gz-theme">
{% zone 'header' tag: 'header' %}{% endzone %}

<main class="gz-layout">
{% content_for_layout %}
</main>

{% zone 'footer' tag: 'footer' %}{% endzone %}

{% theme_javascript %}
</body>
</html>

Multi-Zone Newspaper Layout

{% zone 'topbar' %}{% endzone %}
{% zone 'masthead' %}{% endzone %}
{% zone 'navigation' tag: 'nav' %}{% endzone %}

<main class="np-container">
{% zone 'above_fold' %}{% endzone %}

<div class="np-grid-3col">
<aside class="np-col np-col--left">
{% zone 'left_sidebar' %}{% endzone %}
</aside>
<article class="np-col np-col--main">
{% content_for_layout %}
</article>
<aside class="np-col np-col--right">
{% zone 'right_sidebar' %}{% endzone %}
</aside>
</div>

{% zone 'below_fold' %}{% endzone %}
</main>

{% zone 'footer' tag: 'footer' %}{% endzone %}
Zones vs Page Sections

Zone sections are site-wide -- they appear on every page that uses the layout. They are rendered by {% zone %} tags. Page sections are specific to a single page and are rendered by {% content_for_layout %}.


extends

Enables layout inheritance. Child layouts extend a parent layout and override specific zones while inheriting everything else. This keeps your layouts DRY -- define the HTML boilerplate once in a base layout, then create variants by overriding only the zones that differ.

Basic Inheritance

{%- comment -%} layouts/base.liquid -- parent layout {%- endcomment -%}
<!DOCTYPE html>
<html>
<head>{% theme_css %}</head>
<body>
{% zone 'header' %}<header>Default Header</header>{% endzone %}

<main>
{% zone 'main' %}{% content_for_layout %}{% endzone %}
</main>

{% zone 'footer' %}<footer>Default Footer</footer>{% endzone %}
{% theme_javascript %}
</body>
</html>
{%- comment -%} layouts/landing.liquid -- child layout {%- endcomment -%}
{% extends 'base' %}

{% zone 'header' %}
<header class="landing-header">
<h1>Landing Page</h1>
</header>
{% endzone %}

{%- comment -%} main and footer zones are inherited from base {%- endcomment -%}
{% endextends %}

How It Works

  1. The child declares {% extends 'parent_name' %} at the top
  2. Inside the extends block, the child redefines only the zones it wants to override
  3. The block ends with {% endextends %}
  4. Zones not redefined in the child inherit the parent's content
  5. The parent's full HTML structure wraps everything

Parameters

ParameterTypeDescription
First argumentstringParent layout name (required, matches filename without .liquid)

Using zone.super

Include the parent's zone content within an override using {{ zone.super }}:

{% extends 'base' %}

{% zone 'header' %}
<div class="promo-banner">Limited Time Offer!</div>
{{ zone.super }}
{% endzone %}

{% endextends %}

This renders the promo banner above the parent's default header content.

Multi-Level Inheritance

Layouts can chain multiple levels. For example, article.liquid extends default.liquid, which extends base.liquid:

{%- comment -%} layouts/base.liquid {%- endcomment -%}
<!DOCTYPE html>
<html>
<head>{% theme_css %}</head>
<body>
{% zone 'header' %}{% endzone %}
{% zone 'main' %}{% content_for_layout %}{% endzone %}
{% zone 'footer' %}{% endzone %}
{% theme_javascript %}
</body>
</html>
{%- comment -%} layouts/default.liquid -- extends base {%- endcomment -%}
{% extends 'base' %}

{% zone 'header' %}
{% zone 'topbar' %}{% endzone %}
{% zone 'masthead' %}{% endzone %}
{% zone 'navigation' %}{% endzone %}
{% endzone %}

{% endextends %}
{%- comment -%} layouts/article.liquid -- extends default {%- endcomment -%}
{% extends 'default' %}

{% zone 'main' %}
<article class="article-layout">
{% zone 'article_header' %}{% endzone %}
{% content_for_layout %}
{% zone 'article_footer' %}{% endzone %}
</article>
{% endzone %}

{% endextends %}

Example Use Cases

Layout VariantStrategy
Landing pageExtends default, overrides header with minimal branding
Article layoutExtends default, adds article_header and article_footer zones
Minimal layoutExtends base, overrides header with just a logo
Print layoutExtends base, removes all navigation zones

Standard Liquid Tags

These standard Liquid tags are commonly used in OQO themes:

Control Flow

{% if section.settings.show_title %}
<h2>{{ section.settings.title }}</h2>
{% endif %}

{% if post.image.present? %}
<img src="{{ post.image | image_url: 'medium' }}">
{% elsif section.settings.fallback_image %}
<img src="{{ section.settings.fallback_image | image_url: 'medium' }}">
{% else %}
<div class="placeholder"></div>
{% endif %}

{% unless post.tags.blank? %}
{% for tag in post.tags %}
<a href="{{ tag | tag_url }}">{{ tag.name }}</a>
{% endfor %}
{% endunless %}

{% case section.settings.columns %}
{% when '2' %}
<div class="grid-cols-2">
{% when '3' %}
<div class="grid-cols-3">
{% else %}
<div class="grid-cols-1">
{% endcase %}

Loops

{% for block in section.blocks %}
{% block_wrapper block %}
<div>{{ block.settings.title }}</div>
{% endblock_wrapper %}
{% endfor %}

{% for post in collections.featured limit: 3 offset: 1 %}
<article>{{ post.title }}</article>
{% endfor %}

{% for tag in post.tags %}
<a href="{{ tag | tag_url }}">{{ tag.name }}</a>
{% unless forloop.last %}, {% endunless %}
{% endfor %}

Variables

{% assign card_width = section.settings.card_width | default: 'w-72' %}

{% capture section_style %}
--bgcolor: {{ section.settings.bgcolor }};
--overlay: {{ section.settings.overlay | color_alpha_to_rgba }};
{% endcapture %}

Comments

{%- comment -%}
This is a multi-line comment.
It won't appear in the output.
{%- endcomment -%}

Whitespace Control

Use - to strip whitespace before and after tags:

{%- if condition -%}
content
{%- endif -%}

Without whitespace control:



content


With whitespace control:

content

  • Liquid Objects - Data objects like member, flash, routes, post, collection, section
  • Liquid Filters - Filters like image_url, post_url, and navigation filters (next_post, prev_post)
  • Settings System - Defining section and theme settings
  • Theme Structure - Theme directory layout and file conventions