
Liquid Theme Standards
- 119 installs
- 26 repo stars
- Updated March 18, 2026
- shopify/liquid-skills
This is a copy of liquid-theme-standards by benjaminsehl - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks during AI-assisted development.
About
liquid-theme-standards is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- liquid-theme-standards
- AI & Agent Building
- AI-coding skill
Liquid Theme Standards by the numbers
- 119 all-time installs (skills.sh)
- +8 installs in the week ending Jul 20, 2026 (Skillselion tracking)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/shopify/liquid-skills --skill liquid-theme-standardsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 119 |
|---|---|
| repo stars | ★ 26 |
| Last updated | March 18, 2026 |
| Repository | shopify/liquid-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
CSS, JS & HTML Standards for Shopify Liquid Themes
Core Principles
1. Progressive enhancement — semantic HTML first, CSS second, JS third 2. No external dependencies — native browser APIs only for JavaScript 3. Design tokens — never hardcode colors, spacing, or fonts 4. BEM naming — consistent class naming throughout 5. Defensive CSS — handle edge cases gracefully
CSS in Liquid Themes
Where CSS Lives
| Location | Liquid? | Use For |
|---|---|---|
{% stylesheet %} | No | Component-scoped styles (one per file) |
{% style %} | Yes | Dynamic values needing Liquid (e.g., color settings) |
assets/*.css | No | Shared/global styles |
Critical: {% stylesheet %} does NOT process Liquid. Use inline style attributes for dynamic values:
{%- comment -%} Do: inline variables {%- endcomment -%}
<div
class="hero"
style="--bg-color: {{ section.settings.bg_color }}; --padding: {{ section.settings.padding }}px;"
>
{%- comment -%} Don't: Liquid inside stylesheet {%- endcomment -%}
{% stylesheet %}
.hero { background: {{ section.settings.bg_color }}; } /* Won't work */
{% endstylesheet %}BEM Naming Convention
.block → Component root: .product-card
.block__element → Child: .product-card__title
.block--modifier → Variant: .product-card--featured
.block__element--modifier → Element variant: .product-card__title--largeRules:
- Hyphens separate words:
.product-card, not.productCard - Single element level only:
.block__element, never.block__el1__el2 - Modifier always paired with base class:
class="btn btn--primary", neverclass="btn--primary"alone - Start new BEM scope when a child could be standalone
<!-- Good: single element level -->
<div class="product-card">
<h3 class="product-card__title">{{ product.title }}</h3>
<span class="product-card__button-label">{{ 'add_to_cart' | t }}</span>
</div>
<!-- Good: new BEM scope for standalone component -->
<div class="product-card">
<button class="button button--primary">
<span class="button__label">{{ 'add_to_cart' | t }}</span>
</button>
</div>Specificity
- Target
0 1 0(single class) wherever possible - Maximum
0 4 0for complex parent-child cases - Never use IDs as selectors
- Never use
!important(comment why if absolutely forced to) - Avoid element selectors — use classes
CSS Nesting
/* Do: media queries inside selectors */
.header {
width: 100%;
@media screen and (min-width: 750px) {
width: auto;
}
}
/* Do: state modifiers with & */
.button {
background: var(--color-primary);
&:hover { background: var(--color-primary-hover); }
&:focus-visible { outline: 2px solid var(--color-focus); }
&[disabled] { opacity: 0.5; }
}
/* Do: parent modifier affecting children (single level) */
.card--featured {
.card__title { font-size: var(--font-size-xl); }
}
/* Don't: nested beyond first level */
.parent {
.child {
.grandchild { } /* Too deep */
}
}Design Tokens
Use CSS custom properties for all values — never hardcode colors, spacing, or fonts. Define a consistent scale and reference it everywhere.
Example scale (adapt to your theme's needs):
:root {
/* Spacing — use a consistent scale */
--space-2xs: 0.5rem; --space-xs: 0.75rem; --space-sm: 1rem;
--space-md: 1.5rem; --space-lg: 2rem; --space-xl: 3rem;
/* Typography — relative units */
--font-size-sm: 0.875rem; --font-size-base: 1rem;
--font-size-lg: 1.125rem; --font-size-xl: 1.25rem; --font-size-2xl: 1.5rem;
}Key principles:
- Use
remfor spacing and typography (respects user font size preferences) - Name tokens semantically:
--space-smnot--space-16 - Define in
:rootfor global tokens, on component root for scoped tokens
CSS Variable Scoping
Global — in :root for theme-wide values Component-scoped — on component root, namespaced:
/* Do: namespaced */
.facets {
--facets-padding: var(--space-md);
--facets-z-index: 3;
}
/* Don't: generic names that collide */
.facets {
--padding: var(--space-md);
--z-index: 3;
}Override via inline style for section/block settings:
<section
class="hero"
style="
--hero-bg: {{ section.settings.bg_color }};
--hero-padding: {{ section.settings.padding }}px;
"
>CSS Property Order
1. Layout — position, display, flex-direction, grid-template-columns 2. Box model — width, margin, padding, border 3. Typography — font-family, font-size, line-height, color 4. Visual — background, opacity, border-radius 5. Animation — transition, animation
Logical Properties (RTL Support)
/* Do: logical properties */
padding-inline: 2rem;
padding-block: 1rem;
margin-inline: auto;
border-inline-end: 1px solid var(--color-border);
text-align: start;
inset: 0;
/* Don't: physical properties */
padding-left: 2rem;
text-align: left;
top: 0; right: 0; bottom: 0; left: 0;Defensive CSS
.component {
overflow-wrap: break-word; /* Prevent text overflow */
min-width: 0; /* Allow flex items to shrink */
max-width: 100%; /* Constrain images/media */
isolation: isolate; /* Create stacking context */
}
.image-container {
aspect-ratio: 4 / 3; /* Prevent layout shift */
background: var(--color-surface); /* Fallback for missing images */
}Modern CSS Features
/* Container queries for responsive components */
.product-grid { container-type: inline-size; }
@container (min-width: 400px) {
.product-card { grid-template-columns: 1fr 1fr; }
}
/* Fluid spacing */
.section { padding: clamp(1rem, 4vw, 3rem); }
/* Intrinsic sizing */
.content { width: min(100%, 800px); }Performance
- Animate only
transformandopacity(never layout properties) - Use
will-changesparingly — remove after animation - Use
contain: contentfor isolated rendering - Use
dvhinstead ofvhon mobile
Reduced Motion
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}JavaScript in Liquid Themes
Where JS Lives
| Location | Liquid? | Use For |
|---|---|---|
{% javascript %} | No | Component-specific scripts (one per file) |
assets/*.js | No | Shared utilities, Web Components |
Web Component Pattern
class ProductCard extends HTMLElement {
connectedCallback() {
this.button = this.querySelector('[data-add-to-cart]');
this.button?.addEventListener('click', this.#handleClick.bind(this));
}
disconnectedCallback() {
// Clean up event listeners, abort controllers
}
async #handleClick(event) {
event.preventDefault();
this.button.disabled = true;
try {
const formData = new FormData();
formData.append('id', this.dataset.variantId);
formData.append('quantity', '1');
const response = await fetch('/cart/add.js', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Failed');
this.dispatchEvent(new CustomEvent('cart:item-added', {
detail: await response.json(),
bubbles: true
}));
} catch (error) {
console.error('Add to cart error:', error);
} finally {
this.button.disabled = false;
}
}
}
customElements.define('product-card', ProductCard);<product-card data-variant-id="{{ product.selected_or_first_available_variant.id }}">
<button data-add-to-cart>{{ 'products.add_to_cart' | t }}</button>
</product-card>JavaScript Rules
| Rule | Do | Don't |
|---|---|---|
| Loops | for (const item of items) | items.forEach() |
| Async | async/await | .then() chains |
| Variables | const by default | let unless reassigning |
| Conditionals | Early returns | Nested if/else |
| URLs | new URL() + URLSearchParams | String concatenation |
| Dependencies | Native browser APIs | External libraries |
| Private methods | #methodName() | _methodName() |
| Types | JSDoc @typedef, @param, @returns | Untyped |
AbortController for Fetch
class DataLoader extends HTMLElement {
#controller = null;
async load(url) {
this.#controller?.abort();
this.#controller = new AbortController();
try {
const response = await fetch(url, { signal: this.#controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (error.name !== 'AbortError') throw error;
return null;
}
}
disconnectedCallback() {
this.#controller?.abort();
}
}Component Communication
Parent → Child: Call public methods
this.querySelector('child-component')?.publicMethod(data);Child → Parent: Dispatch custom events
this.dispatchEvent(new CustomEvent('child:action', {
detail: { value },
bubbles: true
}));HTML Standards
Native Elements First
| Need | Use | Not |
|---|---|---|
| Expandable | <details>/<summary> | Custom accordion with JS |
| Dialog/modal | <dialog> | Custom overlay div |
| Tooltip/popup | popover attribute | Custom positioned div |
| Search form | <search> | <div class="search"> |
| Form results | <output> | <span class="result"> |
Progressive Enhancement
{%- comment -%} Works without JS {%- endcomment -%}
<details class="accordion">
<summary>{{ block.settings.heading }}</summary>
<div class="accordion__content">
{{ block.settings.content }}
</div>
</details>
{%- comment -%} Enhanced with JS {%- endcomment -%}
{% javascript %}
// Optional: smooth animation, analytics tracking
{% endjavascript %}Images
{{ image | image_url: width: 800 | image_tag:
loading: 'lazy',
alt: image.alt | escape,
width: image.width,
height: image.height
}}loading="lazy"on all below-fold images- Always set
widthandheightto prevent layout shift - Descriptive
alttext; emptyalt=""for decorative images
JSON Template & Config Files
Theme templates (templates/*.json), section groups (sections/*.json), and config files (config/settings_data.json) are all JSON. Use jq via the bash tool to make surgical edits — it's safer and more reliable than string-based find-and-replace for structured data.
Common patterns
# Add a section to a template
jq '.sections.new_section = {"type": "hero", "settings": {"heading": "Welcome"}}' templates/index.json > /tmp/out && mv /tmp/out templates/index.json
# Update a setting value
jq '.current.sections.header.settings.logo_width = 200' config/settings_data.json > /tmp/out && mv /tmp/out config/settings_data.json
# Reorder sections
jq '.order += ["new_section"]' templates/index.json > /tmp/out && mv /tmp/out templates/index.json
# Remove a section
jq 'del(.sections.old_banner) | .order -= ["old_banner"]' templates/index.json > /tmp/out && mv /tmp/out templates/index.json
# Read a nested value
jq '.sections.header.settings' templates/index.jsonPrefer `jq` over `edit` for any .json file modification — it validates structure, handles escaping, and avoids whitespace/formatting issues.
References
- CSS patterns and examples
- JavaScript patterns and examples
CSS Patterns for Shopify Liquid Themes
Complete Component Example
<section
class="featured-collection"
style="
--section-padding: {{ section.settings.padding | default: 60 }}px;
--columns: {{ section.settings.columns | default: 4 }};
"
>
{% if section.settings.heading != blank %}
<h2 class="featured-collection__heading">{{ section.settings.heading }}</h2>
{% endif %}
<div class="featured-collection__grid">
{% for product in collection.products limit: section.settings.limit %}
{% render 'product-card', product: product %}
{% endfor %}
</div>
</section>
{% stylesheet %}
.featured-collection {
padding-block: var(--section-padding);
container-type: inline-size;
}
.featured-collection__heading {
font-size: var(--font-size-2xl);
margin-block-end: var(--space-lg);
text-align: center;
}
.featured-collection__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--space-md);
}
@container (min-width: 768px) {
.featured-collection__grid {
grid-template-columns: repeat(var(--columns), 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.featured-collection * {
transition: none !important;
}
}
{% endstylesheet %}Layout Patterns
CSS Grid for Page Layouts
.section-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--space-lg);
}Flexbox for Component Layouts
.product-card {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}Page-Width Container
.page-width {
width: min(100%, var(--page-width));
margin-inline: auto;
padding-inline: var(--space-md);
}Full-Bleed with Content Constraint
.full-bleed {
display: grid;
grid-template-columns: var(--space-md) 1fr var(--space-md);
}
.full-bleed > * {
grid-column: 2;
}
.full-bleed > .full-width {
grid-column: 1 / -1;
}Responsive Images
.image-container {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}Using :is() for Parent-Child Relationships
/* Multiple parents, same child */
:is(.hero, .banner) .heading {
font-size: var(--font-size-2xl);
}
/* Same parent, multiple children */
.card:is(.card--featured, .card--promoted) {
border: 2px solid var(--color-accent);
}Don't use :is() for simple comma-separated selectors — use regular comma separation instead.
Variable Override Pattern
Use CSS variables to reduce redundancy across modifiers:
.button {
background: rgb(var(--button-color) / var(--button-opacity, 1));
color: rgb(var(--button-text));
}
.button--secondary {
--button-color: var(--color-secondary);
--button-text: var(--color-foreground);
}
.button--outline {
--button-color: transparent;
--button-text: var(--color-accent);
--button-opacity: 0;
}Focus Styles
:focus-visible {
outline: 2px solid rgb(var(--color-focus));
outline-offset: 2px;
}
/* High contrast mode */
@media (forced-colors: active) {
:focus-visible {
outline: 3px solid LinkText;
}
}Print Styles
@media print {
.no-print,
.cart-drawer,
.navigation__mobile {
display: none !important;
}
a[href^='http']::after {
content: ' (' attr(href) ')';
}
.product-card {
break-inside: avoid;
}
}Animation Patterns
/* Safe defaults — only animate transform and opacity */
.product-card {
transition: transform 0.2s ease;
}
.product-card:hover {
transform: translateY(-2px);
}
/* will-change only during animation */
.product-card:hover {
will-change: transform;
}
.product-card:not(:hover) {
will-change: auto;
}CSS Documentation
/* =============================================================================
Product Card
============================================================================= */
/**
* Card component for displaying product information.
*
* @example
* <div class="product-card product-card--featured">
* <div class="product-card__image">...</div>
* <div class="product-card__info">...</div>
* </div>
*/
.product-card { }JavaScript Patterns for Shopify Liquid Themes
Web Component Lifecycle
class MyComponent extends HTMLElement {
#abortController = null;
connectedCallback() {
this.#abortController = new AbortController();
this.#setup();
}
disconnectedCallback() {
this.#abortController?.abort();
// Clean up all resources
}
#setup() {
// Initialize refs, bind events
}
}
customElements.define('my-component', MyComponent);Event-Driven Architecture
Custom Events with Typed Details
/**
* @typedef {Object} CartUpdateDetail
* @property {number} itemCount - Total items in cart
* @property {number} totalPrice - Cart total in cents
*/
// Dispatching
/** @type {CustomEvent<CartUpdateDetail>} */
const event = new CustomEvent('cart:updated', {
detail: { itemCount: 3, totalPrice: 4500 },
bubbles: true
});
this.dispatchEvent(event);
// Listening
document.addEventListener('cart:updated', (event) => {
const { itemCount, totalPrice } = event.detail;
this.#updateDisplay(itemCount, totalPrice);
});Event Naming Convention
Use namespace:action format:
cart:item-added,cart:updated,cart:emptiedvariant:selected,variant:unavailablefilter:applied,filter:clearedsearch:submitted,search:results-loaded
Data Loading Pattern
class ProductLoader extends HTMLElement {
#controller = null;
async load(url) {
this.#controller?.abort();
this.#controller = new AbortController();
this.setAttribute('aria-busy', 'true');
try {
const response = await fetch(url, {
signal: this.#controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newContent = doc.querySelector('.product-grid');
if (newContent) {
this.querySelector('.product-grid')?.replaceWith(newContent);
}
return newContent;
} catch (error) {
if (error.name === 'AbortError') return null;
console.error('Load error:', error);
throw error;
} finally {
this.setAttribute('aria-busy', 'false');
}
}
disconnectedCallback() {
this.#controller?.abort();
}
}URL Manipulation
// Reading URL parameters
const url = new URL(window.location.href);
const filter = url.searchParams.get('filter');
// Updating URL parameters
const updateURL = (params) => {
const url = new URL(window.location.href);
for (const [key, value] of Object.entries(params)) {
if (value != null) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
}
history.pushState(null, '', url.toString());
};
// Never do this:
// let url = window.location.pathname + '?filter=' + value;Optimistic UI
async addToCart(variantId) {
// 1. Update UI immediately
this.#setButtonState('adding');
this.#incrementCartCount();
try {
// 2. Make request
const formData = new FormData();
formData.append('id', variantId);
formData.append('quantity', '1');
const response = await fetch('/cart/add.js', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Failed');
// 3. Confirm success
this.#setButtonState('added');
} catch (error) {
// 4. Revert on failure
this.#setButtonState('error');
this.#decrementCartCount();
console.error('Add to cart failed:', error);
}
}Debounce Pattern
/**
* @param {Function} func - Function to debounce
* @param {number} wait - Delay in milliseconds
* @returns {Function} Debounced function
*/
const debounce = (func, wait) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
};
// Usage: search input (300ms), resize handler (150ms)
const handleSearch = debounce((query) => {
// Perform search
}, 300);Intersection Observer (Lazy Loading)
class LazyLoader extends HTMLElement {
#observer;
connectedCallback() {
this.#observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
this.#loadContent(entry.target);
this.#observer.unobserve(entry.target);
}
}
},
{ rootMargin: '200px' }
);
for (const el of this.querySelectorAll('[data-lazy]')) {
this.#observer.observe(el);
}
}
disconnectedCallback() {
this.#observer?.disconnect();
}
#loadContent(element) {
const src = element.dataset.lazy;
if (element instanceof HTMLImageElement) {
element.src = src;
}
}
}JSDoc Type Annotations
/**
* @typedef {Object} ProductData
* @property {string} id - Product ID
* @property {string} title - Product title
* @property {number} price - Price in cents
* @property {boolean} available - Whether in stock
* @property {string[]} tags - Product tags
*/
/**
* Formats a price from cents to display string.
* @param {number} cents - Price in cents
* @param {string} [currency='USD'] - Currency code
* @returns {string} Formatted price
*/
const formatPrice = (cents, currency = 'USD') => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency
}).format(cents / 100);
};Error Handling
// Always wrap fetch in try/catch
const fetchJSON = async (url, options = {}) => {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
if (error.name === 'AbortError') return null;
console.error(`Fetch error for ${url}:`, error);
return null;
}
};
// Validate DOM elements before use
const getElement = (selector, context = document) => {
const element = context.querySelector(selector);
if (!element) {
console.warn(`Element not found: ${selector}`);
}
return element;
};File Organization
Group related classes in feature files:
// cart.js — all cart-related components
class CartDrawer extends HTMLElement { }
class CartItem extends HTMLElement { }
class CartCount extends HTMLElement { }
customElements.define('cart-drawer', CartDrawer);
customElements.define('cart-item', CartItem);
customElements.define('cart-count', CartCount);