CSS Architecture
How CSS is organized and compiled in OQO themes.
Core Principle
Themes are self-contained and framework-agnostic.
You compile your CSS framework (Tailwind, Bootstrap, or custom) locally. OQO only handles settings-driven CSS variables and CSS bundling (
@importresolution by inlining partials).
File Structure
your-theme/
├── assets/
│ ├── css/
│ │ ├── tailwind.css # Tailwind source (you compile this)
│ │ ├── _variables.css.liquid # CSS variables from theme settings
│ │ ├── _base.css # Base element styles
│ │ ├── _layout.css # Container/section layout utilities
│ │ ├── _typography.css # Text styling classes
│ │ ├── _components.css # Cards, buttons, etc. (.gz-card-*, .gz-btn-*)
│ │ ├── _sections.css # Section component styles (animations, hover effects)
│ │ ├── _utilities.css # Animations, images
│ │ └── theme.css # Entry point (@imports all partials)
│ ├── framework.css # Pre-compiled CSS framework output (DO NOT EDIT)
│ ├── theme.css # Final bundled output (auto-generated by OQO)
│ └── bundle.css # All CSS merged (auto-generated, for bundle mode)
├── sections/ # Section templates with {% style %} blocks
└── config/
└── settings_schema.json # Theme settings definition
How Compilation Works
On Theme Save
When you save theme settings, OQO automatically compiles your CSS:
- Load framework.css -- Your pre-compiled Tailwind/Bootstrap (if present)
- Render
_variables.css.liquid-- Processed with Liquid using current settings - Resolve
@importstatements --theme.cssentry point has its@importstatements resolved by inlining partial files (plain CSS concatenation) - Bundle output -- All parts concatenated into a single
theme.css
Output Structure
theme.css (generated)
├── [framework.css] # Pre-compiled CSS framework
├── :root { ... } # CSS variables from settings
└── [resolved theme CSS] # Theme stylesheets with @imports inlined
You never need to manually compile theme.css. OQO regenerates it automatically when theme files or settings change.
What Goes Where
| I want to... | Edit this file | Then run |
|---|---|---|
Add a Tailwind utility class (mb-6, gap-4) | Liquid template | tailwindcss ... |
Add a custom component style (.gz-card-*) | css/_components.css | Nothing (auto) |
| Add section animation / hover effects | css/_sections.css | Nothing (auto) |
| Change a theme variable | css/_variables.css.liquid | Nothing (auto) |
| Add section-specific CSS (colors, backgrounds) | {% style %} block in section .liquid file | Nothing (auto) |
Setting ID to CSS Variable Mapping
Theme settings automatically map to CSS variables with a --theme- prefix. Underscores in setting IDs become hyphens in CSS variable names:
| Setting ID | CSS Variable |
|---|---|
primary_color | --theme-primary-color |
content_width | --theme-content-width |
border_radius | --theme-border-radius |
Define the mapping in _variables.css.liquid:
:root {
--theme-primary-color: {{ settings.primary_color | default: '#3B82F6' }};
--theme-content-width: {{ settings.content_width | default: 1280 }}px;
--theme-border-radius: {{ settings.border_radius | default: 8 }}px;
}
Section settings follow the same convention with a --section- prefix:
| Setting ID | CSS Variable |
|---|---|
bg_color | --section-bg-color |
overlay_opacity | --section-overlay-opacity |
Per-Section CSS: The {% style %} Block
This is the core pattern for section-specific CSS. Each section template can include a {% style %}...{% endstyle %} block that defines CSS scoped to that section instance.
How It Works
- You write a
{% style %}block in your section.liquidfile - OQO extracts it, renders the Liquid variables, and compiles it to a standalone CSS file
- Each section instance gets its own CSS file with a content-based hash for cache busting
- In the editor, these files hot-reload instantly when settings change -- no page refresh needed
Basic Example
{%- comment -%} Template HTML {%- endcomment -%}
{% section_wrapper 'section' class: 'cta-01' %}
<div class="cta-01-content">
<h2 class="cta-01-title">{{ section.settings.title }}</h2>
<p class="cta-01-body">{{ section.settings.body }}</p>
</div>
{% endsection_wrapper %}
{% style %}
[data-section-wrapper][data-section-id="{{ section.id }}"] {
background-color: var(--section-bgcolor);
.cta-01-title { color: var(--section-heading); }
.cta-01-body { color: var(--section-text); }
}
{% endstyle %}
The Scoping Selector
Every {% style %} block must use this selector to scope CSS to the specific section instance:
[data-section-wrapper][data-section-id="{{ section.id }}"]
This targets the section wrapper element that {% section_wrapper %} generates automatically. The {{ section.id }} is a unique instance ID, so two instances of the same section type on a page each get their own CSS.
Compiled Output
OQO compiles each {% style %} block into a separate CSS file:
assets/sections/{category}-{name}-{instance_id}-{content_hash}.css
For example: cta-cta_01-S8vQP-jI-a1b2c3d4.css
The content hash changes when the CSS content changes, so browsers cache aggressively and only re-fetch when settings actually change.
What Belongs in {% style %} Blocks
Put in {% style %} blocks: CSS that changes with section settings -- variables, background colors, text colors, spacing driven by settings.
Put in _sections.css: Static CSS that never changes with settings -- animations, hover effects, transitions, perspective transforms. Scope these under the .gz-{category}-{name} class.
/* Static styles that don't change with settings */
.gz-banner-banner_05 {
.banner-05-image-wrap {
perspective: 1000px;
img {
transition: transform 0.4s ease, box-shadow 0.4s ease;
&:hover { transform: scale(1.03) rotateX(-2deg) rotateY(3deg); }
}
}
}
Keep {% style %} blocks lightweight. They are re-rendered on every settings change. Animations, hover effects, and complex layouts should go in _sections.css instead.
Custom Colors Pattern
The custom_colors pattern lets site owners override theme colors on a per-section basis. It uses a checkbox toggle and color_alpha settings with {% if %} guards -- never | default: filters.
The Pattern
Schema settings:
{
"settings": [
{
"type": "checkbox",
"id": "custom_colors",
"label": "Custom Colors",
"info": "Override theme colors for this section"
},
{
"type": "color_alpha",
"id": "bgcolor",
"label": "Background Color",
"show_if": { "setting": "custom_colors", "eq": true }
},
{
"type": "color_alpha",
"id": "heading_color",
"label": "Heading Color",
"show_if": { "setting": "custom_colors", "eq": true }
},
{
"type": "color_alpha",
"id": "text_color",
"label": "Text Color",
"show_if": { "setting": "custom_colors", "eq": true }
}
]
}
Style block:
{% 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.heading_color != blank %}
--section-heading: {{ section.settings.heading_color | color_alpha_to_rgba }};
{% else %}
--section-heading: var(--theme-heading-color);
{% endif %}
{% if section.settings.custom_colors and section.settings.text_color != blank %}
--section-text: {{ section.settings.text_color | color_alpha_to_rgba }};
{% else %}
--section-text: var(--theme-text-color);
{% endif %}
background-color: var(--section-bgcolor);
h2 { color: var(--section-heading); }
p { color: var(--section-text); }
}
{% endstyle %}
Key Rules
| Rule | Why |
|---|---|
Always check section.settings.custom_colors AND != blank | The checkbox must be enabled AND the color must be set |
Use color_alpha type for all section colors | Supports both solid colors and colors with transparency |
Always use color_alpha_to_rgba filter | Handles both JSON {color, alpha} objects and plain hex strings |
Fall back to var(--theme-*) variables | Sections inherit theme colors by default |
| Never use ` | default:` for color fallbacks |
Why Not | default:?
The | default: filter does not work reliably with color settings. An empty color input returns an empty string (''), which is truthy in Liquid -- so | default: never triggers. The explicit != blank check handles this correctly.
{%- comment -%} WRONG -- | default: never fires for empty strings {%- endcomment -%}
--section-bgcolor: {{ section.settings.bgcolor | default: 'var(--theme-background-color)' }};
{%- comment -%} CORRECT -- explicit blank check {%- endcomment -%}
{% 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 %}
Section Wrapper Classes
The {% section_wrapper %} tag automatically adds standard classes and data attributes to every section:
{% section_wrapper 'section' class: 'my-custom-class' %}
<h2>My Section</h2>
{% endsection_wrapper %}
Renders:
<section class="gz-section gz-cta-cta_01 my-custom-class"
data-section-wrapper="true"
data-section-id="S8vQP-jI"
data-section-type="cta/cta_01">
<h2>My Section</h2>
</section>
Auto-Generated Attributes
| Attribute | Example | Purpose |
|---|---|---|
gz-section | gz-section | Common class shared by all sections (spacing, shared styles) |
gz-{category}-{name} | gz-cta-cta_01 | Section-type class for type-specific styles in _sections.css |
data-section-wrapper | true | Marker for the visual editor and CSS scoping |
data-section-id | S8vQP-jI | Unique instance ID (used in {% style %} block selector) |
data-section-type | cta/cta_01 | Section template path |
Use gz-section for styles shared across all sections (like vertical spacing). Use gz-{category}-{name} for type-specific component styles in _sections.css.
Edge-to-Edge Bleed Pattern
Sections can break out of their contained parent to extend to the full viewport width. This is useful for full-width banners, heroes, and navigation bars.
The CSS
.gz-section-bleed {
width: 100vw;
max-width: none;
margin-left: calc(-50vw + 50%);
}
How it works:
width: 100vw-- element expands to full viewport widthmax-width: none-- removes any parent max-width constraintsmargin-left: calc(-50vw + 50%)-- pulls element left to the viewport edge regardless of container width
Optional Bleed (Setting-Driven)
For sections where the user can toggle edge-to-edge:
{%- assign section_class = 'relative' | append_if: section.settings.edge_to_edge, ' gz-section-bleed' -%}
{% section_wrapper 'div' class: section_class %}
<div class="gz-container">
<!-- Content stays contained within max-width -->
</div>
{% endsection_wrapper %}
With the schema setting:
{
"type": "checkbox",
"id": "edge_to_edge",
"label": "Edge to Edge",
"info": "Extend section to viewport edges"
}
Always-Bleed Sections
Navigation and footer sections typically always extend to viewport edges:
{% section_wrapper 'nav' class: 'gz-nav-bg py-4 px-4' %}
<div class="gz-container">
<!-- Navigation content -->
</div>
{% endsection_wrapper %}
Content Containment Inside Bleed Sections
When a section bleeds to viewport edges, wrap inner content in gz-container to keep it centered and contained:
{% section_wrapper 'div' class: 'gz-section-bleed' %}
<div class="gz-container">
<!-- Content is centered within max-width: var(--theme-content-width) -->
</div>
{% endsection_wrapper %}
Using Tailwind CSS
Source File
Create assets/css/tailwind.css:
@import "tailwindcss";
@source "../../sections/**/*.liquid";
@source "../../layouts/**/*.liquid";
@source "../../snippets/**/*.liquid";
The @source directives tell Tailwind to scan your Liquid files for class names.
Compile Locally
# In your theme directory
tailwindcss -i assets/css/tailwind.css -o assets/framework.css --watch
This produces a tree-shaken framework.css with only the utilities you actually use.
When to Recompile Tailwind
Run tailwindcss after:
- Adding new Tailwind utility classes to Liquid templates (e.g.,
mb-6,gap-8) - Removing utility classes (to tree-shake unused CSS)
- Modifying
@sourcedirectives
You do NOT need to run Tailwind after:
- Editing theme CSS files (OQO compiles these automatically)
- Changing theme settings (variables auto-regenerate)
- Editing Liquid template structure (only class name changes matter)
Using Theme Variables in Tailwind
Reference CSS variables in your Tailwind classes:
<div class="bg-[var(--theme-primary-color)]">
...
</div>
Minimal Setup (No Build Tools)
For simpler themes that don't need Tailwind, write CSS by hand. No build tools required.
framework.css-- Contains all your styles as hand-written CSS usingvar(--theme-*)custom properties_variables.css.liquid-- Generates CSS variables from theme settings (only file processed by Liquid at compile time)theme.css(entry point) -- Minimal file that@imports any needed partials like fonts
This approach works well for focused themes with a small number of custom styles.
CSS Loading: The {% theme_css %} Tag
CSS is loaded via the {% theme_css %} tag in your theme layout:
<head>
{% theme_css %}
{% theme_asset 'theme.js' %}
</head>
Output Modes
| Usage | Mode | Output |
|---|---|---|
{% theme_css %} | Default | theme.css + individual section CSS files |
{% theme_css bundle: true %} | Bundle | Single bundle.css file (all CSS merged) |
| (editor preview) | Preview | theme.css + hot-reloadable section CSS links |
Default mode loads theme.css plus separate CSS files for each section on the page. This allows browser caching of section CSS across pages.
Bundle mode loads a single bundle.css file containing all CSS. Use this for production when you want a single HTTP request.
Preview mode (automatic in the theme editor) loads individual section CSS files with cache-busting timestamps for instant hot-reload.
Default Mode Output Example
<link rel="stylesheet" href="/themes/default/assets/theme-abc123.css">
<link rel="stylesheet" href="/themes/default/sections/banner-banner_03-id-hash.css">
<link rel="stylesheet" href="/themes/default/sections/cta-cta_01-id-hash.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.5.0/fonts/remixicon.css">
Developer Guidelines
DO
| Practice | Why |
|---|---|
Use {% style %} blocks for section CSS variables and simple rules | Enables hot-reload and surgical cache invalidation |
Scope with [data-section-wrapper][data-section-id="{{ section.id }}"] | Targets the correct section instance |
Put animations, hover effects, complex CSS in _sections.css | Static styles scoped under .gz-{category}-{name} |
Use --section-* prefix for section CSS variables | Distinguishes from --theme-* global variables |
| Use hyphens in CSS variable names | Convention: --section-bg-color not --section-bg_color |
Use color_alpha_to_rgba filter for all section color settings | Handles both {color, alpha} JSON and plain hex strings |
Use image_url filter for image URLs | Generates proper variant URLs with CDN support |
Use gz-container inside bleed sections | Keeps content centered within the theme's max-width |
DON'T
| Anti-Pattern | Why It Breaks |
|---|---|
Use inline style: attributes for settings-driven CSS | Cannot be hot-reloaded; requires full HTML re-render |
Put animations or hover effects in {% style %} blocks | Re-rendered on every settings change; must be lightweight |
| Hardcode colors in section CSS | Settings and live preview will not work |
| Use underscores in CSS variable names | Convention violation (setting IDs use underscores, CSS vars use hyphens) |
Edit generated files (theme.css, framework.css, bundle.css) | Overwritten on next compilation |
| Reset margins/padding in unlayered theme CSS | Breaks Tailwind utility classes (theme CSS has higher cascade priority) |
| Use ` | default:` for color fallbacks |
Where to Edit -- Quick Reference
| I want to... | Edit this file |
|---|---|
| Add section CSS variables (colors, backgrounds) | {% style %} block in sections/{category}/{name}.liquid |
| Add section component styles (animations, hover) | css/_sections.css (under .gz-{category}-{name}) |
| Add a theme-level CSS variable | css/_variables.css.liquid |
| Add a custom component style | css/_components.css |
| Add animation utilities | css/_utilities.css |
| Add a Tailwind utility class | Liquid template, then run tailwindcss |
Complete Section Example
Here is a full section template showing the {% style %} block, custom_colors pattern, and schema together:
{% section_wrapper 'section' class: 'cta-03' %}
<div class="cta-03-content gz-container">
<h2 class="cta-03-title">{{ section.settings.title }}</h2>
<div class="cta-03-body">{{ section.settings.body }}</div>
<a href="{{ section.settings.button.href }}" class="cta-03-button">
{{ section.settings.button.text }}
</a>
</div>
{% endsection_wrapper %}
{% 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.heading_color != blank %}
--section-heading: {{ section.settings.heading_color | color_alpha_to_rgba }};
{% else %}
--section-heading: var(--theme-heading-color);
{% endif %}
{% if section.settings.custom_colors and section.settings.text_color != blank %}
--section-text: {{ section.settings.text_color | color_alpha_to_rgba }};
{% else %}
--section-text: var(--theme-text-color);
{% endif %}
background-color: var(--section-bgcolor);
.cta-03-title { color: var(--section-heading); }
.cta-03-body { color: var(--section-text); }
.cta-03-button {
background-color: var(--section-heading);
color: var(--section-bgcolor);
}
}
{% endstyle %}
{% schema %}
{
"name": "Call to Action",
"category": "cta",
"settings": [
{ "type": "header", "content": "Colors" },
{
"type": "checkbox",
"id": "custom_colors",
"label": "Custom Colors",
"info": "Override theme colors for this section"
},
{
"type": "color_alpha",
"id": "bgcolor",
"label": "Background Color",
"show_if": { "setting": "custom_colors", "eq": true }
},
{
"type": "color_alpha",
"id": "heading_color",
"label": "Heading Color",
"show_if": { "setting": "custom_colors", "eq": true }
},
{
"type": "color_alpha",
"id": "text_color",
"label": "Text Color",
"show_if": { "setting": "custom_colors", "eq": true }
},
{ "type": "header", "content": "Content" },
{ "type": "text", "id": "title", "label": "Title" },
{ "type": "text", "id": "body", "label": "Body", "html": true },
{ "type": "link", "id": "button", "label": "Button", "with_text": true }
]
}
{% endschema %}
This example demonstrates:
- The
{% section_wrapper %}tag generatinggz-sectionandgz-cta-cta_03classes - The
{% style %}block with the[data-section-wrapper][data-section-id]scoping selector - The
custom_colorscheckbox pattern with!= blankguards color_alphasettings with thecolor_alpha_to_rgbafilter- Fallback to
--theme-*variables when custom colors are not set