Shopify Storefront Styling Architecture
Overview
The storefront search widget uses a three-layer CSS architecture that separates merchant-configurable design tokens from template structure and custom overrides. This makes the styling system non-brittle — merchants can edit templates without breaking admin UI controls, and developers can add new controls without touching templates.
The Three Layers
Layer 1: CSS Variables (auto-generated from settings)
↓ injected first
Layer 2: Template CSS (editable in Advanced editor, uses var() references)
↓ injected second
Layer 3: Custom CSS (merchant escape hatch)
↓ injected last, highest priority
Layer 1 — CSS Variables
Generated by: storefront_search/src/css-variables.ts
Source of truth: UIComponent.value fields in DynamoDB/metafields
Scoped to: .marqo-search-layout (not :root)
Layer 1 reads design tokens (colors, sizes, fonts, radii) from window.MarqoUIConfig.uiComponents and generates a CSS variable block:
.marqo-search-layout {
--marqo-sale-badge-bg: #dc2626;
--marqo-card-border-radius: 8px;
--marqo-vendor-font: Georgia, serif;
/* ... ~53 variables total */
}
It also generates dynamic @media rules for responsive columns and the configurable filter mobile breakpoint.
Key rule: Layer 1 only contains values, never structure or layout rules.
Layer 2 — Template CSS
Stored in: UIComponent.css fields (e.g., product_card.css, filters.css)
Editable by: Merchants via the Advanced Template Editor
Uses: var(--marqo-*, fallback) references
.marqo-sale-badge {
background: var(--marqo-sale-badge-bg, #dc2626);
position: absolute !important;
top: 8px !important;
}
Every var() must include a fallback value that matches the default in ui_settings.py. This ensures the template works even if Layer 1 fails to load.
Key rule: Layer 2 contains structure and layout. It references variables but never sets them.
Layer 3 — Custom CSS
Stored in: custom_css.value.css
Editable by: Merchants via the Custom CSS editor
Purpose: Override anything. Escape hatch for power users.
.marqo-sale-badge { background: hotpink !important; }
Injection Order
In storefront_search/src/utils.ts → loadStylesFromSettings():
generateCssVariables(uiComponents)→ Layer 1- Loop through
uiComponents[key].css→ Layer 2 custom_css.value.css→ Layer 3
All concatenated and injected into a single <style id="marqo-search-styles"> tag.
Note: marqo-loader.js also injects styles early (FOUC prevention). The bundle's loadStylesFromSettings() overwrites the loader's injection with the full three-layer output.
Adding a New Admin Control
Step 1: Define the setting
File: ecom_admin/app/lib/types.ts
Add the field to the appropriate interface:
export interface ProductDisplaySettings {
// ... existing fields
newProperty: string; // Add here
}
Step 2: Add the default value
File: admin_server/admin_server/models/ui_settings.py
Add to the backend defaults so it exists from the start:
"product_display_config": UIComponent(
value={
# ... existing fields
"newProperty": "default_value",
},
),
File: ecom_admin/app/hooks/use-settings.ts
Add the same default to the frontend:
productDisplay: {
// ... existing fields
newProperty: "default_value",
},
Step 3: Add the CSS variable
File: storefront_search/src/css-variables.ts
Add to generateCssVariables():
vars.push(`--marqo-new-property: ${sanitize(dc.newProperty, 'default_value')}`);
Naming convention: --marqo-{component}-{property}
- Components:
sale-badge,cta,card,vendor,title,price,filter,sort-dropdown,page-dropdown,results,star,review - Always use kebab-case
Step 4: Use the variable in template CSS
File: admin_server/admin_server/constants/templates.py
Replace the hardcoded value with a var() reference:
/* Before */
.marqo-element { some-property: default_value; }
/* After */
.marqo-element { some-property: var(--marqo-new-property, default_value); }
Important: The fallback in var() MUST match the default in ui_settings.py.
Step 5: Add the UI control
File: ecom_admin/app/components/settings/{section}-section.tsx
Add a color picker, slider, toggle, etc.:
<FieldGroup label="New Property">
<ColorPicker
value={display.newProperty}
onChange={(color) => updateDisplay({ newProperty: color })}
label="New Property"
/>
</FieldGroup>
Step 6: Update the settings converter
File: ecom_admin/app/lib/settings-converter.ts
The converter translates between frontend types and backend UIComponent format. If your new field is inside an existing component's .value, it usually passes through automatically. But verify:
backendToFrontend(): reads the value from the backend componentfrontendToBackend(): writes the value back to the backend component
Step 7: Update the admin preview
File: ecom_admin/app/components/preview/search-preview.tsx
If applicable, add the setting to the live preview.
Step 8: Update the variables preview list
File: ecom_admin/app/components/settings/advanced-template-editor.tsx
Add the new variable name to the CssVariablesPreview component so merchants can see it when editing Advanced CSS.
What Goes Where
| Type of value | Layer | Example |
|---|---|---|
| Colors | Layer 1 (variable) | --marqo-sale-badge-bg: #dc2626 |
| Font sizes | Layer 1 (variable) | --marqo-vendor-size: 12px |
| Font families | Layer 1 (variable) | --marqo-vendor-font: Georgia, serif |
| Font weights | Layer 1 (variable) | --marqo-vendor-weight: 500 |
| Border radii | Layer 1 (variable) | --marqo-card-border-radius: 8px |
| Border widths | Layer 1 (variable) | --marqo-card-border-width: 1px |
| Spacing | Layer 1 (variable) | --marqo-column-spacing: 16px |
| Column counts per breakpoint | Layer 1 (dynamic @media) | repeat(4, 1fr) at desktop |
| Filter mobile breakpoint | Layer 1 (dynamic @media) | @media (max-width: 768px) |
| Positioning (absolute, flex, grid) | Layer 2 (template CSS) | position: absolute; top: 8px |
| Element visibility | Layer 2 (template CSS) | display: none |
| Transitions/animations | Layer 2 (template CSS) | transition: all 0.3s ease |
| Responsive scaling ratios | Layer 2 (template CSS) | calc(var(--size) * 0.9) |
| Content (text, labels) | Handlebars context | {{saleBadgeLabel}} |
| Behavioral toggles | Handlebars context / JS | {{#if ctaEnabled}} |
| Per-item dynamic values | Inline styles (exceptions) | style="width:{{reviewPercentage}}%" |
Inline Style Exceptions
These inline styles MUST remain as Handlebars variables — they are per-item dynamic values, not design tokens:
| Style | Reason | Location |
|---|---|---|
style="width:{{reviewPercentage}}%" | Per-product star fill | .marqo-stars-fg |
style="display:none" | Okendo widget hydration | data-oke-reviews div |
style="{{filterToggleStyle}}" | Conditional visibility | .marqo-filter-toggle |
Rule: If the value differs per product card or depends on runtime JS logic, it stays as an inline style. If it's a design token that the merchant configures once, it becomes a CSS variable.
Responsive Design
Layer 1 handles (values differ per breakpoint):
- Column counts:
repeat(${mobile}, 1fr)/repeat(${tablet}, 1fr)/repeat(${desktop}, 1fr) - Filter mobile breakpoint:
@media (max-width: ${filterMobileBreakpoint}px)
Layer 2 handles (proportional scaling):
- Font sizes:
calc(var(--marqo-vendor-size) * 0.9)at tablet,* 0.8at mobile - Spacing:
calc(var(--marqo-column-spacing) * 0.75)at tablet,* 0.5at mobile
Merchants control the base values (desktop). The scaling ratios are in template CSS. Power users can change ratios in the Advanced editor.
HTML Templates and Data Attributes
HTML templates use class names and data attributes, not inline styles for design tokens:
<!-- Good: class + data attribute, colors from CSS variables -->
<span class="marqo-sale-badge" data-position="{{saleBadgePosition}}">
{{saleBadgeLabel}}
</span>
<!-- Bad: inline styles for design tokens (old pattern, don't do this) -->
<span style="background:{{saleBadgeBg}};color:{{saleBadgeText}}">
Position-based logic uses data attributes + CSS:
.marqo-sale-badge { left: 8px; right: auto; }
.marqo-sale-badge[data-position="top-right"] { left: auto; right: 8px; }
Alignment uses data attributes on the grid container (set by JS in ui-rendering.ts):
.marqo-results-grid[data-align="center"] .marqo-product-card-content { text-align: center; }
Advanced Template Editor
The admin's Advanced Template Editor lets merchants edit raw HTML/CSS. To keep this non-brittle:
Validation Warnings
When merchants edit HTML, the editor scans for expected CSS class names and warns if any are missing:
Missing elements: .marqo-sale-badge — Sale badge controls won't have any effect
Expected elements are defined in EXPECTED_ELEMENTS in advanced-template-editor.tsx.
Reset to Default
Each template has a "Reset" button that:
- Fetches defaults from
/api/v1/storefront/defaults - Replaces
.htmland.csson thebackendSnapshot - Preserves
.value— merchant's color/toggle settings are kept
CSS Variables Preview
A collapsible panel shows all available --marqo-* variables so merchants know what they can reference when editing CSS.
CSS Namespacing
All CSS class names MUST use the marqo- prefix to avoid conflicts with Shopify themes:
marqo-product-card, marqo-sale-badge, marqo-filter-sidebar, marqo-active
Never use generic class names like active, primary-image, hidden.
Suppression
The suppression system hides native Shopify elements (search grids, pagination, filters) so Marqo can replace them. Suppression is applied in three places:
- Liquid template (
marqo-search-embed.liquid) — server-side, only on search/collection pages viarequest.page_type - Loader (
marqo-loader.js) — client-side, gated byisSearchPage() || isCollectionPage() - Bundle (
suppression-manager.ts) — client-side, gated bygetPageType()
All three must be gated to search/collection pages. Suppression on homepage/product/cart pages hides unrelated content.
File Reference
| File | Purpose |
|---|---|
storefront_search/src/css-variables.ts | Layer 1: generates CSS variables + dynamic responsive rules |
storefront_search/src/utils.ts | loadStylesFromSettings(): three-layer injection |
admin_server/constants/templates.py | Layer 2: HTML/CSS templates with var() references |
admin_server/models/ui_settings.py | Backend defaults for all settings |
ecom_admin/app/lib/types.ts | TypeScript interfaces for all settings |
ecom_admin/app/lib/settings-converter.ts | Frontend ↔ backend format translation |
ecom_admin/app/hooks/use-settings.ts | Frontend defaults + save/load hook |
ecom_admin/app/components/settings/advanced-template-editor.tsx | Validation, reset, variables preview |
extensions/marqo-search-theme/assets/marqo-loader.js | Early style injection, prefetch, suppression |
extensions/marqo-search-theme/blocks/marqo-search-embed.liquid | Server-side config injection + suppression |