Shopify Liquid テーマ向けのCSS、JavaScript、HTMLのコーディング基準です。 スタイルシート内の BEM 命名法(クラス名を「ブロック__要素--状態」の形式で統一する方法)、デザイントークン(色やサイズなど繰り返し使う値を定義したもの)、CSS カスタムプロパティ、テーマ用ウェブコンポーネント、防御的CSS(予期しない使われ方に対応する工夫)、段階的な改善(基本機能から始めて機能を追加する設計)などをカバーしています。 **次のような場合に使用:** .liquid ファイルやテーマアセットファイルで CSS/JavaScript/HTMLを書くとき
CSS, JavaScript, and HTML coding standards for Shopify Liquid themes. Covers BEM naming inside stylesheet tags, design tokens, CSS custom properties, Web Components for themes, defensive CSS, and progressive enhancement. Use when writing CSS/JS/HTML in .liquid files or theme asset files.
| 場所 | Liquid 使用可否 | 用途 |
|---|---|---|
{% stylesheet %} |
不可 | コンポーネントスコープのスタイル(ファイルごとに1つ) |
{% style %} |
可 | Liquid が必要な動的な値(カラー設定など) |
assets/*.css |
不可 | 共有・グローバルスタイル |
重要: {% stylesheet %} は Liquid を処理しません。
動的な値には インライン style 属性を使用してください:
{%- comment -%} 推奨: インライン変数 {%- endcomment -%}
<div
class="hero"
style="--bg-color: {{ section.settings.bg_color }}; --padding: {{ section.settings.padding }}px;"
>
{%- comment -%} 非推奨: stylesheet 内に Liquid {%- endcomment -%}
{% stylesheet %}
.hero { background: {{ section.settings.bg_color }}; } /* 動作しません */
{% endstylesheet %}
.block → コンポーネントルート: .product-card
.block__element → 子要素: .product-card__title
.block--modifier → バリアント: .product-card--featured
.block__element--modifier → 要素バリアント: .product-card__title--large
ルール:
.product-card(.productCard は不可).block__element(.block__el1__el2 は不可)class="btn btn--primary"(class="btn--primary" 単独は不可)<!-- 推奨: 単一の要素レベル -->
<div class="product-card">
<h3 class="product-card__title">{{ product.title }}</h3>
<span class="product-card__button-label">{{ 'add_to_cart' | t }}</span>
</div>
<!-- 推奨: 単独コンポーネントに新しい BEM スコープを適用 -->
<div class="product-card">
<button class="button button--primary">
<span class="button__label">{{ 'add_to_cart' | t }}</span>
</button>
</div>
0 1 0(シングルクラス)を目標とする0 4 0 まで!important を使用しない(厳守)(どうしても必要な場合はコメントで理由を記載)/* 推奨: セレクタ内にメディアクエリを記述 */
.header {
width: 100%;
@media screen and (min-width: 750px) {
width: auto;
}
}
/* 推奨: & を使った状態修飾 */
.button {
background: var(--color-primary);
&:hover { background: var(--color-primary-hover); }
&:focus-visible { outline: 2px solid var(--color-focus); }
&[disabled] { opacity: 0.5; }
}
/* 推奨: 親モディファイアによる子要素への影響(単一レベルのみ) */
.card--featured {
.card__title { font-size: var(--font-size-xl); }
}
/* 非推奨: 第1レベルを超えたネスト */
.parent {
.child {
.grandchild { } /* 深すぎます */
}
}
すべての値に CSS カスタムプロパティを使用し、色・スペーシング・フォントを直接ハードコードしないでください。
一貫したスケールを定義し、全体で参照するようにします。
スケール例(テーマのニーズに合わせて調整してください):
:root {
/* スペーシング — 一貫したスケールを使用 */
--space-2xs: 0.5rem; --space-xs: 0.75rem; --space-sm: 1rem;
--space-md: 1.5rem; --space-lg: 2rem; --space-xl: 3rem;
/* タイポグラフィ — 相対単位を使用 */
--font-size-sm: 0.875rem; --font-size-base: 1rem;
--font-size-lg: 1.125rem; --font-size-xl: 1.25rem; --font-size-2xl: 1.5rem;
}
主要原則:
rem を使用する(ユーザーのフォントサイズ設定を尊重するため)--space-16 ではなく --space-sm:root で定義し、スコープ付きトークンはコンポーネントルートで定義するグローバル — テーマ全体の値は :root で定義
コンポーネントスコープ — コンポーネントルートにネームスペース付きで定義:
/* 推奨: ネームスペースあり */
.facets {
--facets-padding: var(--space-md);
--facets-z-index: 3;
}
/* 非推奨: 衝突する可能性のある汎用名 */
.facets {
--padding: var(--space-md);
--z-index: 3;
}
セクション・ブロックの設定はインラインスタイルで上書き:
<section
class="hero"
style="
--hero-bg: {{ section.settings.bg_color }};
--hero-padding: {{ section.settings.padding }}px;
"
>
position、display、flex-direction、grid-template-columnswidth、margin、padding、borderfont-family、font-size、line-height、colorbackground、opacity、border-radiustransition、animation/* 推奨: 論理プロパティ */
padding-inline: 2rem;
padding-block: 1rem;
margin-inline: auto;
border-inline-end: 1px solid var(--color-border);
text-align: start;
inset: 0;
/* 非推奨: 物理プロパティ */
padding-left: 2rem;
text-align: left;
top: 0; right: 0; bottom: 0; left: 0;
.component {
overflow-wrap: break-word; /* テキストのはみ出しを防止 */
min-width: 0; /* フレックスアイテムの縮小を許可 */
max-width: 100%; /* 画像・メディアのサイズを制限 */
isolation: isolate; /* スタッキングコンテキストを生成 */
}
.image-container {
aspect-ratio: 4 / 3; /* レイアウトシフトを防止 */
background: var(--color-surface); /* 画像が欠けた場合のフォールバック */
}
/* レスポンシブコンポーネントのためのコンテナクエリ */
.product-grid { container-type: inline-size; }
@container (min-width: 400px) {
.product-card { grid-template-columns: 1fr 1fr; }
}
/* 流体スペーシング */
.section { padding: clamp(1rem, 4vw, 3rem); }
/* 固有サイジング */
.content { width: min(100%, 800px); }
transform と opacity のみに限定する(レイアウトプロパティは対象外)will-change は必要最小限に使用し、アニメーション終了後は削除するcontain: content を使用するvh の代わりに dvh を使用する@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;
}
}
| 場所 | Liquid 使用可否 | 用途 |
|---|---|---|
{% javascript %} |
不可 | コンポーネント固有のスクリプト(ファイルごとに1つ) |
assets/*.js |
不可 | 共有ユーティリティ、Web Components |
class ProductCard extends HTMLElement {
connectedCallback() {
this.button = this.querySelector('[data-add-to-cart]');
this.button?.addEventListener('click', this.#handleClick.bind(this));
}
disconnectedCallback() {
// イベントリスナーや AbortController のクリーンアップ
}
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>
| ルール | 推奨 | 非推奨 |
|---|---|---|
| ループ | for (const item of items) |
items.forEach() |
| 非同期処理 | async/await |
.then() チェーン |
| 変数 | 基本は const |
再代入しない限り let は使わない |
| 条件分岐 | 早期リターン | ネストした if/else |
| URL 生成 | new URL() + URLSearchParams |
文字列の連結 |
| 依存関係 | ネイティブブラウザ API | 外部ライブラリ |
| プライベートメソッド | #methodName() |
_methodName() |
| 型定義 | JSDoc の @typedef、@param、@returns |
型なし |
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();
}
}
親 → 子: パブリックメソッドを呼び出す
this.querySelector('child-component')?.publicMethod(data);
子 → 親: カスタムイベントをディスパッチする
this.dispatchEvent(new CustomEvent('child:action', {
detail: { value },
bubbles: true
}));
| 用途 | 使用する要素 | 使わない要素 |
|---|---|---|
| 展開可能なコンテンツ | <details>/<summary> |
JS によるカスタムアコーディオン |
| ダイアログ・モーダル | <dialog> |
カスタムオーバーレイ div |
| ツールチップ・ポップア |
| 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 %}
.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--large
Rules:
.product-card, not .productCard.block__element, never .block__el1__el2class="btn btn--primary", never class="btn--primary" alone<!-- 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>
0 1 0 (single class) wherever possible0 4 0 for complex parent-child cases!important (comment why if absolutely forced to)/* 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 */
}
}
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:
rem for spacing and typography (respects user font size preferences)--space-sm not --space-16:root for global tokens, on component root for scoped tokensGlobal — 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;
"
>
position, display, flex-direction, grid-template-columnswidth, margin, padding, borderfont-family, font-size, line-height, colorbackground, opacity, border-radiustransition, animation/* 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;
.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 */
}
/* 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); }
transform and opacity (never layout properties)will-change sparingly — remove after animationcontain: content for isolated renderingdvh instead of vh on mobile@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;
}
}
| Location | Liquid? | Use For |
|---|---|---|
{% javascript %} |
No | Component-specific scripts (one per file) |
assets/*.js |
No | Shared utilities, Web Components |
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>
| 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 |
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();
}
}
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
}));
| 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"> |
{%- 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 %}
{{ 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 imageswidth and height to prevent layout shiftalt text; empty alt="" for decorative imagesTheme 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.
# 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.json
Prefer jq over edit for any .json file modification — it validates structure, handles escaping, and avoids whitespace/formatting issues.
原文・著作権は Anthropic および各プラグイン作者に帰属します。日本語訳は Claude API による自動翻訳です。