# zudo-css > Pragmatic CSS knowledge for AI --- # INBOX > Source: https://takazudomodular.com/pj/zcss/ja/docs/inbox 下書きおよび未整理のメモです。レビュー後に適切なカテゴリに移動します。 --- # インタラクティブ > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive ホバー・フォーカス・アクティブ状態、トランジション、アニメーション、スクロール動作、インタラクティブパターンに関するベストプラクティスです。 --- # レイアウト > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout CSSレイアウト技術:Flexbox、Grid、配置パターン、スペーシング、サイジング、構成戦略について解説します。 --- # CSS設計 > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology CSSアーキテクチャ戦略:BEM、CSS Modules、ユーティリティファースト、デザイントークン、カスケードレイヤーについて解説します。 --- # 概要 > Source: https://takazudomodular.com/pj/zcss/ja/docs/overview CSSベストプラクティスのドキュメントを執筆・管理するための、プロジェクトレベルの規約です。 --- # レスポンシブ > Source: https://takazudomodular.com/pj/zcss/ja/docs/responsive コンテナクエリ、フルイドデザイン、メディアクエリ、レスポンシブパターンについて解説します。 --- # スタイリング > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling 色、シャドウ、ボーダー、エフェクトなどのビジュアルスタイリングテクニック。 --- # タイポグラフィ > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography フォントサイズ、行クランプ、バーティカルリズム、テキストオーバーフロー、およびタイポグラフィパターンについて解説します。 --- # フォームコントロールのスタイリング > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/forms-and-accessibility/form-control-styling ## 問題 フォーム要素 — チェックボックス、ラジオボタン、レンジスライダー、テキストエリア — は、歴史的にウェブで最もスタイリングが難しい要素です。レンダリングはオペレーティングシステムに深く結びついており、ブラウザはCSSによる見た目の制御を制限しています。そのため、開発者はJavaScriptライブラリ、`` 要素で構築されたカスタムトグルコンポーネント、または隠しinputと兄弟セレクタを使った複雑なCSSハックに頼ることになります。これらの回避策はバンドルを肥大化させ、ネイティブのアクセシビリティを壊し、本来のHTMLセマンティクスから逸脱してしまいます。 モダンCSSは、フォームコントロールを置き換えることなくブランディングやカスタマイズを行うネイティブプロパティを提供しています。 ## 解決方法 4つのモダンCSSプロパティが、JavaScriptベースのフォームカスタマイズの大部分を置き換えます: - **`accent-color`** — 単一の宣言でネイティブのチェックボックス、ラジオボタン、レンジスライダー、プログレスバーにテーマを適用します。ブラウザがコントラストを自動的に処理します。 - **`appearance: none`** — フォームコントロールのOSネイティブレンダリングを除去し、CSSでゼロからスタイリングできる白紙の状態にします。 - **`field-sizing: content`** — テキストエリアやinputをコンテンツに合わせて自動サイズ調整させ、JavaScriptの自動リサイズスクリプトを不要にします。 - **`caret-color`** — inputやテキストエリアの点滅するテキストカーソルの色を変更し、ブランディングの繊細なアクセントになります。 ### 基本原則 最初のツールとして `accent-color` を使いましょう — 単一プロパティ以外のカスタムCSSが不要で、完全なアクセシビリティを維持します。`accent-color` で表現できないデザインが必要な場合にのみ `appearance: none` を使いましょう。`appearance: none` を使用する場合は、Windows High Contrastモードでコントロールが表示されるよう、必ず `@media (forced-colors: active)` 内で復元してください。 Default Browser Styling Newsletter Option A Option B Volume Progress 70% With accent-color Newsletter Option A Option B Volume Progress 70% `} css={` .accent-demo { padding: 1.5rem; } .accent-heading { font-size: 0.8125rem; font-weight: 700; margin: 0 0 0.75rem; color: hsl(215 15% 45%); } .branded-heading { margin-top: 1.25rem; color: hsl(262 80% 50%); } .accent-row { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; } .branded-row { accent-color: hsl(262 80% 50%); } .accent-label { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; color: hsl(215 15% 30%); cursor: pointer; } input[type="range"] { width: 80px; } progress { height: 0.5rem; width: 80px; } `} /> たった1行のCSS — `accent-color: hsl(262 80% 50%)` — でコンテナ内のすべてのネイティブコントロールにテーマが適用されます。ブラウザがアクセントの背景に対してチェックマークやラジオドットの色を自動的にコントラスト調整します。 Enable notifications Accept terms Subscribe to updates `} css={` .custom-checkbox-demo { display: flex; flex-direction: column; gap: 0.875rem; padding: 1.5rem; } .custom-check { display: flex; align-items: center; gap: 0.625rem; cursor: pointer; font-size: 0.875rem; color: hsl(215 15% 25%); } .custom-check-input { appearance: none; width: 1.25rem; height: 1.25rem; border: 2px solid hsl(215 20% 70%); border-radius: 0.25rem; background: hsl(0 0% 100%); cursor: pointer; display: grid; place-content: center; transition: background-color 0.15s ease, border-color 0.15s ease; flex-shrink: 0; } .custom-check-input::before { content: ""; width: 0.65rem; height: 0.65rem; transform: scale(0); transition: transform 0.12s ease-in-out; clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); background: hsl(0 0% 100%); } .custom-check-input:checked { background: hsl(262 80% 50%); border-color: hsl(262 80% 50%); } .custom-check-input:checked::before { transform: scale(1); } .custom-check-input:focus-visible { outline: 2px solid hsl(262 80% 50%); outline-offset: 2px; } .custom-check-input:hover { border-color: hsl(262 60% 60%); } @media (forced-colors: active) { .custom-check-input { appearance: auto; } } `} /> `appearance: none` 宣言はネイティブのチェックボックスレンダリングを除去し、CSSで再構築します:ボックスのボーダー、`::before` の `clip-path` ポリゴンでチェックマーク、チェック状態と非チェック状態のアニメーションに `transform: scale()` を使用します。`@media (forced-colors: active)` ブロックは `appearance: auto` を復元し、Windows High Contrastモードでチェックボックスが機能し続けるようにします。 Select a plan Free Pro Enterprise `} css={` .custom-radio-demo { border: none; padding: 1.5rem; margin: 0; display: flex; flex-direction: column; gap: 0.75rem; } .custom-radio-legend { font-size: 0.8125rem; font-weight: 700; color: hsl(215 15% 35%); margin-bottom: 0.25rem; } .custom-radio { display: flex; align-items: center; gap: 0.625rem; cursor: pointer; font-size: 0.875rem; color: hsl(215 15% 25%); } .custom-radio-input { appearance: none; width: 1.25rem; height: 1.25rem; border: 2px solid hsl(215 20% 70%); border-radius: 50%; background: hsl(0 0% 100%); cursor: pointer; display: grid; place-content: center; transition: border-color 0.15s ease; flex-shrink: 0; } .custom-radio-input::before { content: ""; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: hsl(262 80% 50%); transform: scale(0); transition: transform 0.15s ease-in-out; } .custom-radio-input:checked { border-color: hsl(262 80% 50%); } .custom-radio-input:checked::before { transform: scale(1); } .custom-radio-input:focus-visible { outline: 2px solid hsl(262 80% 50%); outline-offset: 2px; } .custom-radio-input:hover { border-color: hsl(262 60% 60%); } @media (forced-colors: active) { .custom-radio-input { appearance: auto; } } `} /> カスタムラジオボタンもチェックボックスと同じパターンに従います:`appearance: none` でネイティブレンダリングを除去し、`::before` 擬似要素で内側のドットを描画し、`transform: scale()` で選択をスムーズにアニメーションします。円形の `border-radius: 50%` と小さな内側のドットにより、従来のラジオボタンの視覚言語が保持され、ユーザーはコントロールをすぐに認識できます。 Fixed height (default) This textarea has a fixed height. If you type more text than fits, it scrolls internally. Users cannot see all their content at once. field-sizing: content This textarea grows with its content. No JavaScript needed — the browser handles resizing natively. Try typing more text here to see it expand. `} css={` .field-sizing-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; padding: 1.5rem; } .field-sizing-col { display: flex; flex-direction: column; gap: 0.5rem; } .field-sizing-label { font-size: 0.75rem; font-weight: 700; color: hsl(215 15% 45%); } .field-sizing-label-new { color: hsl(150 60% 35%); } .field-sizing-textarea { font-family: inherit; font-size: 0.8125rem; line-height: 1.5; padding: 0.625rem 0.75rem; border: 1.5px solid hsl(215 20% 80%); border-radius: 0.375rem; color: hsl(215 15% 25%); background: hsl(0 0% 100%); resize: vertical; transition: border-color 0.15s ease; } .field-sizing-textarea:focus { border-color: hsl(262 80% 50%); outline: 2px solid transparent; box-shadow: 0 0 0 3px hsl(262 80% 50% / 0.15); } .fixed-textarea { height: 5rem; } .auto-textarea { field-sizing: content; min-height: 3rem; max-height: 12rem; } `} /> `field-sizing: content` プロパティにより、JavaScriptの自動リサイズライブラリが不要になります。テキストエリアはユーザーが入力すると拡張し、コンテンツが削除されると縮小します。`min-height` と `max-height` で範囲を設定し、テキストエリアが1行に縮小したりページ全体を埋め尽くしたりしないようにしましょう。 **ブラウザサポートの注意:** `field-sizing` は Chrome 123+ と Edge 123+(2024年3月リリース)でサポートされています。Firefox と Safari はまだサポートしていません。プログレッシブエンハンスメントとして使用しましょう — 非対応ブラウザではテキストエリアがデフォルトの固定サイズにフォールバックします。 Create Account Full Name Email Bio I agree to the terms Subscribe to newsletter Create Account `} css={` .themed-form { --form-accent: hsl(262 80% 50%); --form-accent-hover: hsl(262 80% 42%); --form-accent-light: hsl(262 80% 50% / 0.15); --form-border: hsl(215 20% 82%); --form-text: hsl(215 15% 20%); --form-text-muted: hsl(215 15% 50%); padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; max-width: 360px; } .themed-form-title { font-size: 1.125rem; font-weight: 700; color: var(--form-text); margin: 0 0 0.25rem; } .themed-field { display: flex; flex-direction: column; gap: 0.375rem; } .themed-label { font-size: 0.8125rem; font-weight: 600; color: var(--form-text); } .themed-input { font-family: inherit; font-size: 0.875rem; padding: 0.5rem 0.75rem; border: 1.5px solid var(--form-border); border-radius: 0.375rem; color: var(--form-text); background: hsl(0 0% 100%); caret-color: var(--form-accent); transition: border-color 0.15s ease, box-shadow 0.15s ease; } .themed-input::placeholder { color: var(--form-text-muted); } .themed-input:focus { border-color: var(--form-accent); outline: 2px solid transparent; box-shadow: 0 0 0 3px var(--form-accent-light); } .themed-textarea { resize: vertical; min-height: 4rem; } .themed-checks { display: flex; flex-direction: column; gap: 0.5rem; accent-color: var(--form-accent); } .themed-check { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; color: var(--form-text); cursor: pointer; } .themed-submit { background: var(--form-accent); color: hsl(0 0% 100%); font-family: inherit; font-size: 0.875rem; font-weight: 600; padding: 0.625rem 1.25rem; border: none; border-radius: 0.375rem; cursor: pointer; transition: background-color 0.15s ease; } .themed-submit:hover { background: var(--form-accent-hover); } .themed-submit:focus-visible { outline: 2px solid var(--form-accent); outline-offset: 2px; } .themed-submit:active { transform: scale(0.98); } `} /> このフォームは4つのプロパティすべてを組み合わせて統一されたブランド体験を実現しています:`accent-color` でネイティブチェックボックスにテーマを適用し、`caret-color` で点滅カーソルをブランドのパープルに合わせ、`box-shadow` を使用したカスタムフォーカスリングでテキスト入力とテキストエリア全体に一貫したフォーカス処理を提供します。送信ボタンも同じカラーパレットに従い、適切な `:hover`、`:focus-visible`、`:active` 状態を備えています。 ## コード例 ### accent-color による手軽なブランディング ```css form { accent-color: hsl(262 80% 50%); } ``` この1行でフォーム内のすべてのチェックボックス、ラジオボタン、レンジスライダー、プログレスバーにテーマが適用されます。追加のセレクタは不要です。 ### ゼロからのカスタムチェックボックス ```css .checkbox { appearance: none; width: 1.25rem; height: 1.25rem; border: 2px solid hsl(215 20% 70%); border-radius: 0.25rem; display: grid; place-content: center; cursor: pointer; } .checkbox::before { content: ""; width: 0.65rem; height: 0.65rem; clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); background: white; transform: scale(0); transition: transform 0.12s ease-in-out; } .checkbox:checked { background: hsl(262 80% 50%); border-color: hsl(262 80% 50%); } .checkbox:checked::before { transform: scale(1); } /* Restore native appearance in Windows High Contrast mode */ @media (forced-colors: active) { .checkbox { appearance: auto; } } ``` ### 自動サイジングテキストエリア ```css .auto-textarea { field-sizing: content; min-height: 3rem; max-height: 20rem; } ``` ### 統一されたフォーム変数 ```css .form { --accent: hsl(262 80% 50%); --accent-ring: hsl(262 80% 50% / 0.15); accent-color: var(--accent); caret-color: var(--accent); } .form input:focus, .form textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); outline: 2px solid transparent; } ``` ## AIがよくやるミス - **ネイティブコントロールを `` 要素で置き換える**:`` に `appearance: none` を使う代わりに、`` と `` で偽のチェックボックスやラジオボタンを構築します。偽のコントロールはキーボードナビゲーション、フォーム送信、スクリーンリーダーのセマンティクスを欠いています。 - **`@media (forced-colors: active)` を忘れる**:`appearance: none` で構築されたカスタムチェックボックスやラジオボタンは、Windows High Contrastモードで見えなくなります。`forced-colors` メディアクエリ内で常に `appearance: auto` を復元しましょう。 - **自動拡張テキストエリアにJavaScriptを使用する**:`field-sizing: content` がネイティブで処理できる(グレースフルデグラデーション付き)のに、リサイズオブザーバーやinputイベントリスナーを追加します。 - **`accent-color` で十分な場合にすべてのフォームスタイルをオーバーライドする**:ネイティブコントロールにブランドカラーを適用するだけで済むデザインに、数十行のカスタムチェックボックスCSSを書きます。 - **カスタムコントロールにフォーカスインジケーターを提供しない**:`appearance` を除去しながら `:focus-visible` スタイルを追加せず、キーボードユーザーに視覚的フォーカス状態がなくなります。 - **CSSカスタムプロパティの代わりに色をハードコードする**:変数の単一セットを定義する代わりに、セレクタ全体に色の値を散在させ、フォームテーマの柔軟性を失わせます。 ## 使い分け - **`accent-color`**:ネイティブフォームコントロールにブランディングが必要で、デザインがカスタムの形状やレイアウトを要求しない場合に使います。これがデフォルトの正しい選択です。 - **`appearance: none`**:`accent-color` では表現できない完全にカスタムなチェックボックス、ラジオボタン、セレクトの外観がデザインで求められる場合に使います。必ず `forced-colors` の復元とペアにしましょう。 - **`field-sizing: content`**:テキストエリアやテキストinputがコンテンツに合わせて拡大すべき場合に使います。`min-height` / `max-height` の範囲を設定してプログレッシブエンハンスメントとして使用しましょう。 - **`caret-color`**:テキストinputやテキストエリアの点滅カーソルをブランドカラーに合わせるべき場合に使います。洗練されたデザインを示す小さなディテールです。 ## 参考リンク - [accent-color — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/accent-color) - [appearance — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/appearance) - [field-sizing — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/field-sizing) - [caret-color — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/caret-color) - [Simplifying Form Styles with accent-color — web.dev](https://web.dev/articles/accent-color) - [Custom Checkbox Styling — Modern CSS Solutions](https://moderncss.dev/pure-css-custom-checkbox-style/) - [forced-colors Media Query — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors) --- # スクロールスナップ > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/scroll/scroll-snap ## 問題 カルーセル、スライドショー、水平スクロールコンテンツセクションは非常に一般的なUIパターンです。AIエージェントはスナップ・トゥ・スライド動作の実装にほぼ必ずJavaScriptライブラリやカスタムスクロールイベントハンドラーを使おうとします。CSS Scroll Snap はこの機能をわずか数行のCSSでネイティブに提供し、JavaScriptソリューションよりも優れたパフォーマンスとタッチデバイス互換性を実現します。しかし、AIがこれを提案することはまれです。 ## 解決方法 CSS Scroll Snap を使用すると、スクロールコンテナ上にスナップポイントを定義でき、スクロールが自然に特定の位置にロックされます。ブラウザがモーメンタム、減速、スナップなどすべての物理演算を処理するため、すべてのデバイスでスムーズなネイティブ感覚のスクロール動作を実現します。 ### 主要プロパティ - **`scroll-snap-type`**(スクロールコンテナに設定):スナップの軸(`x`、`y`、`both`)と厳密さ(`mandatory` または `proximity`)を定義します。 - **`scroll-snap-align`**(子要素に設定):各アイテムがスナップする位置(`start`、`center`、`end`)を定義します。 - **`scroll-snap-stop`**(子要素に設定):スクロールがアイテムを飛ばせるか(`normal`)、各アイテムで停止しなければならないか(`always`)を制御します。 ### mandatory と proximity - **`mandatory`**:スクロールが停止すると、スクロールコンテナは**常に**スナップポイントにスナップします。ユーザーがわずかにスクロールしただけでも、最も近いポイントにスナップします。カルーセルやページネーションコンテンツに最適です。 - **`proximity`**:スナップポイントの近くにいる場合のみスナップします。ユーザーがスナップポイントを通過してスクロールすると、通常のスクロールのように動作します。スナップが便利だが必須ではない長いコンテンツに最適です。 1 Slide One 2 Slide Two 3 Slide Three 4 Slide Four 5 Slide Five Scroll horizontally — slides snap into place `} css={` .carousel { display: flex; gap: 1rem; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 1rem; padding: 1rem; } .carousel::-webkit-scrollbar { height: 6px; } .carousel::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 3px; } .carousel::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } .slide { flex: 0 0 calc(80% - 0.5rem); scroll-snap-align: start; border-radius: 0.75rem; color: white; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 160px; gap: 0.5rem; } .slide-num { font-size: 2.5rem; font-weight: 800; opacity: 0.9; } .slide-label { font-size: 0.875rem; font-weight: 600; opacity: 0.8; } .hint { font-size: 0.75rem; color: #94a3b8; text-align: center; margin: 0.5rem 0 0; font-style: italic; } `} /> ## コード例 ### 水平カルーセル ```css .carousel { display: flex; gap: 1rem; overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; /* Hide scrollbar but keep functionality */ scrollbar-width: none; } .carousel::-webkit-scrollbar { display: none; } .carousel__slide { flex: 0 0 100%; scroll-snap-align: start; } ``` ```html Slide 1 Slide 2 Slide 3 ``` ### マルチアイテムカルーセル(次のアイテムをチラ見せ) ```css .carousel-peek { display: flex; gap: 1rem; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 1rem; padding-inline: 1rem; } .carousel-peek__item { flex: 0 0 calc(80% - 0.5rem); scroll-snap-align: start; border-radius: 0.5rem; background: var(--color-surface, #f5f5f5); padding: 1.5rem; } ``` コンテナの `scroll-padding-inline` により、スナップされたアイテムが端からオフセットされ、次のアイテムがチラ見えします。 ### 縦方向フルページセクション ```css .page-sections { height: 100vh; overflow-y: auto; scroll-snap-type: y mandatory; } .section { height: 100vh; scroll-snap-align: start; display: flex; align-items: center; justify-content: center; } ``` ```html Section 1 Section 2 Section 3 ``` ### 中央スナップの画像ギャラリー ```css .gallery { display: flex; gap: 0.5rem; overflow-x: auto; scroll-snap-type: x proximity; padding-block: 1rem; } .gallery__image { flex: 0 0 auto; width: min(300px, 80vw); aspect-ratio: 3 / 4; object-fit: cover; border-radius: 0.5rem; scroll-snap-align: center; } ``` ここでは `proximity` を使用することで、穏やかなセンタースナップ動作を持つ自由なブラウジングが可能になります。 ### 高速スクロールスキップの防止 ```css .carousel-strict { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; } .carousel-strict__slide { flex: 0 0 100%; scroll-snap-align: start; scroll-snap-stop: always; /* Must stop at every slide */ } ``` `scroll-snap-stop: always` により、ユーザーが一度に複数のスライドをスワイプで飛ばすことを防止します。 ### レスポンシブ対応:カルーセルからグリッドへ ```css .card-scroller { display: flex; gap: 1rem; overflow-x: auto; scroll-snap-type: x mandatory; padding: 1rem; } .card-scroller__item { flex: 0 0 min(280px, 85vw); scroll-snap-align: start; } /* On wider screens, switch to a grid layout */ @media (min-width: 48rem) { .card-scroller { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); overflow-x: visible; scroll-snap-type: none; } .card-scroller__item { scroll-snap-align: unset; } } ``` ## AIがよくやるミス - **Scroll Snap を提案しない**:CSSがネイティブで処理できるスクロールスナップ動作にJavaScriptカルーセルライブラリをデフォルトで使用します。 - **コンテナに `scroll-snap-type` を忘れる**:子要素に `scroll-snap-align` を設定しながら、親でスナップを有効にしていません。 - **常に `mandatory` を使用する**:`proximity` の方が適切な長いスクロールコンテンツに `mandatory` を使用します。長いコンテンツでの `mandatory` はユーザーを閉じ込めてしまう可能性があります。 - **`scroll-padding` を使用しない**:固定ヘッダーがあるページで `scroll-padding` を追加し忘れ、スナップされたコンテンツがヘッダーの背後に隠れてしまいます。 - **アクセシビリティを維持せずにスクロールバーを非表示にする**:CSSでスクロールバーを削除しながら、代替のナビゲーション(矢印、ドット)を提供しません。 - **`scroll-snap-stop` を使用しない**:ステップバイステップのコンテンツ(オンボーディングフローなど)で、スキップを防止するための `scroll-snap-stop: always` を使用していません。 - **スライドアイテムに固定ピクセル幅を使用する**:`min(300px, 85vw)` のようなレスポンシブサイジングの代わりに `flex: 0 0 350px` を使用します。 ## 使い分け - **カルーセルとスライドショー**:フル幅の画像カルーセル、テスティモニアルスライダー、製品ショーケースに使います。 - **水平スクロールセクション**:カードスクローラー、カテゴリナビゲーション、画像ギャラリーに使います。 - **フルページセクションスクロール**:縦方向にスナップする個別のセクションを持つランディングページに使います。 - **オンボーディングフロー**:各ステップがビューにスナップするべきステップバイステップの画面に使います。 - **複雑なカルーセルロジックには不向き**:自動再生、無限ループ、APIドリブンのスライド管理が必要な場合はJavaScriptが必要になるかもしれませんが、スクロールスナップ動作自体はCSSドリブンのままにすべきです。 ## 参考リンク - [scroll-snap-type — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/scroll-snap-type) - [Basic Concepts of Scroll Snap — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll_snap/Basic_concepts) - [Well-Controlled Scrolling with CSS Scroll Snap — web.dev](https://web.dev/articles/css-scroll-snap) - [Practical CSS Scroll Snapping — CSS-Tricks](https://css-tricks.com/practical-css-scroll-snapping/) - [CSS Scroll Snap — Ahmad Shadeed](https://ishadeed.com/article/css-scroll-snap/) --- # :has()セレクター > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/selectors/has-selector ## 問題 CSSには、子要素に基づいて親要素を選択する方法がありませんでした。開発者は、入力が無効な場合にフォームグループをハイライトしたり、画像の有無に基づいてカードレイアウトを変更したりするなど、親子の状態関係にJavaScriptでクラスをトグルすることに頼っていました。AIエージェントが`:has()`を使うことはほとんどなく、代わりにこれらのパターンにJavaScriptベースのソリューションを提案します。 ## 解決方法 `:has()`関係擬似クラスは、指定されたセレクターリストに一致する要素を少なくとも1つ含む要素を選択します。「親セレクター」として機能しますが、はるかに強力です:子、兄弟、子孫など、あらゆる相対的な位置を見て条件的にスタイルを適用できます。 ## コード例 ### 基本的な親の選択 ```css /* Style a card differently when it contains an image */ .card:has(img) { grid-template-rows: 200px 1fr; } .card:has(img) .card-body { padding-top: 0; } ``` ### フォームバリデーションのスタイリング JavaScriptなしで入力の有効性に基づいてフォームグループにスタイルを適用します。 ```css /* Highlight the entire field group when input is invalid */ .field-group:has(:user-invalid) { border-left: 3px solid red; background: #fff5f5; } .field-group:has(:user-invalid) .error-message { display: block; } /* Style label when its sibling input is focused */ .field-group:has(input:focus) label { color: blue; font-weight: bold; } ``` ```html Email Please enter a valid email ``` ### 数量クエリ JavaScriptなしで子要素の数に基づいてレイアウトを適応させます。 ```css /* Switch to grid layout when a list has 5 or more items */ .item-list:has(> :nth-child(5)) { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } /* Single-column layout for fewer items */ .item-list:not(:has(> :nth-child(5))) { display: flex; flex-direction: column; } ``` ```css /* Style based on even/odd number of children */ .grid:has(> :last-child:nth-child(even)) { /* Even number of children */ grid-template-columns: repeat(2, 1fr); } .grid:has(> :last-child:nth-child(odd)) { /* Odd number of children */ grid-template-columns: repeat(3, 1fr); } ``` ### 兄弟の状態に基づくスタイリング ```css /* Change page layout when a sidebar checkbox is checked */ body:has(#sidebar-toggle:checked) .main-content { margin-left: 0; } body:has(#sidebar-toggle:checked) .sidebar { transform: translateX(-100%); } ``` ### 他のセレクターとの組み合わせ ```css /* Style a navigation item that contains the current page link */ nav li:has(> a[aria-current="page"]) { background: #e0e7ff; border-radius: 4px; } /* Style a table row that has an empty cell */ tr:has(td:empty) { opacity: 0.6; } ``` ### `:has()`と直接子結合子の使用 パフォーマンス向上のために直接子結合子`>`を使用しましょう。ブラウザの検索をすべての子孫ではなく直接の子要素に限定します。 ```css /* Preferred: direct child (faster) */ .container:has(> .alert) { border: 2px solid red; } /* Avoid when possible: descendant (slower on large DOMs) */ .container:has(.alert) { border: 2px solid red; } ``` ## ブラウザサポート - Chrome 105+ - Safari 15.4+ - Firefox 121+ - Edge 105+ グローバルサポートは96%を超えています。機能検出は`@supports selector(:has(*))`で利用可能です。 ## AIがよくやるミス - `:has()`で解決できる問題にJavaScriptのクラストグルを提案する - `:has()`の存在を知らず回避策を推奨する - `:has()`内で直接子`>`の方がパフォーマンスが良い場合に子孫セレクターを使用する - `:has()`と`:not()`を逆ロジックのために組み合わせない(例:`.card:not(:has(img))`) - `:has()`が子孫だけでなく兄弟も見ることができることを忘れる(例:`h2:has(+ p)`) - `:has()`のポリフィルを試みる — リアルタイムのDOM認識が必要で、効率的なポリフィルはできない ## 使い分け - 子の状態に基づく親のスタイリング(フォームバリデーション、コンテンツ対応レイアウト) - 子要素の数に基づいてレイアウトを適応させる数量クエリ - JavaScriptなしの状態駆動スタイリング(チェックボックスハック、フォーカス管理) - コンテンツの有無に基づく条件的なコンポーネントスタイリング ## ライブプレビュー Name Email Click an input — the parent field-group highlights via :has(:focus) `} css={` .field-group { font-family: system-ui, sans-serif; padding: 1rem; margin-bottom: 0.75rem; border: 2px solid #e5e7eb; border-radius: 8px; transition: border-color 0.2s, background 0.2s; } .field-group:has(:focus) { border-color: #3b82f6; background: #eff6ff; } .field-group:has(:focus) label { color: #2563eb; font-weight: 700; } label { display: block; font-size: 0.875rem; color: #64748b; margin-bottom: 0.5rem; transition: color 0.2s; } input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 1rem; outline: none; box-sizing: border-box; } input:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 0.5rem; } `} /> Mark as featured Article Title This card changes appearance when the checkbox is checked — all done with :has(:checked) in pure CSS. Click the checkbox to see the card transform `} css={` .card { font-family: system-ui, sans-serif; padding: 1.5rem; border: 2px solid #e5e7eb; border-radius: 12px; background: #fff; transition: all 0.3s; } .card:has(:checked) { border-color: #f59e0b; background: linear-gradient(135deg, #fffbeb, #fef3c7); box-shadow: 0 4px 12px rgba(245, 158, 11, 0.2); } .card:has(:checked) h3 { color: #b45309; } .toggle-label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.875rem; color: #64748b; margin-bottom: 1rem; } .toggle-label input { width: 1.1rem; height: 1.1rem; cursor: pointer; } h3 { margin: 0 0 0.5rem; color: #1e293b; transition: color 0.3s; } p { margin: 0; color: #64748b; line-height: 1.6; } .hint { font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 0.75rem; } `} /> ## 参考リンク - [:has() - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:has) - [:has() CSS relational pseudo-class - Can I Use](https://caniuse.com/css-has) - [CSS :has() Parent Selector - Ahmad Shadeed](https://ishadeed.com/article/css-has-parent-selector/) - [The CSS :has Selector - CSS-Tricks](https://css-tricks.com/the-css-has-selector/) - [Quantity Queries with CSS :has() - Frontend Masters](https://frontendmasters.com/blog/quantity-queries-are-very-easy-with-css-has/) --- # ホバー・フォーカス・アクティブ状態 > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/states-and-transitions/hover-focus-active-states ## 問題 インタラクティブな要素には、ホバー、フォーカス、アクティブ状態の視覚的フィードバックが必要です。AIエージェントは、タッチデバイスでスティッキーなホバー状態を引き起こす `:hover` スタイルを追加したり、`:focus` や `:focus-visible` のスタイリングを省略してキーボードアクセシビリティを壊したり、本来は区別すべき3つの状態すべてに同一のスタイルを適用したりします。その結果、マウスでは動作するものの、タッチユーザーやキーボードナビゲーターにはストレスの多いインターフェースになります。 ## 解決方法 各インタラクション状態を目的に応じてスタイリングし、`@media (hover: hover)` でホバーエフェクトを対応デバイスに限定し、キーボード専用のフォーカスインジケーターには `:focus` ではなく `:focus-visible` を使用しましょう。 ### 3つの状態 - **`:hover`** — ポインティングデバイス(マウス、トラックパッド)が要素の上にある状態です。タッチデバイスでは確実に動作しません。 - **`:focus`** — マウスクリック、キーボードのタブ、プログラム的なフォーカスなど、何らかの方法で要素がフォーカスされた状態です。 - **`:focus-visible`** — 要素がフォーカスされ、**かつ**ブラウザが視覚的インジケーターが適切と判断した状態(通常はキーボードナビゲーション)です。ボタンのマウスクリックでは `:focus-visible` はトリガーされません。 - **`:active`** — 要素がアクティベートされている状態(マウスボタン押下中、タッチで指を押している状態)です。 Primary Button Outline Button Link Style Hover over buttons to see hover state. Press Tab to see focus-visible ring. Click and hold to see active state. `} css={` .demo { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; padding: 1.5rem; } .hint { width: 100%; font-size: 0.75rem; color: #94a3b8; margin: 0.5rem 0 0; font-style: italic; } .btn { padding: 0.625rem 1.25rem; border-radius: 0.375rem; font-size: 0.875rem; font-weight: 600; cursor: pointer; border: 2px solid transparent; text-decoration: none; display: inline-flex; align-items: center; transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; } .btn-primary { background-color: #3b82f6; color: white; } .btn-primary:hover { background-color: #2563eb; } .btn-primary:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } .btn-primary:active { transform: scale(0.96); background-color: #1d4ed8; } .btn-outline { background: transparent; color: #3b82f6; border-color: #3b82f6; } .btn-outline:hover { background: #eff6ff; } .btn-outline:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } .btn-outline:active { transform: scale(0.96); background: #dbeafe; } .btn-link { background: none; color: #3b82f6; padding: 0.625rem 0.25rem; text-decoration: underline; text-underline-offset: 0.2em; } .btn-link:hover { color: #1d4ed8; text-decoration-thickness: 2px; } .btn-link:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; border-radius: 2px; } .btn-link:active { color: #1e40af; } `} /> ## コード例 ### 基本的なボタンの状態 ```css .button { background-color: var(--color-primary, #2563eb); color: white; border: 2px solid transparent; padding: 0.625rem 1.25rem; border-radius: 0.375rem; cursor: pointer; transition: background-color 0.15s ease, transform 0.1s ease; } /* Hover: only on devices that support it */ @media (hover: hover) { .button:hover { background-color: var(--color-primary-dark, #1d4ed8); } } /* Focus-visible: keyboard focus indicator */ .button:focus-visible { outline: 2px solid var(--color-primary, #2563eb); outline-offset: 2px; } /* Active: pressed state */ .button:active { transform: scale(0.97); } ``` ### リンクの状態(LVHAの順序) リンクの擬似クラスは、詳細度の競合を避けるためにLVHAの順序に従うべきです: ```css a:link { color: var(--color-link, #2563eb); text-decoration: underline; text-underline-offset: 0.2em; } a:visited { color: var(--color-link-visited, #7c3aed); } @media (hover: hover) { a:hover { color: var(--color-link-hover, #1d4ed8); text-decoration-thickness: 2px; } } a:focus-visible { outline: 2px solid var(--color-link, #2563eb); outline-offset: 2px; border-radius: 2px; } a:active { color: var(--color-link-active, #1e40af); } ``` ### カードのホバーエフェクト(タッチセーフ) ```css .card { background: var(--color-surface, #ffffff); border-radius: 0.5rem; border: 1px solid var(--color-border, #e5e7eb); padding: 1.5rem; transition: box-shadow 0.2s ease, transform 0.2s ease; } /* Only apply hover elevation on hover-capable devices */ @media (hover: hover) { .card:hover { box-shadow: 0 4px 16px rgb(0 0 0 / 0.1); transform: translateY(-2px); } } /* Keyboard focus */ .card:focus-visible { outline: 2px solid var(--color-primary, #2563eb); outline-offset: 2px; } ``` ### Focus-Visible と Focus の違い ```css /* Remove default focus ring for mouse users */ .interactive:focus { outline: none; } /* Show focus ring only for keyboard users */ .interactive:focus-visible { outline: 2px solid var(--color-primary, #2563eb); outline-offset: 2px; } ``` `:focus` スタイルを完全に削除しない、より安全なアプローチもあります: ```css /* Visible focus ring for keyboard navigation */ .interactive:focus-visible { outline: 2px solid var(--color-primary, #2563eb); outline-offset: 2px; } /* Subtle focus style for mouse clicks (if desired) */ .interactive:focus:not(:focus-visible) { outline: none; } ``` ### タッチ入力とマウス入力の検出 ```css /* Base interactive styles */ .nav-link { padding: 0.5rem 1rem; color: var(--color-text); text-decoration: none; } /* Hover effects only for precise pointers */ @media (hover: hover) and (pointer: fine) { .nav-link:hover { background-color: var(--color-surface-hover, #f3f4f6); } } /* Larger touch targets for coarse pointers */ @media (pointer: coarse) { .nav-link { min-height: 44px; display: flex; align-items: center; padding: 0.75rem 1rem; } } ``` ### フォーム入力のフォーカス状態 ```css .input { border: 1px solid var(--color-border, #d1d5db); border-radius: 0.375rem; padding: 0.5rem 0.75rem; transition: border-color 0.15s ease, box-shadow 0.15s ease; } /* All focus (mouse and keyboard) gets a border change */ .input:focus { border-color: var(--color-primary, #2563eb); box-shadow: 0 0 0 3px rgb(37 99 235 / 0.15); outline: none; } ``` フォーム入力には `:focus-visible` ではなく `:focus` を使うのが通常正しい選択です。ユーザーはフォーカスの方法に関係なく、どの入力フィールドに入力しているかを確認する必要があるためです。 ## AIがよくやるミス - **`@media (hover: hover)` なしで `:hover` を追加する**:ホバーエフェクトがタッチデバイスでタップ後に「スティッキー」な状態で残り、ユーザーを混乱させます。 - **代替なしで `:focus` のアウトラインを削除する**:`:focus` に `outline: none` を書きながら視覚的フォーカスインジケーターを一切提供せず、キーボードユーザーにとってページがアクセス不能になります。 - **`:focus-visible` ではなく `:focus` を使用する**:キーボードフォーカスのみ視覚的インジケーターが必要な場面で、マウスクリックのたびにフォーカスリングを表示してしまいます。 - **すべての状態に同一スタイルを適用する**:`:hover`、`:focus`、`:active` を同じ見た目にしてしまい、インタラクションの種類に関する意味のある視覚的フィードバックが失われます。 - **LVHAの順序を忘れる**:`:visited` の前に `:hover` を書いてしまい、リンクスタイリングで詳細度の競合が発生します。 - **タッチデバイスでテストしない**:ホバーがどこでも機能すると思い込み、モバイルでのインタラクションを一切検証しません。 - **あらゆる要素に `cursor: pointer` を使用する**:div のような非インタラクティブ要素に `cursor: pointer` を追加し、ユーザーを誤解させます。 ## 使い分け - **`:hover` と `@media (hover: hover)`**:マウスやトラックパッドでのみ意味のある視覚的エンハンスメント(色の変化、シャドウ、エレベーション)に使います。 - **`:focus-visible`**:ボタン、リンク、カスタムインタラクティブ要素のキーボードフォーカスインジケーターに使います。 - **`:focus`**:すべてのフォーカスタイプで視覚的インジケーターが必要なフォーム入力に使います。 - **`:active`**:ボタンやインタラクティブ要素の押下・タップフィードバック(スケール、色の変化)に使います。 - **`@media (pointer: coarse)`**:タッチデバイス向けにタッチターゲットサイズとパディングを増やす場合に使います。 ## Tailwind CSS Tailwind は CSS擬似クラスに直接対応する `hover:`、`focus:`、`focus-visible:`、`active:` のバリアントプレフィックスを提供しています。 Primary Button Outline Button Link Style Hover over buttons, tab to see focus ring, click and hold for active state. `} height={130} /> ## 参考リンク - [:focus-visible — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible) - [Solving Sticky Hover States with @media (hover: hover) — CSS-Tricks](https://css-tricks.com/solving-sticky-hover-states-with-media-hover-hover/) - [Style hover, focus, and active states differently — Zell Liew](https://zellwk.com/blog/style-hover-focus-active-states/) - [Focus or Focus-Visible? A Guide to Accessible Focus States — Maya Shavin](https://mayashavin.com/articles/focus-vs-focus-visible-for-accessibility) --- # Flexbox パターン > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/flexbox-and-grid/flexbox-patterns ## 問題 Flexboxは最もよく使われるCSSレイアウトモデルですが、AIエージェントはしばしば誤った使い方をします。よくあるミスとしては、CSS Gridの方が適切な場面でflexboxを使う、オーバーフローを防ぐための `min-width: 0` を忘れる、スティッキーフッターに flex-grow ではなく固定の高さを使う、flex軸を理解せずに `justify-content` と `align-items` をデフォルトで使う、などがあります。 ## 解決方法 Flexboxは一次元のレイアウトモデルです。単一の軸(行または列)に沿ってスペースを分配するのに優れています。コンポーネントレベルのレイアウト、ナビゲーションバー、ツールバー、アイテムが一方向に並ぶあらゆるシナリオに使いましょう。 ## コード例 ### センタリング(水平・垂直) ```css .centered-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; } ``` ```html Perfectly centered ``` Perfectly centered `} css={`.centered-container { display: flex; justify-content: center; align-items: center; min-height: 200px; background: #f1f5f9; border-radius: 8px; } .content { background: #3b82f6; color: #fff; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-family: system-ui, sans-serif; }`} height={220} /> ### 等しい高さのカラム 行コンテナ内のflexアイテムは、デフォルトで `align-items: stretch` により同じ高さに引き伸ばされます。 ```css .columns { display: flex; gap: 1rem; } .column { flex: 1; /* No need for align-items or explicit height */ } ``` ```html Short content Much longer content that determines the height of all columns. All siblings will match this height automatically. Medium content here ``` Short Much longer content that determines the height of all columns. All siblings match this height automatically. Medium content here `} css={`.columns { display: flex; gap: 12px; padding: 12px; font-family: system-ui, sans-serif; } .column { flex: 1; background: #8b5cf6; color: #fff; padding: 16px; border-radius: 8px; font-size: 16px; } .column p { margin: 0 0 8px 0; }`} /> ### スティッキーフッター コンテンツが少ない場合はフッターがビューポートの下部に固定され、コンテンツが多い場合はコンテンツの下に自然に流れます。 ```css body { display: flex; flex-direction: column; min-height: 100vh; margin: 0; } main { flex: 1; } /* header and footer need no special styles */ ``` ```html Header Main content Footer ``` Header Main content (short) Footer sticks to bottom `} css={`.page { display: flex; flex-direction: column; min-height: 300px; font-family: system-ui, sans-serif; font-size: 16px; } .header { background: #3b82f6; color: #fff; padding: 12px 20px; } .main { flex: 1; padding: 20px; background: #f1f5f9; } .footer { background: #22c55e; color: #fff; padding: 12px 20px; }`} height={320} /> ### space-between と折り返しの問題 アイテムが折り返される場合、`justify-content: space-between` は最後の行に不自然な隙間を生じさせることがあります。一定のスペーシングには `gap` を使いましょう。 ```css /* Problematic: last row items spread apart */ .bad-wrap { display: flex; flex-wrap: wrap; justify-content: space-between; } /* Better: consistent gaps between items */ .good-wrap { display: flex; flex-wrap: wrap; gap: 1rem; } .good-wrap > * { flex: 0 1 calc(33.333% - 1rem); } ``` space-between (broken last row): 1 2 3 4 5 gap (consistent spacing): 1 2 3 4 5 `} css={`.label { font-family: system-ui, sans-serif; font-size: 14px; font-weight: 600; margin-bottom: 8px; color: #334155; } .bad-wrap { display: flex; flex-wrap: wrap; justify-content: space-between; margin-bottom: 20px; } .good-wrap { display: flex; flex-wrap: wrap; gap: 12px; } .item { width: 30%; background: #ef4444; color: #fff; padding: 12px; border-radius: 8px; text-align: center; font-family: system-ui, sans-serif; font-size: 16px; margin-bottom: 12px; } .item.good { background: #22c55e; width: calc(33.333% - 12px); margin-bottom: 0; }`} /> ### min-width: 0 によるオーバーフロー防止 flexアイテムはデフォルトで `min-width: auto` を持ち、コンテンツサイズ以下に縮小できません。これにより制約のあるレイアウトでテキストのオーバーフローが発生します。 ```css .card { display: flex; gap: 1rem; } .card-content { flex: 1; min-width: 0; /* Allow content to shrink and enable text-overflow */ } .card-content h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } ``` ### ナビゲーションバー ```css .navbar { display: flex; align-items: center; gap: 1rem; } .navbar-logo { margin-right: auto; /* Pushes nav items to the right */ } ``` ```html Logo About Contact ``` ## AIがよくやるミス - **二次元レイアウトにflexboxを使う。** アイテムが行と列の両方で同時に揃う必要がある場合は、CSS Gridが正しい選択です。Flexboxは一方の軸のみを制御します。 - **スティッキーフッターに `min-height: 100vh` ではなく `height: 100vh` を使う。** 固定の高さはコンテンツが長いページでオーバーフローを引き起こします。 - **flexアイテムに `min-width: 0` を忘れる。** これがないと、flexアイテムはコンテンツの幅以下に縮小せず、水平方向のオーバーフローが発生します。 - **スペーシングに `gap` ではなく `margin` を使う。** flexアイテムのmarginはアイテムが隣接する部分でスペーシングが二重になり、先頭/末尾の子要素にワークアラウンドが必要になります。`gap` はアイテム間にのみ適用されます。 - **`justify-content: space-between` と `flex-wrap: wrap` を併用する。** 最後の行に予測不能な隙間ができます。代わりに `gap` と計算された flex-basis を使いましょう。 - **`flex: 1` で十分なのに `flex: 1 1 0` と書く。** ショートハンド `flex: 1` は `flex-grow: 1; flex-shrink: 1; flex-basis: 0%` をすでに設定しています。 - **不必要にflexコンテナをネストする。** シンプルな `margin-right: auto` や `gap` でスペーシングを実現できる場合は、追加のコンテナでアイテムを囲むのは避けましょう。 ## 使い分け ### Flexbox が適しているケース - 単一軸のレイアウト(ボタンの行、ナビゲーションバー、ツールバー) - サイズが不明または可変のアイテム間でスペースを分配する場合 - コンテナ内でコンテンツを垂直センタリングする場合 - コンポーネントレベルのレイアウト(カードの内部、フォーム行、メディアオブジェクト) - `flex-direction: column` を使ったスティッキーフッター ### 代わりにCSS Gridを使うべき場合 - アイテムが行と列の両方で揃う必要がある場合(カードのグリッド) - 名前付きエリアを持つ複雑なページレベルのレイアウト - レスポンシブなグリッドへのアイテムの自動配置が必要な場合 - アイテムが複数の行や列にまたがる必要がある場合 ## Tailwind CSS Tailwindはすべてのflexboxプロパティに対応するユーティリティクラスを提供しています。以下は、この記事の主要なパターンをTailwindクラスで表現したものです。 ### センタリング Perfectly centered `} height={220} /> ### 等しい高さのカラム Short Much longer content that determines the height of all columns. All siblings match this height automatically. Medium content here `} /> ### スティッキーフッター Header Main content (short) Footer sticks to bottom `} height={320} /> ## 参考リンク - [A Complete Guide to Flexbox - CSS-Tricks](https://css-tricks.com/snippets/css/a-guide-to-flexbox/) - [Flexbox - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Flexbox) - [Solved by Flexbox](https://philipwalton.github.io/solved-by-flexbox/) - [Flexbox Layout - web.dev](https://web.dev/learn/css/flexbox) --- # センタリングテクニック > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/positioning/centering-techniques ## 問題 要素のセンタリングはCSSの最も基本的なタスクの1つですが、AIエージェントは過度に複雑な方法や不適切な方法を使いがちです。よくあるミスとしては、非インライン要素に `text-align: center` を使う、flexboxやgridで十分なのに `transform: translate(-50%, -50%)` を適用する、複数のセンタリング手法を重ねて使う、レイアウトが求めていないのに `position: absolute` に頼る、などがあります。 ## 解決方法 モダンCSSには、目的に合った簡潔なセンタリング手法が用意されています。適切な手法はコンテキストによって異なります:何をセンタリングするのか、どの軸が必要か、親要素に高さが定義されているか、を考慮しましょう。 ## コード例 ### margin: auto による水平センタリング 幅が定義されたブロックレベル要素には、`margin: auto` が最もシンプルな水平センタリング手法です。垂直方向のセンタリングはできません。 ```css .centered-block { width: 600px; /* or max-width */ margin-inline: auto; } ``` ```html Horizontally centered block element. ``` `margin-inline: auto` は `margin-left: auto; margin-right: auto` の論理プロパティ版で、すべての書字方向で正しく動作します。 ### インライン/テキストコンテンツの水平センタリング ```css .text-center { text-align: center; } ``` これはブロックコンテナ内のインラインコンテンツ(テキスト、``、``、inline-block要素)をセンタリングします。ブロックレベルの子要素はセンタリングされません。 ### Flexbox センタリング(両軸) ```css .flex-center { display: flex; justify-content: center; /* horizontal */ align-items: center; /* vertical */ } ``` ```html Centered both ways ``` 垂直センタリングを見えるようにするには、親要素に高さ(または `min-height`)が必要です。 ### Grid センタリング(両軸、最も簡潔) ```css .grid-center { display: grid; place-items: center; } ``` ```html Centered with one line ``` `place-items: center` は `align-items: center; justify-items: center` のショートハンドです。CSSで最も簡潔なセンタリング手法です。 ### place-content を使った Grid センタリング 単一の子要素をセンタリングする別の方法です: ```css .grid-center-alt { display: grid; place-content: center; min-height: 100vh; } ``` 違い:`place-items` はグリッドエリア内でアイテムを揃え、`place-content` はグリッドトラック自体を揃えます。単一の子要素のセンタリングでは、どちらも同じ結果になります。 ### Flex または Grid 内での margin: auto によるセンタリング ```css .container { display: flex; /* or display: grid */ min-height: 100vh; } .child { margin: auto; } ``` flexまたはgridアイテムに `margin: auto` を設定すると、その軸の利用可能なスペースをすべて吸収し、水平・垂直の両方向にセンタリングされます。 ### 絶対配置と inset を使ったセンタリング 位置指定された親要素の上にオーバーレイコンテンツをセンタリングする場合に使います: ```css .overlay-parent { position: relative; } .overlay-centered { position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content; } ``` ### Transform テクニック(レガシー) ```css .transform-center { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } ``` このテクニックは今でも有効ですが、モダンCSSではほとんどの場合最適な選択肢ではありません。非Retinaディスプレイではサブピクセルレンダリングによりテキストがぼやけることがあります。flexboxまたはgridを使いましょう。 ## ライブプレビュー Centered with Flexbox `} css={` .flex-center { display: flex; justify-content: center; align-items: center; height: 200px; background: #f0f4f8; } .box { padding: 16px 24px; background: #3b82f6; color: white; border-radius: 8px; font-family: system-ui, sans-serif; font-weight: 600; } `} /> Centered with Grid `} css={` .grid-center { display: grid; place-items: center; height: 200px; background: #f0fdf4; } .box { padding: 16px 24px; background: #22c55e; color: white; border-radius: 8px; font-family: system-ui, sans-serif; font-weight: 600; } `} /> ## クイックリファレンス | シナリオ | 手法 | | --- | --- | | ブロック要素、水平のみ | `margin-inline: auto`(幅の指定が必要) | | インライン/テキストコンテンツ、水平のみ | 親要素に `text-align: center` | | 単一の子要素、両軸 | `display: grid; place-items: center` | | 複数の子要素、両軸 | `display: flex; justify-content: center; align-items: center` | | flex/grid内の子要素、両軸 | 子要素に `margin: auto` | | 位置指定された親上のオーバーレイ | `position: absolute; inset: 0; margin: auto` | | レガシー / flexbox以前のコードベース | `transform: translate(-50%, -50%)` | ## AIがよくやるミス - **`transform: translate(-50%, -50%)` をデフォルトとして使う。** このテクニックはレガシーなフォールバックです。モダンCSSにはflexboxやgridを使ったより簡潔な方法があります。 - **親要素に高さを設定し忘れる。** 垂直センタリングには親要素に高さまたは min-height が必要です。これがないと、親要素がコンテンツの高さに縮み、センタリングが効いていないように見えます。 - **ブロック要素に `text-align: center` を使う。** `text-align` はインラインコンテンツにのみ作用します。`` の中で別の `` をセンタリングすることはできません。 - **複数のセンタリング手法を重ねる。** `margin: auto` と `justify-content: center` の両方を適用するのは冗長です。1つの方法を選びましょう。 - **フロー内の要素に `position: absolute` を使う。** 絶対配置は要素をドキュメントフローから外すため、レイアウト内のコンテンツのセンタリングには通常不適切です。 - **`margin-inline: auto` の代わりに `margin: 0 auto` を書く。** どちらも左右方向では動作しますが、物理プロパティ版は垂直方向のマージンを0にリセットしてしまい、意図したスペーシングを上書きする可能性があります。`margin-inline: auto` は水平方向のみに影響します。 ## 使い分け ### Grid `place-items: center` 単一の子要素をセンタリングするのに最適です。最も簡潔な構文です。親要素を他のレイアウト目的でflexコンテナにする必要がない場合に使いましょう。 ### Flexbox センタリング 親要素がすでにflexコンテナである場合、または複数のアイテムを一方の軸でセンタリングしつつ別の軸で配置する場合に最適です。 ### margin-inline: auto 既知の幅またはmax-widthを持つブロック要素を水平センタリングするのに最適です。親要素のdisplayタイプを変更する必要がありません。 ### 絶対配置 オーバーレイ、モーダル、ツールチップ、およびドキュメントフローから外して他のコンテンツの上に配置する必要がある要素にのみ使いましょう。 ## Tailwind CSS Tailwindは、すべてのセンタリング手法に対応する簡潔なユーティリティクラスを提供しています。 ### Flexbox センタリング Centered with Flexbox `} height={220} /> ### Grid センタリング Centered with Grid `} height={220} /> ### mx-auto による水平センタリング Centered with mx-auto + w-fit `} height={100} /> ## 参考リンク - [Centering in CSS - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/How_to/Layout_cookbook/Center_an_element) - [The Complete Guide to Centering in CSS - Modern CSS Solutions](https://moderncss.dev/complete-guide-to-centering-in-css/) - [Centering Things - CSS-Tricks](https://css-tricks.com/centering-css-complete-guide/) - [Learn CSS Box Alignment - web.dev](https://web.dev/learn/css/box-alignment) --- # fit-content, max-content, min-content > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/sizing/fit-content ## 問題 CSSのブロックレベル要素はデフォルトで `width: auto` となり、コンテナいっぱいに広がります。要素をコンテンツに合わせて縮小したい場合(タグ、ツールチップ、ブロックスタイルのCTAボタンなど)、AIエージェントは通常 `width: auto`(デフォルトなので何もしない)や `width: 100%`(必要なことの逆)を使いがちです。内在サイジングキーワード(intrinsic sizing keywords)の `fit-content`、`max-content`、`min-content` はこの問題を直接解決しますが、AIエージェントはこれらをほとんど使いません。 ## 解決方法 CSSは、要素がコンテンツに基づいてサイズを決定するための3つの内在サイジングキーワードを提供しています: - **`fit-content`** — 要素はコンテンツに合わせて縮小しますが、コンテナ幅を超えることはありません。最もよく必要とされる動作です。 - **`max-content`** — 要素はすべてのコンテンツを1行に収めるように拡張します。コンテナからはみ出すことがあります。 - **`min-content`** — 要素は最小の分割不可能なコンテンツ(最も長い単語など)がオーバーフローしない最も狭い幅に縮小します。 ## コード例 ### ブロック要素をコンテンツに合わせて縮小する ```css /* The element is as wide as its content, up to the container width */ .tag { width: fit-content; padding: 0.25rem 0.75rem; background: #e0e7ff; border-radius: 9999px; } ``` ```html New Feature A longer tag label that still shrinks to fit ``` `width: fit-content` がなければ、各 `.tag` は親要素いっぱいに広がります。これを付けることで、各タグはテキストとパディング分の幅だけになります。 ### 縮小フィット要素のセンタリング コンテンツと同じ幅でブロック要素をセンタリングする一般的なパターン: ```css .centered-tag { width: fit-content; margin-inline: auto; } ``` `display: inline-block` に切り替えて `text-align: center` の親で囲むよりもクリーンです。 ### 3つのキーワードの比較 ```css .min { width: min-content; /* Shrinks to the longest word. Text wraps aggressively. */ } .max { width: max-content; /* Expands to fit all content on one line. May overflow container. */ } .fit { width: fit-content; /* Like max-content, but capped at the container width. */ } ``` ```html This text wraps at every opportunity This text stays on one line even if it overflows the container This text stays on one line if it fits, otherwise wraps at 300px ``` ### 固定幅カラムでの min-content の使用 `min-content` はグリッドレイアウトで、カラムを最も狭いコンテンツに合わせたい場合に便利です: ```css .table-layout { display: grid; grid-template-columns: min-content 1fr min-content; gap: 1rem; } ``` ```html Status Description of the item that can be long Actions ``` 最初と最後のカラムはコンテンツに合わせて縮小し、中央のカラムが残りのスペースを取ります。 ### グリッドでの fit-content() 関数 `fit-content()` 関数(括弧付き)はグリッドトラックサイジング専用です。最大長さの引数を受け取ります: ```css .sidebar-layout { display: grid; grid-template-columns: fit-content(300px) 1fr; gap: 2rem; } ``` サイドバーカラムはコンテンツに合わせて縮小しますが、300px を超えることはありません。これは `minmax(auto, 300px)` とは異なり、`fit-content()` はスペースが余っていてもコンテンツ幅を超えて拡張しません。 ### 実用例:通知バッジ ```css .badge { display: block; width: fit-content; padding: 0.125rem 0.5rem; font-size: 0.75rem; background: #ef4444; color: white; border-radius: 9999px; } ``` ```html 3 99+ ``` 各バッジはコンテンツにぴったりの幅になります。固定幅もオーバーフローもありません。 ## ライブプレビュー width: 100% (default block) New Feature Badge width: fit-content New Feature Badge `} css={` .demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .section { flex: 1; } .section h4 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #334155; } .tag { padding: 6px 16px; background: #e0e7ff; border-radius: 9999px; font-size: 14px; margin-bottom: 8px; } .full { width: 100%; } .fit { width: fit-content; } `} /> ## AIがよくやるミス - **`width: fit-content` が必要な場面で `width: auto` を使っている。** ブロック要素の `width: auto` は「コンテナいっぱいに広がる」という意味で、コンテンツへの縮小フィットとは逆です。AIエージェントは `auto` が「自動サイジング」を意味すると思いがちです。 - **縮小フィットのワークアラウンドとして `display: inline-block` を使っている。** `inline-block` は確かに縮小フィット動作を引き起こしますが、要素のフォーマットコンテキストを変更し、配置の問題を引き起こす可能性があります。`width: fit-content` は display タイプを変更せずに同じサイジングを実現します。 - **`fit-content`(キーワード)と `fit-content()`(関数)を混同している。** キーワードは `width`、`height`、`min-width` などが値を受け取る場所ならどこでも使えます。関数はグリッドトラックサイジング(`grid-template-columns`、`grid-template-rows`)でのみ有効です。 - **`fit-content` が必要な場面で `max-content` を使っている。** `max-content` はコンテナ幅を無視するためオーバーフローを引き起こす可能性があります。`fit-content` がほぼ常に安全な選択です。 - **縮小すべき要素に `width: 100%` を設定している。** AIエージェントはボタン、タグ、バッジに `width: 100%` を頻繁に適用し、コンテンツに合わせてサイズを決めるべきところでコンテナいっぱいに広がらせてしまいます。 - **`min-content` がテキストを積極的に折り返すことを忘れている。** すべてのソフトラップの機会で改行されるため、非常に狭く読みにくい要素になる可能性があります。主にグリッドトラックサイジングに便利で、一般的な要素幅には向いていません。 ## 使い分け ### fit-content - コンテンツに合わせて縮小すべきブロック要素:タグ、バッジ、ツールチップ、キャプション - `margin-inline: auto` でコンテンツ幅の要素をセンタリング - 「コンテンツと同じ幅で、コンテナより広くならない」ことが必要なすべてのシナリオ ### max-content - コンテンツの自然な1行幅を知る必要がある、または強制する必要がある場合 - アニメーションや測定のための内在サイズの計算 - オーバーフローする可能性があるため、最終レイアウトにはほとんど使われない ### min-content - できるだけ狭くしたいグリッドカラム(アイコンカラム、ステータスラベル) - 要素がオーバーフローする前に必要な最小スペースの把握 - テキストを積極的に折り返すため、単独の `width` 値としてはほとんど使われない ### fit-content() 関数 - カラムをコンテンツに合わせて縮小しつつ最大幅に制限したいグリッドトラックサイジング - サイドバーレイアウト:`grid-template-columns: fit-content(250px) 1fr` ## Tailwind CSS Tailwind は内在サイジングのための `w-fit`、`w-min`、`w-max` ユーティリティを提供しています。 ### w-fit とデフォルト幅の比較 Default (stretches) New Feature Badge w-fit (shrinks) New Feature Badge `} height={140} /> ### センタリングされた fit-content 要素 Centered Badge `} height={80} /> ## 参考リンク - [fit-content - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/fit-content) - [max-content - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/max-content) - [min-content - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/min-content) - [fit-content() - CSS-Tricks](https://css-tricks.com/almanac/functions/f/fit-content/) - [Understanding min-content, max-content, and fit-content in CSS - LogRocket Blog](https://blog.logrocket.com/understanding-min-content-max-content-fit-content-css/) --- # Object Fit と Object Position > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/specialized/object-fit-and-position ## 問題 画像や動画を固定サイズのコンテナに配置すると、置換要素(replaced elements)のデフォルトが `object-fit: fill` であるため、引き伸ばされたり歪んだりします。開発者はしばしば `` の `background-image` に切り替えることで回避しますが、これによりセマンティクス、アクセシビリティ(`alt` テキスト)、ネイティブの遅延読み込み、SEOのインデックス対象としての認識が失われます。`` や `` 要素に対する正しい解決策は `object-fit` です。HTMLを放棄せずにコンテンツのフィル方法を制御できます。 ## 解決方法 `object-fit` は、置換要素のコンテンツがコンテナに合わせてどのようにリサイズされるかを制御します。背景画像に対する `background-size` と同じように動作しますが、実際の ``、`` などの置換要素に適用されます。`object-position` はコンテンツの要素ボックス内での配置を制御し、焦点を選択できます。 ### 基本原則 #### object-fit の値 - **`fill`**(デフォルト)— ボックスにぴったり合うようにコンテンツを引き伸ばします。アスペクト比を無視します。写真にはほぼ不適切です。 - **`contain`** — アスペクト比を維持しつつ、コンテンツがボックス内に完全に収まるようにスケーリングします。余白(レターボックス)が生じることがあります。 - **`cover`** — アスペクト比を維持しつつ、コンテンツがボックス全体をカバーするようにスケーリングします。画像の一部がクリップされることがあります。 - **`none`** — リサイズなし。コンテンツは固有のサイズで表示されます。はみ出した部分はクリップされます。 - **`scale-down`** — `none` または `contain` のうち、より小さい結果を生むものとして動作します。拡大を防ぎます。 #### object-position `background-position` とまったく同じように動作します。キーワード値(`top`、`center`、`right`)、パーセンテージ、または長さの値を受け付けます。デフォルトは `50% 50%`(中央揃え)です。`object-fit: cover` で画像がクリップされる際に、どの部分を表示するかを制御するのに使います。 #### aspect-ratio との関係 `aspect-ratio` プロパティはボックスの比率を定義し、`object-fit` はコンテンツがそのボックスをどう埋めるかを制御します。両者は連携して動作します:要素に `aspect-ratio` を設定してコンテナの形状を定義し、`object-fit` で画像コンテンツの適応方法を制御します。 ```css img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } ``` これにより、常に16:9のフレームを維持しつつ、内部の写真がエッジまで埋まるレスポンシブな画像が得られます。 ## ライブプレビュー fill (default) contain cover none scale-down `} css={` .fit-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; padding: 16px; font-family: system-ui, sans-serif; } .fit-item { display: flex; flex-direction: column; gap: 8px; } .fit-label { font-size: 12px; font-weight: 600; color: hsl(220 15% 40%); text-align: center; padding: 4px 8px; background: hsl(220 20% 95%); border-radius: 4px; } .fit-item img { width: 100%; height: 160px; border: 2px solid hsl(220 20% 90%); border-radius: 6px; background: hsl(220 20% 97%); } .fit-fill { object-fit: fill; } .fit-contain { object-fit: contain; } .fit-cover { object-fit: cover; } .fit-none { object-fit: none; } .fit-scale-down { object-fit: scale-down; } `} /> Anna K. Marco R. Sara L. James T. Yuki N. `} css={` .avatar-grid { display: flex; gap: 20px; padding: 24px; justify-content: center; font-family: system-ui, sans-serif; } .avatar-card { display: flex; flex-direction: column; align-items: center; gap: 8px; } .avatar { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; border: 3px solid hsl(220 20% 90%); } .avatar-name { font-size: 13px; font-weight: 500; color: hsl(220 15% 35%); } `} /> left top center (default) right bottom 25% 75% `} css={` .position-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; padding: 16px; font-family: system-ui, sans-serif; } .position-item { display: flex; flex-direction: column; gap: 6px; } .position-label { font-size: 12px; font-weight: 600; color: hsl(220 15% 40%); padding: 4px 8px; background: hsl(220 20% 95%); border-radius: 4px; text-align: center; } .position-item img { width: 100%; height: 140px; object-fit: cover; border: 2px solid hsl(220 20% 90%); border-radius: 6px; } .pos-left-top { object-position: left top; } .pos-center { object-position: center; } .pos-right-bottom { object-position: right bottom; } .pos-custom { object-position: 25% 75%; } `} /> Getting Started with CSS Grid Learn the fundamentals of CSS Grid layout and build responsive layouts with ease. Flexbox Deep Dive Master one-dimensional layouts with flexbox patterns for real-world components. Modern Color Systems Explore oklch, color-mix, and relative color syntax for design-system-ready palettes. `} css={` .card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; font-family: system-ui, sans-serif; } .card { border-radius: 10px; overflow: hidden; border: 1px solid hsl(220 20% 90%); background: white; } .card-image { width: 100%; height: 160px; object-fit: cover; display: block; } .card-body { padding: 12px 14px; } .card-title { font-size: 14px; font-weight: 700; color: hsl(220 25% 20%); margin: 0 0 6px; line-height: 1.3; } .card-text { font-size: 12px; color: hsl(220 15% 50%); margin: 0; line-height: 1.5; } `} /> object-fit(推奨) <img> with object-fit: cover Semantic HTML — it's an image Built-in alt text for accessibility Native lazy loading with loading="lazy" Indexed by search engines Works with <picture> and srcset background-image(非推奨) <div> with background-image Non-semantic — it's a div, not an image No alt text — invisible to screen readers No native lazy loading Not indexed as an image by search engines Cannot use <picture> or srcset `} css={` .comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 16px; font-family: system-ui, sans-serif; } .comparison-column { display: flex; flex-direction: column; gap: 8px; } .comparison-header { font-size: 13px; font-weight: 700; text-align: center; padding: 6px 12px; border-radius: 6px; } .comparison-header.good { background: hsl(150 50% 92%); color: hsl(150 60% 30%); } .comparison-header.bad { background: hsl(0 50% 94%); color: hsl(0 60% 40%); } .comparison-demo { height: 120px; border-radius: 8px; overflow: hidden; border: 2px solid hsl(220 20% 90%); } .comparison-img { width: 100%; height: 100%; object-fit: cover; display: block; } .comparison-bg { width: 100%; height: 100%; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='500' height='300' viewBox='0 0 500 300'%3E%3Cdefs%3E%3ClinearGradient id='p' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='hsl(200,80%25,55%25)'/%3E%3Cstop offset='100%25' stop-color='hsl(240,70%25,50%25)'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='500' height='300' fill='url(%23p)'/%3E%3Ccircle cx='250' cy='130' r='60' fill='hsl(45,90%25,60%25)' opacity='0.9'/%3E%3Crect x='100' y='220' width='300' height='16' rx='8' fill='hsl(0,0%25,100%25)' opacity='0.3'/%3E%3Crect x='140' y='250' width='220' height='16' rx='8' fill='hsl(0,0%25,100%25)' opacity='0.3'/%3E%3C/svg%3E"); background-size: cover; background-position: center; } .comparison-code { font-size: 12px; color: hsl(220 15% 45%); text-align: center; } .comparison-code code { background: hsl(220 20% 95%); padding: 2px 5px; border-radius: 3px; font-size: 11px; } .comparison-list { list-style: none; padding: 0; margin: 0; font-size: 11px; display: flex; flex-direction: column; gap: 3px; } .comparison-list li { padding: 3px 6px; border-radius: 4px; line-height: 1.4; } .pro { background: hsl(150 45% 94%); color: hsl(150 50% 28%); } .con { background: hsl(0 45% 96%); color: hsl(0 50% 38%); } .pro::before { content: "✓ "; font-weight: 700; } .con::before { content: "✗ "; font-weight: 700; } `} /> ## クイックリファレンス | シナリオ | CSS | | --- | --- | | 画像がコンテナを埋め、比率を維持、端をクリップ | `object-fit: cover` | | 画像がコンテナ内に収まり、レターボックスあり | `object-fit: contain` | | 画像を固有サイズのまま、リサイズなし | `object-fit: none` | | 固有サイズ以上に拡大しない | `object-fit: scale-down` | | cover でクリップされる際の焦点を制御 | `object-position: top` または `object-position: 25% 75%` | | cover を使ったレスポンシブ16:9フレーム | `aspect-ratio: 16/9; object-fit: cover` | | 任意のアスペクト比からの円形アバター | `border-radius: 50%; object-fit: cover` | ## AIがよくやるミス - **`object-fit` の代わりに `background-image` を使う。** コンテンツが意味のある画像(装飾ではない)である場合は、`` と `object-fit` を使いましょう。`background-image` は純粋に装飾的な背景にのみ使います。 - **画像に明示的なサイズを設定し忘れる。** `object-fit` は、要素のボックスサイズがコンテンツの固有サイズと異なる場合にのみ効果があります。`` に `width` と `height`(または `aspect-ratio`)を設定しましょう。 - **カード内の画像に `display: block` を設定しない。** インライン画像はベースライン揃えにより下部に小さな隙間が生じます。`display: block` を追加して解消しましょう。 - **意図的に `object-fit: fill` を使う。** `fill` はデフォルトで画像を引き伸ばします。明示的に `object-fit: fill` を設定している場合、画像が歪みます。`cover` または `contain` を使いましょう。 - **`cover` を使う際に `object-position` を無視する。** デフォルトの中央揃えは画像の間違った部分をクリップする可能性があります。`object-position` で表示する部分を制御しましょう。 - **`aspect-ratio` と `object-fit` を組み合わせない。** `width: 100%` のみを設定し、`aspect-ratio` や固定の `height` を指定しないと、画像ボックスは固有の比率に従うため、`object-fit` が不要になります。 ## 参考リンク - [object-fit — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) - [object-position — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) - [Replaced Elements — CSS-Tricks](https://css-tricks.com/almanac/properties/o/object-fit/) - [aspect-ratio — web.dev](https://web.dev/articles/aspect-ratio) --- # BEM戦略 > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/architecture/bem-strategy :::note[歴史的背景] BEM は、CSS のスコープが広く利用可能になる以前の時代に生まれた**伝統的な CSS 命名規約**です。他のプログラミング言語では当たり前に使える「スコープ」を、グローバルなプレーン CSS 上でシミュレートするために考案されました。 **最近のプロジェクトのほとんどは BEM を必要としません。** 現代のツールがスコープを自動的に処理してくれます: - **CSS Modules** — クラス名はビルド時にローカルスコープされる - **Vue / Svelte のスコープスタイル** — `` がコンポーネントごとにユニークなセレクタを生成する - **Tailwind CSS** — ユーティリティファーストのアプローチにより、命名規約自体が不要になる - **CSS-in-JS**(styled-components、Emotion)— スタイルがデフォルトでコンポーネントにスコープされる BEM は**基礎知識**として依然価値があります。なぜ現代のスコープソリューションが生まれ、コンポーネントの境界をどう考えるかを説明してくれます。また、現代のツールが使えない環境では今も有効です。 BEM は「今日使っているツールの背景にある考え方」を理解するためのものであり、新しいプロジェクトで採用するパターンとして手を伸ばすものではありません。 ::: ## 問題 CSS の命名に規約がないと、名前の衝突、詳細度(specificity)の争い、スタイル間の不明瞭な関係が生じます。複数人で開発するプロジェクトでは、チームメンバーごとに異なる命名パターンを使い、一貫性が失われます。AI エージェントは `.title`、`.container`、`.btn-blue` のような汎用的なクラス名を生成しがちで、コンポーネント間で名前が衝突しやすくなります。さらに、`.sidebar .nav ul li a.active` のような深くネストしたセレクタをデフォルトで使い、`!important` なしではオーバーライド不可能な詳細度の連鎖を作ってしまいます。 ## 解決方法 BEM(Block Element Modifier)は、`.block__element--modifier` という厳格な命名規約を提供します。これにより、フラットで単一クラスのセレクタが作られ、詳細度の問題を完全に回避しつつ、名前自体でコンポーネントの構造を伝えることができます。 - **Block**: 独立した再利用可能なコンポーネント(`.card`、`.nav`、`.form`) - **Element**: Block の一部で、Block 名とダブルアンダースコアで接頭辞を付ける(`.card__title`、`.card__body`) - **Modifier**: Block または Element のバリエーションで、ダブルハイフンを末尾に付ける(`.card--featured`、`.card__title--large`) すべてのセレクタが同じ詳細度(クラス1つ分)を持つため、カスケードが予測可能になり、オーバーライドも簡単です。 ## コード例 ### BEM の命名規約 ``` .block → 独立したコンポーネント .block__element → Block の子要素 .block--modifier → Block のバリエーション .block__element--modifier → Element のバリエーション ``` 例: ```css /* Block */ .card { } .nav { } .form { } /* Element */ .card__title { } .card__body { } .card__image { } /* Modifier */ .card--featured { } .card__title--large { } ``` ### Card コンポーネント BEM 命名を使った完全な Card コンポーネントです。featured Modifier は、構造を維持しながらカードの外観を変更します。 Standard Card This is a regular card using BEM naming. Each class clearly shows its role within the component. Read more Featured Card This card uses the --featured modifier. The modifier class is added alongside the base block class. Read more `} css={`* { box-sizing: border-box; margin: 0; } .card-grid { display: flex; gap: 16px; padding: 16px; font-family: system-ui, sans-serif; } .card { flex: 1; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background: #fff; } .card--featured { border-color: #3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15); } .card__image { width: 100%; height: 120px; object-fit: cover; display: block; } .card__body { padding: 16px; } .card__title { font-size: 18px; font-weight: 600; color: #1e293b; margin-bottom: 8px; } .card__text { font-size: 14px; color: #64748b; line-height: 1.5; margin-bottom: 12px; } .card__link { font-size: 14px; color: #3b82f6; text-decoration: none; font-weight: 500; } .card__link:hover { text-decoration: underline; } .card__link--primary { background: #3b82f6; color: #fff; padding: 6px 16px; border-radius: 4px; } .card__link--primary:hover { background: #2563eb; text-decoration: none; }`} height={340} /> ### Navigation コンポーネント アクティブ状態を `.active` のような別クラスではなく、Modifier として表現する Nav コンポーネントです。 Home About Services Contact `} css={`* { box-sizing: border-box; margin: 0; } .nav { display: flex; gap: 4px; background: #1e293b; padding: 8px; border-radius: 8px; font-family: system-ui, sans-serif; } .nav__item { padding: 10px 20px; color: #94a3b8; text-decoration: none; font-size: 14px; font-weight: 500; border-radius: 6px; transition: background 0.2s, color 0.2s; } .nav__item:hover { background: #334155; color: #f1f5f9; } .nav__item--active { background: #3b82f6; color: #fff; } .nav__item--active:hover { background: #2563eb; }`} height={60} /> ### Form コンポーネント BEM を使ったフォームで、入力フィールドにエラー状態の Modifier を適用しています。エラーのスタイリングは親セレクタではなく、命名によって要素にスコープされています。 Email Password Required field Submit `} css={`* { box-sizing: border-box; margin: 0; } .form { max-width: 400px; padding: 24px; font-family: system-ui, sans-serif; } .form__group { margin-bottom: 16px; } .form__label { display: block; font-size: 14px; font-weight: 500; color: #374151; margin-bottom: 4px; } .form__input { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; outline: none; transition: border-color 0.2s; } .form__input:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); } .form__input--error { border-color: #ef4444; } .form__input--error:focus { border-color: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); } .form__error { display: block; font-size: 12px; color: #ef4444; margin-top: 4px; } .form__submit { background: #3b82f6; color: #fff; border: none; padding: 10px 24px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; } .form__submit:hover { background: #2563eb; }`} height={260} /> ## よくあるミス ### Element を深くネストしすぎる BEM の Element は DOM のネスト構造を反映してはいけません。Element 名はフラットにして、Block のみを参照しましょう。 ```css /* 誤り: DOM ツリーを反映している */ .card__body__title__text { } /* 正しい: Block へのフラットな参照 */ .card__text { } ``` ### ベースクラスなしで Modifier を使う Modifier クラスは常にベースの Block または Element クラスとペアで使うべきです。Modifier は特定のプロパティのみをオーバーライドし、ベースクラスが基盤を提供します。 ```html ... ... ``` ### BEM をレイアウトに使う BEM はコンポーネントクラスの命名のためのものであり、ページレベルのレイアウトのためのものではありません。レイアウトの関心事には別のユーティリティクラスやレイアウトクラスを使いましょう。 ```html ... ... ``` ## BEM とモダン CSS CSS ネスティング(CSS nesting)は現在すべての主要ブラウザでサポートされており、BEM をさらに使いやすくします。`&` セレクタを使えば、すべてのルールを Block 内に書くことができ、命名規約を維持しながら繰り返しを減らせます。 ```css .card { border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; &__image { width: 100%; display: block; } &__title { font-size: 18px; font-weight: 600; } &__body { padding: 16px; } &--featured { border-color: #3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15); } } ``` ℹ️ Information This is a standard alert message. ⚠️ Warning Something needs your attention. ✅ Success Operation completed successfully. `} css={`* { box-sizing: border-box; margin: 0; } .alert { display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px; border-left: 4px solid #3b82f6; background: #eff6ff; border-radius: 0 6px 6px 0; margin: 8px 16px; font-family: system-ui, sans-serif; &__icon { font-size: 18px; flex-shrink: 0; line-height: 1.4; } &__content { flex: 1; } &__title { font-size: 14px; font-weight: 600; color: #1e293b; display: block; margin-bottom: 2px; } &__text { font-size: 13px; color: #475569; line-height: 1.4; } &--warning { border-left-color: #f59e0b; background: #fffbeb; } &--success { border-left-color: #22c55e; background: #f0fdf4; } }`} height={230} /> ## 使い分け **最近のプロジェクトのほとんどは BEM を必要としません。** フロントエンドフレームワークを使って新しいプロジェクトを始めるなら、ほぼ確実にもっと良いスコープの手段が利用できます。 ### BEM に取って代わったもの - **CSS Modules** → デフォルトでローカルスコープ。命名規約は不要 - **Vue / Svelte のスコープスタイル** → `` が衝突防止を自動的に処理する - **Tailwind CSS** → クラスに名前をつける必要がなく、ユーティリティを直接組み合わせる - **CSS-in-JS** → コンポーネントレベルのスコープが組み込み済み ### BEM が今も有効な場面 - **ビルドツールなし** — グローバルスタイルシートを書くプレーン HTML/CSS プロジェクト - **レガシーコードベース** — より良いアーキテクチャに向けて段階的にリファクタリングしている場合 - **フレームワーク合意なしの複数チーム** — ツールの選定でチームが合意できない場合に、共通の命名規約で衝突を防ぐ - **サーバーレンダリングされたアプリ** — スタイルをスコープするビルドステップなしで HTML と CSS が別々に配信される場合 ### 基礎知識としての BEM 本番で BEM を一切書かなくても、理解する価値はあります。CSS Modules やスコープスタイルがなぜ存在するのか、どんな問題を解決しているのかを説明してくれます。フラットなセレクタ、単一クラスの詳細度、コンポーネントスコープの命名といった概念は、現代のツールがスタイルをどう考えるかにそのまま引き継がれています。 ## 参考リンク - [BEM — Block Element Modifier](https://getbem.com/) - [BEM Methodology — Quick Start](https://en.bem.info/methodology/quick-start/) - [MindBEMding — Getting your head round BEM syntax - CSS Wizardry](https://csswizardry.com/2013/01/mindbemding-getting-your-head-round-bem-syntax/) --- # カスタムプロパティパターンカタログ > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/design-systems/custom-properties-advanced/pattern-catalog 堅牢なデザインシステム、レスポンシブレイアウト、コンポーネントアーキテクチャを構築するためのCSSカスタムプロパティパターンの包括的なコレクションです — すべてインタラクティブなデモ付きです。 ## レスポンシブカスタムプロパティ `@media`でブレークポイントごとに変化するカスタムプロパティは、ユーティリティクラスなしのレスポンシブデザインシステムを作成します。トークンを一度定義すれば、メディアクエリがそれを適応させます。 ```css :root { --content-padding: 1rem; } @media (min-width: 768px) { :root { --content-padding: 2rem; } } ``` `--content-padding`を参照するすべての要素がブレークポイントで自動的に更新されます — コンポーネントごとのオーバーライドは不要です。 Responsive Container This container's padding, gap, and font size all adapt via custom properties at breakpoints. Resize the viewport toggle to see them change. Card A Card B Card C `} css={` :root { --content-padding: 1rem; --content-gap: 0.75rem; --content-font: 0.875rem; } @media (min-width: 768px) { :root { --content-padding: 2rem; --content-gap: 1.5rem; --content-font: 1rem; } } .page { font-family: system-ui, sans-serif; } .container { background: hsl(220 20% 97%); border: 1px solid hsl(220 15% 88%); border-radius: 12px; padding: var(--content-padding); font-size: var(--content-font); } .container h2 { margin: 0 0 0.5rem; font-size: 1.15em; color: hsl(220 30% 30%); } .container p { margin: 0 0 1rem; color: hsl(220 15% 50%); line-height: 1.5; } .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--content-gap); } .card { background: white; border: 1px solid hsl(220 15% 88%); border-radius: 8px; padding: var(--content-padding); text-align: center; font-weight: 600; color: hsl(220 30% 40%); } `} /> ## CSSカウンターとカスタムプロパティ CSSカウンターは自動番号付けを生成します。カスタムプロパティと組み合わせることで、カウンターのスタイリングを設定可能にします — 親要素から色、サイズ、形状を変更できます。 Define your base custom properties on the root element Create fallback chains for component-level overrides Use scoped properties for variant styling Add calc() for computed relationships between values `} css={` .counter-demo { font-family: system-ui, sans-serif; --counter-bg: hsl(250 80% 60%); --counter-color: white; --counter-size: 2rem; } .steps { list-style: none; padding: 0; margin: 0; counter-reset: step-counter; } .step { counter-increment: step-counter; display: flex; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid hsl(250 20% 92%); color: hsl(250 20% 30%); line-height: 1.5; } .step:last-child { border-bottom: none; } .step::before { content: counter(step-counter); display: flex; align-items: center; justify-content: center; min-width: var(--counter-size); height: var(--counter-size); background: var(--counter-bg); color: var(--counter-color); border-radius: 50%; font-weight: 700; font-size: 0.875rem; flex-shrink: 0; } `} /> ## コンポーネントツリーのカスタムプロパティ継承 親コンポーネントがカスタムプロパティを設定し、深くネストされた子が自動的にそれを継承します — プロップドリリングも追加クラスも不要です。これはコンポーネントテーマの最も強力なパターンの一つです。 Purple Theme The badge below inherits from the card. Inherited Color No Prop Drilling Teal Theme Same card structure, different accent. Cascade Power Zero JS `} css={` .card-list { font-family: system-ui, sans-serif; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .themed-card { --_accent: var(--card-accent, hsl(220 70% 55%)); background: white; border: 2px solid var(--_accent); border-radius: 12px; padding: 1.25rem; } .themed-card-title { margin: 0 0 0.5rem; font-size: 1rem; color: var(--_accent); } .themed-card-body { margin: 0 0 1rem; font-size: 0.85rem; color: hsl(220 15% 45%); line-height: 1.5; } .themed-card-footer { display: flex; gap: 0.5rem; flex-wrap: wrap; } /* Tags inherit --_accent from the card ancestor */ .tag { background: var(--_accent); color: white; padding: 0.2rem 0.65rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; } `} /> ## calcベースのスペーシングスケール 単一のベーススペーシング単位を定義し、`calc()`でスケール全体を導出します。ベース値を変更すると、すべてのスペーシングトークンが一度に再構成されます。 ```css :root { --space-unit: 8px; --space-xs: calc(var(--space-unit) * 0.5); --space-sm: var(--space-unit); --space-md: calc(var(--space-unit) * 2); --space-lg: calc(var(--space-unit) * 3); --space-xl: calc(var(--space-unit) * 5); } ``` Spacing Scale (base: 8px) xs (4px) sm (8px) md (16px) lg (24px) xl (40px) Card using the scale Padding: md, gap: sm Same scale, same rhythm Consistent spacing everywhere `} css={` :root { --space-unit: 8px; --space-xs: calc(var(--space-unit) * 0.5); --space-sm: var(--space-unit); --space-md: calc(var(--space-unit) * 2); --space-lg: calc(var(--space-unit) * 3); --space-xl: calc(var(--space-unit) * 5); } .spacing-demo { font-family: system-ui, sans-serif; } .spacing-title { margin: 0 0 1rem; font-size: 1rem; color: hsl(220 30% 30%); } .scale-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } .scale-label { font-size: 0.8rem; color: hsl(220 15% 50%); min-width: 5.5rem; text-align: right; font-variant-numeric: tabular-nums; } .scale-bar { height: 1.25rem; background: hsl(220 80% 60%); border-radius: 4px; } .scale-xs { width: var(--space-xs); } .scale-sm { width: var(--space-sm); } .scale-md { width: var(--space-md); } .scale-lg { width: var(--space-lg); } .scale-xl { width: var(--space-xl); } .card-demo { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm); margin-top: var(--space-lg); } .space-card { background: hsl(220 30% 96%); border: 1px solid hsl(220 20% 88%); border-radius: 8px; padding: var(--space-md); } .space-card h4 { margin: 0 0 var(--space-xs); font-size: 0.9rem; color: hsl(220 30% 30%); } .space-card p { margin: 0; font-size: 0.8rem; color: hsl(220 15% 55%); } `} /> ## カスタムプロパティによるカラーシステム HSLコンポーネントを個別のカスタムプロパティに分離することで、最大の柔軟性を得られます。1つの色定義からホバー状態、明るい/暗いバリアント、透明度を導出できます。 ```css :root { --primary-h: 220; --primary-s: 80%; --primary-l: 50%; } .button { background: hsl(var(--primary-h) var(--primary-s) var(--primary-l)); } .button:hover { background: hsl(var(--primary-h) var(--primary-s) calc(var(--primary-l) - 10%)); } ``` Primary Secondary Accent Hover each button — the darkened hover state is computed from the same HSL base using calc() `} css={` :root { --primary-h: 220; --primary-s: 80%; --primary-l: 50%; --secondary-h: 160; --secondary-s: 60%; --secondary-l: 42%; --accent-h: 340; --accent-s: 75%; --accent-l: 55%; } .color-demo { font-family: system-ui, sans-serif; } .button-row { display: flex; gap: 0.75rem; flex-wrap: wrap; } .btn { border: none; padding: 0.65rem 1.5rem; border-radius: 8px; font-weight: 600; font-size: 0.9rem; color: white; cursor: pointer; transition: background 0.15s; } .btn-primary { background: hsl(var(--primary-h) var(--primary-s) var(--primary-l)); } .btn-primary:hover { background: hsl(var(--primary-h) var(--primary-s) calc(var(--primary-l) - 10%)); } .btn-secondary { background: hsl(var(--secondary-h) var(--secondary-s) var(--secondary-l)); } .btn-secondary:hover { background: hsl(var(--secondary-h) var(--secondary-s) calc(var(--secondary-l) - 10%)); } .btn-accent { background: hsl(var(--accent-h) var(--accent-s) var(--accent-l)); } .btn-accent:hover { background: hsl(var(--accent-h) var(--accent-s) calc(var(--accent-l) - 10%)); } .color-hint { font-size: 0.8rem; color: hsl(220 15% 55%); margin-top: 1rem; } `} /> ## 参考リンク - [Using CSS custom properties (variables) - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Cascading_variables/Using_custom_properties) - [A Complete Guide to Custom Properties - CSS-Tricks](https://css-tricks.com/a-complete-guide-to-custom-properties/) - [Custom Properties as State - Chris Coyier](https://css-tricks.com/custom-properties-as-state/) --- # カラートークンパターン > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/design-systems/tight-token-strategy/color-tokens ## 問題 Tailwind CSS はデフォルトで約22のカラーファミリーを持ち、各ファミリーに11のシェード(50〜950)があるため、240以上のカラーユーティリティがあります。実際には、あるコンポーネントで `blue-500` を使い、別のコンポーネントで `blue-600` を使い、3つ目で `indigo-500` を使う — すべて「プライマリボタンの青」のつもりです。制約がなければすべてのシェードが等しく有効なので、不整合は静かに広がっていきます。 同じずれはグレーでも起きます。ある開発者はカード背景に `gray-100` を使い、別の開発者は `slate-50` を選び、3人目は `zinc-200` を使います。すべて「明るい背景」ですが、どれも一致しません。時間が経つにつれて、UIは視覚的な統一感を損なう微妙に異なるトーンのパッチワークになっていきます。 ## 解決方法 すべてのデフォルトカラーをリセットし、目的別に整理された小さなセマンティックカラートークンのセットを定義します。リセット後、`bg-blue-500` や `text-gray-700` はもう動作しません — チームはプロジェクトの意図的なカラー語彙を使うことを強制されます。 ### トークンカテゴリ 以下のカテゴリはセマンティックレイヤリングのアプローチに従っています — 生のパレット値を、コンポーネントが参照するロールベースのトークンに置き換えます。これは [Three-Tier Color Strategy](../../../styling/color/three-tier-color-strategy)(パレット → テーマ → コンポーネント)と同じ原則を、Tailwind の `@theme` システムに特化して適用したものです。 カラーを5つのグループに整理します: 1. **ブランドカラー** — `primary`、`secondary`、`accent`、それぞれに `light`、`base`、`dark` のバリアント 2. **セマンティック/ステートカラー** — フィードバックやステータス用の `success`、`warning`、`error`、`info` 3. **サーフェスカラー** — 背景用の `surface`、`surface-alt`、`surface-inverse` 4. **テキストカラー** — 読みやすい階層のための `text`、`text-muted`、`text-inverse` 5. **ボーダーカラー** — エッジやフォーカスリング用の `border`、`border-focus` ### @theme カラーブロック [アプローチB](../#アプローチb-デフォルトテーマをスキップ推奨)(デフォルトテーマなしの個別インポート)を使う場合、リセット行は不要です。アプローチA(`@import "tailwindcss"`)を使う場合は `--color-*: initial;` を先頭に追加してください。 ```css @theme { /* アプローチAの場合、追加: --color-*: initial; */ /* ── Brand ── */ --color-primary-light: hsl(217 91% 60%); --color-primary: hsl(221 83% 53%); --color-primary-dark: hsl(224 76% 48%); --color-secondary-light: hsl(250 80% 68%); --color-secondary: hsl(252 78% 60%); --color-secondary-dark: hsl(255 70% 52%); --color-accent-light: hsl(38 95% 64%); --color-accent: hsl(33 95% 54%); --color-accent-dark: hsl(28 90% 46%); /* ── State ── */ --color-success: hsl(142 71% 45%); --color-warning: hsl(38 92% 50%); --color-error: hsl(0 84% 60%); --color-info: hsl(199 89% 48%); /* ── Surface ── */ --color-surface: hsl(0 0% 100%); --color-surface-alt: hsl(210 40% 96%); --color-surface-inverse: hsl(222 47% 11%); /* ── Text ── */ --color-text: hsl(222 47% 11%); --color-text-muted: hsl(215 16% 47%); --color-text-inverse: hsl(210 40% 98%); /* ── Border ── */ --color-border: hsl(214 32% 91%); --color-border-focus: hsl(221 83% 53%); } ``` この設定後、`bg-surface`、`text-primary`、`border-border-focus` などの Tailwind ユーティリティだけが利用可能なカラーオプションになります。`bg-gray-100` を使おうとするとビルドエラーになります。 ## デモ ### デフォルトグレー vs セマンティックサーフェストークン 左側は Tailwind の22種類のデフォルトグレーシェードのサンプルです — すべて背景として技術的に有効です。右側はそれらを置き換える3つのセマンティックサーフェストークンです。選択肢が少ないほど、決定が速くなり、一貫性が保証されます。 Default grays (sample) slate-50 slate-100 slate-200 slate-300 gray-700 gray-500 zinc-500 zinc-900 neutral-100 neutral-400 stone-300 stone-900 12 of 240+ color utilities shown. Which is "card background"? Semantic surfaces surfacehsl(0 0% 100%) surface-althsl(210 40% 96%) surface-inversehsl(222 47% 11%) 3 surface tokens. "Card background" is always surface. `} css={`.demo { display: flex; gap: 20px; padding: 16px; font-family: system-ui, sans-serif; font-size: 13px; color: hsl(222 47% 11%); } .col { flex: 1; } .heading { font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); margin-bottom: 10px; } .swatch-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; } .swatch-grid.tight { grid-template-columns: 1fr; gap: 6px; } .swatch { border: 1px solid hsl(214 32% 91%); border-radius: 6px; padding: 8px 6px; font-size: 10px; text-align: center; display: flex; flex-direction: column; gap: 2px; } .swatch.lg { padding: 14px 12px; font-size: 13px; flex-direction: row; justify-content: space-between; align-items: center; } .swatch span { font-weight: 600; } .swatch em { font-style: normal; font-size: 11px; opacity: 0.7; } .note { margin-top: 8px; font-size: 11px; color: hsl(215 16% 47%); line-height: 1.4; } .note code { background: hsl(210 40% 96%); padding: 1px 4px; border-radius: 3px; font-size: 10px; }`} /> ### セマンティックカラートークンを使ったボタンセット これらのボタンはセマンティックカラートークンのみを使用しています — `primary`、`secondary`、`error`、`accent`。数値カラーシェードは一切使われていません。プロジェクト内のすべてのボタンがこれらのトークンを使うため、パレットは一貫性を保ちます。 Primary Action Secondary Delete Upgrade Primary Outline Secondary Outline Delete Outline Upgrade Outline bg-primary bg-secondary bg-error bg-accent `} css={`.btn-demo { padding: 20px; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 12px; background: hsl(0 0% 100%); } .btn-row { display: flex; gap: 10px; flex-wrap: wrap; } .btn { padding: 8px 18px; border: 2px solid transparent; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; color: hsl(210 40% 98%); } /* ── Solid variants ── */ .btn-primary { background: hsl(221 83% 53%); } .btn-secondary { background: hsl(252 78% 60%); } .btn-danger { background: hsl(0 84% 60%); } .btn-accent { background: hsl(33 95% 54%); color: hsl(222 47% 11%); } /* ── Outline variants ── */ .btn.outline { background: transparent; } .btn-primary.outline { border-color: hsl(221 83% 53%); color: hsl(221 83% 53%); } .btn-secondary.outline { border-color: hsl(252 78% 60%); color: hsl(252 78% 60%); } .btn-danger.outline { border-color: hsl(0 84% 60%); color: hsl(0 84% 60%); } .btn-accent.outline { border-color: hsl(33 95% 54%); color: hsl(28 90% 46%); } .token-labels { display: flex; gap: 10px; flex-wrap: wrap; } .token-labels span { font-size: 10px; font-family: monospace; background: hsl(210 40% 96%); padding: 2px 8px; border-radius: 4px; color: hsl(215 16% 47%); }`} /> ### サーフェス、テキスト、ボーダートークンを使ったカード このカードは5つのトークンカテゴリすべてが連携して動作することを示しています。背景には `surface` と `surface-alt` を、テキストには `text` と `text-muted` を、ボーダーには `border` を、バッジには `primary` ブランドカラーを使用しています。コンポーネント内のすべてのカラーが正確に1つのセマンティックトークンに対応しています。 Project Dashboard Active This card uses semantic color tokens for every color value. No numeric shades like gray-200 or blue-500 appear anywhere. Status Healthy Errors 3 issues Updated 2 hours ago View Details Dismiss surface → card bg surface-alt → header, footer bg text → headings, body text-muted → labels, meta border → card border, dividers primary → badge, action button `} css={`.card-demo { display: flex; gap: 20px; padding: 16px; font-family: system-ui, sans-serif; font-size: 13px; background: hsl(210 40% 96%); color: hsl(222 47% 11%); } /* ── Card ── */ .card { flex: 1; background: hsl(0 0% 100%); /* surface */ border: 1px solid hsl(214 32% 91%); /* border */ border-radius: 8px; overflow: hidden; } .card-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: hsl(210 40% 96%); /* surface-alt */ border-bottom: 1px solid hsl(214 32% 91%); /* border */ } .card-title { font-weight: 700; font-size: 15px; color: hsl(222 47% 11%); /* text */ } .badge { font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 99px; background: hsl(221 83% 53%); /* primary */ color: hsl(210 40% 98%); /* text-inverse */ } .card-body { padding: 16px; } .card-text { color: hsl(222 47% 11%); /* text */ line-height: 1.6; margin: 0 0 14px 0; } .card-text code { background: hsl(210 40% 96%); padding: 1px 5px; border-radius: 3px; font-size: 12px; color: hsl(0 84% 60%); /* error — for code highlighting */ } .meta-row { display: flex; gap: 20px; } .meta-label { font-size: 11px; color: hsl(215 16% 47%); /* text-muted */ margin-bottom: 2px; } .meta-value { font-weight: 600; font-size: 13px; } .meta-value.success { color: hsl(142 71% 45%); } /* success */ .meta-value.error { color: hsl(0 84% 60%); } /* error */ .card-footer { display: flex; gap: 8px; padding: 12px 16px; background: hsl(210 40% 96%); /* surface-alt */ border-top: 1px solid hsl(214 32% 91%); /* border */ } .btn-view { padding: 6px 14px; border: none; border-radius: 5px; font-size: 12px; font-weight: 600; cursor: pointer; background: hsl(221 83% 53%); /* primary */ color: hsl(210 40% 98%); /* text-inverse */ } .btn-dismiss { padding: 6px 14px; border: 1px solid hsl(214 32% 91%); /* border */ border-radius: 5px; font-size: 12px; font-weight: 600; cursor: pointer; background: transparent; color: hsl(215 16% 47%); /* text-muted */ } /* ── Token map ── */ .token-map { width: 200px; flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; font-size: 11px; color: hsl(215 16% 47%); padding-top: 4px; } .tm-row { display: flex; align-items: center; gap: 8px; } .tm-swatch { width: 14px; height: 14px; border-radius: 3px; border: 1px solid hsl(214 32% 91%); flex-shrink: 0; }`} /> ## パレット拡張の命名規則 タイトトークンセットでプロジェクトを始めると、各カラーファミリーは通常1つの値しか持ちません。最初の名前はシンプルに — `gray` であって `gray1` ではありません。後でそのファミリーに2つ目のシェードが必要になったら、`gray2` を追加します。3つ目は `gray3` になります。 ```css @theme { /* ── Initial palette ── */ --color-gray: hsl(25 5% 45%); /* ── Added later when a dark card surface was needed ── */ --color-gray2: hsl(0 3% 13%); } ``` この「最初は番号なし」ルールにより、初期のトークン名がクリーンに保たれ、パレットが拡張される際のリネームの連鎖を防ぎます: - `gray` → 初日から使われているオリジナルのグレー - `gray2` → ダーク背景のために後から追加 - `gray3` → ミュートなボーダーのためにさらに後から追加 1から番号を振る方式(`gray1`、`gray2`、`gray3`)と比較すると、最初のトークンに無意味なサフィックスが付き、後から `gray1` を遡って挿入しようとすると既存の参照すべてを更新する必要があります。 このパターンはすべてのカラーファミリーに適用されます:`primary` / `primary2`、`surface` / `surface2`、`accent` / `accent2` など。 ### 実例 zmod プロジェクトではまさにこのパターンを使っています: ```css --zd-color-gray: rgb(120, 113, 108); /* Original gray */ --zd-color-gray2: #201f1f; /* Added later for dark backgrounds */ ``` `gray2` が追加された際にリネームは必要ありませんでした — オリジナルの `gray` はコードベース全体でそのまま残りました。 ### ビフォー・アフター:パレットの拡張 このデモは、最初は単一の `gray` トークンを使ったシンプルなカードUIを示しています。デザインが後からダークなカードサーフェスを必要としたとき、オリジナルの `gray` に触れることなく `gray2` が追加されます。 Phase 1 — One gray gray Settings Theme Default Language English Last saved 2 min ago gray handles all muted text. One token, clean name. → Phase 2 — Add gray2 gray gray2 Settings Theme Default Language English Last saved 2 min ago gray2 added for the dark header. Original gray unchanged — no renaming needed. `} css={`.growth-demo { display: flex; align-items: flex-start; gap: 12px; padding: 16px; font-family: system-ui, sans-serif; font-size: 13px; color: hsl(222 47% 11%); background: hsl(210 40% 96%); } .growth-col { flex: 1; display: flex; flex-direction: column; gap: 8px; } .growth-arrow { font-size: 24px; font-weight: 700; color: hsl(215 16% 47%); padding-top: 90px; flex-shrink: 0; } .growth-heading { font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); } .growth-tokens { display: flex; gap: 10px; flex-wrap: wrap; } .growth-token { display: flex; align-items: center; gap: 5px; font-size: 12px; } .growth-token code { background: hsl(0 0% 100%); padding: 1px 6px; border-radius: 3px; font-size: 11px; } .growth-swatch { width: 14px; height: 14px; border-radius: 3px; border: 1px solid hsl(214 32% 91%); } .growth-card { border: 1px solid hsl(214 32% 91%); border-radius: 8px; overflow: hidden; background: hsl(0 0% 100%); } .growth-card__header { padding: 10px 14px; font-weight: 700; font-size: 14px; background: hsl(210 40% 96%); border-bottom: 1px solid hsl(214 32% 91%); color: hsl(222 47% 11%); } .growth-card__header--dark { background: hsl(0 3% 13%); /* gray2 */ color: hsl(210 40% 98%); border-bottom: none; } .growth-card__body { padding: 12px 14px; display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; } .growth-card__label { font-size: 12px; color: hsl(25 5% 45%); /* gray */ font-weight: 500; } .growth-card__value { font-size: 12px; font-weight: 600; } .growth-card__footer { padding: 8px 14px; border-top: 1px solid hsl(214 32% 91%); } .growth-card__hint { font-size: 11px; color: hsl(25 5% 45%); /* gray */ } .growth-note { font-size: 11px; color: hsl(215 16% 47%); line-height: 1.4; } .growth-note code { background: hsl(0 0% 100%); padding: 1px 4px; border-radius: 3px; font-size: 10px; }`} /> ### なぜ1から番号を振らないのか `gray1` から始めるのは対称的に見えますが、問題が生じます: - **最も一般的なトークンへのビジュアルノイズ** — 参照の大多数が最初のカラーを使います。`gray1` は至る所に無意味な数字を追加します。 - **リネームの圧力** — `gray` で始めて後で「整理」が必要になった場合、一貫性のために `gray1` にリネームしたくなるかもしれません。それはすべてのファイルに影響します。「最初は番号なし」ルールはその圧力を完全に排除します。 - **意図の伝達** — `gray2` は「これはオリジナルと並んで追加された2番目のグレーです」と明確に伝えます。`gray1` / `gray2` では、両方とも最初から計画されていたように見えます。 ## 使い分け このカラートークン戦略は、親記事の[スペーシングトークン戦略](./index.mdx)と組み合わせて使うと最も効果的です。合わせることで、視覚的なずれの最も一般的な2つの原因 — スペーシングとカラー — を小さく意図的なデザイン語彙に制約できます。 カラートークンを適用すべきケース: - プロジェクトに複数の開発者がカラーの選択を行っている場合 - デザインシステムがhex値ではなく名前付きカラー(例:「primary」「surface」)を指定している場合 - ブランドに厳格なカラーガイドラインがあり、一貫して適用する必要がある場合 ## 参考リンク - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 @theme Directive](https://tailwindcss.com/docs/functions-and-directives#theme-directive) --- # タイトトークン戦略 > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/design-systems/tight-token-strategy ## 問題 Tailwind CSS はデフォルトで膨大なトークンセットを提供しています。spacing スケールだけでも `0`、`0.5`、`1`、`1.5`、`2`、`2.5`、`3`、`3.5`、`4`、`5`、`6`、`7`、`8`、`9`、`10`、`11`、`12`、`14`、`16`、`20`、`24`、`28`、`32`、`36`、`40`、`44`、`48`、`52`、`56`、`60`、`64`、`72`、`80`、`96` と、30以上の数値ステップがあります。これにカラー、フォントサイズ、border-radius などのカテゴリを掛け合わせると、利用可能なユーティリティの空間は膨大になります。 実際には、チームの誰もがいつでも好きな値を選べるということを意味します。ある人は `p-4` を書き、別の人は `p-5` を使い、さらに別の人が `p-6` を選ぶ — すべて「中くらいのパディング」のつもりです。すべての値が有効なので間違いはありませんが、結果として一貫性のない、ずれていくUIが生まれます。デザインレビューはどの数値ステップが「正しい」かの議論になり、後からスペーシングをリファクタリングするにはコードベース全体に散らばった何百ものユーティリティクラスを監査する必要があります。 根本的な原因は、Tailwind のデフォルトトークンが**汎用的な数値スケール**であり、**セマンティックなデザイン判断**ではないということです。「どれくらい」は分かっても「なぜ」が分かりません。 ## 解決方法 **すべて**の Tailwind デフォルトを小さく意図的なセマンティックトークン(semantic token)のセットに置き換えます。Tailwind CSS v4 の `@theme` ディレクティブは、ワイルドカードパターンですべてのビルトイントークンをリセットし、プロジェクトが実際に必要なトークンのみを定義できるようにします。 この戦略には2つの重要なアイデアがあります: 1. **すべてをリセットする** — `--spacing-*: initial;`、`--color-*: initial;` などのワイルドカードを使ってすべてのデフォルト値を削除します。この後、`p-4` や `bg-gray-500` のようなユーティリティはもう存在しません。使おうとするとビルドエラーになります。まさにそれが狙いです — 無効なトークンはコードレビューではなくビルド時に検出されます。 2. **セマンティックな軸を定義する** — 単一の数値スペーシングスケールの代わりに、目的ごとに異なるスケールを定義します。プロダクション向けのアプローチとして、スペーシングを2つの軸に分割します: - **hsp**(horizontal spacing):インラインのギャップ、水平方向のパディング、水平方向のマージン用 - **vsp**(vertical spacing):セクション間の垂直ギャップ、垂直方向のパディング、ブロックレベルのマージン用 各軸には `2xs` から `2xl` までの限られたスケールがあり、チームには軸ごとにちょうど7つの選択肢が与えられます。`0` と `1px` のユーティリティ値と合わせて、これがプロジェクトのスペーシング語彙のすべてです。 ### トークン一覧表 **水平スペーシング(hsp)**: | トークン | 値 | 用途 | | --- | --- | --- | | `hsp-2xs` | 5px | タイトなインラインスペーシング | | `hsp-xs` | 12px | 小さなパディング | | `hsp-sm` | 20px | デフォルトの水平パディング | | `hsp-md` | 40px | 中セクション | | `hsp-lg` | 60px | 大セクション | | `hsp-xl` | 100px | 特大スペーシング | | `hsp-2xl` | 250px | ヒーロー / フィーチャースペーシング | **垂直スペーシング(vsp)**: | トークン | 値 | 用途 | | --- | --- | --- | | `vsp-2xs` | 4px | 最小ギャップ | | `vsp-xs` | 8px | タイトなコンポーネントギャップ | | `vsp-sm` | 20px | デフォルトの垂直ギャップ | | `vsp-md` | 35px | セクションギャップ | | `vsp-lg` | 50px | 大セクションギャップ | | `vsp-xl` | 65px | ページセクションギャップ | | `vsp-2xl` | 80px | ヒーロー / 主要セクションギャップ | ## コード例 ### デフォルトをリセットする2つのアプローチ Tailwind CSS v4でタイトトークン戦略を実現する方法は2つあります。どちらもプロジェクトのトークンのみが存在する状態を作りますが、仕組みが異なります。 #### アプローチA: `--*: initial` による明示的リセット すべてをインポートし、`@theme` 内で不要なものをリセットします: ```css @import "tailwindcss"; @theme { /* Tailwindのデフォルトをすべてリセット */ --spacing-*: initial; --color-*: initial; --font-size-*: initial; --font-family-*: initial; --font-weight-*: initial; --line-height-*: initial; --letter-spacing-*: initial; --border-radius-*: initial; --shadow-*: initial; --inset-shadow-*: initial; --drop-shadow-*: initial; --breakpoint-*: initial; /* この後にトークンを定義... */ } ``` これは動作しますが、生成されるCSSにはデフォルトテーマレイヤーの内部変数が残ります。 #### アプローチB: デフォルトテーマをスキップ(推奨) 必要なレイヤーのみをインポートし、デフォルトテーマを完全にスキップします: ```css @import "tailwindcss/preflight"; @import "tailwindcss/utilities"; @theme { /* リセット不要 — デフォルトテーマは読み込まれていない */ /* トークンを直接定義... */ } ``` `@import "tailwindcss"` は3つのレイヤーのインポートと等価です:`tailwindcss/preflight`(ブラウザリセット)、`tailwindcss/theme`(全デフォルトトークン)、`tailwindcss/utilities`(ユーティリティクラスエンジン)。preflight + utilities のみをインポートすることで、デフォルトテーマは単純に読み込まれません。 **アプローチBを推奨する理由:** - 記述量が少ない — `--*: initial` リセットブロックが不要 - CSS出力がわずかに小さい(約1KB減)— Tailwindの内部変数が出力されない - メンタルモデルがクリーン — ゼロにリセットするのではなく、ゼロから始める #### アプローチBで失われるもの(アプローチAとの比較) `tailwindcss/theme` をスキップすると、以下のTailwind内部変数が生成CSSに出力されなくなります: | 変数 | 用途 | 必要か? | | --- | --- | --- | | `--default-transition-duration` | `transition` ユーティリティのデフォルト duration | `transition` クラスを使う場合は `@theme` に `--default-transition-duration: 0.15s;` を追加 | | `--default-transition-timing-function` | `transition` ユーティリティのデフォルト easing | 必要なら `--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);` を追加 | | `--default-font-family` | `html` の `font-family` フォールバック | `--font-sans` を定義するか自分で `font-family` を設定していれば不要 | | `--default-mono-font-family` | `code`/`pre` のフォントフォールバック | `--font-mono` を定義していれば不要 | | `--animate-spin` | `animate-spin` のキーフレーム定義 | `animate-spin` を使う場合は手動で追加 | | `--ease-in-out` | 名前付きイージングカーブ | 参照する場合は手動で追加 | | `--container-*` | コンテナクエリの幅 | `@container` サイズユーティリティを使わなければ不要 | 実際には、`transition-colors` などの transition ユーティリティを使う場合、`@theme` に以下の2行を追加してください: ```css @theme { --default-transition-duration: 0.15s; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); /* ...トークン... */ } ``` ### 完全な例(アプローチB) プロジェクトのメインCSSファイル(例:`app.css`)に配置します: ```css @import "tailwindcss/preflight"; @import "tailwindcss/utilities"; @theme { /* ======================================== * プロジェクトトークンのみを定義 — Spacing * ======================================== */ --spacing-0: 0; --spacing-1px: 1px; /* Horizontal spacing */ --spacing-hsp-2xs: 5px; --spacing-hsp-xs: 12px; --spacing-hsp-sm: 20px; --spacing-hsp-md: 40px; --spacing-hsp-lg: 60px; --spacing-hsp-xl: 100px; --spacing-hsp-2xl: 250px; /* Vertical spacing */ --spacing-vsp-2xs: 4px; --spacing-vsp-xs: 8px; --spacing-vsp-sm: 20px; --spacing-vsp-md: 35px; --spacing-vsp-lg: 50px; --spacing-vsp-xl: 65px; --spacing-vsp-2xl: 80px; } ``` この設定後: - `p-4` — **ビルドエラー**(`--spacing-4` トークンが存在しない) - `bg-gray-500` — **ビルドエラー**(`--color-gray-500` トークンが存在しない) - `px-hsp-sm` — **動作する**(`padding-inline: 20px` に解決される) - `py-vsp-md` — **動作する**(`padding-block: 35px` に解決される) ### コンポーネントでの使い方 タイトトークンセットを使うと、Tailwind のクラスは自己文書化されます。クラス名から直接意図を読み取ることができます: ```html Page Title Introductory paragraph with standard vertical spacing below. Card A Card B ``` すべてのスペーシング値が、その軸(水平 vs 垂直)とスケール内での相対的なサイズを伝えます。 ### デモ:セマンティックトークン vs 任意の値 以下のデモは、同じカードレイアウトを2通りの方法で構築しています。左のカードはタイトなセマンティックトークンアプローチを使用し、右のカードは任意の数値スペーシング値を使用しています。あらゆる値が許可されている場合にどのように不整合が生じるかをシミュレートしています。 Semantic tokens Article Title Body text with consistent spacing defined by design tokens. Tag A Tag B Arbitrary values Article Title Body text where each developer picked different padding values. Tag A Tag B `} css={`/* shared */ .demo-container { display: flex; gap: 20px; padding: 16px; font-family: system-ui, sans-serif; font-size: 14px; } .demo-column { flex: 1; } .label { font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-bottom: 8px; } /* ── Semantic token card ── */ .card-semantic { border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; } .card-header-semantic { padding: 8px 20px; /* vsp-xs / hsp-sm */ font-weight: 700; font-size: 16px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; } .card-body-semantic { padding: 20px 20px; /* vsp-sm / hsp-sm */ color: #334155; line-height: 1.6; } .card-footer-semantic { padding: 8px 20px; /* vsp-xs / hsp-sm */ display: flex; gap: 12px; /* hsp-xs */ border-top: 1px solid #e2e8f0; background: #f8fafc; } .tag-semantic { background: #3b82f6; color: #fff; padding: 4px 12px; /* vsp-2xs / hsp-xs */ border-radius: 4px; font-size: 12px; } /* ── Arbitrary value card ── */ .card-arbitrary { border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; } .card-header-arbitrary { padding: 10px 16px; /* dev A picked 10/16 */ font-weight: 700; font-size: 16px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; } .card-body-arbitrary { padding: 14px 24px; /* dev B picked 14/24 */ color: #334155; line-height: 1.6; } .card-footer-arbitrary { padding: 12px 18px; /* dev C picked 12/18 */ display: flex; gap: 8px; border-top: 1px solid #e2e8f0; background: #f8fafc; } .tag-arbitrary-a { background: #3b82f6; color: #fff; padding: 6px 10px; /* dev A */ border-radius: 4px; font-size: 12px; } .tag-arbitrary-b { background: #3b82f6; color: #fff; padding: 3px 14px; /* dev B */ border-radius: 4px; font-size: 12px; }`} height={320} /> 「Semantic tokens」カードでは、すべてのセクションがトークン一覧表の値を使用しています:ヘッダー/フッターの垂直パディングに `vsp-xs`(8px)、水平パディングに `hsp-sm`(20px)、ボディの垂直パディングに `vsp-sm`(20px)、タグの水平パディングに `hsp-xs`(12px)。結果は明確なリズムを持つ視覚的に一貫したカードです。 「Arbitrary values」カードでは、3人の開発者がそれぞれ微妙に異なるパディング値を選びました — 垂直方向に `10px`、`14px`、`12px`、水平方向に `16px`、`24px`、`18px`。タグの内部パディングも不揃いです。全体的にカードは微妙にバランスが崩れて見えます。この不整合は実際のプロジェクトでは何十ものコンポーネントにわたって蓄積されます。 ### デモ:セマンティックスペーシングを使ったページレイアウト AppName Docs Blog About Welcome This layout uses semantic spacing tokens throughout. Every value is intentional and comes from the project's tight token set. Consistency Every component uses the same spacing vocabulary. Readability Class names tell you the intent, not just the number. Guardrails Invalid tokens cause build errors, not visual bugs. `} css={`.page { font-family: system-ui, sans-serif; font-size: 14px; color: #1e293b; } /* ── Header ── */ .page-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 20px; /* vsp-xs / hsp-sm */ background: #1e293b; color: #fff; } .logo { font-weight: 700; font-size: 16px; } .nav { display: flex; gap: 12px; /* hsp-xs */ } .nav a { color: #94a3b8; text-decoration: none; font-size: 13px; } .nav a:hover { color: #fff; } /* ── Main ── */ .page-main { padding: 35px 20px; /* vsp-md / hsp-sm */ } .page-title { font-size: 24px; font-weight: 700; margin: 0 0 8px 0; /* pb: vsp-xs */ } .page-intro { color: #475569; line-height: 1.6; margin: 0 0 35px 0; /* pb: vsp-md */ max-width: 600px; } /* ── Card grid ── */ .card-grid { display: flex; gap: 20px; /* hsp-sm */ flex-wrap: wrap; } .feature-card { flex: 1; min-width: 160px; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px 20px; /* vsp-sm / hsp-sm */ } .feature-title { font-weight: 700; font-size: 15px; margin-bottom: 4px; /* vsp-2xs */ } .feature-desc { color: #64748b; line-height: 1.5; font-size: 13px; }`} height={340} /> このレイアウトのすべてのスペーシング値は、トークン一覧表のトークンに直接対応しています。ヘッダーは `vsp-xs` / `hsp-sm` を使い、メインセクションは `vsp-md` / `hsp-sm` を使い、カードグリッドはギャップに `hsp-sm` を使っています。コード(または実際のプロジェクトの Tailwind クラス)を読めば、各スペーシング値がどのセマンティックスロットに対応しているかがすぐに分かります。 ## 使い分け ### 適しているケース - **大規模チーム** — 複数の開発者が同じコンポーネントに触れる場合、制約されたトークンセットがスペーシングのずれを防ぎます - **デザインシステム駆動のプロジェクト** — デザイナーがピクセル値ではなく名前付きトークンでスペーシング仕様を渡す場合 - **プロダクションアプリケーション** — 視覚的な一貫性がユーザーの信頼やブランド認知に直接影響する場合 - **長期的なコードベース** — 何年も保守・リファクタリングされるプロジェクトでは、タイトなトークンセットがグローバルなスペーシング変更を容易にします(1つのトークンを更新すれば、アプリ全体が調整されます) ### 不要なケース - **プロトタイプやハッカソン** — 一貫性よりスピードが重要な場合 - **小規模な個人プロジェクト** — 1人の開発者が全体のコンテキストを把握している場合 - **Tailwind 学習プロジェクト** — フルのデフォルトスケールを使うこと自体が学習プロセスの一部である場合 ## 詳細ガイド 各カテゴリの詳細なトークン戦略については以下を参照してください: - [カラートークンパターン](./color-tokens) — セマンティックカラースケール、ブランドカラー、ステートカラー、サーフェスレイヤー(基盤となるアーキテクチャについては [3層カラー戦略](../../../styling/color/three-tier-color-strategy) も参照) - [タイポグラフィトークンパターン](./typography-tokens) — フォントサイズ、line-height、font-weight、letter-spacing のトークン戦略 - [トークンプレビュー](./token-preview) — 利用可能なすべてのトークンのビジュアルリファレンス - [コンポーネントトークンと任意の値](./component-tokens) — システムトークンと任意の値の使い分け - [2層サイズ戦略](../two-tier-size-strategy/) — width/height のサイジングが抽象レイヤーをスキップし、セマンティックなテーマトークンを直接使う理由 ## 参考リンク - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 @theme Directive](https://tailwindcss.com/docs/functions-and-directives#theme-directive) --- # コンテナクエリ > Source: https://takazudomodular.com/pj/zcss/ja/docs/responsive/container-queries ## 問題 メディアクエリ(media query)はビューポートの幅に応じて反応しますが、コンポーネントのコンテナの幅には対応しません。コンポーネントがサイドバー、モーダル、あるいは制約のあるレイアウトに配置された場合、ビューポートベースのメディアクエリではコンポーネントのレイアウトを実際の利用可能スペースに適応させることができません。AIエージェントはコンポーネントレベルのレスポンシブ対応にほぼ常に `@media` クエリを使い、コンテナクエリ(container query)を完全に無視してしまいます。 ## 解決方法 CSSコンテナクエリ(`@container`)を使うと、コンポーネントをビューポートではなく親コンテナのサイズに応じて変化させることができます。これにより、コンポーネントが異なるレイアウトコンテキストで真に再利用可能になります。コンテナクエリは Baseline 2023 であり、すべてのモダンブラウザでサポートされています。 ### コンテナの設定 親要素を `container-type` を使ってコンテインメントコンテキスト(containment context)として宣言する必要があります。最も一般的な値は `inline-size` で、コンテナのインライン方向(水平方向)のサイズに基づくクエリを有効にします。 ```css .card-wrapper { container-type: inline-size; } ``` ### コンテナへのクエリ ```css @container (min-width: 400px) { .card { display: grid; grid-template-columns: 200px 1fr; } } ``` ### 基本的なコンテナクエリ このデモでは iframe がコンテナの境界として機能します。ビューポートボタンを使って、カードレイアウトがコンテナの幅に応じて変化する様子を確認しましょう。 Responsive Card This card uses container queries to adapt its layout. At narrow widths it stacks vertically; at wider widths it switches to a horizontal layout. `} css={` .card-wrapper { container-type: inline-size; padding: 1rem; } .card { display: flex; flex-direction: column; border-radius: 0.5rem; overflow: hidden; background: #f8fafc; border: 1px solid #e2e8f0; } .card__image { width: 100%; height: 120px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); } .card__body { padding: 1rem; } .card__title { font-size: 1.125rem; font-weight: 700; margin: 0 0 0.5rem; color: #1e293b; } .card__text { font-size: 0.875rem; color: #64748b; margin: 0; line-height: 1.5; } @container (min-width: 500px) { .card { flex-direction: row; } .card__image { width: 200px; height: auto; min-height: 150px; } } @container (min-width: 700px) { .card { gap: 1rem; } .card__image { width: 280px; } .card__title { font-size: 1.375rem; } } `} /> ## 名前付きコンテナ コンテナがネストされている場合、`@container` クエリは `container-type` が設定されている最も近い祖先要素にマッチします。特定のコンテナをターゲットにするには、`container-name` を使い、クエリ内でその名前を参照します。 ```css .sidebar { container-type: inline-size; container-name: sidebar; } .main-content { container-type: inline-size; container-name: main; } /* Only responds to the sidebar container */ @container sidebar (max-width: 300px) { .nav-list { flex-direction: column; } } ``` `container` ショートハンドプロパティで両方を組み合わせることができます: ```css .sidebar { container: sidebar / inline-size; } ``` Sidebar (narrow container) ★ Featured This card adapts to its sidebar container Main Content (wide container) ★ Featured Same card component adapts to the wider main container, showing a horizontal layout with more space `} css={` .page-layout { display: grid; grid-template-columns: 180px 1fr; gap: 1rem; padding: 1rem; } .sidebar { container: sidebar / inline-size; } .main-content { container: main / inline-size; } .section-label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin: 0 0 0.5rem; } .info-card { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 0.5rem; padding: 0.75rem; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 0.5rem; } .info-card__icon { font-size: 1.5rem; color: #f59e0b; line-height: 1; } .info-card__content { display: flex; flex-direction: column; gap: 0.25rem; } .info-card__title { font-size: 0.875rem; color: #1e293b; } .info-card__text { font-size: 0.75rem; color: #64748b; line-height: 1.4; } @container main (min-width: 300px) { .info-card { flex-direction: row; text-align: left; align-items: flex-start; } } `} height={220} /> ## コンテナクエリの単位 コンテナクエリ単位(container query unit)は、クエリコンテナの寸法に対する相対値です。コンポーネント内でのフルイドサイジングに便利です。 - `cqw` — コンテナの幅の1% - `cqh` — コンテナの高さの1% - `cqi` — コンテナのインラインサイズの1% - `cqb` — コンテナのブロックサイズの1% - `cqmin` — `cqi` と `cqb` の小さい方 - `cqmax` — `cqi` と `cqb` の大きい方 ```css .card-container { container-type: inline-size; } .card__title { /* 5% of the container's inline size, clamped */ font-size: clamp(1rem, 5cqi, 2rem); } .card__body { /* Padding relative to container width */ padding: 2cqi; } ``` Fluid Title This text and padding scale with the container width using cqi units. Resize using the viewport buttons to see everything scale proportionally. cqi sized `} css={` .cqu-demo { container-type: inline-size; padding: 1rem; } .cqu-card { background: linear-gradient(135deg, #1e293b, #334155); color: white; padding: clamp(0.75rem, 4cqi, 2.5rem); border-radius: clamp(0.375rem, 1.5cqi, 1rem); } .cqu-card__title { font-size: clamp(1rem, 5cqi, 2.25rem); font-weight: 700; margin: 0 0 clamp(0.25rem, 1.5cqi, 0.75rem); line-height: 1.2; } .cqu-card__text { font-size: clamp(0.75rem, 2.5cqi, 1.125rem); color: #cbd5e1; margin: 0 0 clamp(0.5rem, 2cqi, 1rem); line-height: 1.5; } .cqu-card__badge { display: inline-block; background: #3b82f6; color: white; font-size: clamp(0.625rem, 2cqi, 0.875rem); font-weight: 600; padding: clamp(0.125rem, 0.5cqi, 0.375rem) clamp(0.375rem, 1.5cqi, 0.75rem); border-radius: 9999px; } `} /> ## コンテナ幅に適応するカードコンポーネント よくある実用的なユースケースとして、狭いサイドバー、中幅のグリッドカラム、全幅のメインエリアなど、どのレイアウトコンテキストでも機能するカードコンポーネントがあります。 Mountain Retreat A peaceful getaway nestled in the mountains with stunning views and fresh mountain air. $120 / night Forest Cabin A cozy cabin surrounded by towering trees and natural beauty. $95 / night Beach House Oceanfront living with direct beach access and sunset views from every room. $200 / night `} css={` .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); gap: 1rem; padding: 1rem; } .card-cell { container-type: inline-size; } .adaptive-card { display: flex; flex-direction: column; border-radius: 0.5rem; overflow: hidden; background: #ffffff; border: 1px solid #e2e8f0; height: 100%; } .adaptive-card__media { width: 100%; height: 100px; background: linear-gradient(135deg, #3b82f6, #6366f1); } .adaptive-card__body { padding: 0.75rem; display: flex; flex-direction: column; flex: 1; } .adaptive-card__title { font-size: 1rem; font-weight: 700; margin: 0 0 0.375rem; color: #1e293b; } .adaptive-card__desc { font-size: 0.8125rem; color: #64748b; margin: 0 0 0.75rem; line-height: 1.4; flex: 1; } .adaptive-card__price { font-size: 0.875rem; font-weight: 700; color: #059669; } @container (min-width: 350px) { .adaptive-card { flex-direction: row; } .adaptive-card__media { width: 140px; height: auto; min-height: 120px; } .adaptive-card__body { padding: 1rem; } } `} height={400} /> ## コンテナクエリとメディアクエリの比較 重要な違い:メディアクエリは**ビューポート**に応じて反応し、コンテナクエリは**親コンテナ**に応じて反応します。このデモでは、同じページ上の幅が異なる2つのコンテナに同一コンポーネントを配置しています。メディアクエリ版はビューポートが変わっていないため両方とも同じ見た目になります。コンテナクエリ版はそれぞれのコンテナに独立して適応します。 Using @container (adapts to each container) Container Query Card Narrow container Container Query Card Wide container Using @media (both look the same) Media Query Card Narrow container Media Query Card Wide container `} css={` .demo-heading { font-size: 0.8125rem; font-weight: 700; color: #475569; margin: 0 0 0.5rem; padding: 0 0.75rem; } .comparison-layout { display: grid; grid-template-columns: 180px 1fr; gap: 0.75rem; padding: 0 0.75rem; } .narrow-container { min-width: 0; } .wide-container { min-width: 0; } /* ---- Container Query version ---- */ .cq-container { container-type: inline-size; } .cq-card { display: flex; flex-direction: column; border: 1px solid #bfdbfe; border-radius: 0.375rem; overflow: hidden; background: #eff6ff; } .cq-card__image { width: 100%; height: 48px; background: #3b82f6; } .cq-card__body { padding: 0.5rem; display: flex; flex-direction: column; gap: 0.125rem; } .cq-card__body strong { font-size: 0.75rem; color: #1e293b; } .cq-card__body span { font-size: 0.6875rem; color: #64748b; } @container (min-width: 300px) { .cq-card { flex-direction: row; } .cq-card__image { width: 80px; height: auto; min-height: 60px; } } /* ---- Media Query version ---- */ .mq-card { display: flex; flex-direction: column; border: 1px solid #fecaca; border-radius: 0.375rem; overflow: hidden; background: #fef2f2; } .mq-card__image { width: 100%; height: 48px; background: #ef4444; } .mq-card__body { padding: 0.5rem; display: flex; flex-direction: column; gap: 0.125rem; } .mq-card__body strong { font-size: 0.75rem; color: #1e293b; } .mq-card__body span { font-size: 0.6875rem; color: #64748b; } @media (min-width: 300px) { .mq-card { flex-direction: row; } .mq-card__image { width: 80px; height: auto; min-height: 60px; } } `} height={380} /> 上のデモでは、`@container` カードはそれぞれ独立して適応します。狭いコンテナ内のカードは縦に積み重なり、広いコンテナ内のカードは横並びになります。`@media` カードはビューポート(iframe)が `300px` より広いため両方とも横並びになります。どちらのカードも実際のコンテナの幅を認識していません。 ## コード例 ### レスポンシブカードコンポーネント ```css .card-container { container-type: inline-size; container-name: card; } /* Base: stacked layout */ .card { display: flex; flex-direction: column; } .card__image { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } /* When container is wide enough: horizontal layout */ @container card (min-width: 500px) { .card { flex-direction: row; } .card__image { width: 200px; aspect-ratio: 1; } } /* When container is very wide: add extra spacing */ @container card (min-width: 800px) { .card { gap: 2rem; padding: 2rem; } .card__image { width: 300px; } } ``` ### コンテナに適応するナビゲーション ```css .nav-wrapper { container-type: inline-size; container-name: nav; } .nav-list { display: flex; flex-direction: column; gap: 0.25rem; list-style: none; padding: 0; margin: 0; } /* Horizontal layout when container allows */ @container nav (min-width: 600px) { .nav-list { flex-direction: row; gap: 1rem; } } ``` ### コンテナクエリとコンテナクエリ単位の組み合わせ ```css .widget-wrapper { container: widget / inline-size; } .widget__title { font-size: clamp(1rem, 5cqi, 2rem); } .widget__body { padding: clamp(0.5rem, 3cqi, 1.5rem); } @container widget (min-width: 400px) { .widget { display: grid; grid-template-columns: auto 1fr; gap: 1rem; } } ``` ## AIがよくやるミス - **コンポーネントレイアウトにメディアクエリを使う**: AIエージェントは、コンポーネントがビューポートではなくコンテナに適応する必要がある場合でも、デフォルトで `@media` クエリを使ってしまいます。 - **`container-type` を忘れる**: 親要素に `container-type` を設定せずに `@container` ルールを書いてしまいます。コンテナは明示的に宣言する必要があります。 - **`container-type: size` を不必要に使う**: 高さベースのコンテインメント(`size`)はレイアウトの問題を引き起こす可能性があります。ほとんどの場合は `inline-size` を使いましょう。 - **ネストされたコンテナに名前を付けない**: コンテナがネストされているとき、`container-name` を省略すると曖昧さが生じます。`@container` クエリは最も近い祖先コンテナにマッチします。ネスト時は特定の祖先をターゲットにするためにコンテナに名前を付けましょう。 - **要素自体にクエリしてしまう**: `@container` クエリは `container-type` が設定された最も近い祖先をターゲットにするのであって、スタイルを適用する要素自体ではありません。コンテナとスタイル対象の要素は別の要素である必要があります。 ## 使い分け - **コンポーネントレベルのレスポンシブ対応**: 異なるレイアウト幅に配置される可能性のある再利用可能なコンポーネント(カード、ナビゲーション、フォームグループ)に使いましょう。 - **サイドバーとメインコンテンツ**: 同じページ上で、同じコンポーネントが広いコンテキストと狭いコンテキストの両方に表示される場合に使いましょう。 - **デザインシステムのコンポーネント**: 異なるアプリケーションやレイアウトで再利用するために構築されたコンポーネントに使いましょう。 - **ページレベルのレイアウトには不向き**: シングルカラムとマルチカラムのページレイアウト切り替えなど、マクロなレイアウトの関心事には引き続き `@media` クエリを使いましょう。 ## 参考リンク - [CSS Container Queries — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Containment/Container_queries) - [@container — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@container) - [Container Query Units — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_size_and_style_queries#container_query_length_units) - [Container Queries Unleashed — Josh W. Comeau](https://www.joshwcomeau.com/css/container-queries-unleashed/) - [CSS Container Queries — CSS-Tricks](https://css-tricks.com/css-container-queries/) --- # OKLCH カラースペース > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/color/oklch-color-space ## 問題 AIエージェントはほぼ常に `hex`、`rgb()`、または `hsl()` 形式で色を生成します。これらの古いカラースペースには根本的な欠陥があります:知覚的に均一ではないということです。HSLでは、同じ明度値を持つ2つの色(例:`hsl(60, 100%, 50%)` の黄色と `hsl(240, 100%, 50%)` の青)は、知覚される明るさが大きく異なります。これにより、色相値を調整するだけでは一貫したアクセシブルなカラーパレットを作ることがほぼ不可能になります。HSLで生成されたAIのパレットは、コントラスト比が不均一で、中間トーンがくすみ、スペクトラム全体で知覚される明るさが「飛ぶ」ことがよくあります。 ## 解決方法 OKLCH(`oklch()`)は、Oklab 知覚カラーモデルに基づくCSS色関数です。3つのコンポーネントを使用します: - **L** — 明度(Lightness)(0% = 黒、100% = 白)、知覚的にリニア - **C** — 彩度(Chroma)(0 = グレー、高い = より鮮やか)、色の鮮やかさを表す - **H** — 色相(Hue)(0〜360度)、カラーホイール上の角度 重要な利点:Lを一定にしてHを変更すると、知覚される明るさは同じままです。これによりパレット作成が予測可能になり、人間の目に同じ明るさに見える色のセットを生成できます。 ### OKLCH が HSL より優れている理由 ```css /* HSL: These "look" like the same lightness, but they're not */ .yellow { color: hsl(60, 100%, 50%); /* Appears very bright */ } .blue { color: hsl(240, 100%, 50%); /* Appears much darker */ } /* OKLCH: Same lightness = same perceived brightness */ .yellow { color: oklch(80% 0.18 90); /* Visually bright */ } .blue { color: oklch(80% 0.18 264); /* Equally bright */ } ``` ## コード例 ### 基本的な OKLCH 構文 ```css :root { /* oklch(lightness chroma hue) */ --brand-primary: oklch(55% 0.25 264); /* Vivid blue */ --brand-secondary: oklch(65% 0.2 150); /* Teal-green */ --brand-accent: oklch(70% 0.22 30); /* Warm orange */ /* With alpha transparency */ --overlay: oklch(20% 0 0 / 0.5); /* Semi-transparent black */ } ``` ### 知覚的に均一なパレットの作成 明度と彩度を固定して色相のみを回転させると、すべての色が同じ視覚的重みを持ちます: ```css :root { /* Categorical palette — all colors appear equally prominent */ --chart-1: oklch(65% 0.2 30); /* Red-orange */ --chart-2: oklch(65% 0.2 90); /* Yellow */ --chart-3: oklch(65% 0.2 150); /* Green */ --chart-4: oklch(65% 0.2 210); /* Cyan */ --chart-5: oklch(65% 0.2 270); /* Blue */ --chart-6: oklch(65% 0.2 330); /* Magenta */ } ``` OKLCH — Same lightness (65%), different hues Red H:30 Yellow H:90 Green H:150 Cyan H:210 Blue H:270 Magenta H:330 All colors appear equally bright — perceptually uniform HSL — Same lightness (50%), different hues Red H:0 Yellow H:60 Green H:120 Cyan H:180 Blue H:240 Magenta H:300 Yellow appears much brighter than blue — not perceptually uniform `} css={`.color-demo { padding: 1.5rem; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 1.5rem; } .color-demo h3 { font-size: 0.85rem; color: #444; margin: 0 0 0.75rem; font-weight: 600; } .swatches { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.5rem; } .swatch { aspect-ratio: 1; border-radius: 8px; display: flex; align-items: flex-end; justify-content: center; padding: 0.25rem; } .swatch span { font-size: 0.65rem; color: white; text-shadow: 0 1px 3px rgba(0,0,0,0.6); font-weight: 600; } .note { font-size: 0.8rem; color: #666; margin: 0.5rem 0 0; font-style: italic; }`} height={340} /> ### 単一色相の明度スケール ```css :root { --blue-hue: 264; --blue-chroma: 0.15; --blue-50: oklch(97% var(--blue-chroma) var(--blue-hue)); --blue-100: oklch(93% var(--blue-chroma) var(--blue-hue)); --blue-200: oklch(85% var(--blue-chroma) var(--blue-hue)); --blue-300: oklch(75% var(--blue-chroma) var(--blue-hue)); --blue-400: oklch(65% var(--blue-chroma) var(--blue-hue)); --blue-500: oklch(55% var(--blue-chroma) var(--blue-hue)); --blue-600: oklch(45% var(--blue-chroma) var(--blue-hue)); --blue-700: oklch(37% var(--blue-chroma) var(--blue-hue)); --blue-800: oklch(30% var(--blue-chroma) var(--blue-hue)); --blue-900: oklch(22% var(--blue-chroma) var(--blue-hue)); } ``` ### OKLCH カスタムプロパティによるテーマ設定 ```css :root { --hue: 264; --chroma: 0.2; --color-primary: oklch(55% var(--chroma) var(--hue)); --color-primary-light: oklch(75% var(--chroma) var(--hue)); --color-primary-dark: oklch(35% var(--chroma) var(--hue)); --color-primary-subtle: oklch(95% 0.03 var(--hue)); --color-surface: oklch(99% 0.005 var(--hue)); --color-text: oklch(20% 0.02 var(--hue)); --color-text-muted: oklch(45% 0.02 var(--hue)); } /* Change the entire theme by adjusting one variable */ .theme-green { --hue: 150; } .theme-red { --hue: 25; } ``` ### アクセシブルなカラーペア OKLCHでは、明度差を制御することでコントラストを保証できます: ```css :root { /* A lightness difference of ~45-50% in oklch roughly maps to WCAG AA 4.5:1 */ --bg: oklch(97% 0.01 264); --text: oklch(25% 0.02 264); --btn-bg: oklch(50% 0.2 264); --btn-text: oklch(98% 0.01 264); } ``` ### OKLCH vs HSL — 実際の比較 ```css /* Creating "same lightness" grays in HSL — they're not truly equal */ .hsl-problem { --gray-warm: hsl(30, 10%, 50%); --gray-cool: hsl(210, 10%, 50%); /* These two grays have visibly different perceived brightness */ } /* OKLCH grays are genuinely perceptually matched */ .oklch-solution { --gray-warm: oklch(55% 0.02 60); --gray-cool: oklch(55% 0.02 250); /* These two grays actually look equally bright */ } ``` ## AIがよくやるミス - `oklch()` でより一貫したパレットが作れるのに、すべての色値にデフォルトで `hex` や `hsl()` を使っている - HSLの明度が知覚的に均一だと思い込んでいる — `hsl(60, 100%, 50%)` と `hsl(240, 100%, 50%)` は同じ明度値にもかかわらず、明るさが大きく異なって見える - 特定の色相/明度の組み合わせでガモットを超える彩度値を使っている — ブラウザはクリップしますが、結果が意図と異なる可能性がある - マルチカラーパレット生成でOKLCHの色相回転を活用していない — AIは色相を回転させる代わりに、各色を独立してハードコードしがち - 非常に高い彩度が極端な明度でガモット外になることを考慮せず、明度値を均等に配置して(10%、20%、30%...)カラースケールを作っている - より簡単な `black` と `white` キーワードで十分なのに、黒と白に `oklch(0% 0 0)` と `oklch(100% 0 0)` を使っている ## 使い分け - **デザインシステムのカラートークン**: OKLCHは異なる色相間で一貫した明度スケールを簡単に生成できます - **データビジュアライゼーションパレット**: 同じ知覚的な明るさのカテゴリカルカラーにより、1つの色が視覚的に支配することを防ぎます - **アクセシブルなテーマ設定**: 背景とテキスト間の明度差を制御することで、予測可能なコントラストを確保します - **動的テーマ設定**: 色相カスタムプロパティを回転させるだけで、視覚的な調和を保ちながらパレット全体をシフトします ### hex/rgb を使い続ける場面 - OKLCHをサポートしていない古いブラウザ(2023年以前)をターゲットにしていて、フォールバックが非現実的な場合 - hex や rgb 値のみを受け付けるデザインツールやAPIとインターフェースする場合 - 知覚的均一性が関係ない単一色の宣言 ## 参考リンク - [MDN: oklch()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/oklch) - [OKLCH in CSS: why we moved from RGB and HSL — Evil Martians](https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl) - [OKLCH Color Picker & Converter](https://oklch.com/) - [CSS-Tricks: oklch()](https://css-tricks.com/almanac/functions/o/oklch/) - [Oklab color space — Wikipedia](https://en.wikipedia.org/wiki/Oklab_color_space) --- # バックドロップフィルターとグラスモーフィズム > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/effects/backdrop-filter-and-glassmorphism ## 問題 グラスモーフィズム(glassmorphism)は、AppleのiOSやmacOSで広まったすりガラス風の美しいデザインで、`backdrop-filter: blur()` と半透明の背景を組み合わせて実現します。AIエージェントはこの実装でよく間違いを犯します。要素自体にブラーをかけてしまう、完全に不透明な背景を使ってブラー効果を隠してしまう、Safariに必要な `-webkit-` プレフィックスを忘れる、あるいはブラーすべき視覚的なコンテンツがない単色背景の上にエフェクトを適用してしまう、といったケースです。 ## 解決方法 視覚的にリッチな背景(グラデーション、画像、カラフルなコンテンツ)の上に配置した半透明の要素に `backdrop-filter: blur()` を使用します。ブラーは要素自体のコンテンツではなく、要素の**背後**にあるものに対して適用されます。ブラー値は見た目とパフォーマンスのバランスが最も良い8〜16pxの範囲にしましょう。Safariとの互換性のために、常に `-webkit-` プレフィックスを含めてください。 ### 基本原則 #### 半透明の背景が必要 要素には半透明の `background-color` が必要です。そうすることでブラーされた背景が透けて見えます。完全に不透明な背景を設定すると、グラスモーフィズムの目的そのものが台無しになります。 #### 背後にリッチなコンテンツが必要 `backdrop-filter` は要素の背後にあるものをブラーします。単色の白い背景の上では、ブラーするものがないためエフェクトは見えません。グラスモーフィズムは常にグラデーション、画像、カラフルなレイヤードコンテンツの上で使いましょう。 #### パフォーマンスの考慮 グラスモーフィズム要素はそれぞれGPUでのブラー計算を発生させます。1つのビューポートにつきガラス要素は2〜3個に制限しましょう。`backdrop-filter` の値を直接アニメーションすることは避けてください。 ## コード例 ### 基本的なすりガラスカード ```css .glass-card { background: hsl(0deg 0% 100% / 0.15); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid hsl(0deg 0% 100% / 0.2); border-radius: 16px; padding: 24px; } ``` ```html Frosted Glass Content is readable over a blurred background. ``` ### 鮮やかな背景の設定 ガラスエフェクトは背後にリッチな視覚コンテンツがある場合にのみ機能します。 ```css .vibrant-background { min-height: 100vh; background: radial-gradient(circle at 20% 80%, hsl(280deg 80% 60% / 0.6), transparent 50%), radial-gradient(circle at 80% 20%, hsl(200deg 80% 60% / 0.6), transparent 50%), linear-gradient(135deg, hsl(220deg 60% 20%), hsl(280deg 60% 30%)); display: grid; place-items: center; padding: 40px; } ``` ### ダークテーマのグラスモーフィズム ```css .glass-dark { background: hsl(220deg 20% 10% / 0.4); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid hsl(0deg 0% 100% / 0.08); border-radius: 12px; box-shadow: 0 8px 32px hsl(0deg 0% 0% / 0.3); } ``` ### ライトテーマのグラスモーフィズム ```css .glass-light { background: hsl(0deg 0% 100% / 0.6); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid hsl(0deg 0% 100% / 0.3); border-radius: 12px; box-shadow: 0 4px 16px hsl(0deg 0% 0% / 0.08); } ``` ### すりガラスのナビゲーションバー ```css .glass-nav { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: hsl(0deg 0% 100% / 0.7); backdrop-filter: blur(10px) saturate(180%); -webkit-backdrop-filter: blur(10px) saturate(180%); border-bottom: 1px solid hsl(0deg 0% 0% / 0.06); padding: 12px 24px; } ``` `blur()` と合わせて `saturate(180%)` を追加すると、ブラーされたコンテンツの色が強調され、Appleのガラスエフェクトのような鮮やかさを再現できます。 ### ガラスモーダルオーバーレイ ```css .glass-overlay { position: fixed; inset: 0; background: hsl(0deg 0% 0% / 0.3); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); display: grid; place-items: center; z-index: 200; } .glass-modal { background: hsl(0deg 0% 100% / 0.2); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid hsl(0deg 0% 100% / 0.15); border-radius: 20px; padding: 32px; max-width: 500px; width: 90%; box-shadow: 0 16px 48px hsl(0deg 0% 0% / 0.2); } ``` ```html Modal Title Modal content over a frosted backdrop. ``` ### 事前ブラー画像によるパフォーマンス重視の代替手法 パフォーマンスが重要な場合(モバイル端末やガラス要素が多い場合)は、ランタイムの `backdrop-filter` の代わりに事前にブラーした画像を使いましょう。 ```css .faux-glass { position: relative; overflow: hidden; border-radius: 16px; } .faux-glass::before { content: ""; position: absolute; inset: -20px; background: url("background-preblurred.jpg") center / cover; filter: blur(0); /* image is already blurred */ z-index: -1; } .faux-glass-content { position: relative; background: hsl(0deg 0% 100% / 0.15); padding: 24px; } ``` ## ライブプレビュー Frosted GlassContent is readable over a blurred, colorful background.`} css={` .vibrant-bg { width: 100%; height: 100%; background: radial-gradient(circle at 20% 80%, hsl(280deg 80% 60% / 0.8), transparent 50%), radial-gradient(circle at 80% 20%, hsl(200deg 80% 60% / 0.8), transparent 50%), radial-gradient(circle at 50% 50%, hsl(340deg 80% 50% / 0.5), transparent 60%), linear-gradient(135deg, hsl(220deg 60% 20%), hsl(280deg 60% 30%)); display: flex; justify-content: center; align-items: center; padding: 24px; font-family: system-ui, sans-serif; } .glass-card { background: hsl(0deg 0% 100% / 0.15); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid hsl(0deg 0% 100% / 0.25); border-radius: 16px; padding: 32px; max-width: 340px; color: white; } .glass-card h2 { margin: 0 0 8px; font-size: 22px; } .glass-card p { margin: 0; font-size: 14px; opacity: 0.9; } `} height={280} /> SettingsNotificationsONDark ModeONAuto-saveOFF`} css={` .vibrant-bg { width: 100%; height: 100%; background: radial-gradient(circle at 70% 30%, hsl(330deg 80% 50% / 0.6), transparent 50%), radial-gradient(circle at 30% 70%, hsl(180deg 80% 50% / 0.5), transparent 50%), linear-gradient(135deg, #0f172a, #1e1b4b); display: flex; justify-content: center; align-items: center; padding: 24px; font-family: system-ui, sans-serif; } .glass-panel { background: hsl(220deg 20% 10% / 0.4); backdrop-filter: blur(16px) saturate(180%); -webkit-backdrop-filter: blur(16px) saturate(180%); border: 1px solid hsl(0deg 0% 100% / 0.1); border-radius: 16px; padding: 24px; width: 280px; color: white; box-shadow: 0 8px 32px hsl(0deg 0% 0% / 0.3); } .glass-panel h3 { margin: 0 0 16px; font-size: 18px; font-weight: 600; } .item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid hsl(0deg 0% 100% / 0.08); font-size: 14px; } .item:last-child { border-bottom: none; } .toggle { background: hsl(150deg 60% 45% / 0.3); color: hsl(150deg 60% 70%); padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600; } .toggle.off { background: hsl(0deg 0% 50% / 0.3); color: hsl(0deg 0% 70%); } `} height={300} /> ## AIがよくやるミス - **`backdrop-filter: blur()` の代わりに `filter: blur()` を使ってしまう** — `filter` は要素自体とそのすべてのコンテンツをブラーするため、テキストが読めなくなります。`backdrop-filter` は要素の背後にあるものだけをブラーします。 - **完全に不透明な背景を使う** — `background: white` や `background: rgba(255, 255, 255, 1)` を設定すると、ブラーされた背景が完全に覆い隠され、エフェクトが見えなくなります。 - **`-webkit-` プレフィックスを忘れる** — Safariでは標準の `backdrop-filter` に加えて `-webkit-backdrop-filter` が必要です。これがないと、Safariユーザーにはブラーが表示されません。 - **単色背景の上に適用する** — 単一の平坦な色の上にガラスエフェクトを置いても、目に見える効果は得られません。ブラーが意味を持つには、要素の背後に視覚的な変化がなければなりません。 - **ガラス要素が多すぎる** — 各 `backdrop-filter` はGPUによるコストの高いブラー処理を発生させます。すべてのカード、ボタン、ナビゲーション項目に使用すると、深刻なフレーム落ちを引き起こします。 - **過度なブラー値** — `blur(40px)` やそれ以上の値を使用すること。16pxを超える値は指数関数的にコストが増加し、12〜16pxより見た目が良くなることはほとんどありません。 - **ライトテーマとダークテーマで調整しない** — ダーク背景に合わせたガラスエフェクトはライト背景では色あせて見え、その逆も同様です。背景の不透明度とボーダーにはテーマごとの調整が必要です。 ## 使い分け - ページコンテンツの上をスクロールする固定ナビゲーションバー - 下のページのコンテキストを維持する必要があるモーダルオーバーレイ - 鮮やかな画像やグラデーションの上に配置されたヒーローセクションのカードやオーバーレイ - リッチなビジュアルコンテンツの上にあるアプリケーションのサイドバーパネル - 空間的なコンテキストの維持が重要なツールチップやポップオーバー要素 ## Tailwind CSS Tailwindは、カスタムCSSを書かずにグラスモーフィズム効果を実現するための `backdrop-blur-*`、`backdrop-brightness-*`、`backdrop-saturate-*` ユーティリティを提供しています。 ### すりガラスカード Frosted Glass Content is readable over a blurred, colorful background. `} height={300} /> ### ダークガラスパネル Settings Notifications ON Dark Mode ON Auto-save OFF `} height={320} /> ## 参考リンク - [backdrop-filter — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter) - [Next-level Frosted Glass with backdrop-filter — Josh W. Comeau](https://www.joshwcomeau.com/css/backdrop-filter/) - [Glassmorphism Design Trend: Implementation Guide — Developer Playground](https://playground.halfaccessible.com/blog/glassmorphism-design-trend-implementation-guide) - [Blending Modes in CSS — Ahmad Shadeed](https://ishadeed.com/article/blending-modes-css/) --- # CSSのみのパターンライブラリ > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/effects/gradient-techniques/css-pattern-library グラデーションのみで構築されたCSS装飾背景パターンの包括的なコレクションです。以下のすべてのパターンは `background-size` と `background-repeat` を使ってシームレスにタイリングし、画像は一切不要で、CSSカスタムプロパティでカスタマイズできます。 ## 斜めストライプ 45度の `repeating-linear-gradient` で、クラシックな斜めストライプを作成します。同じ位置にある2つのハードカラーストップが、色間の瞬時のトランジションを生みます。リピーティングバリアントがパターンを自動的にタイリングします。 `} css={` .diagonal-stripes { --stripe-color-1: hsl(220 70% 55%); --stripe-color-2: hsl(220 70% 40%); --stripe-width: 12px; width: 100%; height: 100%; background: repeating-linear-gradient( 45deg, var(--stripe-color-1) 0px, var(--stripe-color-1) var(--stripe-width), var(--stripe-color-2) var(--stripe-width), var(--stripe-color-2) calc(var(--stripe-width) * 2) ); } `} /> ## 横罫線(ノートパッド風) 単一の `repeating-linear-gradient` が、白い背景上に均等に配置された横線を描きます。罫線入りノートを模倣しています。トリックは、透明から透明へのスパンと、各罫線のための薄い色付きスライスを使うことです。 The quick brown fox jumps over the lazy dog. CSS gradients can simulate notebook paper without any images. `} css={` .notebook { --line-color: hsl(210 40% 80%); --line-spacing: 28px; --line-width: 1px; width: 100%; height: 100%; padding: 20px 32px; background-color: hsl(45 50% 97%); background-image: repeating-linear-gradient( to bottom, transparent 0px, transparent calc(var(--line-spacing) - var(--line-width)), var(--line-color) calc(var(--line-spacing) - var(--line-width)), var(--line-color) var(--line-spacing) ); background-size: 100% var(--line-spacing); background-position: 0 10px; } .notebook-text { font-family: 'Georgia', serif; font-size: 15px; line-height: 28px; color: hsl(220 20% 30%); margin: 0; } `} /> ## 水玉模様 2つのオフセットされた `radial-gradient` レイヤーが、均等に配置されたドットパターンを作成します。2番目のレイヤーは両方向にタイルサイズの半分だけずらされているため、ドットが最初のレイヤーのギャップの間に入ります。 `} css={` .polka-dots { --dot-color: hsl(340 70% 60%); --bg-color: hsl(340 30% 95%); --dot-size: 12px; --dot-spacing: 36px; width: 100%; height: 100%; background-color: var(--bg-color); background-image: radial-gradient( circle, var(--dot-color) var(--dot-size), transparent var(--dot-size) ), radial-gradient( circle, var(--dot-color) var(--dot-size), transparent var(--dot-size) ); background-size: var(--dot-spacing) var(--dot-spacing); background-position: 0 0, calc(var(--dot-spacing) / 2) calc(var(--dot-spacing) / 2); } `} /> ## チェッカーボード 25%間隔で4つのハードストップを持つ `conic-gradient` が、各タイルに2色の象限を生成します。`background-size` で繰り返すと、象限がクラシックなチェッカーボードに揃います。 `} css={` .checkerboard { --color-1: hsl(0 0% 15%); --color-2: hsl(0 0% 95%); --tile-size: 40px; width: 100%; height: 100%; background-image: conic-gradient( var(--color-1) 25%, var(--color-2) 25% 50%, var(--color-1) 50% 75%, var(--color-2) 75% ); background-size: var(--tile-size) var(--tile-size); } `} /> ## ジグザグ / ノコギリ歯エッジ 2つの `linear-gradient` の三角形を並べて配置すると、ジグザグエッジが作成されます。擬似要素の背景として適用することで、追加のマークアップなしに装飾的なノコギリ歯ボーダーが得られます。 Zigzag bottom edge using gradient triangles `} css={` .zigzag-card { --zigzag-color: hsl(250 60% 50%); --zigzag-size: 16px; width: 100%; position: relative; background: var(--zigzag-color); padding: 32px 24px; padding-bottom: calc(32px + var(--zigzag-size)); } .zigzag-content { color: hsl(0 0% 100%); font-family: system-ui, sans-serif; font-size: 18px; font-weight: 600; } .zigzag-card::after { content: ''; position: absolute; bottom: 0; left: 0; width: 100%; height: var(--zigzag-size); background: linear-gradient( 135deg, var(--zigzag-color) 33.33%, transparent 33.33% ), linear-gradient( 225deg, var(--zigzag-color) 33.33%, transparent 33.33% ); background-size: calc(var(--zigzag-size) * 2) 100%; background-position: left bottom; transform: translateY(100%); } `} /> ## カーボンファイバー レイヤードされた `radial-gradient` と `linear-gradient` がカーボンファイバーの織り模様をシミュレートします。繊細な放射状のドットグリッドがストライプの線形グラデーションの上に重なり、すべてがダークなベースカラーの上に配置されます。 `} css={` .carbon-fiber { --highlight: hsl(0 0% 22%); --base: hsl(0 0% 12%); --dot: hsl(0 0% 17%); --dot-size: 2px; --cell-size: 8px; width: 100%; height: 100%; background: radial-gradient( circle, var(--dot) var(--dot-size), transparent var(--dot-size) ), radial-gradient( circle, var(--dot) var(--dot-size), transparent var(--dot-size) ), repeating-linear-gradient( to bottom, transparent 0px, transparent calc(var(--cell-size) / 2), var(--highlight) calc(var(--cell-size) / 2), var(--highlight) var(--cell-size) ), var(--base); background-size: var(--cell-size) var(--cell-size), var(--cell-size) var(--cell-size), var(--cell-size) var(--cell-size); background-position: 0 0, calc(var(--cell-size) / 2) calc(var(--cell-size) / 2), 0 0; } `} /> ## グリッド / 方眼紙 2つのレイヤーの `linear-gradient` パス — 1つは水平、もう1つは垂直 — が細い線を描き、交差してグリッドを形成します。カスタムプロパティを調整することで、線の太さ、間隔、色を変更できます。 `} css={` .graph-paper { --line-color: hsl(200 40% 75%); --bg-color: hsl(0 0% 100%); --cell-size: 24px; --line-width: 1px; width: 100%; height: 100%; background-color: var(--bg-color); background-image: linear-gradient( to right, var(--line-color) 0px, var(--line-color) var(--line-width), transparent var(--line-width) ), linear-gradient( to bottom, var(--line-color) 0px, var(--line-color) var(--line-width), transparent var(--line-width) ); background-size: var(--cell-size) var(--cell-size); } `} /> ## ダイヤモンド / アーガイル 反対方向の対角角度に回転した2つの `linear-gradient` レイヤーが、重なり合うダイヤモンド形状を作成します。3番目のグラデーションがダイヤモンド間のクラシックなアーガイル「ステッチ」ラインを追加します。 `} css={` .argyle { --color-1: hsl(210 50% 45%); --color-2: hsl(210 50% 35%); --bg-color: hsl(210 50% 55%); --stitch-color: hsl(0 0% 100% / 0.15); --diamond-size: 60px; width: 100%; height: 100%; background-color: var(--bg-color); background-image: repeating-linear-gradient( 120deg, var(--stitch-color) 0px, var(--stitch-color) 1px, transparent 1px, transparent 30px ), repeating-linear-gradient( 60deg, var(--stitch-color) 0px, var(--stitch-color) 1px, transparent 1px, transparent 30px ), linear-gradient( 45deg, var(--color-1) 25%, transparent 25%, transparent 75%, var(--color-1) 75% ), linear-gradient( -45deg, var(--color-2) 25%, transparent 25%, transparent 75%, var(--color-2) 75% ); background-size: var(--diamond-size) var(--diamond-size), var(--diamond-size) var(--diamond-size), var(--diamond-size) var(--diamond-size), var(--diamond-size) var(--diamond-size); } `} /> --- # レイヤードナチュラルシャドウ > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/shadows-and-borders/layered-natural-shadows ## 問題 シャドウはUIデザインにおいて最も重要な奥行きの手がかりの1つですが、AIエージェントはほぼ常に単一のフラットな `box-shadow` 宣言を生成します。単一のシャドウは不自然に見えます。なぜなら、現実世界のシャドウは均一なブラーではないからです。オブジェクトが面の上にあるとき、底部近くに密で暗いコンタクトシャドウと、より遠くに広がる柔らかく薄いシャドウを落とします。単一の `box-shadow` ではこのレイヤードな振る舞いを再現できません。 ## 解決方法 ブラー半径と垂直オフセットを段階的に増加させた、カンマ区切りの複数の `box-shadow` 値を使います。各レイヤーは自然光の振る舞いの異なる側面を表現します。ページ全体で単一の暗示された光源方向と一致するよう、すべてのシャドウの一貫性を保ちましょう。 ### 基本原則 #### 一貫した光源 ページ上のすべてのシャドウは、水平方向と垂直方向のオフセット間で同じ比率を共有すべきです。一般的な慣習は、上方やや左からの光源です。つまり、垂直オフセットは水平オフセットのおよそ2倍になります。 #### エレベーションモデル 要素が視聴者に向かって「浮き上がる」につれて、3つのプロパティが変化します。 - **オフセットが増加する** — シャドウが要素からより遠くに移動する - **ブラーが拡大する** — シャドウがより柔らかく拡散する - **不透明度が減少する** — 要素が高く浮き上がるにつれてシャドウが薄くなる #### 色に合わせたシャドウ 純粋な黒のシャドウ(`rgba(0, 0, 0, ...)`)は避けましょう。代わりに、背景の色相を低彩度で合わせます。これにより、黒いシャドウが引き起こす色褪せた脱色した見た目を防げます。 ## コード例 ### 基本的なレイヤードシャドウ ```css .card { box-shadow: 0 1px 1px hsl(0deg 0% 0% / 0.075), 0 2px 2px hsl(0deg 0% 0% / 0.075), 0 4px 4px hsl(0deg 0% 0% / 0.075), 0 8px 8px hsl(0deg 0% 0% / 0.075), 0 16px 16px hsl(0deg 0% 0% / 0.075); } ``` 各レイヤーは前のオフセットとブラーを2倍にしています。累積効果として、滑らかで自然な奥行きのあるシャドウになります。 ### エレベーションレベル ```css /* Low elevation — resting on surface */ .elevation-1 { box-shadow: 0 1px 1px hsl(220deg 60% 50% / 0.07), 0 2px 2px hsl(220deg 60% 50% / 0.07), 0 4px 4px hsl(220deg 60% 50% / 0.07); } /* Medium elevation — card hover */ .elevation-2 { box-shadow: 0 1px 1px hsl(220deg 60% 50% / 0.06), 0 2px 2px hsl(220deg 60% 50% / 0.06), 0 4px 4px hsl(220deg 60% 50% / 0.06), 0 8px 8px hsl(220deg 60% 50% / 0.06), 0 16px 16px hsl(220deg 60% 50% / 0.06); } /* High elevation — modal / dialog */ .elevation-3 { box-shadow: 0 1px 1px hsl(220deg 60% 50% / 0.05), 0 2px 2px hsl(220deg 60% 50% / 0.05), 0 4px 4px hsl(220deg 60% 50% / 0.05), 0 8px 8px hsl(220deg 60% 50% / 0.05), 0 16px 16px hsl(220deg 60% 50% / 0.05), 0 32px 32px hsl(220deg 60% 50% / 0.05); } ``` ### 色付き背景に合わせたシャドウ ```css /* On a blue-tinted background */ .card-on-blue { background: hsl(220deg 80% 98%); box-shadow: 0 1px 2px hsl(220deg 60% 50% / 0.1), 0 3px 6px hsl(220deg 60% 50% / 0.08), 0 8px 16px hsl(220deg 60% 50% / 0.06); } /* On a warm background */ .card-on-warm { background: hsl(30deg 80% 98%); box-shadow: 0 1px 2px hsl(30deg 40% 40% / 0.1), 0 3px 6px hsl(30deg 40% 40% / 0.08), 0 8px 16px hsl(30deg 40% 40% / 0.06); } ``` ### シャープ + ディフューズの組み合わせ ```css /* Tight contact shadow + wide ambient shadow */ .card-sharp-diffuse { box-shadow: 0 1px 3px hsl(0deg 0% 0% / 0.12), 0 8px 24px hsl(0deg 0% 0% / 0.06); } ``` ### 完全なカードの例 ```html Card Title Card content goes here. ``` ```css .shadow-card { padding: 24px; border-radius: 8px; background: white; box-shadow: 0 0.5px 1px hsl(220deg 60% 50% / 0.06), 0 1px 2px hsl(220deg 60% 50% / 0.06), 0 2px 4px hsl(220deg 60% 50% / 0.06), 0 4px 8px hsl(220deg 60% 50% / 0.06), 0 8px 16px hsl(220deg 60% 50% / 0.06); transition: box-shadow 0.3s ease; } .shadow-card:hover { box-shadow: 0 1px 2px hsl(220deg 60% 50% / 0.05), 0 2px 4px hsl(220deg 60% 50% / 0.05), 0 4px 8px hsl(220deg 60% 50% / 0.05), 0 8px 16px hsl(220deg 60% 50% / 0.05), 0 16px 32px hsl(220deg 60% 50% / 0.05), 0 32px 64px hsl(220deg 60% 50% / 0.05); } ``` ## ライブプレビュー Flat Shadow Single box-shadow value Layered Shadow Multiple layered values `} css={` .demo { display: flex; gap: 32px; justify-content: center; padding: 40px 20px; background: #f8fafc; font-family: system-ui, sans-serif; } .card { padding: 24px; border-radius: 8px; background: white; width: 200px; } .card h3 { font-size: 16px; font-weight: 600; margin-bottom: 8px; } .card p { font-size: 14px; color: #64748b; } .flat { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .layered { box-shadow: 0 1px 1px hsl(220deg 60% 50% / 0.075), 0 2px 2px hsl(220deg 60% 50% / 0.075), 0 4px 4px hsl(220deg 60% 50% / 0.075), 0 8px 8px hsl(220deg 60% 50% / 0.075), 0 16px 16px hsl(220deg 60% 50% / 0.075); } `} /> ## AIがよくやるミス - **単一のフラットシャドウ** — どこでも `box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1)` を使うこと。これは奥行きのない均一で人工的な見た目になります。 - **純粋な黒のシャドウカラー** — `rgba(0, 0, 0, ...)` はシャドウの下の領域を脱色し、色付きの背景上でグレーの色褪せた見た目を作ります。 - **一貫しない光の方向** — 異なる要素に異なるオフセット角度を生成し、統一された光源の錯覚を壊す。 - **すべてのエレベーションで同じシャドウ** — カード、モーダル、ドロップダウン、ツールチップに同じシャドウを使うが、それぞれ異なるエレベーションレベルを持つべき。 - **過度に暗いシャドウ** — 複数レイヤーに低い不透明度を分散する代わりに、単一のシャドウに高い不透明度値(0.2-0.5)を設定する。 ## 使い分け - 物理的に存在感のあるカードや浮き上がった面 - 複数のUIレイヤーが重なるエレベーションシステム(カード、ドロップダウン、モーダル、ツールチップ) - 要素がページから浮き上がるように見せるホバー状態 - 人工的に重くならずに奥行き感を出したい要素 ## 参考リンク - [Designing Beautiful Shadows in CSS — Josh W. Comeau](https://www.joshwcomeau.com/css/designing-shadows/) - [Smoother & Sharper Shadows with Layered Box-Shadows — Tobias Ahlin](https://tobiasahlin.com/blog/layered-smooth-box-shadows/) - [box-shadow — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow) --- # clamp()を使った流体フォントサイズ > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/font-sizing/fluid-font-sizing ## 問題 レスポンシブタイポグラフィは、従来、異なるブレークポイントでフォントサイズを調整するために複数のメディアクエリを必要としていました。これはサイズ間の急激な変化を生み、冗長でメンテナンスしにくいCSSになります。AIエージェントは、流体スケーリングの代わりに、任意のブレークポイントで固定の`px`や`rem`値を生成しがちで、モバイルではテキストが小さすぎ、デスクトップでは大きすぎ、ブレークポイント間の遷移が不自然になります。 ## 解決方法 CSSの`clamp()`関数を使えば、1行でフォントサイズをビューポート幅に応じて最小値と最大値の間で滑らかにスケーリングできます。構文は`clamp(min, preferred, max)`で、preferred値には通常、`rem`ベースと`vw`コンポーネントを組み合わせてビューポート相対のスケーリングを実現します。 ### 計算式 `clamp()`のpreferred(中間)値は、ビューポート幅の一次関数として計算します。一般的な式は次のとおりです: ``` preferred = base-rem + (slope × 1vw) ``` slopeは、目標のフォントサイズ範囲とビューポート範囲から算出します: ``` slope = (max-size - min-size) / (max-viewport - min-viewport) ``` ## コード例 ### 基本的な流体タイポグラフィ ```css /* 本文テキスト: 320px〜1200pxのビューポートで16px〜20pxにスケール */ body { font-size: clamp(1rem, 0.909rem + 0.45vw, 1.25rem); } /* h1: 28px〜48pxにスケール */ h1 { font-size: clamp(1.75rem, 1.295rem + 2.27vw, 3rem); } /* h2: 24px〜36pxにスケール */ h2 { font-size: clamp(1.5rem, 1.227rem + 1.36vw, 2.25rem); } /* h3: 20px〜28pxにスケール */ h3 { font-size: clamp(1.25rem, 1.068rem + 0.91vw, 1.75rem); } ``` ### 流体タイプスケールシステム ```css :root { /* ビューポート範囲: 320px〜1200px */ --step--1: clamp(0.833rem, 0.787rem + 0.23vw, 1rem); --step-0: clamp(1rem, 0.909rem + 0.45vw, 1.25rem); --step-1: clamp(1.2rem, 1.042rem + 0.79vw, 1.563rem); --step-2: clamp(1.44rem, 1.186rem + 1.27vw, 1.953rem); --step-3: clamp(1.728rem, 1.339rem + 1.95vw, 2.441rem); --step-4: clamp(2.074rem, 1.494rem + 2.9vw, 3.052rem); } body { font-size: var(--step-0); } h1 { font-size: var(--step-4); } h2 { font-size: var(--step-3); } h3 { font-size: var(--step-2); } small { font-size: var(--step--1); } ``` Heading One Heading Two Heading Three Body text scales smoothly between minimum and maximum sizes as the viewport width changes. Try switching between Mobile, Tablet, and Full viewports to see the fluid scaling in action. `} css={`.fluid-demo { padding: 1.5rem; font-family: system-ui, sans-serif; } .fluid-demo h1 { font-size: clamp(1.75rem, 1.295rem + 2.27vw, 3rem); line-height: 1.2; margin: 0 0 0.5rem; color: #1a1a2e; } .fluid-demo h2 { font-size: clamp(1.5rem, 1.227rem + 1.36vw, 2.25rem); line-height: 1.25; margin: 0 0 0.5rem; color: #2d2d4e; } .fluid-demo h3 { font-size: clamp(1.25rem, 1.068rem + 0.91vw, 1.75rem); line-height: 1.3; margin: 0 0 0.5rem; color: #3d3d5c; } .fluid-demo p { font-size: clamp(1rem, 0.909rem + 0.45vw, 1.25rem); line-height: 1.6; color: #444; margin: 0; }`} /> ### コンテナクエリとの併用 ```css /* ビューポートではなくコンテナ幅に対する流体サイズ */ .card-title { font-size: clamp(1rem, 0.5rem + 3cqi, 1.5rem); } ``` ## AIがよくやるミス - `clamp()`の代わりにメディアクエリのブレークポイントで固定の`px`や`rem`値を使い、サイズの急激な変化を引き起こす - preferred値で`rem`コンポーネントを省略し(`vw`のみ使用)、ズームやアクセシビリティが壊れる - 最小値を小さくしすぎる(本文テキストで`1rem`/16px未満)と、モバイルでテキストが読めなくなる - 極端なビューポート幅で計算式をテストせず、ウルトラワイドモニターで文字が異常に大きくなる - 上限・下限のない`calc()`と`vw`だけの組み合わせ(例: `calc(1rem + 1vw)`)を使う — `clamp()`なら自然に上下限が設定される - すべてのテキスト要素に流体サイズを適用する — 本文テキストは固定の`1rem`で十分な場合が多い。流体サイズが効果的なのは見出しやディスプレイテキストのみ ## 使い分け - モバイルとデスクトップ間でスケールが必要な見出しやディスプレイテキスト - すべてのステップが流体的に調整されるべきタイプスケールシステム - 小さな画面で縮小が必要な大きなテキストのヒーローセクション - ブレークポイントベースのフォントサイズ変更で目に見える段差が生じるあらゆるケース `clamp()`の使用を避けるべき場面: - すべてのサイズで`1rem`で十分な本文テキスト - 固定寸法のコンポーネント内のテキスト - 特定のブレークポイントでの精密な制御が必要な場面 ## 参考リンク - [MDN: clamp()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/clamp) - [Modern Fluid Typography Using CSS Clamp — Smashing Magazine](https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/) - [Linearly Scale font-size with CSS clamp() — CSS-Tricks](https://css-tricks.com/linearly-scale-font-size-with-css-clamp-based-on-the-viewport/) - [Fluid Type Scale Calculator](https://www.fluid-type-scale.com/) - [Utopia — Fluid Responsive Design](https://utopia.fyi/) --- # フォント読み込み戦略 > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/fonts/font-loading-strategies ## 問題 Webフォントは、レイアウトシフト(CLS)やパフォーマンス低下の主要な原因です。ブラウザがカスタムフォントをダウンロードする際、待機中に何を表示するかを決める必要があります — 不可視テキスト(FOIT: Flash of Invisible Text)か、フォールバックスタイルのテキスト(FOUT: Flash of Unstyled Text)です。AIエージェントは通常、`@font-face`宣言やGoogle Fontsの``タグを追加するだけで読み込み動作を無視します。その結果、フォント読み込み時に目に見えるレイアウトシフトが発生したり、読み込み中にテキストが空白になったり、遅延可能なフォントに対して不要なネットワークリクエストが発生したりします。 ## 解決方法 堅牢なフォント読み込み戦略は、いくつかの技術を組み合わせます:レンダリング動作を制御する`font-display`ディスクリプタ、重要なフォントの``、フォールバックとしてのシステムフォントスタック、レイアウトシフトを最小化するメトリックオーバーライドです。 ## コード例 ### font-displayの値 ```css @font-face { font-family: "MyFont"; src: url("/fonts/myfont.woff2") format("woff2"); /* swap: フォールバックを即座に表示し、フォント読み込み後にスワップ。 コンテンツを読めるようにすべき本文テキストに最適。 */ font-display: swap; } @font-face { font-family: "HeadingFont"; src: url("/fonts/heading.woff2") format("woff2"); /* optional: フォントがすでにキャッシュされている場合のみ使用。 レイアウトの安定性が重要な非クリティカルテキストに最適。 */ font-display: optional; } ``` #### font-display値のまとめ | 値 | ブロック期間 | スワップ期間 | 最適な用途 | | ---------- | ---------------------- | ---------------------- | -------------------------------- | | `auto` | ブラウザのデフォルト | ブラウザのデフォルト | ほとんどの場合、適切ではない | | `block` | 短い(3秒) | 無制限 | アイコンフォントのみ | | `swap` | 極めて短い | 無制限 | 本文テキスト、コンテンツフォント | | `fallback` | 非常に短い(100ms) | 短い(3秒) | FOUTとCLSのバランス | | `optional` | なし | なし | 非クリティカルフォント、CLS最大制御 | ### 重要なフォントのプリロード ```html ``` `crossorigin`属性は同一オリジンのフォントでも必須です — これがないとフォントが2回フェッチされます。 system-ui (Default System Font) The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. 0123456789 ui-monospace (System Monospace) The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. 0123456789 Georgia (Serif Fallback) The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. 0123456789 `} css={`.font-demo { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; font-family: system-ui, sans-serif; } .font-card { background: #f8f9fa; border-radius: 8px; padding: 1rem; border-left: 4px solid #6c63ff; } .font-card h3 { font-size: 0.8rem; color: #6c63ff; margin: 0 0 0.5rem; font-weight: 600; } .font-card p { font-size: 1.1rem; line-height: 1.5; color: #333; margin: 0; }`} height={280} /> ### フォールバックとしてのシステムフォントスタック ```css :root { --font-system: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace; } body { font-family: "MyFont", var(--font-system); } code { font-family: var(--font-mono); } ``` ### メトリックオーバーライドによるレイアウトシフトの軽減 ```css @font-face { font-family: "MyFont"; src: url("/fonts/myfont.woff2") format("woff2"); font-display: swap; } /* フォールバックフォントのメトリックをWebフォントに合わせて調整 */ @font-face { font-family: "MyFont Fallback"; src: local("Arial"); size-adjust: 104.7%; ascent-override: 93%; descent-override: 25%; line-gap-override: 0%; } body { font-family: "MyFont", "MyFont Fallback", sans-serif; } ``` ### 完全な戦略:最適なパフォーマンス ```html @font-face { font-family: "Body"; src: url("/fonts/body-regular.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; } @font-face { font-family: "Body"; src: url("/fonts/body-bold.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: swap; } @font-face { font-family: "Heading"; src: url("/fonts/heading.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: optional; } ``` ### フォントサブセッティング ```css /* ラテン文字サブセットのみ — ファイルサイズが大幅に削減される */ @font-face { font-family: "MyFont"; src: url("/fonts/myfont-latin.woff2") format("woff2"); font-display: swap; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } ``` ## AIがよくやるミス - `font-display`をまったく指定せず、ブラウザのデフォルト動作(ほとんどのブラウザで`auto`、FOITを引き起こす)に任せてしまう - すべてのフォントウェイトとスタイルをプリロードし、ネットワークを圧迫してページの読み込みを遅くする - ``に`crossorigin`属性を付け忘れ、フォントが2回ダウンロードされる - `woff2`ではなく`woff`のみを使用する — `woff2`は15〜30%圧縮率が高く、すべてのモダンブラウザでサポートされている - Google Fontsを`display=swap`パラメータなしの``で読み込む(例: `fonts.googleapis.com/css2?family=Roboto&display=swap`) - システムフォントのフォールバックスタックを提供せず、`sans-serif`だけをフォールバックにする - デザインで使用しないウェイトのフォントまで読み込む(例: レギュラーとボールドしか使わないのに6ウェイト読み込む) - 本文テキストに`font-display: block`を使い、低速回線で最大3秒間テキストが不可視になる ## 使い分け ### font-display: swap - 本文テキストや主要な読み物コンテンツ - すぐに読める状態であるべきテキスト ### font-display: optional - レイアウトの安定性が重要な見出しやディスプレイフォント - 装飾目的のフォント - フォントがキャッシュされている可能性が高い再訪問時 ### プリロード - 最も重要なフォントファイル1つ(通常は本文のレギュラーウェイト) - ランディングページのファーストビュー見出しフォント - 1〜2ファイルを超えてはいけません ### システムフォントスタック - パフォーマンスが最優先の場合 - 社内ツールや管理画面 - カスタムWebフォントのフォールバックチェーン ## 参考リンク - [Best practices for fonts — web.dev](https://web.dev/articles/font-best-practices) - [The Best Font Loading Strategies — CSS-Tricks](https://css-tricks.com/the-best-font-loading-strategies-and-how-to-execute-them/) - [A Comprehensive Guide to Font Loading Strategies — Zach Leatherman](https://www.zachleat.com/web/comprehensive-webfonts) - [Ensure text remains visible during webfont load — Chrome Developers](https://developer.chrome.com/docs/lighthouse/performance/font-display) - [A New Way To Reduce Font Loading Impact — Smashing Magazine](https://www.smashingmagazine.com/2021/05/reduce-font-loading-impact-css-descriptors/) --- # テキストオーバーフローとラインクランプ > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/text-control/text-overflow-and-clamping ## 問題 カード、リスト項目、ナビゲーションなど、制約のあるUI領域にテキストを収めるために切り詰めるのはよくある要件です。AIエージェントはJavaScriptベースの解決策に頼ったり、1行の切り詰めしか処理できない不完全なCSSを生成したりしがちです。特に複数行のクランプは、正確なプロパティの組み合わせが必要で、間違えやすいものです。レガシーの`-webkit-line-clamp`アプローチには相互依存する3つの必須プロパティがあり、いずれか1つでも省略するとサイレントに失敗します。 ## 解決方法 CSSには2つの主要な切り詰めパターンがあります:`text-overflow`を使った1行の省略記号と、`-webkit-line-clamp`を使った複数行クランプ(標準の`line-clamp`プロパティもより広く採用されつつあります)です。 ## コード例 ### 1行の省略記号 ```css .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } ``` 3つのプロパティすべてが必須です: - `white-space: nowrap` は行の折り返しを防ぐ - `overflow: hidden` はあふれたコンテンツをクリップする - `text-overflow: ellipsis` は`...`インジケーターを表示する ```html This is a very long text that will be truncated with an ellipsis at the end ``` ### 複数行クランプ(レガシー構文) ```css .line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } ``` このパターンが動作するには4つのプロパティすべてが必須です。`-webkit-`プレフィックスが付いていますが、すべての主要ブラウザ(Chrome、Firefox、Safari、Edge)でサポートされており、完全に仕様化された動作です。 ```html Card Title Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. ``` Single-line Ellipsis This is a very long single line of text that will be truncated with an ellipsis at the end when it overflows the container width 2-line Clamp Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 3-line Clamp Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. `} css={`.overflow-demo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .demo-card { background: #f0f4ff; border-radius: 8px; padding: 1rem; } .demo-card h3 { font-size: 0.85rem; color: #4f46e5; margin: 0 0 0.75rem; font-weight: 600; } .demo-card p { font-size: 0.9rem; color: #333; margin: 0; } .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }`} height={260} /> ### モダンな`line-clamp`プロパティ 標準の`line-clamp`プロパティは構文を簡素化します。2025年時点では、Chromiumベースのブラウザがこれをサポートしています。 ```css .line-clamp-modern { line-clamp: 3; overflow: hidden; } ``` ### クロスブラウザ対応パターン 最大限の互換性のために、両方のアプローチを組み合わせましょう: ```css .line-clamp { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; line-clamp: 3; } ``` ### 実用的なカードコンポーネント ```css .card { max-width: 320px; padding: 1rem; } .card__title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .card__description { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } ``` ```html A very long card title that might overflow Card description text that can span multiple lines but will be clamped to exactly three lines with an ellipsis at the end of the third line. ``` ### 展開可能なクランプテキスト ```css .expandable { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .expandable.is-expanded { -webkit-line-clamp: unset; } ``` ### クランプテキストのアクセシビリティ対応 ```html Truncated visible content... ``` ## AIがよくやるミス - 1行切り詰めに必要な3つのプロパティのうち1つを忘れる — `white-space`、`overflow`、`text-overflow`のすべてを設定する必要がある - 複数行クランプで`display: -webkit-box`や`-webkit-box-orient: vertical`を忘れ、クランプがサイレントに失敗する - CSSの代わりにJavaScriptで文字数によるテキスト切り詰めを行う。これはフォントサイズや画面幅が異なると正しく動作しない - コンテナに幅の制約を設定しない — `text-overflow: ellipsis`は要素に制限された幅(明示的またはflex/gridの親からの暗黙的な幅)が必要 - `text-overflow: ellipsis`なしで`overflow: hidden`を使い、視覚的なインジケーターなしにテキストが文字の途中でクリップされる - `-webkit-line-clamp`をインライン要素に適用する — `-webkit-box`ディスプレイモデルのブロックレベルボックスが必要 - クランプされたテキストがスクリーンリーダーからコンテンツを隠すことを考慮しない — フルテキストはDOMに残っているが、視覚のみのユーザーはどれだけ隠されているかのコンテキストを失う ## 使い分け ### 1行の切り詰め - 動的なラベルを持つナビゲーション項目 - 可変幅のコンテンツを含むテーブルセル - 幅が制約されたタグやバッジ - パンくずリンク ### 複数行クランプ - グリッドレイアウトのカード説明文 - ソーシャルインターフェースのコメントプレビュー - 一覧ページの商品説明 - 記事の抜粋やティーザー ### 切り詰めるべきでない場合 - ユーザーが全文を読む必要がある主要コンテンツ - エラーメッセージやバリデーションテキスト - アクセシビリティ上重要なラベルや説明 - 切り詰めた部分によって意味が変わるコンテンツ(例: 価格、日付) ## Tailwind CSS Tailwindは1行の省略記号に`truncate`、複数行クランプに`line-clamp-*`ユーティリティを提供しています。 truncate This is a very long single line of text that will be truncated with an ellipsis at the end when it overflows line-clamp-2 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam. line-clamp-3 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. `} height={200} /> ## 参考リンク - [MDN: text-overflow](https://developer.mozilla.org/en-US/docs/Web/CSS/text-overflow) - [MDN: -webkit-line-clamp](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-line-clamp) - [MDN: line-clamp](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/line-clamp) - [CSS-Tricks: line-clamp](https://css-tricks.com/almanac/properties/l/line-clamp/) - [How to use CSS line-clamp — LogRocket](https://blog.logrocket.com/css-line-clamp/) --- # タッチターゲットのサイズ設定 > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/forms-and-accessibility/touch-target-sizing ## 問題 小さなインタラクティブターゲットは、ウェブで最もよく見られるアクセシビリティの問題の1つです。運動障害のあるユーザー、高齢者、モバイルデバイスを使用するすべての人が、小さなボタンやリンクを正確にタップすることに苦労します。AIエージェントは、特にアイコンのみのコントロール(16-24pxしかない場合もあります)など、小さすぎるボタン、アイコンボタン、ナビゲーションリンクを日常的に生成します。WCAGは最小ターゲットサイズを要求しており、これを満たさないとフラストレーションの多いアクセス不能な体験を生み出します。 ## 解決方法 すべてのインタラクティブ要素は、最小タッチターゲットサイズとして **44x44 CSSピクセル**(WCAG 2.1 Level AAA / ベストプラクティス)、または最低でも **24x24 CSSピクセル**(WCAG 2.2 Level AA)を持つ必要があります。ターゲット領域には要素のコンテンツ、パディング、追加のクリック可能なスペースが含まれるため、パディングが要件を満たすための主要なツールになります。 ### WCAGターゲットサイズ要件 - **Level AAA(2.5.5)**:すべてのインタラクティブターゲットで少なくとも **44x44px**。これが推奨される基準です。 - **Level AA(2.5.8)**:少なくとも **24x24px**、隣接するターゲット間に少なくとも **24px** のスペース。 ✓ 44px targets (accessible) Save Edit ✕ min-height: 44px · min-width: 44px ✗ 24px targets (too small) Save Edit ✕ height: 24px — hard to tap on mobile `} css={` .demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 1.5rem; } .section { text-align: center; } .section-title { font-size: 0.8125rem; font-weight: 700; margin: 0 0 1rem; padding: 0.375rem 0.75rem; border-radius: 0.375rem; display: inline-block; } .good-title { background: #f0fdf4; color: #166534; } .bad-title { background: #fef2f2; color: #991b1b; } .btn-row { display: flex; gap: 0.5rem; justify-content: center; align-items: center; flex-wrap: wrap; } .btn { border: none; border-radius: 0.375rem; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } .btn-good { min-height: 44px; min-width: 44px; padding: 0.5rem 1rem; background: #3b82f6; color: white; font-size: 0.875rem; } .icon-btn-good { padding: 0.5rem; font-size: 1rem; } .btn-bad { height: 24px; min-width: 24px; padding: 0 0.5rem; background: #94a3b8; color: white; font-size: 0.6875rem; } .icon-btn-bad { padding: 0 0.375rem; font-size: 0.625rem; } .size-label { font-size: 0.6875rem; color: #94a3b8; margin: 0.75rem 0 0; } `} /> ## コード例 ### 最小ボタンサイズ ```css .button { min-height: 44px; min-width: 44px; padding: 0.625rem 1.25rem; display: inline-flex; align-items: center; justify-content: center; } ``` ### 十分なターゲットを持つアイコンボタン アイコンボタンは見た目のアイコンは小さいですが、大きなクリック可能領域を維持する必要があります: ```css .icon-button { /* Visual size is small, but clickable area is 44x44 */ min-height: 44px; min-width: 44px; padding: 0.625rem; display: inline-flex; align-items: center; justify-content: center; background: none; border: none; cursor: pointer; border-radius: 0.375rem; } .icon-button svg { width: 1.25rem; height: 1.25rem; } ``` ### 擬似要素によるクリック可能領域の拡張 要素の見た目のサイズを大きくできない場合、クリックターゲットを不可視に拡張します: ```css .compact-link { position: relative; /* Visual styling stays compact */ font-size: 0.875rem; padding: 0.25rem; } .compact-link::after { content: ""; position: absolute; inset: -0.5rem; /* Expand touch area by 8px in each direction */ /* Ensures minimum 44x44 clickable area */ } ``` ### ナビゲーションリンク ```css .nav-link { display: flex; align-items: center; min-height: 44px; padding: 0.5rem 1rem; text-decoration: none; color: var(--color-text); } /* On touch devices, ensure even more generous sizing */ @media (pointer: coarse) { .nav-link { min-height: 48px; padding: 0.75rem 1rem; } } ``` ### チェックボックスとラジオボタンのターゲット ネイティブのチェックボックスとラジオボタンは非常に小さいことで知られています。ラベルとパディングを使いましょう: ```css .form-check { display: flex; align-items: center; gap: 0.5rem; min-height: 44px; padding-block: 0.5rem; cursor: pointer; } .form-check input[type="checkbox"], .form-check input[type="radio"] { width: 1.25rem; height: 1.25rem; margin: 0; cursor: pointer; } ``` input を `` で囲むことで、ラベルテキスト全体がクリック可能になり、実効タッチターゲットが大幅に拡大します。 ### 狭いスペースでの閉じるボタン ```css .close-button { /* Visually compact but tap-friendly */ display: flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; min-width: 44px; min-height: 44px; padding: 0; background: none; border: none; cursor: pointer; /* Negative margin to visually align while maintaining target */ margin: -0.375rem; } ``` ### ターゲット間のスペーシング 隣接するターゲットには、誤タップを防ぐために十分なスペースが必要です: ```css .button-group { display: flex; gap: 0.5rem; /* At least 8px between targets */ } .button-group .button { min-height: 44px; min-width: 44px; } /* Vertical list of tappable items */ .action-list { display: flex; flex-direction: column; gap: 0.25rem; } .action-list__item { min-height: 44px; padding: 0.5rem 1rem; display: flex; align-items: center; } ``` ### pointer クエリによるレスポンシブターゲットサイジング ```css .interactive { min-height: 36px; padding: 0.5rem 1rem; } /* Fine pointer (mouse): slightly smaller targets acceptable */ @media (pointer: fine) { .interactive { min-height: 32px; padding: 0.375rem 0.75rem; } } /* Coarse pointer (touch): larger targets needed */ @media (pointer: coarse) { .interactive { min-height: 48px; padding: 0.75rem 1rem; } } ``` ## AIがよくやるミス - **アイコンボタンを小さくしすぎる**:44px最小値に達するためのパディングなしで、24pxや32pxのアイコンボタンを生成します。 - **見た目のサイズのみに頼る**:要素の視覚的サイズがタッチターゲットサイズと等しいと思い込みます。パディングや擬似要素もターゲット領域にカウントされます。 - **インラインリンクを無視する**:段落内のリンクは非常に小さなタッチターゲットになりえます。`padding-block` を追加するか `line-height` を増やすことで改善できます。 - **実際のタッチデバイスでテストしない**:マウスを使ったデスクトップでは問題なく見えるターゲットサイズが、スマートフォンではタップ不可能になります。 - **ターゲットを密集させる**:複数の小さなボタンやリンクを十分なスペースなしに隣接させ、誤タップを引き起こします。 - **`min-width`/`min-height` ではなく `width`/`height` のみを使用する**:固定サイズでは、テキストが折り返したりコンテンツが予想より長い場合に要素が拡大できません。 - **`@media (pointer: coarse)` を忘れる**:精度が低いタッチデバイスでターゲットサイズを増やしていません。 ## 使い分け - **すべてのインタラクティブ要素**:ボタン、リンク、フォームコントロール、アイコンボタン — すべて最小ターゲットサイズを満たす必要があります。 - **モバイルファーストデザイン**:タッチターゲットはデフォルトで少なくとも44x44pxにし、マウスのみのコンテキストではわずかに縮小するオプションを用意します。 - **アイコンのみのコントロール**:閉じるボタン、メニュートグル、アクションアイコン — これらが最もサイズ不足になりやすい要素です。 - **ナビゲーションアイテム**:水平・垂直両方のナビゲーションリンクに適用します。 - **フォームコントロール**:チェックボックス、ラジオボタン、セレクト、およびそのラベルに適用します。 ## 参考リンク - [Understanding SC 2.5.5: Target Size (Enhanced) — W3C](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html) - [Understanding SC 2.5.8: Target Size (Minimum) — W3C](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) - [Accessible Target Sizes Cheatsheet — Smashing Magazine](https://www.smashingmagazine.com/2023/04/accessible-tap-target-sizes-rage-taps-clicks/) - [Foundations: Target Sizes — TetraLogical](https://tetralogical.com/blog/2022/12/20/foundations-target-size/) - [All Accessible Touch Target Sizes — LogRocket](https://blog.logrocket.com/ux-design/all-accessible-touch-target-sizes/) --- # スクロール駆動アニメーション > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/scroll/scroll-driven-animations ## 問題 プログレスバー、パララックス背景、要素のリビールアニメーションなどのスクロール連動エフェクトは、従来JavaScriptの `scroll` イベントリスナーやIntersection Observerのコールバックを必要としていました。これらのアプローチはメインスレッドで実行され、負荷がかかるとカクつきの原因になり、複雑さも増します。AIエージェントはCSSネイティブのScroll-Driven Animations APIをほとんど提案しませんが、このAPIはJavaScriptゼロでスクロール位置に連動したパフォーマンスの高いコンポジタースレッドアニメーションを提供します。 ## 解決方法 CSS Scroll-Driven Animations を使用すると、`@keyframes` アニメーションを時間ではなくスクロールの進行状況に接続できます。2種類のタイムラインが利用可能です: - **`scroll()`** — コンテナのスクロール位置を追跡します(0% = 先頭、100% = 完全にスクロール済み)。グローバルなプログレスバーやパララックスに使います。 - **`view()`** — 要素がビューポートに入ってから出るまでの可視性を追跡します。リビールアニメーションや要素レベルのエフェクトに使います。 どちらのタイムラインも標準の `@keyframes` 構文を使用し、`transform` と `opacity` をアニメーションする場合はコンポジタースレッドで実行されるため、スムーズな60fpsのパフォーマンスが保証されます。 Scroll down Watch elements fade and slide in as they enter the viewport ↓ Card One Fades in on scroll using view() timeline Card Two Each card animates independently Card Three Runs on the compositor thread — smooth 60fps Card Four No JavaScript needed ↑ Scroll back up to replay `} css={` .scroll-container { height: 100%; overflow-y: auto; padding: 1rem; } .intro { text-align: center; padding: 2rem 1rem 3rem; color: #475569; } .intro h2 { margin: 0 0 0.5rem; font-size: 1.25rem; color: #1e293b; } .intro p { margin: 0; font-size: 0.875rem; } .arrow { font-size: 1.5rem; margin-top: 1rem; animation: bounce 1.5s ease infinite; } @keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(8px); } } .reveal-card { color: white; padding: 1.5rem; border-radius: 0.75rem; margin-bottom: 1.5rem; display: flex; flex-direction: column; gap: 0.375rem; animation: card-reveal linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; } .reveal-card strong { font-size: 1.125rem; } .reveal-card span { font-size: 0.8125rem; opacity: 0.85; } @keyframes card-reveal { from { opacity: 0; transform: translateY(32px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } .spacer { padding: 3rem 1rem; text-align: center; color: #94a3b8; font-size: 0.875rem; } `} /> ## コード例 ### 読了プログレスバー ユーザーがページをスクロールするにつれて埋まるプログレスバーです: ```css .progress-bar { position: fixed; top: 0; left: 0; width: 100%; height: 3px; background: var(--color-primary, #2563eb); transform-origin: left; animation: scale-progress linear; animation-timeline: scroll(); } @keyframes scale-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } } ``` ```html ``` ### スクロール時のフェードイン(View Timeline) 要素がビューポートに入るとフェードインします: ```css .reveal { animation: fade-in linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; } @keyframes fade-in { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: translateY(0); } } ``` `animation-range: entry 0% entry 100%` は、要素がビューポートに入り始めてから完全に内側に入るまでアニメーションが再生されることを意味します。 ### パララックス背景 ```css .hero { position: relative; height: 80vh; overflow: hidden; } .hero__background { position: absolute; inset: -20% 0; background-image: url("hero.jpg"); background-size: cover; background-position: center; animation: parallax linear; animation-timeline: scroll(); } @keyframes parallax { from { transform: translateY(-10%); } to { transform: translateY(10%); } } ``` 背景がスクロールよりも遅く移動することで、パララックスの奥行き効果を生み出します。完全にJavaScript不要です。 ### スティッキーヘッダーのスクロール時縮小 ```css .header { position: sticky; top: 0; animation: header-shrink linear both; animation-timeline: scroll(); animation-range: 0 200px; } @keyframes header-shrink { from { padding-block: 1.5rem; font-size: 1.5rem; } to { padding-block: 0.5rem; font-size: 1rem; } } ``` `animation-range: 0 200px` はアニメーションを最初の200pxのスクロールに制限するため、ヘッダーは素早く縮小してから縮小された状態を維持します。 ### View Timeline を使用したスタガード付きリビールアニメーション ```css .card { animation: card-reveal linear both; animation-timeline: view(); animation-range: entry 10% entry 90%; } @keyframes card-reveal { from { opacity: 0; transform: translateY(32px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } ``` 各カードがビューポートに入ると、他のカードとは独立してリビールアニメーションがトリガーされます。 ### 特定コンテナの水平スクロールプログレス ```css .scrollable-section { overflow-y: auto; max-height: 60vh; scroll-timeline-name: --section-scroll; scroll-timeline-axis: block; } .section-progress { height: 3px; background: var(--color-primary, #2563eb); transform-origin: left; animation: scale-progress linear; animation-timeline: --section-scroll; } @keyframes scale-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } } ``` 名前付きスクロールタイムライン(`scroll-timeline-name`)を使用すると、ドキュメントルートだけでなく、特定のスクロールコンテナにアニメーションをリンクできます。 ### View Timeline のレンジの解説 `animation-range` プロパティは、View Timeline の名前付きレンジを受け取ります: ```css /* Full visibility lifecycle */ .element { animation-timeline: view(); } /* entry: element enters the viewport */ .entry-anim { animation-range: entry 0% entry 100%; } /* exit: element leaves the viewport */ .exit-anim { animation-range: exit 0% exit 100%; } /* contain: element is fully contained in viewport */ .contain-anim { animation-range: contain 0% contain 100%; } /* cover: from first pixel entering to last pixel leaving */ .cover-anim { animation-range: cover 0% cover 100%; } ``` ## AIがよくやるミス - **スクロール連動アニメーションにJavaScriptを使用する**:CSS `animation-timeline` がネイティブで処理できるユースケースに `addEventListener('scroll')` やIntersection Observerを使おうとします。 - **このAPIの存在を知らない**:最もよくあるミスです。AIエージェントはCSSだけで処理できるプログレスバーやリビールアニメーションにJavaScriptを生成します。 - **`animation-range` を忘れる**:`animation-range` がないと、View Timeline アニメーションは可視性のライフサイクル全体にまたがります。ほとんどのユースケースでは `entry` や `contain` のような特定のレンジが必要です。 - **高コストなプロパティをアニメーションする**:`height` や `margin` のようなレイアウトをトリガーするプロパティにScroll-Driven Animationsを使用します。コンポジタースレッドのパフォーマンスのために `transform` と `opacity` に限定しましょう。 - **フォールバックを提供しない**:Scroll-Driven Animationsはすべてのブラウザでサポートされているわけではありません(Firefoxは限定的なサポート)。アニメーションなしでもコンテンツが表示され使用可能であることを常に確認しましょう。 - **`view()` が適切な場面で `scroll()` を使用する**:`scroll()` はスクロールコンテナの位置をグローバルに追跡します。`view()` は個々の要素の可視性を追跡します。要素のリビールには `scroll()` ではなく `view()` が必要です。 ## 使い分け - **読了プログレスバー**:ドキュメントスクロールを追跡する `scroll()` タイムラインに使います。 - **要素のリビールアニメーション**:要素がビューポートに入る際のフェードイン、スライドアップ、スケールアニメーションをトリガーする `view()` タイムラインに使います。 - **パララックスエフェクト**:背景レイヤーを異なる速度で移動させる `scroll()` タイムラインに使います。 - **ヘッダーの変形**:スクロール深度に基づいてスティッキーヘッダーを縮小・リスタイリングする場合に使います。 - **複雑なインタラクションロジックには不向き**:Scroll-Driven Animationsは宣言的で、スクロール位置に応答します。「一度だけアニメーション」や「ディレイ後にトリガー」のようなロジックには、JavaScriptやIntersection Observerが必要な場合があります。 - **常にプログレッシブエンハンスメントとして**:アニメーションなしでもコンテンツが機能することを確認しましょう。必要に応じて `@supports (animation-timeline: scroll())` でフィーチャーディテクションを使用できます。 ## 参考リンク - [CSS Scroll-Driven Animations — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations) - [Scroll-Driven Animation Timelines — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations/Timelines) - [animation-timeline — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation-timeline) - [Animate Elements on Scroll — Chrome for Developers](https://developer.chrome.com/docs/css-ui/scroll-driven-animations) - [Scroll-Driven Animations Guide — design.dev](https://design.dev/guides/scroll-timeline/) - [Introduction to CSS Scroll-Driven Animations — Smashing Magazine](https://www.smashingmagazine.com/2024/12/introduction-css-scroll-driven-animations/) --- # :is()と:where()セレクター > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/selectors/is-where-selectors ## 問題 同じ宣言を共有するCSSセレクターは、すべての組み合わせを個別に書く必要があり、冗長で繰り返しの多いルールセットになります。異なる詳細度レベルのセレクターリストは、簡単にオーバーライドできるベーススタイルの作成を困難にします。リセット、デフォルト、ライブラリCSSを構築する際、コンシューマーのスタイルとの詳細度の競合は常に課題です。AIエージェントは通常、繰り返しの多いセレクターを生成し、詳細度を意図的に管理するために`:is()`や`:where()`を使うことはほとんどありません。 ## 解決方法 `:is()`と`:where()`は、セレクターリストを受け取り、そのリストの少なくとも1つのセレクターに一致する要素をマッチさせる関数擬似クラスセレクターです。セレクターをグループ化することで繰り返しを減らします。重要な違いは詳細度です: - **`:is()`** は最も具体的な引数の詳細度を取る - **`:where()`** は常にゼロの詳細度`(0, 0, 0)`を持つ これにより、`:where()`は簡単にオーバーライドできるべきデフォルト/ベーススタイルに最適であり、`:is()`は詳細度を保持しながらセレクターをグループ化するのに適しています。 ## コード例 ### `:is()`によるセレクターの繰り返し削減 ```css /* Without :is() — verbose and repetitive */ article h1, article h2, article h3, section h1, section h2, section h3, aside h1, aside h2, aside h3 { line-height: 1.2; } /* With :is() — concise */ :is(article, section, aside) :is(h1, h2, h3) { line-height: 1.2; } ``` ### `:where()`によるゼロ詳細度のデフォルト どんな単一クラスでも簡単にオーバーライドできるベーススタイルを作成します。 ```css /* Base styles with zero specificity — trivially overridable */ :where(h1, h2, h3, h4, h5, h6) { margin-block: 0; font-weight: 700; } :where(ul, ol) { padding-left: 1.5rem; } :where(a) { color: #2563eb; text-decoration: underline; } /* Any class override wins without specificity battles */ .nav-link { color: inherit; text-decoration: none; } ``` ### オーバーライド可能なリセットの構築 ```css /* A reset that never fights with author styles */ :where(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; } :where(html) { line-height: 1.5; -webkit-text-size-adjust: 100%; } :where(img, picture, video, canvas, svg) { display: block; max-width: 100%; } :where(input, button, textarea, select) { font: inherit; } ``` ### `:is()`と`:where()`の戦略的な組み合わせ 詳細度を寄与させたい部分には`:is()`を、ゼロにしたい部分には`:where()`を使用します。 ```css /* The .article class contributes specificity (0,1,0), but the element selectors inside :where() add nothing */ .article :where(p, li, blockquote) { line-height: 1.8; max-width: 65ch; } /* Override with just a class — no specificity fight */ .compact-text { line-height: 1.4; } ``` ### 寛容なセレクターリスト `:is()`と`:where()`の両方が寛容なセレクターリストを使用します。リスト内の無効なセレクターがルール全体を無効にすることはありません。 ```css /* If :未来的-selector is invalid, the rest still works */ :is(.card, .panel, :未来的-selector) { border-radius: 8px; } /* Without :is(), one invalid selector breaks the entire rule */ /* .card, .panel, :未来的-selector { border-radius: 8px; } — entire rule is dropped */ ``` ### ネストされたセレクターの簡略化 ```css /* Complex nesting without :is() */ .sidebar nav ul li a, .sidebar nav ol li a { color: #374151; text-decoration: none; } /* Simplified with :is() */ .sidebar nav :is(ul, ol) li a { color: #374151; text-decoration: none; } ``` ### 詳細度の比較 ```css /* :is() specificity = highest argument */ :is(.class, #id) p { /* Specificity: (1, 0, 1) because #id is the highest */ color: blue; } /* :where() specificity = always zero */ :where(.class, #id) p { /* Specificity: (0, 0, 1) — only the p contributes */ color: blue; } /* A simple class wins over :where(#id) */ .text { /* Specificity: (0, 1, 0) — wins over :where(#id) p's (0, 0, 1) */ color: red; } ``` ### ライブラリ/デザインシステムパターン ```css /* Design system default — zero specificity via :where() */ :where(.ds-button) { padding: 0.5rem 1rem; border: 1px solid #d1d5db; border-radius: 4px; background: white; cursor: pointer; } :where(.ds-button.primary) { background: #2563eb; color: white; border-color: #2563eb; } /* Consumer can override with just a class — no !important needed */ .my-button { background: #16a34a; border-color: #16a34a; } ``` ## ブラウザサポート - Chrome 88+ - Firefox 78+ - Safari 14+ - Edge 88+ `:is()`と`:where()`の両方がグローバルサポート96%を超えています。プロダクション利用に安全です。 ## AIがよくやるミス - `:where()`の存在を知らず、詳細度の競合を引き起こす冗長なセレクターリストを生成する - `:where()`の方が適切な場合に`:is()`を使用する(例:簡単にオーバーライドできるべきベース/リセットスタイル) - セレクターの全組み合わせを手動で書き出し、`:is()`でグループ化しない - ライブラリ/デザインシステムCSSでコンシューマーとの詳細度競合を避けるために`:where()`を活用しない - 詳細度の動作を混同する — `:is()`が`:where()`のようにゼロ詳細度だと思い込む - `:is()`と`:where()`が寛容なセレクターリストを使用すること(無効なセレクターがルールを壊さない)を認識していない - `:where()`なら簡単にオーバーライドできたはずのベーススタイルのオーバーライドに`!important`を使用する ## 使い分け - **`:is()`**:詳細度を保持しながら繰り返しを減らすためにセレクターをグループ化する - **`:where()`**:ゼロ詳細度のベーススタイル、リセット、デザインシステムのデフォルトを作成する - **両方**:未知または将来のセレクターを適切に処理する寛容なセレクターリストを構築する - **ライブラリCSSには`:where()`**:コンシューマーのスタイルが常に`!important`なしでオーバーライドできることを保証する - **組み合わせ**:セレクターの詳細度を寄与させる部分に`:is()`を、ゼロ詳細度の部分に`:where()`を使用する ## ライブプレビュー Article Heading (h1) Sub-heading (h2) Regular paragraph text. Section heading (h3) More paragraph text. Heading Outside Article This heading is not styled — :is(article, section) limits scope `} css={` * { margin: 0; } article, .outside { font-family: system-ui, sans-serif; padding: 1.25rem; border-radius: 12px; } article { background: #f0fdf4; border: 1px solid #86efac; margin-bottom: 1rem; } :is(article, section) :is(h1, h2, h3) { color: #166534; line-height: 1.2; margin-bottom: 0.5rem; padding-bottom: 0.25rem; border-bottom: 2px solid #bbf7d0; } article p, .outside p { color: #64748b; line-height: 1.6; margin-bottom: 0.75rem; font-size: 0.9rem; } .outside { background: #f8fafc; border: 1px dashed #d1d5db; } .outside h2 { color: #6b7280; font-size: 1.1rem; margin-bottom: 0.5rem; } `} /> Default link (styled by :where) Custom link (single class overrides :where) :where(a) has zero specificity (0,0,0), so even a single .my-link class (0,1,0) wins without any !important `} css={` :where(a) { color: #2563eb; text-decoration: underline; font-weight: 600; } .my-link { color: #dc2626; text-decoration: none; background: #fef2f2; padding: 0.25rem 0.75rem; border-radius: 6px; border: 1px solid #fca5a5; } .demo { font-family: system-ui, sans-serif; display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap; } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; margin-top: 1rem; } `} /> ## 参考リンク - [:is() - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:is) - [:where() - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:where) - [:is() pseudo-class - Can I Use](https://caniuse.com/css-matches-pseudo) - [:where() pseudo-class - Can I Use](https://caniuse.com/mdn-css_selectors_where) - [CSS :where() Selector: The Zero-Specificity Superpower - McNeece](https://www.mcneece.com/2025/03/css-where-selector-the-zero-specificity-superpower/) --- # トランジションのベストプラクティス > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/states-and-transitions/transition-best-practices ## 問題 CSSトランジションは仕上げを加え、ユーザーが状態変化を理解する助けになります。しかし、AIエージェントは一貫して同じミスを犯します:レイアウト再計算を引き起こす高コストなプロパティ(`width`、`height`、`margin` など)をトランジションしたり、汎用的な `transition: all 0.3s ease` を使用したり、`display` やその他の離散プロパティをトランジションするための `transition-behavior: allow-discrete` プロパティを考慮しなかったりします。その結果、カクつくアニメーション、パフォーマンスの低下、スムーズなUIトランジションの機会損失につながります。 ## 解決方法 可能な限り **低コストなプロパティ**(`transform`、`opacity`)のみをトランジションし、`all` ではなく具体的なプロパティリストを使用し、適切なイージングカーブを選択し、エントリー/エグジットアニメーションには `transition-behavior: allow-discrete` と `@starting-style` を活用しましょう。 ### パフォーマンスの階層 1. **低コスト(コンポジターのみ)**:`transform`、`opacity` — GPUコンポジタースレッドで実行され、レイアウトもペイントも発生しません。 2. **中程度(ペイントのみ)**:`background-color`、`color`、`box-shadow` — リペイントは発生しますがレイアウトは発生しません。 3. **高コスト(レイアウトトリガー)**:`width`、`height`、`margin`、`padding`、`top`、`left` — 完全なレイアウト再計算が発生します。 ✓ transform + opacity Compositor-only (smooth) Hover me ✗ width + box-shadow Layout-triggering (janky) Hover me `} css={` .demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 1.5rem; } .demo-col { text-align: center; } .label { font-size: 0.75rem; color: #94a3b8; margin: 0.75rem 0 0; font-style: italic; } .card { padding: 1.5rem 1rem; border-radius: 0.5rem; text-align: center; font-size: 0.8125rem; display: flex; flex-direction: column; align-items: center; gap: 0.375rem; } .card-icon { font-size: 1.5rem; margin-bottom: 0.25rem; } .card-sub { font-size: 0.6875rem; opacity: 0.7; } .card-good { background: #f0fdf4; border: 2px solid #22c55e; color: #166534; transition: transform 0.25s ease, opacity 0.25s ease; } .card-good:hover { transform: translateY(-6px) scale(1.02); opacity: 0.9; } .card-bad { background: #fef2f2; border: 2px solid #ef4444; color: #991b1b; transition: box-shadow 0.6s ease, padding 0.6s ease; } .card-bad:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.3); padding: 2rem 1.5rem; } `} /> ## コード例 ### 適切なプロパティをトランジションする ```css /* Good: transform and opacity are cheap */ .card { transition: transform 0.2s ease, opacity 0.2s ease; } @media (hover: hover) { .card:hover { transform: translateY(-4px); opacity: 0.95; } } /* Bad: animating width triggers layout recalculation */ .card-bad { transition: width 0.3s ease, height 0.3s ease; } ``` 高コストなプロパティのトランジションは `transform` の等価物に置き換えましょう: ```css /* Instead of transitioning width */ .expandable { transform: scaleX(0); transform-origin: left; transition: transform 0.3s ease; } .expandable.open { transform: scaleX(1); } /* Instead of transitioning top/left */ .slide-in { transform: translateX(-100%); transition: transform 0.3s ease; } .slide-in.visible { transform: translateX(0); } ``` ### 具体的に指定する — transition: all は避ける ```css /* Bad: transitions every property change, including unintended ones */ .element { transition: all 0.3s ease; } /* Good: only transition what you intend */ .element { transition: background-color 0.15s ease, transform 0.2s ease; } ``` ### イージング関数の選び方 ```css /* Default ease — good general purpose */ .fade { transition: opacity 0.2s ease; } /* ease-out — element arriving (enters fast, decelerates) */ .slide-enter { transition: transform 0.3s ease-out; } /* ease-in — element leaving (starts slow, accelerates) */ .slide-exit { transition: transform 0.3s ease-in; } /* ease-in-out — continuous motion (both ends decelerate) */ .move { transition: transform 0.4s ease-in-out; } /* Custom cubic-bezier for a snappy, natural feel */ .bounce { transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } /* Custom ease-out for UI interactions */ .interact { transition: transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1); } ``` ### デュレーションのガイドライン ```css /* Micro-interactions: 100-200ms */ .button { transition: background-color 0.15s ease; } /* State changes: 200-300ms */ .panel { transition: transform 0.25s ease-out; } /* Complex or large animations: 300-500ms */ .modal-backdrop { transition: opacity 0.35s ease; } ``` ### transition-behavior: allow-discrete で display をトランジションする 従来、`display: none` は離散プロパティであるためトランジションできませんでした。`transition-behavior: allow-discrete` プロパティと `@starting-style` を組み合わせることで、これが可能になります。 ```css .tooltip { /* Final visible state */ opacity: 1; transform: translateY(0); display: block; /* Transition including discrete display change */ transition: opacity 0.2s ease, transform 0.2s ease, display 0.2s allow-discrete; /* Starting state for entry animation */ @starting-style { opacity: 0; transform: translateY(-4px); } } .tooltip[hidden] { /* Exit state */ opacity: 0; transform: translateY(-4px); display: none; } ``` ### Popover のエントリー/エグジットアニメーション ```css [popover] { /* Final open state */ opacity: 1; transform: translateY(0) scale(1); transition: opacity 0.25s ease, transform 0.25s ease, overlay 0.25s allow-discrete, display 0.25s allow-discrete; /* Entry animation starting state */ @starting-style { opacity: 0; transform: translateY(8px) scale(0.96); } } /* Exit state */ [popover]:not(:popover-open) { opacity: 0; transform: translateY(8px) scale(0.96); } ``` ### Dialog とバックドロップのトランジション ```css dialog { opacity: 1; transform: translateY(0); transition: opacity 0.3s ease, transform 0.3s ease, overlay 0.3s allow-discrete, display 0.3s allow-discrete; @starting-style { opacity: 0; transform: translateY(16px); } } dialog:not([open]) { opacity: 0; transform: translateY(16px); } dialog::backdrop { background: rgb(0 0 0 / 0.5); opacity: 1; transition: opacity 0.3s ease, display 0.3s allow-discrete; @starting-style { opacity: 0; } } ``` ### スタガードトランジション ```css .list-item { opacity: 0; transform: translateY(8px); transition: opacity 0.3s ease, transform 0.3s ease; } .list-item.visible { opacity: 1; transform: translateY(0); } .list-item:nth-child(1) { transition-delay: 0ms; } .list-item:nth-child(2) { transition-delay: 50ms; } .list-item:nth-child(3) { transition-delay: 100ms; } .list-item:nth-child(4) { transition-delay: 150ms; } ``` ## AIがよくやるミス - **`transition: all` を使用する**:レイアウトをトリガーするプロパティを含むすべてのプロパティをトランジションしてしまい、パフォーマンス問題と意図しない視覚的変化を引き起こします。 - **高コストなプロパティをアニメーションする**:`transform`(translate、scale)を使用する代わりに `width`、`height`、`margin`、`top`、`left` をトランジションしてしまいます。 - **すべてに `linear` や `ease` を使用する**:インタラクションの種類にイージングを合わせていません。入場する要素には `ease-out` を、退場する要素には `ease-in` を使いましょう。 - **長すぎるデュレーション**:単純な状態変化に `0.5s` 以上を使用しています。マイクロインタラクションは `100-200ms` にすべきです。 - **`transition-behavior: allow-discrete` を知らない**:`display` を `allow-discrete` と `@starting-style` でトランジションする代わりに、JavaScript でディレイ付きのクラストグルを使用してしまいます。 - **`@starting-style` を忘れる**:`transition-behavior: allow-discrete` を使用しながら `@starting-style` を省略するため、エントリーアニメーションにトランジション元となる開始状態がありません。 - **ページロード時にトランジションが発火する**:トランジションのスコープを適切に設定せず、ページの初回レンダリング時に発火してしまい、気が散るアニメーションを引き起こします。 ## 使い分け - **状態変化**:インタラクティブ要素のホバー、フォーカス、アクティブ、開閉状態に使います。 - **`transform` と `opacity`**:モーションには常にこれらを優先しましょう。コンポジタースレッドで実行され、カクつきが発生しません。 - **`transition-behavior: allow-discrete`**:ツールチップ、ポップオーバー、ダイアログ、ドロップダウンメニューで `display: none` から `display: block` へのアニメーションに使います。 - **`@starting-style`**:ページに入場する要素や初めて表示される要素の初期状態を定義するために使います。 - **複雑なシーケンスには使わない**:マルチステップのシーケンスにはCSS `@keyframes` アニメーションを使いましょう。トランジションは2状態間の変化を扱います。 ## 参考リンク - [Using CSS Transitions — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Transitions/Using) - [transition-behavior — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transition-behavior) - [An Interactive Guide to CSS Transitions — Josh W. Comeau](https://www.joshwcomeau.com/animation/css-transitions/) - [Ten Tips for Better CSS Transitions and Animations — Josh Collinsworth](https://joshcollinsworth.com/blog/great-transitions) - [Transitioning Top-Layer Entries and the Display Property — Smashing Magazine](https://www.smashingmagazine.com/2025/01/transitioning-top-layer-entries-display-property-css/) - [Four New CSS Features for Smooth Entry and Exit Animations — Chrome for Developers](https://developer.chrome.com/blog/entry-exit-animations/) --- # CSS Grid パターン > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/flexbox-and-grid/grid-patterns ## 問題 CSS Gridは最も強力なCSSレイアウトシステムですが、AIエージェントはしばしば活用しきれなかったり、誤った使い方をしたりします。よくあるミスとしては、`auto-fill` と `auto-fit` の混同、レスポンシブパターンではなく固定のカラム数を使う、可読性が大幅に向上するのに `grid-template-areas` を避ける、`minmax()` がネイティブでレスポンシブに対応できるのにJavaScriptやメディアクエリに頼る、などがあります。 ## 解決方法 CSS Gridは二次元のレイアウトシステムです。ページレベルのレイアウト、レスポンシブなカードグリッド、行と列の両方でアイテムを揃える必要があるあらゆるシナリオに使いましょう。`auto-fill`/`auto-fit` と `minmax()` の組み合わせにより、メディアクエリを1つも使わずにレスポンシブレイアウトが実現できます。 ## コード例 ### auto-fill vs auto-fit アイテムの数がグリッドに収まる数より少ない場合に、違いが現れます。 ```css /* auto-fill: keeps empty tracks, preserving the grid structure */ .grid-fill { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; } /* auto-fit: collapses empty tracks, items stretch to fill space */ .grid-fit { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } ``` 5カラム分の幅があるコンテナに3つのアイテムがある場合: - `auto-fill` は5カラムを作り、2つは空のまま残します。アイテムは約200pxの幅を維持します。 - `auto-fit` は2つの空カラムを折りたたみます。アイテムはコンテナの全幅に広がります。 **`auto-fit` を使う場面:** カードグリッド、ギャラリーレイアウトなど、アイテムが利用可能なスペースを埋めるべきほとんどのUIパターン。 **`auto-fill` を使う場面:** アイテム数に関係なくカラム幅を一定に保ちたい場合(フォームフィールドやデータ入力レイアウトなど)。 auto-fill (empty tracks preserved): 1 2 3 auto-fit (items stretch to fill): 1 2 3 `} css={`.label { font-family: system-ui, sans-serif; font-size: 14px; font-weight: 600; color: #334155; margin-bottom: 8px; } .grid-fill { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; margin-bottom: 20px; } .grid-fit { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 10px; } .item { background: #3b82f6; color: #fff; padding: 16px; border-radius: 8px; text-align: center; font-family: system-ui, sans-serif; font-size: 16px; } .item.fit { background: #22c55e; }`} /> ### レスポンシブなカードグリッド(メディアクエリ不要) ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 1.5rem; } ``` ```html Card 1 Card 2 Card 3 Card 4 ``` Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} css={`.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 140px), 1fr)); gap: 12px; padding: 8px; } .card { background: #8b5cf6; color: #fff; padding: 20px 16px; border-radius: 8px; text-align: center; font-family: system-ui, sans-serif; font-size: 16px; }`} /> ### 名前付きグリッドエリア グリッドエリアは複雑なレイアウトを読みやすく保守しやすくします。 ```css .page-layout { display: grid; grid-template-areas: "header header" "sidebar main" "footer footer"; grid-template-columns: 250px 1fr; grid-template-rows: auto 1fr auto; min-height: 100vh; } .header { grid-area: header; } .sidebar { grid-area: sidebar; } .main { grid-area: main; } .footer { grid-area: footer; } /* Responsive: stack on narrow viewports */ @media (max-width: 768px) { .page-layout { grid-template-areas: "header" "main" "sidebar" "footer"; grid-template-columns: 1fr; } } ``` ```html Header Sidebar Main Content Footer ``` Header Sidebar Main Content Footer `} css={`.page-layout { display: grid; grid-template-areas: "header header" "sidebar main" "footer footer"; grid-template-columns: 120px 1fr; grid-template-rows: auto 1fr auto; min-height: 280px; gap: 8px; padding: 8px; font-family: system-ui, sans-serif; font-size: 16px; } .header { grid-area: header; background: #3b82f6; color: #fff; padding: 12px 16px; border-radius: 8px; } .sidebar { grid-area: sidebar; background: #f59e0b; color: #fff; padding: 12px 16px; border-radius: 8px; } .main { grid-area: main; background: #22c55e; color: #fff; padding: 16px; border-radius: 8px; } .footer { grid-area: footer; background: #8b5cf6; color: #fff; padding: 12px 16px; border-radius: 8px; }`} height={320} /> ### grid-template ショートハンドによる複雑なレイアウト `grid-template` ショートハンドは `grid-template-rows`、`grid-template-columns`、`grid-template-areas` を1つの宣言にまとめます。 ```css .dashboard { display: grid; grid-template: "nav nav nav" 60px "side main aside" 1fr "footer footer footer" 80px / 200px 1fr 250px; min-height: 100vh; gap: 1rem; } ``` ### 複数の行や列にまたがる ```css .featured-grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: auto; gap: 1rem; } .featured-item { grid-column: span 2; grid-row: span 2; } ``` ### Dense パッキング(隙間の埋め合わせ) ```css .masonry-like { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-auto-flow: dense; gap: 1rem; } ``` `dense` キーワードは、DOMの後方にある小さなアイテムで空のセルを埋めるようブラウザに指示し、より詰まったレイアウトを作ります。 ## AIがよくやるミス - **`auto-fit` を意図しているのに `auto-fill` を使う。** ほとんどのUIパターンでは、アイテムが利用可能なスペースを埋めるように広がることを望みます(`auto-fit`)。AIエージェントは空のトラックを残す `auto-fill` を選びがちです。 - **`min()` ガードなしで `minmax(300px, 1fr)` を書く。** 300px未満のビューポートで水平オーバーフローが発生します。常に `minmax(min(100%, 300px), 1fr)` を使いましょう。 - **`grid-template-areas` を避ける。** AIエージェントは行/列番号で配置しがちで、読みにくいコードになります。名前付きエリアは自己ドキュメンティングで、リファクタリングも容易です。 - **`repeat(auto-fit, ...)` の代わりに明示的なカラム数を設定する。** 固定の `grid-template-columns: repeat(3, 1fr)` はレスポンシブにするためにメディアクエリが必要です。`auto-fit` と `minmax()` なら自動的に対応できます。 - **カードのグリッドにflexboxを使う。** カードが行と列の両方で一貫したサイズで揃う必要がある場合、CSS Gridが正しいツールです。 - **`gap` を忘れてグリッドアイテムにmarginを使う。** Gridには `gap` サポートが組み込まれています。グリッドアイテムのmarginはグリッドトラックの外側にスペースを追加し、整列を崩します。 - **フルページのグリッドレイアウトに `min-height: 100vh` を設定しない。** これがないと、グリッドはコンテンツの高さしか取らず、フッターなどのエリアが下端に届きません。 ## 使い分け ### CSS Grid が適しているケース - 二次元レイアウト(行と列を同時に扱う) - header、sidebar、main、footerを持つページレベルのレイアウト - auto-fit/auto-fill を使ったレスポンシブなカードグリッド - 複数の行や列にまたがるアイテムを含むレイアウト - 名前付きエリアによる可読性が有益なレイアウト ### 代わりにFlexboxを使うべき場合 - 一次元のレイアウトのみ必要な場合(単一の行または列) - アイテムの幅が不明で、スペースを分配する必要がある場合(ナビゲーション、ツールバー) - アイテムを折り返したいが、行間でのカラム整列は不要な場合 ## Tailwind CSS Tailwindはレスポンシブブレークポイントプレフィックス付きのグリッドユーティリティを提供しています。CSS Gridの `auto-fit`/`auto-fill` と `minmax()` はユーティリティとして提供されていませんが、Tailwindのレスポンシブプレフィックス(`sm:`、`md:`、`lg:`)が明示的なブレークポイントベースの代替手段を提供します。 ### レスポンシブカードグリッド Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} /> ### カラムのスパン Spans 2 columns 1 col 1 col 1 col 1 col `} /> ## 参考リンク - [A Complete Guide to CSS Grid - CSS-Tricks](https://css-tricks.com/snippets/css/complete-guide-grid/) - [CSS Grid Layout - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout) - [Auto-Sizing Columns: auto-fill vs auto-fit - CSS-Tricks](https://css-tricks.com/auto-sizing-columns-css-grid-auto-fill-vs-auto-fit/) - [A Deep Dive Into CSS Grid minmax() - Ahmad Shadeed](https://ishadeed.com/article/css-grid-minmax/) - [Learn CSS Grid - web.dev](https://web.dev/learn/css/grid) --- # ポジショニングガイド > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/positioning/positioning-guide ## 問題 CSSのポジショニングはAIエージェントに頻繁に誤用されます。最もよくあるエラーとしては、flexboxやgridを使うべきレイアウトタスクに `position: absolute` を使う、包含要素に `position: relative` を設定し忘れる、モバイルのビューポートの問題を考慮せずに `position: fixed` を使う、`position: sticky` にスクロールコンテナと閾値が必要であることを理解していない、などがあります。 ## 解決方法 CSSの `position` は、通常のドキュメントフローに対して要素を移動または調整します。各値には特定の目的があり、間違った値を選ぶと画面サイズによって崩れる脆弱なレイアウトになります。 ## コード例 ### static(デフォルト) 要素は通常のドキュメントフロー内にあります。`top`、`right`、`bottom`、`left`、`z-index` プロパティは効果がありません。 ```css .element { position: static; /* default, rarely needs to be written explicitly */ } ``` ### relative 要素はフロー内に残りますが、元の位置からオフセットできます。絶対配置された子要素の包含ブロックを作成します。 ```css .parent { position: relative; /* Establishes containing block for children */ } .badge { position: relative; top: -4px; /* Shifts up 4px from its normal position */ } ``` `position: relative` は主に `position: absolute` の子要素のための包含ブロックの確立、またはレイアウトに影響を与えない軽微な視覚的オフセットに使いましょう。 ### absolute 要素はドキュメントフローから外れ、最も近い位置指定された祖先(`static` 以外の `position` を持つ祖先)を基準に配置されます。 ```css .card { position: relative; /* Containing block */ } .card-badge { position: absolute; top: -8px; right: -8px; } ``` ```html New Card content ``` New Card content goes here. The badge is absolutely positioned relative to this card. `} css={`.card { position: relative; background: #f1f5f9; border: 2px solid #e2e8f0; border-radius: 12px; padding: 24px; margin: 16px; font-family: system-ui, sans-serif; font-size: 16px; } .badge { position: absolute; top: -10px; right: -10px; background: #ef4444; color: #fff; padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600; } .card-text { margin: 0; color: #334155; }`} /> ### inset を使った絶対配置センタリング `inset` ショートハンドは `top`、`right`、`bottom`、`left` を置き換えます。 ```css .overlay { position: absolute; inset: 0; /* top: 0; right: 0; bottom: 0; left: 0 */ } .modal { position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content; } ``` ### fixed 要素はドキュメントフローから外れ、ビューポートを基準に配置されます。ページをスクロールしても動きません。 ```css .sticky-header { position: fixed; top: 0; left: 0; right: 0; z-index: 100; } /* Prevent content from being hidden behind the fixed header */ body { padding-top: 60px; /* Match the header height */ } ``` This area represents a page with scrollable content. Notice the floating button in the bottom-right corner — it stays in place regardless of scroll position. + `} css={`.page { position: relative; min-height: 200px; background: #f1f5f9; padding: 20px; font-family: system-ui, sans-serif; font-size: 16px; border-radius: 8px; } .content { color: #334155; } .content p { margin: 0; line-height: 1.6; } .fab { position: absolute; bottom: 16px; right: 16px; width: 48px; height: 48px; border-radius: 50%; background: #3b82f6; color: #fff; border: none; font-size: 24px; cursor: pointer; box-shadow: 0 4px 12px rgba(59,130,246,0.4); display: flex; align-items: center; justify-content: center; }`} /> ### sticky 要素はスクロール閾値を超えるまでは `relative` として動作し、その後は包含ブロック内で `fixed` として振る舞います。 ```css .table-header { position: sticky; top: 0; background: white; z-index: 10; } ``` ```html Name Value ``` Sticky Header — scroll down Section 1 content Section 2 content Section 3 content Section 4 content Section 5 content Section 6 content Section 7 content `} css={`.scroll-container { height: 280px; overflow-y: auto; border-radius: 8px; border: 2px solid #e2e8f0; font-family: system-ui, sans-serif; font-size: 16px; } .sticky-header { position: sticky; top: 0; background: #3b82f6; color: #fff; padding: 12px 20px; font-weight: 600; z-index: 10; } .content-block { padding: 20px; border-bottom: 1px solid #e2e8f0; color: #334155; min-height: 80px; }`} height={300} /> ### スティッキーサイドバー ```css .page { display: flex; align-items: flex-start; /* Critical: prevents sidebar from stretching */ } .sidebar { position: sticky; top: 1rem; width: 250px; flex-shrink: 0; } .main-content { flex: 1; min-width: 0; } ``` ```html Sticky sidebar Scrollable content ``` ## position: sticky が動作しない理由 スティッキーポジショニングはいくつかの状況でサイレントに失敗します。 ### 閾値が設定されていない ```css /* Broken: no top, bottom, left, or right value */ .sticky-broken { position: sticky; } /* Fixed: threshold tells the browser when to stick */ .sticky-working { position: sticky; top: 0; } ``` ### 親要素に overflow: hidden または overflow: auto がある ```css /* Broken: overflow on parent prevents sticking */ .parent { overflow: hidden; /* or overflow: auto, overflow: scroll */ } .parent .sticky-child { position: sticky; top: 0; /* This will not stick */ } ``` ### 親要素にスクロール可能な高さがない スティッキー要素は親要素の中で固定されます。親要素がスティッキー要素自体と同じ高さしかない場合、スクロールする余地がありません。 ## AIがよくやるミス - **レイアウトに `position: absolute` を使う。** 絶対配置は要素をフローから外すため、レイアウトが脆弱になります。レイアウトにはflexboxやgridを使い、絶対配置はオーバーレイ、バッジ、装飾的な要素にのみ使いましょう。 - **親要素に `position: relative` を忘れる。** 位置指定された祖先がないと、絶対配置された要素は初期包含ブロック(通常はビューポート)を基準に配置され、意図した親要素を基準にしません。 - **モバイルのスティッキーヘッダーに `position: fixed` を使う。** モバイルでのfixed配置は仮想キーボード、アドレスバーのリサイズ、スクロールパフォーマンスの問題を引き起こすことがあります。実際のモバイルデバイスでテストしましょう。 - **`position: sticky` に閾値を設定しない。** スティッキー要素には `top`、`right`、`bottom`、`left` のうち少なくとも1つを `auto` 以外の値に設定する必要があります。閾値がないと固定されません。 - **スタッキングコンテキスト(stacking context)を理解せずに `z-index` を使う。** `z-index: 9999` を設定しても、要素が最前面に表示されるとは限りません。同じスタッキングコンテキスト内での重なり順のみを制御します。詳細はスタッキングコンテキストガイドを参照してください。 - **`inset: 0` の代わりに `top: 0; right: 0; bottom: 0; left: 0` を使う。** `inset` ショートハンドの方がより簡潔で読みやすいです。 - **レスポンシブデザインでパーセンテージ幅の絶対配置を使う。** 絶対配置された要素のサイズは包含ブロックを基準にしており、ビューポートを基準にしていません。包含ブロックのサイズが予期せず変わると崩れます。 ## 使い分け ### relative - 絶対配置された子要素のための包含ブロックの確立 - レイアウトに影響を与えない軽微な視覚的オフセット - スタッキングコンテキストの作成(z-indexとの組み合わせ) ### absolute - カードやモーダル上のバッジ、ラベル、閉じるボタン - 位置指定されたコンテナの上に重なるオーバーレイ要素 - ツールチップの矢印やポップオーバー - ドキュメントフローに影響を与えるべきでない装飾的な要素 ### fixed - スクロール中も表示されるナビゲーションバー - 「トップに戻る」ボタン - Cookie同意バナー - フローティングアクションボタン ### sticky - テーブルのスクロール中に表示されるヘッダー - 長いスクロール可能なリスト内のセクションヘッダー - コンテナ内でユーザーに追従するサイドバー - 商品ページの「カートに追加」バー ## 参考リンク - [position - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/position) - [CSS Position Property - web.dev](https://web.dev/learn/css/layout#positioning) - [position: sticky - CSS-Tricks](https://css-tricks.com/position-sticky-2/) - [CSS Positioning Explained - InterviewBuzz](https://interviewbuzz.com/blog/css-positioning-explained-master-relative-absolute-fixed-sticky) --- # aspect-ratio > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/sizing/aspect-ratio ## 問題 画像、動画、埋め込みコンテンツ、カードレイアウトのアスペクト比を維持することはよくある要件です。長年にわたり、唯一の信頼できるテクニックは「padding-top ハック」でした。パーセントベースの padding を使って比率を持つボックスを作る方法です。AIエージェントは今でも古いハックを生成したり、さらに悪い場合には画面サイズが変わると崩れる固定ピクセルサイズを使ったりします。モダンな `aspect-ratio` プロパティは、追加のマークアップなしで1行でこの問題を解決します。 ## 解決方法 `aspect-ratio` CSSプロパティは、要素の推奨アスペクト比を設定します。ブラウザはこの比率を維持するように要素の寸法を調整し、比率を超えるコンテンツは `overflow` や `object-fit` で処理できます。 ```css .element { aspect-ratio: 16 / 9; } ``` これだけで十分です。ラッパーの div も、absolute positioning も、パーセント padding の計算も不要です。 ## コード例 ### 基本的な画像のアスペクト比 ```css .thumbnail { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 0.5rem; } ``` ```html ``` 画像はコンテナの幅いっぱいに広がり、16:9の比率を維持します。`object-fit: cover` により、画像は歪みなくボックスを埋め、必要に応じてトリミングされます。 16 / 9 4 / 3 1 / 1 `} css={`.container { display: flex; gap: 12px; padding: 8px; font-family: system-ui, sans-serif; } .thumbnail { flex: 1; aspect-ratio: 16 / 9; background: linear-gradient(135deg, #3b82f6, #8b5cf6); border-radius: 8px; display: flex; align-items: center; justify-content: center; } .ratio-4-3 { aspect-ratio: 4 / 3; background: linear-gradient(135deg, #22c55e, #14b8a6); } .ratio-1 { aspect-ratio: 1; background: linear-gradient(135deg, #f59e0b, #ef4444); } .label { color: #fff; font-size: 16px; font-weight: 600; }`} /> ### レスポンシブ動画コンテナ ```css .video-wrapper { width: 100%; aspect-ratio: 16 / 9; } .video-wrapper iframe { width: 100%; height: 100%; border: 0; } ``` ```html ``` ### 正方形カードグリッド ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); gap: 1rem; } .card { aspect-ratio: 1; /* Square */ overflow: hidden; border-radius: 0.5rem; } .card img { width: 100%; height: 100%; object-fit: cover; } ``` ```html ``` Card Title Card Title Card Title `} css={`.card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 8px; font-family: system-ui, sans-serif; } .card { border-radius: 8px; overflow: hidden; border: 2px solid #e2e8f0; } .card-img { aspect-ratio: 16 / 9; background: linear-gradient(135deg, #3b82f6, #6366f1); } .card-img.img2 { background: linear-gradient(135deg, #22c55e, #14b8a6); } .card-img.img3 { background: linear-gradient(135deg, #f59e0b, #ef4444); } .card-body { padding: 12px; font-size: 14px; font-weight: 600; color: #334155; }`} /> ### フォールバックコンテンツ付きアスペクト比 コンテンツがアスペクト比ボックスからはみ出す可能性がある場合: ```css .card-fixed { aspect-ratio: 4 / 3; overflow: hidden; padding: 1rem; } ``` コンテンツが4:3の比率で許容される高さを超えた場合、クリップされます。スクロール可能なオーバーフローにするには、代わりに `overflow: auto` を使いましょう。 ### 古い padding-top ハック(参考用) これはAIエージェントが生成をやめるべきレガシーテクニックです: ```css /* OLD: padding-top hack — do not use in new code */ .video-wrapper-old { position: relative; width: 100%; padding-top: 56.25%; /* 9/16 = 0.5625 */ height: 0; } .video-wrapper-old iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } ``` ```css /* NEW: aspect-ratio — use this instead */ .video-wrapper-new { width: 100%; aspect-ratio: 16 / 9; } .video-wrapper-new iframe { width: 100%; height: 100%; } ``` モダンなバージョンはより短く、読みやすく、ラッパーのトリックが不要で、`padding` プロパティを誤用しません。 ### 画像のレイアウトシフト防止 画像に `aspect-ratio` を設定すると、画像の読み込み前にスペースが確保され、Cumulative Layout Shift(CLS)を防止できます: ```css img { aspect-ratio: attr(width) / attr(height); width: 100%; height: auto; } ``` 実際には、ブラウザは `width` と `height` のHTML属性からアスペクト比を自動計算します。`` タグには必ず両方の属性を含めましょう: ```html ``` ### 円形アバター ```css .avatar { width: 3rem; aspect-ratio: 1; border-radius: 50%; object-fit: cover; } ``` ```html ``` ## AIがよくやるミス - **まだ padding-top ハックを生成している。** `aspect-ratio` プロパティは2021年から完全なブラウザサポートがあります(グローバルカバレッジ96%以上)。モダンブラウザのターゲットであれば padding ハックはもう不要です。 - **画像に `object-fit` を忘れている。** `` に `aspect-ratio` を設定して `object-fit: cover`(または `contain`)を付けないと、画像の自然な比率が指定された比率と異なる場合に歪みが発生します。 - **aspect-ratio の代わりに固定ピクセルサイズを使っている。** AIエージェントは16:9要素に `width: 640px; height: 360px` を設定しがちです。これは小さい画面で崩れます。レスポンシブな動作には `width: 100%; aspect-ratio: 16 / 9` を使いましょう。 - **`` タグに `width` と `height` 属性を含めていない。** これらの属性により、ブラウザは画像読み込み前に正しいスペースを確保でき、レイアウトシフトを防止します。これは Core Web Vitals の要件です。 - **CSS で `width` と `height` の両方を `aspect-ratio` と一緒に設定している。** 両方の寸法が明示的に設定されていると、`aspect-ratio` は効果がありません。一方の寸法(通常は `width`)を設定し、`aspect-ratio` にもう一方を計算させましょう。 - **flex/grid アイテムで `aspect-ratio` がストレッチとどう相互作用するか理解していない。** `align-items: stretch`(デフォルト)の flex コンテナでは、アイテムの高さはコンテナによって決定され、`aspect-ratio` を上書きします。アイテムに `align-items: flex-start` または `align-self: start` を設定しましょう。 ## 使い分け ### aspect-ratio が最適な場面 - レイアウトシフトを防ぐための画像と画像プレースホルダー - 動画埋め込み(YouTube、Vimeo iframe) - 一貫したプロポーションが必要なカードレイアウト - アバター画像(正方形/円形には `aspect-ratio: 1` を使用) - 固定プロポーションのヒーローセクション - 幅と高さの関係を維持する必要があるすべての要素 ### aspect-ratio と一緒に object-fit を使う場面 - コンテンツ(画像または動画)が指定された比率に合わない可能性がある場合 - `cover` はボックスを完全に埋め、必要に応じて端をトリミングします - `contain` はコンテンツ全体をボックス内に収め、空きスペースを残します ### padding-top ハックを残す場面 - Internet Explorer をサポートする必要がある場合(2022年6月にサポート終了済み) - まだ更新できないレガシーコードベースで作業している場合 ## 参考リンク - [aspect-ratio - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio) - [CSS aspect-ratio property - web.dev](https://web.dev/articles/aspect-ratio) - [Aspect Ratio Boxes - CSS-Tricks](https://css-tricks.com/aspect-ratio-boxes/) - [A closer look at the CSS aspect-ratio property - LogRocket Blog](https://blog.logrocket.com/a-closer-look-at-the-css-aspect-ratio/) --- # マルチカラムレイアウト > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/specialized/multi-column-layout ## 問題 新聞スタイルの段組みテキストやmasonry/Pinterestスタイルのレイアウトを作るには、従来はJavaScriptライブラリや複雑なCSS Gridのハックが必要でした。AIエージェントは、CSS Multi-Column Layout(ネイティブのCSSモジュールで、わずかなプロパティでカラムベースのコンテンツフローを処理できる)を検討せずに、こうした重い解決策に手を出しがちです。優れたブラウザサポートにもかかわらず、マルチカラムレイアウトは広く活用されていません。特に、JavaScriptなしでコンテンツをカラムを通じて縦方向にフローさせる機能は注目に値します。 ## 解決方法 CSS Multi-Column Layoutは `columns` プロパティ系を使ってコンテンツを複数のカラムに分割します。コンテンツは各カラム内で上から下へ流れ、次のカラムの上部に続きます。これは新聞とまったく同じ挙動です。この縦方向のフローこそが、行を左から右に埋めるCSS Gridとの主な違いです。 このモジュールはインラインのテキストコンテンツ(記事、段落)とブロックレベルの要素(カード、画像)の両方に対応しており、さまざまなレイアウトパターンに活用できます。 ### 基本原則 #### column-count vs column-width カラムを定義する2つの基本的なアプローチがあります: ```css /* Fixed number of columns */ .fixed-columns { column-count: 3; } /* Minimum column width — browser decides the count */ .flexible-columns { column-width: 250px; } /* Shorthand: column-width then column-count */ .shorthand { columns: 250px 3; /* At least 250px wide, at most 3 columns */ } ``` `column-count` はコンテナの幅に関係なく指定した数のカラムを作ります。`column-width` は最小幅を設定し、ブラウザが何カラム収まるかを計算します。指定値より広くなることはありますが、狭くなることはありません。レスポンシブレイアウトでは、`column-width` のみを使う方がメディアクエリなしでビューポートに適応するため、通常はより良い選択です。 #### Gap と Rule ```css .styled-columns { column-count: 3; column-gap: 2rem; /* Space between columns */ column-rule: 1px solid hsl(0 0% 80%); /* Vertical divider */ } ``` `column-gap` はカラム間のスペースを制御します(デフォルトは `1em`)。`column-rule` はカラム間に縦線を描きます。`border` と同じ構文を使います。ルールはスペースを取らず、gapの中央に描画されます。 #### break-inside によるカードの分割防止 ブロックレベルの要素(カード、figure、リストアイテム)がマルチカラムコンテナに配置されると、ブラウザがカラム境界をまたいで分割することがあります。これを防ぐには: ```css .card { break-inside: avoid; } ``` 各カードが単一のカラム内に収まるべきmasonryスタイルのカードレイアウトでは必須です。 ## コード例 ### マガジンテキストレイアウト `column-count` と `column-gap`、`column-rule` を使ったクラシックな新聞スタイルの段組みテキストです。テキストはカラム間を自然に流れます。 Multi-column layout transforms ordinary text into a polished, magazine-style reading experience. The browser automatically balances content across columns, ensuring each column is roughly the same height. This second paragraph continues flowing into whichever column has space. Notice how the column rule provides a subtle visual separator between columns, improving readability without adding clutter. The column-gap property controls the breathing room between columns. A wider gap improves readability for longer-form content, while a tighter gap works well for short, scannable text blocks. Unlike CSS Grid, which places items into rows left-to-right, multi-column layout flows content top-to-bottom within each column before moving to the next. This is the natural reading pattern for newspaper and magazine articles. `} css={`.magazine-text { column-count: 3; column-gap: 1.5rem; column-rule: 1px solid hsl(220 15% 80%); font-family: Georgia, 'Times New Roman', serif; font-size: 14px; line-height: 1.6; color: hsl(220 15% 25%); padding: 16px; } .magazine-text p { margin: 0 0 0.75rem 0; }`} /> ### 幅ベースのレスポンシブカラム `column-count` なしで `column-width` を使うと、ブラウザが何カラム収まるかを自動的に判断します。これは自然にレスポンシブです。メディアクエリは不要です。指定した幅は最小値で、ブラウザはカラムを広くすることはあっても狭くすることはありません。 This layout uses column-width instead of column-count. The browser calculates how many columns fit based on the container width. On a wide screen, you might see three or four columns. On a narrow screen, it may collapse to a single column. The column-width value acts as a minimum — columns will be at least this wide. If the container has extra space, the browser distributes it evenly, making each column wider than the minimum rather than adding another column that would be too narrow. This approach is ideal for responsive designs because the layout adapts to any container width without breakpoints. It is one of the simplest ways to create a responsive multi-column text layout in CSS. Try resizing the preview to see the columns reflow. At narrow widths the content will be in a single column. As the width grows, additional columns appear automatically. `} css={`.responsive-text { column-width: 180px; column-gap: 1.25rem; column-rule: 2px solid hsl(260 60% 70%); font-family: system-ui, sans-serif; font-size: 14px; line-height: 1.6; color: hsl(260 20% 20%); padding: 16px; } .responsive-text p { margin: 0 0 0.75rem 0; }`} /> ### Masonry スタイルのカードギャラリー マルチカラムコンテナ内のブロックレベルカードに `break-inside: avoid` を付けて、カードがカラムをまたいで分割されるのを防ぎます。高さの異なるカードでmasonryエフェクトを示しています。 Mountain Vista A breathtaking panoramic view of snow-capped peaks stretching across the horizon at dawn. City Lights Urban skyline at dusk. Ocean Waves Rolling waves crash against weathered rocks along a misty coastline. Forest Path A winding trail through ancient trees. Desert Sunset Golden light painting the sand dunes in warm amber tones as the sun dips below the flat horizon line. Garden Bloom Spring flowers in full color fill a terraced hillside garden with vibrant hues. Starry Night Clear skies reveal the milky way. Waterfall Crystal-clear water cascading down moss-covered rocks into a serene pool below. `} css={`.masonry-gallery { column-count: 3; column-gap: 12px; padding: 12px; } .masonry-card { break-inside: avoid; background: hsl(210 80% 55%); color: hsl(0 0% 100%); border-radius: 8px; padding: 14px; margin-bottom: 12px; font-family: system-ui, sans-serif; } .masonry-card h3 { margin: 0 0 6px 0; font-size: 15px; font-weight: 700; } .masonry-card p { margin: 0; font-size: 13px; line-height: 1.5; opacity: 0.9; } .masonry-card--medium { background: hsl(160 55% 42%); } .masonry-card--tall { background: hsl(280 55% 55%); }`} /> `break-inside: avoid` がなければ、ブラウザはカードをカラム境界で分割し、カードの上半分が1つのカラムに、下半分が次のカラムに表示されます。マルチカラムコンテナ内のブロックレベルコンテンツには、このプロパティが不可欠です。 ### カラムスパン `column-span: all` を使って、カラムフローから抜け出す全幅の要素を作ります。見出し、プルクオート、セクション区切りなど、すべてのカラムにまたがるべき要素に使います。 This is the opening paragraph of the article. The text flows across multiple columns in a traditional newspaper layout. Each column is balanced automatically by the browser. "Multi-column layout is the most underused CSS feature for long-form content." After the pull-quote, the column flow resumes. The browser creates a new column context below the spanning element. Content continues to fill columns from left to right. This technique is commonly used in magazine layouts where a key quote or image needs to break free from the column structure to create visual emphasis and break up long runs of text. Any element can span all columns — headings, images, horizontal rules, or custom dividers. Just apply column-span: all and the element stretches across the full container width. `} css={`.spanning-article { column-count: 3; column-gap: 1.25rem; column-rule: 1px solid hsl(220 15% 82%); font-family: Georgia, 'Times New Roman', serif; font-size: 13px; line-height: 1.6; color: hsl(220 15% 25%); padding: 16px; } .spanning-article p { margin: 0 0 0.75rem 0; } .pull-quote { column-span: all; margin: 1rem 0; padding: 1rem 1.5rem; background: hsl(45 90% 95%); border-left: 4px solid hsl(45 90% 50%); font-style: italic; font-size: 16px; color: hsl(45 40% 25%); }`} /> `column-span` は `all` または `none` のみを受け付けます。特定のカラム数にまたがることはできません。スパンする要素はカラムフローを中断し、その下に新しいカラムコンテキストが始まります。 ### Columns vs Grid の比較 マルチカラムレイアウトとCSS Gridはどちらもマルチカラムの見た目を作りますが、コンテンツのフロー方向が根本的に異なります。Columnsはコンテンツを**縦方向**(上から下、次のカラムへ)にフローさせ、Gridはコンテンツを**横方向**(左から右、行ごと)にフローさせます。 Multi-Column (vertical flow) 1 2 3 4 5 6 7 8 9 CSS Grid (horizontal flow) 1 2 3 4 5 6 7 8 9 `} css={`.comparison { display: flex; gap: 16px; padding: 12px; font-family: system-ui, sans-serif; } .comparison-panel { flex: 1; } .comparison-heading { font-size: 13px; font-weight: 700; color: hsl(220 15% 30%); margin: 0 0 10px 0; text-align: center; } .column-layout { column-count: 3; column-gap: 8px; } .grid-layout { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .numbered-item { padding: 10px; border-radius: 6px; text-align: center; font-size: 16px; font-weight: 700; color: hsl(0 0% 100%); } .numbered-item--col { background: hsl(210 80% 55%); margin-bottom: 8px; break-inside: avoid; } .numbered-item--grid { background: hsl(160 55% 42%); }`} /> マルチカラムレイアウトでは、アイテムは最初のカラムを1-2-3と下に並び、2番目は4-5-6、3番目は7-8-9となります。CSS Gridでは、アイテムは行ごとに埋まります:最初の行が1-2-3、2番目の行が4-5-6、3番目の行が7-8-9です。 **マルチカラムを使う場面:** コンテンツが新聞のように縦方向にフローすべき場合 — 長文テキスト、画像ギャラリー、masonryカードレイアウト。 **CSS Gridを使う場面:** アイテムが横方向に左から右に埋まるべき場合 — 商品グリッド、ダッシュボード、フォームレイアウト、水平方向の順序が重要なデザイン。 ## AIがよくやるミス - **マルチカラムレイアウトを完全に無視する。** AIエージェントは、masonryレイアウトや新聞スタイルのテキストを作るよう求められると、ほぼ常にCSS GridやJavaScriptに手を出します。マルチカラムレイアウトの方がシンプルで、これらのユースケースにより適切です。 - **`column-width` の方が良いのに `column-count` を使う。** 固定のカラム数は狭いビューポートで崩れます。`column-width` はメディアクエリなしで自然なレスポンシブ性を提供します。 - **ブロックレベルコンテンツに `break-inside: avoid` を忘れる。** これがないと、カード、画像、その他のブロック要素がカラム境界をまたいで分割されます。非常によくあるビジュアルバグです。 - **`column-span` が数値を受け付けると期待する。** `column-span: all` または `column-span: none` のみが有効です。3カラムのうち2カラムにまたがることはできません。 - **マルチカラムのフロー方向をGridと混同する。** マルチカラムレイアウトではコンテンツは上から下にフローします。横方向(行ごと)の順序が必要な場合は、CSS Gridが正しいツールです。 - **`break-inside: avoid` なしで `margin-bottom` を使う。** カラムコンテナ内では、ブロックアイテムのmarginはカラム区切りを防ぎません。カードスタイルのレイアウトでは、常にmarginと `break-inside: avoid` を組み合わせましょう。 ## 使い分け ### マルチカラムレイアウトが適しているケース - 新聞やマガジンスタイルのテキストをカラムにまたがって流す - 高さの異なるカードを使ったmasonry/Pinterestスタイルのレイアウト - カラムにまたがって分配すべきリスト(ナビゲーションリンク、タグクラウド) - 次のカラムに移る前にカラム内を縦方向にフローすべきコンテンツ ### 代わりにCSS Gridを使うべき場合 - アイテムが横方向(左から右)に行を埋めるべき場合(商品グリッド、ダッシュボード) - 行と列の配置を正確に制御する必要がある場合 - 特定の行と列にまたがるアイテムが必要な場合 - アイテムの横方向の順序がデザインにとって重要な場合 ## 参考リンク - [CSS Multi-column Layout - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_multicol_layout) - [Guide to CSS Multi-Column Layout - CSS-Tricks](https://css-tricks.com/guide-responsive-friendly-css-columns/) - [columns - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/columns) - [break-inside - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/break-inside) - [column-span - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/column-span) --- # コンポーネントファースト戦略 > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/architecture/component-first-strategy ## 問題 プロジェクトが Tailwind CSS のようなユーティリティファースト CSS フレームワークとコンポーネントフレームワーク(React、Vue、Svelte など)を併用している場合、開発者や AI エージェントは従来の CSS パターンに回帰しがちです。コンポーネント内でユーティリティクラスを組み合わせる代わりに、`.profile-card`、`.btn-primary`、`.sidebar-nav` のようなカスタム CSS クラス名を作成し、別のスタイルシートや CSS Modules を使ってしまいます。 これによりコードベースが断片化します: - Tailwind ユーティリティをインラインで使うコンポーネントもあれば - BEM 命名や CSS Modules でカスタム CSS クラスを導入するコンポーネントもあり - 同じファイル内で両方のアプローチを混在させるコンポーネントもある この一貫性のなさがプロジェクトの保守を難しくします。UI のある部分がユーティリティで、カスタム CSS で、あるいはその両方でスタイリングされているのか、確信が持てなくなります。 AI エージェントにとって、これは特によくある失敗パターンです。「プロフィールカードを作って」というタスクを与えると、エージェントはトレーニングデータで最も多く見られるパターンである CSS Module 付きの `.profile-card` クラスを生成しがちです。Tailwind を専用で使っているプロジェクトであっても、AI が生成したコードがプロジェクトの規約から逸脱するカスタム CSS クラスを持ち込みます。時間の経過とともに、コードベースは相反するスタイリングアプローチのパッチワークになっていきます。 ## 解決方法 **コンポーネントファースト戦略**: プロジェクトがコンポーネントベースのフレームワークとユーティリティ CSS フレームワークを使っている場合、UI は常に**ユーティリティクラスを持つコンポーネント**として表現します。UI レベルの CSS クラス名を別のスタイルシートで作成してはいけません。 - カードが必要? ユーティリティクラスを使った `` コンポーネントを作成する - ボタンのバリアントが必要? `` コンポーネントを作成する - レイアウトパターンが必要? `` コンポーネントを作成する コンポーネント自体が抽象化です。`.card` や `.btn-primary` のような CSS クラス名は不要です。コンポーネントがカプセル化を担い、ユーティリティクラスがスタイリングを担います。 ## コード例 ### アンチパターン: Tailwind プロジェクトでのカスタム CSS クラス コンポーネントベースの Tailwind プロジェクトで**やってはいけない**例です。カスタム CSS クラス名と別のスタイルシートを作成し、ユーティリティフレームワークを完全に迂回しています: ```jsx // ProfileCard.module.css // .profileCard { display: flex; gap: 1rem; padding: 1.5rem; ... } // .avatar { width: 64px; height: 64px; border-radius: 50%; ... } // .name { font-size: 1.25rem; font-weight: 600; ... } // .role { color: #6b7280; font-size: 0.875rem; ... } function ProfileCard({ name, role, avatar }) { return ( {name} {role} ); } ``` このアンチパターンの CSS は従来のコンポーネント CSS のように見えます。カスタムクラス名、別ファイル、BEM 風の命名です: John Doe Developer `} css={`.profile-card { display: flex; gap: 1rem; padding: 1.5rem; background: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); font-family: system-ui, sans-serif; } .profile-card__avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; } .profile-card__body { display: flex; flex-direction: column; justify-content: center; } .profile-card__name { font-size: 1.25rem; font-weight: 600; margin: 0; color: #1e293b; } .profile-card__role { color: #6b7280; font-size: 0.875rem; margin: 0.25rem 0 0; }`} height={120} /> 見た目は問題ありませんが、命名の判断、別の CSS ファイル、そしてプロジェクトの他の Tailwind ベースの部分と競合するスタイリングアプローチを持ち込んでしまいます。 ### 推奨: コンポーネントファースト + ユーティリティクラス 同じ結果を、コンポーネント内に直接ユーティリティクラスを組み合わせて実現します: ```jsx function ProfileCard({ name, role, avatar }) { return ( {name} {role} ); } ``` John Doe Developer `} css={`.flex { display: flex; } .flex-col { flex-direction: column; } .justify-center { justify-content: center; } .gap-4 { gap: 1rem; } .p-6 { padding: 1.5rem; } .m-0 { margin: 0; } .mt-1 { margin-top: 0.25rem; } .mb-0 { margin-bottom: 0; } .bg-white { background: #fff; } .rounded-lg { border-radius: 8px; } .rounded-full { border-radius: 9999px; } .shadow-md { box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .w-16 { width: 64px; } .h-16 { height: 64px; } .object-cover { object-fit: cover; } .text-xl { font-size: 1.25rem; } .text-sm { font-size: 0.875rem; } .font-semibold { font-weight: 600; } .text-slate-800 { color: #1e293b; } .text-gray-500 { color: #6b7280; } body { font-family: system-ui, sans-serif; }`} height={120} /> CSS ファイルは不要です。考えるべきクラス名もありません。コンポーネントがビジュアルデザインをカプセル化します。カードの見た目を変更したいときは、コンポーネントという1つのファイルを編集するだけです。 ### Props によるコンポーネントバリアント CSS の修飾子クラス(`.btn--primary`、`.btn--secondary`)を作る代わりに、コンポーネントの props でバリアントを制御します: ```jsx function Button({ variant = 'primary', children, ...props }) { const styles = { primary: 'bg-blue-500 hover:bg-blue-700 text-white', secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800', outline: 'bg-transparent hover:bg-blue-50 text-blue-600 border border-blue-500', }; return ( {children} ); } ``` 使い方は一目瞭然です: ```jsx Save Cancel Details ``` `.btn-primary` クラスを保守する必要はありません。`variant` prop は TypeScript で型チェックでき、JSDoc でドキュメント化でき、エディタで自動補完が効きます。 以下のデモでは視覚的な出力を再現するために CSS クラスを使っています。実際のプロジェクトでは、バリアントのロジックはコンポーネントコード内に存在し、ユーティリティクラスがスタイリングを処理します。カスタム CSS クラスは作成しません。 Save Cancel Details `} css={`* { box-sizing: border-box; } body { font-family: system-ui, sans-serif; } .demo { display: flex; gap: 12px; padding: 20px; } .btn { font-weight: 600; padding: 0.5rem 1rem; border-radius: 0.25rem; border: none; font-size: 1rem; cursor: pointer; } .btn-primary { background-color: #3b82f6; color: #fff; } .btn-primary:hover { background-color: #1d4ed8; } .btn-secondary { background-color: #e5e7eb; color: #1f2937; } .btn-secondary:hover { background-color: #d1d5db; } .btn-outline { background-color: transparent; color: #2563eb; border: 1px solid #3b82f6; } .btn-outline:hover { background-color: #eff6ff; }`} height={80} /> ### コンポーネントコンポジション 複雑なレイアウトは、CSS クラスを追加するのではなく、小さなコンポーネントを組み合わせて構築します: ```jsx function UserList({ users }) { return ( {users.map((user) => ( {user.name} {user.email} {user.status} ))} ); } ``` 各パーツ — ``、``、リストレイアウト — がコンポーネントです。`.user-list__item`、`.user-list__avatar`、`.user-list__badge` のようなクラス名は不要です。 ### コンポーネント内のレスポンシブパターン ユーティリティフレームワークはレスポンシブな振る舞いにブレークポイントプレフィックスを使います。これらはコンポーネントのマークアップに直接記述します: ```jsx function ProductGrid({ products }) { return ( {products.map((product) => ( ))} ); } ``` Item 1 Item 2 Item 3 Item 4 Item 5 Item 6 `} css={`* { box-sizing: border-box; } body { font-family: system-ui, sans-serif; } .grid { display: grid; } .grid-cols-1 { grid-template-columns: 1fr; } .gap-4 { gap: 1rem; } .card { background: #8b5cf6; color: #fff; padding: 1.5rem; border-radius: 8px; text-align: center; font-size: 1rem; font-weight: 500; } @media (min-width: 480px) { .md\\:grid-cols-2 { grid-template-columns: repeat(2, 1fr); } } @media (min-width: 720px) { .lg\\:grid-cols-3 { grid-template-columns: repeat(3, 1fr); } }`} height={280} /> グリッド用の別 CSS ファイルはありません。`.product-grid` や `.product-grid--responsive` クラスもありません。レスポンシブな振る舞いはインラインで宣言され、マークアップと同じ場所で確認できます。 ## コンポーネントが使えない場合 コンポーネントファーストのアプローチには、再利用可能なコンポーネントを作成できることが前提です。以下のような状況ではそれができません: - **CMS からサーバーレンダリングされた HTML** — マークアップは固定で、スタイルシートの追加のみ可能 - **サードパーティ UI フレームワーク** — 変更できない固定の HTML を出力するライブラリ - **メールテンプレート** — インラインスタイルとテーブルレイアウトに限定される - **ビルドステップのない静的 HTML サイト** — コンポーネントフレームワークが利用できない このような場合は、他の CSS 戦略にフォールバックします: | 状況 | 推奨アプローチ | |---|---| | ビルドツールあり、HTML の変更不可 | [CSS Modules](../css-modules-strategy) または Tailwind `@apply` | | ビルドツールなし、グローバル CSS のみ | [BEM 命名規約](../bem-strategy) | | 移行中のレガシーコードベース | 段階的なコンポーネント抽出 | これらは**例外**であり、デフォルトではありません。React、Vue、Svelte、Astro、またはコンポーネントをサポートするフレームワークを使っているなら、コンポーネントファーストのアプローチをデフォルトの選択にしましょう。 ## メリットとデメリット ### メリット - **命名の判断が不要。** コンポーネント名が抽象化そのものです。`.card-header` や `.btn-primary` を議論する必要はありません。 - **単一の信頼できる情報源。** スタイルとマークアップが1つのファイルに存在します。コンポーネントを変更すれば、使われているすべての場所が変わります。 - **AI フレンドリー。** AI エージェントはプロジェクト固有の命名規約を推測する必要なく、ユーティリティベースのコンポーネントを確実に生成できます。 - **CSS ファイル管理が不要。** 別のスタイルシートも、未使用の CSS も、インポートチェーンもありません。 - **Props が修飾子に取って代わる。** `variant="primary"` は `.btn--primary` よりも明確で、TypeScript の型チェックもサポートします。 - **デザインの一貫性。** ユーティリティはデザイントークンのスケールに紐づいており(例: `p-4` = `1rem`、`p-6` = `1.5rem`)、一貫したスペーシングとサイジングを強制します。 ### デメリット - **クラスリストが冗長になる。** JSX 内の長いユーティリティ文字列は見づらくなることがあります。ただし 2026 年以降、コードは AI が書く時代です。`clsx`/`cn` のようなヘルパーは人間の可読性のためのものであり、新規プロジェクトでわざわざ依存に追加する必要はありません。既存プロジェクトで使っているならそのまま使えますが、これから始めるなら不要です。 - **学習コスト。** ユーティリティフレームワークに慣れていない開発者は、その語彙を学ぶ必要があります。 - **コンポーネントフレームワークが必要。** プレーン HTML/CSS 環境では適用できません(上記の例外を参照)。 ## コンポーネント階層の変数は不要 階層型デザイントークン戦略 — [3階層カラー戦略](/ja/docs/styling/color/three-tier-color-strategy)や[3階層フォントサイズ戦略](/ja/docs/typography/font-sizing/three-tier-font-size-strategy)など — において、最も具体的なレベルは**コンポーネント階層**です。特定のコンポーネントにスコープされた CSS カスタムプロパティ、たとえば `--_dialog-side-spacing`、`--_card-shadow`、`--_nav-font-size`(`--_` プレフィックスはローカルスコープを示す)のようなものです。これらの変数により、コンポーネントは自身のデザイン上の判断を CSS ファイル内でカプセル化できます。 コンポーネントファーストのアプローチでは、この階層は不要です。スコープ付きカスタムプロパティを定義するようなコンポーネントごとの CSS ファイルがないからです。スタイリングはコンポーネントのマークアップ内でユーティリティクラスとして直接表現され、コンポーネントフレームワーク自体(React、Vue、Svelte、Astro)がスコープの境界を提供します。 `` コンポーネントに `--_dialog-side-spacing` 変数は不要で、`px-hsp-sm` や `px-hsp-md` を直接使います([タイトトークン戦略](/ja/docs/methodology/design-systems/tight-token-strategy/)で定義されたプロジェクトトークン)。`` に `--_card-shadow` は不要で、`shadow-md` を使います。コンポーネントファイル自体が、そのコンポーネントのスタイリング判断における単一の信頼できる情報源です。上位の階層(パレット、テーマ、スケール)はグローバルなデザイントークンとして引き続き存在します。コンポーネントアーキテクチャ自体がスコープを担うため、コンポーネント階層だけが不要になります。 一方、一般的な CSS アプローチ(BEM、CSS Modules)でスタイリングする場合は、コンポーネント階層の変数は依然として有用です。コンポーネントフレームワークによるスコープがない状況では、CSS カスタムプロパティはコンポーネントのスタイルシート内でデザイン上の判断をカプセル化するための数少ない手段の1つです。 ## 使い分け **以下の場合は常にコンポーネントファーストのアプローチを使いましょう:** - プロジェクトがコンポーネントフレームワーク(React、Vue、Svelte、Astro、Solid など)を使っている - プロジェクトがユーティリティ CSS フレームワーク(Tailwind CSS、UnoCSS など)を使っている **以下の場合のみ他のアプローチにフォールバックします:** - HTML を変更できない(CMS の出力、サードパーティウィジェット、レガシーマークアップ) - コンポーネントフレームワークが利用できない(静的 HTML、メールテンプレート) - プロジェクトが明示的に別の CSS 規約を選択しており、アプローチを混在させるべきでない ### AI エージェント向けのルール コンポーネントベースの Tailwind プロジェクトでコードを生成する場合: 1. **常にコンポーネントを作成する** — CSS クラスではなく 2. **ユーティリティクラスを直接使う** — コンポーネントのマークアップ内で 3. **CSS Module ファイルやカスタムクラス名を生成しない** — 明示的に求められない限り 4. **バリアントには props を使う** — `.btn--primary` のような CSS 修飾子ではなく 5. **コンポーネントを組み合わせる** — 複雑な UI は小さなコンポーネントから構築する。CSS を増やすのではなく ### よく使われるツール - **[Tailwind CSS](https://tailwindcss.com/)** — 最も人気のあるユーティリティファーストフレームワーク。ビルドステップで使用している CSS のみを生成します。 - **[UnoCSS](https://unocss.dev/)** — オンデマンドで高速な代替フレームワーク。プラグインベースのアーキテクチャで、Tailwind プリセットと互換性があります。 - **[clsx](https://github.com/lukeed/clsx)** / **[tailwind-merge](https://github.com/dcastil/tailwind-merge)** — コンポーネント内で条件付きユーティリティクラスを組み合わせるためのヘルパー。 ## 参考リンク - [Tailwind CSS Documentation](https://tailwindcss.com/docs) - [Utility-First Fundamentals - Tailwind CSS](https://tailwindcss.com/docs/utility-first) - [Extracting Components and Partials - Tailwind CSS](https://tailwindcss.com/docs/reusing-styles#extracting-components-and-partials) - [UnoCSS Documentation](https://unocss.dev/guide/) - [CSS Utility Classes and "Separation of Concerns" - Adam Wathan](https://adamwathan.me/css-utility-classes-and-separation-of-concerns/) --- # テーマレシピ > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/design-systems/custom-properties-advanced/theming-recipes CSSカスタムプロパティを使った完全なテーマシステムレシピです。各レシピはプロダクション対応のパターンで、自分のプロジェクトに適応できます。これらのレシピは [Three-Tier Color Strategy](../../../../styling/color/three-tier-color-strategy) で説明されているレイヤードアーキテクチャ — パレットトークン、セマンティックテーマトークン、コンポーネントスコープのオーバーライド — をカスケードと `var()` フォールバックを使って実装しています。 ## ライト/ダークテーマとカスタムプロパティ カラーパレット全体をカスタムプロパティとして定義し、1つのクラストグルですべてを切り替えます。個別の色に対するJavaScriptロジックは不要です — カスケードがすべてを処理します。 Light Theme Clean and bright for daytime reading. All colors come from custom properties. Action Dark Theme Easy on the eyes for nighttime use. Same markup, swapped properties. Action `} css={` .light-theme { --surface: hsl(220 20% 98%); --surface-raised: hsl(0 0% 100%); --text-primary: hsl(220 25% 15%); --text-secondary: hsl(220 15% 45%); --accent: hsl(220 80% 55%); --accent-hover: hsl(220 80% 45%); --accent-text: hsl(0 0% 100%); --border: hsl(220 15% 88%); } .dark-theme { --surface: hsl(225 25% 12%); --surface-raised: hsl(225 20% 18%); --text-primary: hsl(220 15% 90%); --text-secondary: hsl(220 15% 65%); --accent: hsl(220 80% 65%); --accent-hover: hsl(220 80% 75%); --accent-text: hsl(225 25% 12%); --border: hsl(225 15% 28%); } .theme-comparison { font-family: system-ui, sans-serif; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .theme-panel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; } .theme-panel h3 { margin: 0 0 0.5rem; color: var(--text-primary); font-size: 1.05rem; } .theme-panel p { margin: 0 0 1.25rem; color: var(--text-secondary); font-size: 0.85rem; line-height: 1.6; } .theme-btn { background: var(--accent); color: var(--accent-text); border: none; padding: 0.55rem 1.25rem; border-radius: 8px; font-weight: 600; font-size: 0.85rem; cursor: pointer; transition: background 0.15s; } .theme-btn:hover { background: var(--accent-hover); } `} /> ## ブランドテーマのオーバーライド デフォルトテーマから開始し、コンポーネントをブランドカラーのコンテナでラップしてオーバーライドします。子要素はカスケードを通じて自動的に新しい値を取得します。 Default Brand Uses the base theme colors defined on :root. No overrides needed. Standard Custom Brand Wrapped in a brand override container — accent and surface swap instantly. Branded `} css={` :root { --brand-accent: hsl(220 75% 55%); --brand-accent-light: hsl(220 75% 95%); --brand-text: hsl(220 25% 20%); --brand-muted: hsl(220 15% 55%); } .brand-override { --brand-accent: hsl(280 70% 55%); --brand-accent-light: hsl(280 70% 95%); --brand-text: hsl(280 25% 20%); --brand-muted: hsl(280 15% 45%); } .brand-demo { font-family: system-ui, sans-serif; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .brand-card { background: var(--brand-accent-light); border: 2px solid var(--brand-accent); border-radius: 12px; padding: 1.5rem; } .brand-card h3 { margin: 0 0 0.5rem; color: var(--brand-text); font-size: 1rem; } .brand-card p { margin: 0 0 1rem; color: var(--brand-muted); font-size: 0.85rem; line-height: 1.5; } .brand-badge { display: inline-block; background: var(--brand-accent); color: white; padding: 0.25rem 0.85rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; } `} /> ## コンポーネントAPIパターン カスタムプロパティのセットをコンポーネントのパブリックスタイリングAPIとして公開します。コンシューマーは必要なプロパティだけをオーバーライドし、コンポーネントが残りを内部的に処理します。 Default Pill Green Wide Pink Square Orange Each button uses the same .api-btn class. Visual differences come entirely from custom property overrides via inline styles. `} css={` .api-demo { font-family: system-ui, sans-serif; display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; } .api-btn { /* Public API */ --_bg: var(--btn-bg, hsl(220 75% 55%)); --_color: var(--btn-color, white); --_padding: var(--btn-padding, 0.6rem 1.5rem); --_radius: var(--btn-radius, 8px); background: var(--_bg); color: var(--_color); padding: var(--_padding); border-radius: var(--_radius); border: none; font-weight: 600; font-size: 0.9rem; cursor: pointer; transition: filter 0.15s; } .api-btn:hover { filter: brightness(0.9); } .api-code { margin-top: 1rem; } .api-code p { font-family: system-ui, sans-serif; font-size: 0.8rem; color: hsl(220 15% 55%); line-height: 1.5; } .api-code code { background: hsl(220 20% 94%); padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } `} /> ## Surface/Contentレイヤーパターン 3つの論理レイヤーを定義します — **surface**(背景)、**content**(テキスト)、**accent**(インタラクティブなハイライト)。コンポーネントは生の色ではなくレイヤーを参照するため、ページ全体のテーマ切り替えが簡単になります。これは [Three-Tier Color Strategy](../../../../styling/color/three-tier-color-strategy) の実践例です — 以下のレイヤーは Tier 2(セマンティックテーマトークン)に対応しています。 AppName Docs Blog About Navigation Getting Started Components Theming Welcome This layout uses a surface/content/accent layer system. Every section references layer tokens instead of hard-coded colors. Get Started `} css={` /* Layer definitions */ :root { --surface-base: hsl(220 20% 97%); --surface-raised: hsl(0 0% 100%); --surface-overlay: hsl(220 25% 93%); --content-primary: hsl(220 25% 15%); --content-secondary: hsl(220 15% 45%); --accent-base: hsl(250 70% 55%); --accent-hover: hsl(250 70% 45%); --accent-subtle: hsl(250 70% 95%); --accent-text: white; --border-subtle: hsl(220 15% 88%); } .layer-page { font-family: system-ui, sans-serif; background: var(--surface-base); border-radius: 12px; overflow: hidden; border: 1px solid var(--border-subtle); } .layer-header { background: var(--surface-raised); border-bottom: 1px solid var(--border-subtle); padding: 0.75rem 1.25rem; display: flex; align-items: center; justify-content: space-between; } .layer-logo { font-weight: 700; color: var(--accent-base); font-size: 0.95rem; } .layer-nav { display: flex; gap: 1rem; } .layer-nav a { color: var(--content-secondary); text-decoration: none; font-size: 0.85rem; } .layer-nav a:hover { color: var(--accent-base); } .layer-body { display: grid; grid-template-columns: 10rem 1fr; } .layer-sidebar { background: var(--surface-overlay); padding: 1rem; border-right: 1px solid var(--border-subtle); } .layer-sidebar h4 { margin: 0 0 0.5rem; font-size: 0.8rem; color: var(--content-secondary); text-transform: uppercase; letter-spacing: 0.05em; } .layer-sidebar ul { list-style: none; padding: 0; margin: 0; } .layer-sidebar li { margin-bottom: 0.25rem; } .layer-sidebar a { color: var(--content-primary); text-decoration: none; font-size: 0.85rem; display: block; padding: 0.3rem 0.5rem; border-radius: 6px; } .layer-sidebar a:hover { background: var(--accent-subtle); color: var(--accent-base); } .layer-content { padding: 1.5rem; background: var(--surface-raised); } .layer-content h2 { margin: 0 0 0.5rem; color: var(--content-primary); font-size: 1.1rem; } .layer-content p { margin: 0 0 1.25rem; color: var(--content-secondary); font-size: 0.85rem; line-height: 1.6; } .layer-action { background: var(--accent-base); color: var(--accent-text); border: none; padding: 0.55rem 1.25rem; border-radius: 8px; font-weight: 600; font-size: 0.85rem; cursor: pointer; transition: background 0.15s; } .layer-action:hover { background: var(--accent-hover); } `} /> ## 参考リンク - [A Complete Guide to Custom Properties - CSS-Tricks](https://css-tricks.com/a-complete-guide-to-custom-properties/) - [Dark Mode in CSS - CSS-Tricks](https://css-tricks.com/a-complete-guide-to-dark-mode-on-the-web/) - [Component-Level Art Direction with CSS Custom Properties - Sara Soueidan](https://www.sarasoueidan.com/blog/component-level-art-direction-with-css-custom-properties/) --- # タイポグラフィトークンパターン > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/design-systems/tight-token-strategy/typography-tokens ## 問題 Tailwind CSS はデフォルトで13のフォントサイズステップ(`text-xs` から `text-9xl` まで)、9つの font-weight 値(`font-thin` から `font-black` まで)、6つ以上の line-height 値、複数の font-family オプションを提供しています。チームは本文テキストに `text-sm`、`text-base`、`text-lg` を交互に使い、あるコンポーネントでは `font-semibold` を使い、別のコンポーネントでは同じ視覚的な強調のために `font-bold` を使うことになります。 このタイポグラフィのずれは、スペーシングやカラーのずれよりも見つけにくいものです。違いが微妙だからです — 14px vs 16px の本文テキスト、`font-medium` vs `font-semibold` など — しかし、誰も正確に指摘できないまま不一致な印象を与えるインターフェースへと蓄積されていきます。 ## 解決方法 すべてのデフォルトタイポグラフィトークンをリセットし、**抽象的なサイズ名**を使った小さなセットを定義します: - **フォントサイズ**:6サイズ — `xs`、`sm`、`base`、`lg`、`xl`、`2xl` - **フォントウェイト**:3ウェイト — `normal`、`medium`、`bold` - **行の高さ**:3値 — `tight`、`normal`、`relaxed` - **フォントファミリー**:2ファミリー — `sans`、`mono` ### なぜセマンティック名ではなく抽象名なのか トークンをタイポグラフィ上の役割で命名するのは自然な発想です — `caption`、`body`、`subheading`、`heading`、`display`。最初はすっきりして見えますが、**トークン名が用途のコンテキストをハードコードしてしまう**という問題があります。 例えば、`subheading` トークンが 20px だとします。商品価格、ナビゲーションリンク、インフォコールアウトにも 20px のテキストが必要になりました。これらはどれもサブヘディングではありません。2つの悪い選択肢が残ります: 1. **サブヘディングでないものに `text-subheading` を使う** — 誤解を招き、他の開発者を混乱させます 2. **別の名前で新しい 20px トークンを作る** — トークンが肥大化し、同じ値が重複します `lg` のような抽象名ならこの問題を解決できます。20px テキストが必要な要素はすべて `text-lg` を使います。役割はトークン名ではなくコンテキストから決まります。 これは [Three-Tier Color Strategy](../../../../styling/color/three-tier-color-strategy/) や [Three-Tier Font-Size Strategy](../../../../typography/font-sizing/three-tier-font-size-strategy/) と同じ考え方です。コアレイヤーでは中立的で再利用可能な名前を使います。`heading` や `caption` のようなセマンティック名は**テーマレイヤー**に属します — CSS カスタムプロパティやコンポーネントレベルのトークンとして、コアサイズを参照する形で定義します: ```css /* Core layer (@theme) — abstract, reusable scale */ --font-size-lg: 1.25rem; /* Theme layer (project CSS) — semantic aliases */ :root { --font-subheading: var(--font-size-lg); } ``` ### @theme タイポグラフィブロック [アプローチB](../#approach-b-skip-the-default-theme-recommended)(デフォルトテーマなしの個別インポート)を使う場合、リセット行は不要です — トークンを直接定義するだけです。アプローチA(`@import "tailwindcss"`)を使う場合は、以下のコメント内のリセット行を追加してください。 ```css @theme { /* If using Approach A (@import "tailwindcss"), uncomment these resets: --font-size-*: initial; --font-weight-*: initial; --line-height-*: initial; --font-family-*: initial; --letter-spacing-*: initial; */ /* ── Font sizes with paired line-heights (6 steps) ── */ --font-size-xs: 0.75rem; /* 12px */ --font-size-xs--line-height: 1.5; --font-size-sm: 0.875rem; /* 14px */ --font-size-sm--line-height: 1.5; --font-size-base: 1rem; /* 16px */ --font-size-base--line-height: 1.75; --font-size-lg: 1.25rem; /* 20px */ --font-size-lg--line-height: 1.5; --font-size-xl: 1.75rem; /* 28px */ --font-size-xl--line-height: 1.25; --font-size-2xl: 2.5rem; /* 40px */ --font-size-2xl--line-height: 1.25; /* ── Font weights (3 steps) ── */ --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-bold: 700; /* ── Line heights (3 steps — for manual overrides) ── */ --line-height-tight: 1.25; --line-height-normal: 1.5; --line-height-relaxed: 1.75; /* ── Font families (2 families) ── */ --font-family-sans: "Inter", system-ui, sans-serif; --font-family-mono: "JetBrains Mono", ui-monospace, monospace; } ``` 各フォントサイズは `--font-size-*--line-height` の規約を使って**最適な line-height とペアリング**されています。`text-base` と書くだけで `font-size: 1rem` と `line-height: 1.75` の両方が設定されます — 別途 `leading-*` クラスを指定する必要はありません。スタンドアロンの line-height トークンは、ペアリングされた値が合わない場合の手動オーバーライド用に残してあります。 この設定後: - `text-sm` — **動作する**(`font-size: 0.875rem; line-height: 1.5` に解決される) - `text-3xl` — **ビルドエラー**(`--font-size-3xl` トークンが存在しない) - `font-semibold` — **ビルドエラー**(`--font-weight-semibold` トークンが存在しない) - `font-bold` — **動作する**(`font-weight: 700` に解決される) - `leading-normal` — **動作する**(`line-height: 1.5` に解決される) ## デモ ### デフォルトフォントサイズ vs 抽象タイポグラフィトークン 左のカラムは Tailwind の13のデフォルトフォントサイズステップ — `text-xs` から `text-9xl` までを示しています。右のカラムはそれらを置き換える6つの抽象サイズです。各トークンはスケール内のステップであり、特定の UI 上の役割には紐づいていません。 Default sizes (all 13) text-xsThe quick brown fox text-smThe quick brown fox text-baseThe quick brown fox text-lgThe quick brown fox text-xlThe quick brown fox text-2xlThe quick brown fox text-3xlThe quick brown fox text-4xlQuick brown text-5xlQuick text-6xl60px text-7xl72px text-8xl96px text-9xl128px Abstract sizes (6) Fine print and labels xs — 12px Secondary text, descriptions sm — 14px Default body text size base — 16px Card titles, sub-sections lg — 20px Page headings xl — 28px Hero text 2xl — 40px `} css={`.demo { display: flex; gap: 24px; padding: 16px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .col { flex: 1; min-width: 0; } .heading { font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); margin-bottom: 10px; } .size-list { display: flex; flex-direction: column; gap: 2px; } .size-list.abstract { gap: 6px; } .size-row { display: flex; align-items: baseline; gap: 8px; min-height: 22px; } .size-row.dim { opacity: 0.4; } .token { font-size: 10px; font-family: monospace; color: hsl(215 16% 47%); width: 56px; flex-shrink: 0; } .hint { font-size: 11px; color: hsl(215 16% 47%); } .sample { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; } /* Abstract column */ .size-row-lg { display: flex; flex-direction: column; gap: 1px; padding: 6px 10px; border-left: 3px solid hsl(221 83% 53%); } .token-right { font-size: 10px; font-family: monospace; color: hsl(221 83% 53%); font-weight: 600; }`} /> ### タイポグラフィスケールカード 6つの抽象サイズすべてとペアリングされた line-height を示す視覚的な階層カードです。これがプロジェクトの完全なタイポグラフィ語彙であり、どのコンテキストでも再利用できます。 Typography Scale Display 2xl — 40px · line-height 1.25 Heading xl — 28px · line-height 1.25 Subheading lg — 20px · line-height 1.5 Body text for paragraphs and descriptions. base — 16px · line-height 1.75 Secondary text and supporting details sm — 14px · line-height 1.5 Caption text for labels and timestamps xs — 12px · line-height 1.5 `} css={`.scale-card { max-width: 560px; margin: 16px auto; border: 1px solid hsl(214 32% 91%); border-radius: 10px; overflow: hidden; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .scale-header { padding: 12px 20px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); background: hsl(210 40% 96%); border-bottom: 1px solid hsl(214 32% 91%); } .scale-body { padding: 16px 20px; } .scale-row { display: flex; flex-direction: column; gap: 4px; padding: 8px 0; } .divider { height: 1px; background: hsl(214 32% 91%); } .scale-sample { line-height: 1.2; } .scale-sample.sz-2xl { font-size: 40px; font-weight: 700; line-height: 1.25; } .scale-sample.sz-xl { font-size: 28px; font-weight: 700; line-height: 1.25; } .scale-sample.sz-lg { font-size: 20px; font-weight: 500; line-height: 1.5; } .scale-sample.sz-base { font-size: 16px; font-weight: 400; line-height: 1.75; } .scale-sample.sz-sm { font-size: 14px; font-weight: 400; line-height: 1.5; color: hsl(215 16% 47%); } .scale-sample.sz-xs { font-size: 12px; font-weight: 400; line-height: 1.5; color: hsl(215 16% 47%); } .scale-meta { display: flex; flex-direction: column; gap: 1px; } .scale-specs { font-size: 11px; font-family: monospace; color: hsl(221 83% 53%); font-weight: 600; }`} /> ### 同じサイズ、異なる役割 抽象名の最大の利点は、`text-lg` がカードタイトル、価格表示、ナビゲーションリンクのどれにも同じように使えることです。`subheading` のようなセマンティック名では、同じ 20px の値に対して3つの別々のトークンが必要になります。 Card title Premium Plan text-lg Product price $49.99 text-lg Nav link Documentation text-lg `} css={`.roles-demo { display: flex; gap: 16px; padding: 16px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .role-card { flex: 1; padding: 14px 16px; border: 1px solid hsl(214 32% 91%); border-radius: 8px; display: flex; flex-direction: column; gap: 6px; } .role-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); } .role-sample { line-height: 1.3; } .role-token { font-size: 11px; font-family: monospace; color: hsl(221 83% 53%); font-weight: 600; background: hsl(210 40% 96%); padding: 2px 6px; border-radius: 3px; align-self: flex-start; }`} /> ### 抽象タイポグラフィトークンを使った記事レイアウト 6つの抽象フォントサイズ、3つのウェイト、3つの line-height のみを使った完全な記事レイアウトです。`text-lg` がサブタイトルとセクション見出しの両方に再利用されていることに注目してください — 同じサイズ、異なる役割です。セマンティック命名では、同じ値に対して別々の `subtitle` トークンと `section-heading` トークンが必要になります。 March 4, 2026 Building Consistent Interfaces How typography tokens eliminate visual drift across teams When every developer on the team reaches for different font sizes, the interface develops subtle inconsistencies. One component uses 14px body text, another uses 16px, and a third uses 18px. None are wrong — they are all valid Tailwind utilities — but the result feels fragmented. The Six-Size Approach By constraining the type scale to six abstract sizes, every text element maps to a clear step in the scale. There is no ambiguity about which size to pick — and no awkward semantic mismatch when the same size serves a different role. Key Insight Six font sizes, three weights, and three line-heights produce a type system that covers every common UI pattern — from timestamps to hero text — without naming any of them after a specific role. Token names describe scale position (xs → 2xl), not usage. The role comes from context. Design Systems Typography 5 min read `} css={`.article { max-width: 540px; margin: 16px auto; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); border: 1px solid hsl(214 32% 91%); border-radius: 10px; overflow: hidden; } .article-header { padding: 24px 24px 16px; } .article-date { font-size: 12px; /* xs */ font-weight: 400; /* normal */ line-height: 1.5; /* xs paired */ color: hsl(215 16% 47%); display: block; margin-bottom: 6px; } .article-title { font-size: 28px; /* xl */ font-weight: 700; /* bold */ line-height: 1.25; /* xl paired */ margin: 0 0 6px 0; } .article-subtitle { font-size: 20px; /* lg — same token as h2 below */ font-weight: 500; /* medium */ line-height: 1.5; /* lg paired */ color: hsl(215 16% 47%); margin: 0; } .article-body { padding: 0 24px 20px; } .article-paragraph { font-size: 16px; /* base */ font-weight: 400; /* normal */ line-height: 1.75; /* base paired */ margin: 0 0 16px 0; color: hsl(222 47% 11%); } .article-h2 { font-size: 20px; /* lg — same token as subtitle above */ font-weight: 700; /* bold */ line-height: 1.25; /* tight (override) */ margin: 20px 0 10px 0; } .article-callout { background: hsl(210 40% 96%); border-left: 3px solid hsl(221 83% 53%); padding: 12px 16px; border-radius: 0 6px 6px 0; margin: 16px 0; } .callout-label { font-size: 12px; /* xs */ font-weight: 700; /* bold */ line-height: 1.5; /* xs paired */ color: hsl(221 83% 53%); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; } .callout-text { font-size: 16px; /* base */ font-weight: 400; /* normal */ line-height: 1.75; /* base paired */ margin: 0; color: hsl(222 47% 11%); } .article-aside { font-size: 14px; /* sm */ font-weight: 400; /* normal */ line-height: 1.5; /* sm paired */ color: hsl(215 16% 47%); margin: 0; font-style: italic; } .article-footer { padding: 12px 24px; background: hsl(210 40% 96%); border-top: 1px solid hsl(214 32% 91%); display: flex; align-items: center; gap: 8px; } .footer-tag { font-size: 12px; /* xs */ font-weight: 500; /* medium */ background: hsl(221 83% 53%); color: hsl(210 40% 98%); padding: 3px 10px; border-radius: 99px; } .footer-read { font-size: 12px; /* xs */ font-weight: 400; /* normal */ color: hsl(215 16% 47%); margin-left: auto; }`} /> ## 使い分け タイポグラフィトークンは、[カラートークン](./color-tokens)や親記事の[スペーシング戦略](./index.mdx)と自然に組み合わさります。これらを合わせることで、視覚的なずれの最も一般的な3つの原因を制約するタイトなデザイントークンシステムの核を形成します。 タイポグラフィトークンを適用すべきケース: - プロジェクトで実際に3つ以上のフォントサイズが使われている場合 — ちょうど6つに制約しましょう - 複数の開発者がマークアップを書き、それぞれが異なるテキストサイズを使っている場合 - どのコンテキストでも再利用できるフォントサイズトークンが必要な場合 — 特定のコンポーネントの役割に紐づけないトークンが求められるケース ## 関連記事 - [Three-Tier Font-Size Strategy](../../../../typography/font-sizing/three-tier-font-size-strategy/) — これらのトークン選択の背景にある完全な概念的アーキテクチャ(スケール → テーマ → コンポーネント) - [Three-Tier Color Strategy](../../../../styling/color/three-tier-color-strategy/) — 同じ三層アーキテクチャをカラーに適用したもの - [Color Token Patterns](./color-tokens) — 同じタイトトークンアプローチを使ったセマンティックカラースケール ## 参考リンク - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 @theme Directive](https://tailwindcss.com/docs/functions-and-directives#theme-directive) --- # 2層サイズ戦略 > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/design-systems/two-tier-size-strategy ## 問題 [タイトトークン戦略](./tight-token-strategy/)を使用している場合、Tailwind のデフォルトはすべてリセットされます。これには `h-4`、`w-4`、`size-8` などの幅・高さユーティリティを支える数値スペーシングスケールも含まれます。アイコンのサイズ指定やカードの幅設定が必要になった瞬間、これらのクラスは機能しなくなります。 特に AI エージェントがよくやるのは、数値スペーシングトークンを再追加することです: ```css @theme { /* "Just add back what we need" */ --spacing-3: 12px; --spacing-4: 16px; --spacing-5: 20px; --spacing-8: 32px; --spacing-10: 40px; --spacing-16: 64px; } ``` これでは本末転倒です。Tailwind のデフォルト数値スケールをセマンティックな意味なく再インポートしているだけです。`h-4 w-4` は何も伝えません — アイコンなのか、スペーサーなのか、装飾要素なのか分かりません。タイトトークン戦略が解決しようとしていた問題に逆戻りしてしまいます。 ### このプロパティが特殊な理由 CSS プロパティの中には、抽象化する価値のある自然なスケールを持つものがあります: - **スペーシング**(padding、margin、gap)— UI 全体で一貫したリズムがある → セマンティック軸(hsp/vsp)が理にかなう - **フォントサイズ** — キャプションから見出しまで明確な階層がある → 抽象スケール(xs〜2xl)が理にかなう - **カラー** — 生の値のパレットをロールにマッピングする → 3層が理にかなう **幅・高さは異なります。** 16px のアイコン、40px のアバター、320px のカード、64px のサイドバートグルには共通点がありません。自然な段階もリズムも階層もありません。`size-4`、`size-8`、`size-16` のような抽象スケールは、意味があるふりをした単なる任意の数値です。 ## 解決方法 **2層アプローチ**を使います — 抽象スケールは完全にスキップします: | 層 | 名前 | 目的 | 例 | | --- | --- | --- | --- | | 1 | **テーマ** | ビジュアルシステムを定義するデザインレベルのサイズ | `--icon-sm: 16px` | | 2 | **コンポーネント** | 1つのコンポーネントに固有の一回限りのサイズ | `w-[28px] h-[28px]` | 重要なポイント:**幅・高さには Tier 0(抽象スケール)がありません。** セマンティックな名前が最初で唯一のトークンレイヤーです。それ以外はすべて任意の値です。 ### Tier 1: テーマトークン サイズが**デザイン判断**を表す場合 — UI の構成に関する意図的な選択の場合にトークンを定義します: ```css @theme { /* Icon sizes — a design system decision */ --spacing-icon-sm: 16px; --spacing-icon-md: 20px; --spacing-icon-lg: 24px; /* Avatar sizes — a design system decision */ --spacing-avatar-sm: 32px; --spacing-avatar-md: 40px; --spacing-avatar-lg: 56px; /* Layout sizes — an architectural decision */ --spacing-content-width: 800px; --spacing-card-width: 300px; } ``` これにより `w-icon-md h-icon-md`、`w-avatar-sm h-avatar-sm`、`max-w-content-width` などのユーティリティが生成されます。クラス名が自己文書化されており、そのサイズが何のためのものか一目で分かります。 トークンを作成するかどうかは**アーキテクチャとデザインの判断**であり、使用回数の閾値ではありません。デザインが「メインカラムは 800px」と定めているから `--spacing-content-width: 800px` を定義するのです — たとえ今日1つのコンポーネントしか使っていなくても。これはブランドカラーやタイプスケールの選択と同じ種類の判断です:デザインから来るのであって、何ファイルがその値を参照しているか数えて決めるものではありません。 これはアプリケーションアーキテクチャにおける「このロジックをユーティリティ関数に切り出すべきか?」の議論に似ています。答えは「3ファイルがインポートしたら」ではなく、その概念がシステム内で名前を持つに値するかどうかです。 ### Tier 2: 任意の値 それ以外のすべて — 一回限りのコンポーネント寸法、計算されたレイアウト、構造的な詳細 — には Tailwind のブラケット構文を使います: ```html ... ... ... ``` 値が1つのコンポーネントの構造的な詳細(ボタンの正確なパディング、グリッドのカラムテンプレートなど)に過ぎない場合、任意の値が正しい選択です。値がシステムを定義するデザイン判断を表す場合は、現在何個のコンポーネントが使っているかに関係なく Tier 1 に属します。 ## デモ ### 間違ったアプローチ: 数値サイズの再インポート このデモは、Tailwind の数値スペーシングスケールを幅・高さに再追加した場合に何が起こるかを示しています。クラス名には意味がありません — `size-4`、`size-5`、`size-8` は何をサイズ指定しているのか伝えてくれません。 @theme tokens added --spacing-3: 12px --spacing-4: 16px --spacing-5: 20px --spacing-8: 32px --spacing-10: 40px --spacing-16: 64px Usage in components h-4 w-4 ← Icon? Spacer? Dot? h-5 w-5 ← Bigger icon? Badge? h-8 w-8 ← Avatar? Button? Thumbnail? h-10 w-10 ← Who knows? Numbers tell you how big, but not what for. This is the spacing-drift problem all over again. `} css={`.wrong-demo { padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 0.75rem; } .wrong-demo__section { display: flex; flex-direction: column; gap: 0.4rem; } .wrong-demo__label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .wrong-demo__tokens { display: flex; flex-wrap: wrap; gap: 4px; } .wrong-demo__tokens code { font-size: 0.65rem; background: hsl(0 80% 95%); color: hsl(0 60% 40%); padding: 2px 6px; border-radius: 3px; border: 1px solid hsl(0 60% 85%); } .wrong-demo__examples { display: flex; flex-direction: column; gap: 6px; } .wrong-demo__row { display: flex; align-items: center; gap: 8px; } .wrong-demo__box { background: hsl(215 16% 47%); border-radius: 3px; flex-shrink: 0; } .wrong-demo__row code { font-size: 0.7rem; font-weight: 600; color: hsl(222 47% 11%); width: 56px; } .wrong-demo__q { font-size: 0.65rem; color: hsl(0 60% 50%); font-style: italic; } .wrong-demo__verdict { font-size: 0.7rem; color: hsl(0 60% 40%); background: hsl(0 80% 95%); padding: 0.4rem 0.6rem; border-radius: 6px; border-left: 3px solid hsl(0 60% 50%); line-height: 1.4; }`} /> ### 正しいアプローチ: 共有サイズにテーマトークンを使う セマンティックなテーマトークンを使えば、クラス名が自己文書化されます。`w-icon-md h-icon-md` の意味はすべての開発者が分かります。アイコンサイズを変更すれば、すべてのコンポーネントが一度に更新されます。 @theme tokens --spacing-icon-sm: 16px --spacing-icon-md: 20px --spacing-icon-lg: 24px --spacing-avatar-sm: 32px --spacing-avatar-md: 40px Toolbar w-icon-md h-icon-md Comment T Takeshi Great article! w-avatar-sm h-avatar-sm Header App T Same tokens, different component `} css={`.right-demo { padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 0.75rem; } .right-demo__section { display: flex; flex-direction: column; gap: 0.4rem; } .right-demo__label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .right-demo__tokens { display: flex; flex-wrap: wrap; gap: 4px; } .right-demo__tokens code { font-size: 0.65rem; background: hsl(142 50% 93%); color: hsl(142 50% 30%); padding: 2px 6px; border-radius: 3px; border: 1px solid hsl(142 40% 78%); } .right-demo__components { display: flex; gap: 12px; } .right-demo__comp { flex: 1; background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; padding: 0.6rem; display: flex; flex-direction: column; gap: 0.4rem; } .right-demo__comp-label { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .right-demo__toolbar { display: flex; gap: 6px; } .right-demo__icon-btn { padding: 6px; background: hsl(210 40% 96%); border-radius: 6px; } .right-demo__icon { background: hsl(221 83% 53%); border-radius: 3px; } .right-demo__comment { display: flex; gap: 8px; align-items: flex-start; } .right-demo__avatar { background: hsl(221 83% 53%); border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: hsl(0 0% 100%); font-size: 0.7rem; font-weight: 700; } .right-demo__comment-body { min-width: 0; } .right-demo__comment-name { font-size: 0.72rem; font-weight: 600; } .right-demo__comment-text { font-size: 0.68rem; color: hsl(215 16% 47%); } .right-demo__header { display: flex; justify-content: space-between; align-items: center; background: hsl(222 47% 11%); color: hsl(0 0% 100%); padding: 0.4rem 0.6rem; border-radius: 6px; } .right-demo__header-logo { font-size: 0.75rem; font-weight: 700; } .right-demo__header-icons { display: flex; gap: 8px; align-items: center; } .right-demo__usage { font-size: 0.6rem; color: hsl(142 50% 30%); background: hsl(142 50% 93%); padding: 2px 6px; border-radius: 3px; align-self: flex-start; }`} /> ### 比較: 抽象的な数値 vs セマンティックな名前 同じ UI 要素を2つの方法でサイズ指定した比較です。左側は何を意味するか分からない抽象的な数値トークン、右側はそのサイズが何のためかを正確に伝えるセマンティックなテーマトークンです。 Abstract numbers size-4 icon? T size-8 avatar? size-5 bigger icon? T size-10 bigger avatar? Numbers don't communicate intent Semantic names icon-sm small icon T avatar-sm small avatar icon-md medium icon T avatar-md medium avatar Names tell you what it's for `} css={`.compare { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .compare__col { display: flex; flex-direction: column; gap: 0.5rem; } .compare__heading { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .compare__card { background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; padding: 0.6rem; display: flex; flex-direction: column; gap: 8px; flex: 1; } .compare__row { display: flex; align-items: center; gap: 8px; } .compare__box { background: hsl(215 16% 47%); border-radius: 3px; flex-shrink: 0; } .compare__circle { background: hsl(221 83% 53%); border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: hsl(0 0% 100%); font-size: 0.65rem; font-weight: 700; } .compare__row code { font-size: 0.68rem; font-weight: 600; width: 72px; } .compare__what { font-size: 0.62rem; color: hsl(0 60% 50%); font-style: italic; } .compare__clear { font-size: 0.62rem; color: hsl(142 50% 30%); } .compare__note { font-size: 0.65rem; padding: 0.3rem 0.5rem; border-radius: 4px; line-height: 1.3; } .compare__note--bad { background: hsl(0 80% 95%); color: hsl(0 60% 40%); border-left: 3px solid hsl(0 60% 50%); } .compare__note--good { background: hsl(142 50% 93%); color: hsl(142 50% 30%); border-left: 3px solid hsl(142 50% 40%); }`} /> ### Tier 2 の実践: 一回限りの任意の値 コンポーネント固有のサイジングには Tailwind のブラケット構文を使います。これらの値はコンポーネント内にとどめます。コンテキスト外では意味を持たないため、トークンに昇格させません。 Icon button — custom padding for visual balance w-[28px] h-[28px] p-[4px] Sidebar — structural width Sidebar w-[240px] Grid layout — template columns Nav Content area grid-cols-[200px_1fr] Main content — calculated height Main h-[calc(100vh-64px)] All arbitrary — unique to one component, not worth tokenizing `} css={`.arb-demo { padding: 1rem; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 0.6rem; } .arb-demo__section { display: flex; flex-direction: column; gap: 4px; } .arb-demo__label { font-size: 0.65rem; color: hsl(215 16% 47%); } .arb-demo__row { display: flex; align-items: center; gap: 10px; } .arb-demo__row code { font-size: 0.65rem; font-weight: 600; color: hsl(221 83% 53%); background: hsl(210 40% 96%); padding: 2px 6px; border-radius: 3px; } .arb-demo__icon-btn { width: 28px; height: 28px; padding: 4px; background: hsl(210 40% 96%); border: 1px solid hsl(214 32% 91%); border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; } .arb-demo__icon { background: hsl(221 83% 53%); border-radius: 3px; } .arb-demo__sidebar { width: 80px; height: 28px; background: hsl(222 47% 11%); color: hsl(0 0% 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; font-weight: 600; } .arb-demo__grid { display: grid; grid-template-columns: 60px 1fr; width: 160px; height: 28px; border: 1px solid hsl(214 32% 91%); border-radius: 4px; overflow: hidden; } .arb-demo__grid-col1 { background: hsl(210 40% 96%); font-size: 0.6rem; display: flex; align-items: center; justify-content: center; border-right: 1px solid hsl(214 32% 91%); } .arb-demo__grid-col2 { font-size: 0.6rem; display: flex; align-items: center; justify-content: center; color: hsl(215 16% 47%); } .arb-demo__calc { width: 120px; height: 28px; background: hsl(210 40% 96%); border: 1px solid hsl(214 32% 91%); border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: hsl(215 16% 47%); } .arb-demo__note { font-size: 0.65rem; color: hsl(215 16% 47%); font-style: italic; }`} /> ## Tailwind CSS との統合 Tailwind v4 プロジェクトでの使い方: **Tier 1** → `@theme` ブロックに `--spacing-*` プレフィックスを付けて定義します(`w-*` や `h-*` ユーティリティで使えるようにするため): ```css @theme { /* Element sizing tokens — only shared, semantic sizes */ --spacing-icon-sm: 16px; --spacing-icon-md: 20px; --spacing-icon-lg: 24px; --spacing-avatar-sm: 32px; --spacing-avatar-md: 40px; --spacing-avatar-lg: 56px; } ``` 使い方:`w-icon-md h-icon-md`、`w-avatar-sm h-avatar-sm` **Tier 2** → 一回限りの値には Tailwind のブラケット構文を使います: ```html ... ... ... ``` ## AI がよくやるミス - **数値スペーシングスケールの再追加** — `--spacing-4: 16px`、`--spacing-8: 32px` などを `@theme` にインポートし直すと、タイトトークン戦略が台無しになります。意味のない数値でしかありません - **抽象的なサイズトークンの作成** — `--size-sm`、`--size-md`、`--size-lg` に 16px、32px、64px のような値を設定するのは汎用的すぎます。「小さいサイズ」とは何でしょうか? アイコン? アバター? ボタン? - **スペーシングトークンを要素のサイジングに使用** — `hsp-sm` は水平方向の padding/margin 用であり、アイコンの幅用ではありません。異なる概念には異なるトークンを使います - **すべてのユニークな寸法にトークンを追加** — すべてのピクセル値が名前に値するわけではありません。`top-[calc(100%-2px)]` のような計算されたオフセットは構造的な詳細であり、デザイン判断ではありません - **Tailwind のデフォルトを「念のため」移植** — デザイン上の理由なく数値スペーシングトークンを追加すると、タイト戦略が防ごうとしていた制約のないパレットが生まれます。トークンはデザイン判断から来るべきであり、将来の使用を見越して作るものではありません ## 使い分け - **タイトトークン戦略を使用しているプロジェクト**で、要素のサイジング(アイコン、アバター、カード、サムネイル)が必要な場合 - **AI エージェントがコンポーネントを構築する場合** — AI は一貫して `h-4 w-4` を使おうとしますが、タイトトークンプロジェクトではこれは存在しません。この記事が正しいアプローチを説明しています - **チームが数値サイズトークンの追加を議論している場合** — この記事がなぜ追加すべきでないかの根拠を提供します ### Tailwind + コンポーネントファースト vs 一般的な CSS 2層アプローチは CSS 方法論に関係なく同じように機能しますが、Tier 2 のメカニズムが異なります: - **Tailwind + コンポーネントファースト** — Tier 2 の値は JSX 内の Tailwind ブラケット構文:`w-[28px]`、`grid-cols-[240px_1fr]`。コンポーネントファイルがスコーピングを提供します。CSS カスタムプロパティは不要です。 - **一般的な CSS(BEM、CSS Modules)** — Tier 2 の値はコンポーネントスコープの CSS カスタムプロパティ:`--_button-width: 28px`、`--_grid-sidebar: 240px`。アンダースコアプレフィックス(`--_`)がローカルスコープを示します。 ### 他のトークン戦略との比較 すべての CSS プロパティが同じ数の層を必要とするわけではありません: | プロパティ | 層数 | 理由 | | --- | --- | --- | | カラー | 3(パレット → テーマ → コンポーネント) | 生の値にはパレット構造があり、抽象化する価値がある | | フォントサイズ | 3(スケール → テーマ → コンポーネント) | キャプションから見出しまで明確な階層がある | | スペーシング | 2(セマンティック軸 hsp/vsp) | 一貫したリズムがあり、すでにセマンティック | | **幅・高さ** | **2(テーマ → コンポーネント)** | **自然なスケールがない — セマンティックな名前のみ** | 関連記事: - [3層カラー戦略](../color/three-tier-color-strategy/) — カラーの完全な3層アーキテクチャ - [3層フォントサイズ戦略](../../typography/font-sizing/three-tier-font-size-strategy/) — フォントサイズの3層アーキテクチャ - [コンポーネントトークンと任意の値](./tight-token-strategy/component-tokens/) — トークンと任意の値の使い分けに関する一般的なフレームワーク ## リファレンス - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 Arbitrary Values](https://tailwindcss.com/docs/adding-custom-styles#using-arbitrary-values) --- # zudo-css とは > Source: https://takazudomodular.com/pj/zcss/ja/docs/overview/what-is-zudo-css zudo-css は、主に AI コーディングエージェント向けに設計された CSS ベストプラクティスのドキュメントサイトです。AI エージェントが開発中に参照・適用できる、厳選された CSS テクニックやパターンを構造化されたリファレンスとして提供します。 ## 目的 AI コーディングエージェントは、動作はするものの古いパターンに従っていたり、シンプルなタスクを複雑にしすぎたり、モダンな CSS の機能を見逃していたりする CSS を生成しがちです。このドキュメントは、そのギャップを埋めるために以下を提供します。 - 問題を起点とした記事(何がうまくいかないかから始まる構成) - 各テクニックの動作を示すライブ CssPreview デモ - アプローチの選択に関する判断ガイダンス - AI がよくやるミスのセクション(具体的な落とし穴を解説) ## 技術スタック このサイトは以下の技術で構築されています。 - **Astro 5** — コンテンツコレクション(Content Collections)を備えた静的サイトジェネレーター - **MDX** — Markdown とインタラクティブコンポーネントを組み合わせたコンテンツフォーマット - **Tailwind CSS v4** — `@tailwindcss/vite` によるユーティリティファーストのスタイリング - **React 19** — 目次、サイドバー、カラースキームピッカーのためのインタラクティブアイランド - **Shiki** — デュアルテーマ対応のシンタックスハイライト **Cloudflare Pages** に GitHub Actions 経由でデプロイされています。 ## 記事カテゴリ 記事は CSS の領域ごとに整理されています。 | カテゴリ | トピック | | --- | --- | | Layout | センタリング、Flexbox、Grid、subgrid、ポジショニング、スタッキングコンテキスト、anchor positioning、aspect-ratio、論理プロパティ、gap vs margin、マルチカラム、fit/max/min-content、clamp() | | Typography | 3階層フォントサイズ戦略、フルイドフォントサイズ、行の高さ、テキストオーバーフロー/行クランプ、バーティカルリズム、フォントローディング、可変フォント、日本語フォント、text-wrap balance/pretty | | Color | 3階層カラー戦略、パレット戦略、OKLCH、color-mix()、currentColor、ダークモード、コントラスト/アクセシビリティ | | Visual | 多層シャドウ、スムーズシャドウトランジション、グラデーション、ボーダー、clip-path/mask、フィルター、backdrop-filter、ブレンドモード、3D transforms、@property、CSSのみのパターン | | Responsive | コンテナクエリ、clamp() によるフルイドデザイン、メディアクエリのベストプラクティス、レスポンシブグリッドパターン、レスポンシブ画像 | | Interactive | トランジション、hover/focus/active 状態、scroll snap、scroll-driven animations、view transitions、:has()、:is()/:where()、親状態の子スタイリング、フォームコントロール、タッチターゲット、overscroll、prefers-reduced-motion | | Methodology | コンポーネントファースト戦略、タイトトークン戦略(コンポーネントトークン、タイポグラフィトークン、カラートークン)、2階層サイズ戦略、BEM、CSS Modules、カスケードレイヤー、カスタムプロパティパターン、テーマ設定レシピ | ## 記事の構成 すべての記事は一貫したパターンに従っています。 1. **問題** — よくあるミスとうまくいかない点 2. **解決方法** — CssPreview デモ付きの推奨アプローチ 3. **追加セクション** — より深いテクニックと追加デモ 4. **使い分け** — テクニックを適用するための判断ガイダンス ## CssPreview デモ 各記事で最も価値のあるパートです。CssPreview は CSS デモを、ビューポートコントロール(モバイル 320px、タブレット 768px、フル幅)付きの分離された iframe 内でレンダリングします。デモ内のインタラクションはすべて CSS のみで、JavaScript は使用しません。 ## AI との統合 このサイトには、すべての記事をインデックス化する `css-wisdom` という Claude Code スキルが含まれています。インストールすると、AI エージェントは開発中に `/css-wisdom ` を呼び出して関連する CSS パターンを検索できます。詳しくは [css-wisdom スキル](../css-wisdom-skill) のページを参照してください。 --- # clamp()によるフルイドデザイン > Source: https://takazudomodular.com/pj/zcss/ja/docs/responsive/fluid-design-with-clamp ## 問題 従来のレスポンシブデザインは離散的なブレークポイントに依存しています。特定のビューポート幅で固定値を設定し、その間で急激に変化します。これにより不自然な遷移が生じ、管理するために複数のメディアクエリが必要になります。AIエージェントは通常、フルイドスケーリングを使わず、硬直したブレークポイントベースの値(例:モバイルで `font-size: 1rem`、デスクトップで `font-size: 1.5rem`)を生成してしまい、コード量が増え、洗練されていない体験になります。 ## 解決方法 CSS の `clamp()` 関数は、通常ビューポート単位を含む推奨値に基づいて、最小値と最大値の間でフルイドにスケールする値を定義します。これにより、多くのサイジングに関してブレークポイントが不要になります。 ```css /* clamp(minimum, preferred, maximum) */ font-size: clamp(1rem, 0.5rem + 1.5vw, 2rem); ``` - **Minimum**: 値が取りうる最小サイズです。 - **Preferred**: フルイドな中間値で、通常 `vw` と `rem` ベースを組み合わせます。 - **Maximum**: 値が取りうる最大サイズです。 Fluid Heading This text and its container use clamp() for fluid sizing. The heading, paragraph text, padding, and container width all scale smoothly between minimum and maximum values based on viewport width. No breakpoints needed. Fluid padding & width `} css={` .fluid-demo { max-width: clamp(16rem, 90%, 50rem); margin: 0 auto; padding: clamp(0.75rem, 0.5rem + 2vw, 2.5rem); } .fluid-heading { font-size: clamp(1.25rem, 0.75rem + 2.5vw, 2.5rem); font-weight: 700; color: #1e293b; margin: 0 0 0.75rem; line-height: 1.2; } .fluid-text { font-size: clamp(0.875rem, 0.8rem + 0.25vw, 1.125rem); line-height: 1.6; color: #475569; margin: 0 0 1.5rem; } .fluid-box { background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; padding: clamp(0.75rem, 0.5rem + 1.5vw, 2rem); border-radius: 0.5rem; font-size: clamp(0.75rem, 0.65rem + 0.5vw, 1.125rem); font-weight: 600; text-align: center; } `} /> ## コード例 ### フルイドタイポグラフィ ```css h1 { font-size: clamp(1.75rem, 1rem + 2.5vw, 3rem); } h2 { font-size: clamp(1.375rem, 0.875rem + 1.5vw, 2.25rem); } h3 { font-size: clamp(1.125rem, 0.75rem + 1vw, 1.75rem); } p { font-size: clamp(1rem, 0.875rem + 0.25vw, 1.125rem); } ``` ### フルイドスペーシング ```css .section { padding-block: clamp(2rem, 1rem + 3vw, 5rem); padding-inline: clamp(1rem, 0.5rem + 2vw, 3rem); } .stack > * + * { margin-block-start: clamp(1rem, 0.5rem + 1vw, 2rem); } ``` ### フルイドレイアウト寸法 ```css .container { max-width: clamp(20rem, 90vw, 75rem); margin-inline: auto; } .sidebar { width: clamp(15rem, 25vw, 20rem); } .gap-fluid { gap: clamp(0.5rem, 0.25rem + 1vw, 2rem); } ``` ### フルイドな行の高さと文字間隔 ```css p { font-size: clamp(1rem, 0.875rem + 0.25vw, 1.125rem); line-height: clamp(1.5, 1.4 + 0.2vw, 1.8); letter-spacing: clamp(0px, 0.02em + 0.01vw, 0.04em); } ``` ### 推奨値の計算方法 推奨値の計算式は以下のパターンに従います: ``` preferred = base-rem-value + viewport-unit-value ``` 特定の2つのビューポート幅の間でスケールする値を計算するには: ```css /* Scale from 1rem at 320px to 2rem at 1200px: Slope = (max - min) / (max-viewport - min-viewport) Slope = (2 - 1) / (75 - 20) = 0.01818rem per rem of viewport In vw: 0.01818 * 100 = 1.818vw Intercept = min - slope * min-viewport Intercept = 1 - 0.01818 * 20 = 0.636rem Result: clamp(1rem, 0.636rem + 1.818vw, 2rem) */ .fluid-text { font-size: clamp(1rem, 0.636rem + 1.818vw, 2rem); } ``` ## AIがよくやるミス - **ブレークポイントだけに頼る**: 単一の `clamp()` 式で済む場面で、固定値を使った複数の `@media` クエリを生成してしまいます。 - **`rem` なしで `vw` だけを使う**: `font-size: clamp(1rem, 3vw, 2rem)` のように推奨値が純粋な `vw` になっているケースです。これではユーザーのフォントサイズ設定に連動してスケールしません。必ず `vw` と `rem` ベースを組み合わせましょう。 - **非現実的な最小値/最大値**: 範囲が狭すぎる(フルイドな変化が見えない)か、広すぎる(極端なサイズでテキストが読めなくなる)設定をしてしまいます。 - **アクセシビリティを忘れる**: `vw` 単位を使ったフルイドタイポグラフィはブラウザのズームに干渉する可能性があります。200%ズームで必ずテストし、テキストが読めることを確認しましょう。 - **単純な `max-width` で十分な場面で `clamp()` を使う**: すべての値をフルイドにする必要はありません。スムーズなスケーリングが体験を改善する場面で `clamp()` を使いましょう。 - **最小値/最大値にピクセル値を使う**: ピクセルはユーザーのフォントサイズ設定に応じてスケールしません。最小値と最大値には `rem` を使いましょう。 ## 使い分け - **タイポグラフィ**: モバイルとデスクトップの間でスムーズにスケールすべき見出しや本文のフォントサイズに使いましょう。 - **スペーシング**: ビューポートに比例して拡大すべきパディング、マージン、ギャップに使いましょう。 - **レイアウト幅**: フルイドな挙動が必要なコンテナ幅、サイドバー幅、max-width に使いましょう。 - **色や離散的な値には不向き**: `clamp()` は数値のCSS値で機能します。`display`、`color`、`grid-template-columns` パターンなどのプロパティには適用できません。 ## 参考リンク - [clamp() — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/clamp) - [Modern Fluid Typography Using CSS Clamp — Smashing Magazine](https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/) - [Fluid Typography with CSS Clamp — XenonStack](https://www.xenonstack.com/blog/fluid-typography-css-clamp) - [CSS Clamp Guide — Clamp Generator](https://clampgenerator.com/guides/css-clamp/) --- # color-mix() > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/color/color-mix ## 問題 CSSでティント、シェード、半透明のカラーバリアントを作成するには、従来はすべての色値を手動で計算する必要がありました。AIエージェントはすべてのシェードを個別の hex や rgb 値としてハードコードしがちで(例:`#3366cc`、`#5588dd`、`#99bbee` を独立して生成)、パレットが脆く保守しにくくなります。ベースのブランドカラーが変わると、すべての派生値を再計算する必要があります。Sassのような CSSプリプロセッサは `lighten()` や `darken()` でこれを解決しましたが、ネイティブCSSには同等の機能がありませんでした — `color-mix()` が登場するまでは。 ## 解決方法 `color-mix()` は、指定されたカラースペースで2つの色をブレンドし、結果の色を返すCSS関数です。カラースペース、2つの色、およびミックス比率を制御するオプションのパーセンテージ値を取ります。 ``` color-mix(in , ?, ?) ``` カラースペースパラメータは、補間の計算方法を決定します。`oklch` や `oklab` を使うと、`srgb` よりも知覚的に均一なブレンドが得られます。 ## コード例 ### 基本構文 ```css :root { --brand: oklch(55% 0.25 264); /* Mix 70% brand with 30% white = a lighter tint */ --brand-light: color-mix(in oklch, var(--brand) 70%, white); /* Mix 70% brand with 30% black = a darker shade */ --brand-dark: color-mix(in oklch, var(--brand) 70%, black); /* Equal mix of two colors */ --blend: color-mix(in oklch, var(--brand), orange); } ``` ### 単一ベースカラーからティントとシェードを作成 ```css :root { --brand: oklch(50% 0.22 264); /* Tints (lighter) — mixing with white */ --brand-50: color-mix(in oklch, var(--brand) 5%, white); --brand-100: color-mix(in oklch, var(--brand) 10%, white); --brand-200: color-mix(in oklch, var(--brand) 25%, white); --brand-300: color-mix(in oklch, var(--brand) 40%, white); --brand-400: color-mix(in oklch, var(--brand) 60%, white); /* Base */ --brand-500: var(--brand); /* Shades (darker) — mixing with black */ --brand-600: color-mix(in oklch, var(--brand) 80%, black); --brand-700: color-mix(in oklch, var(--brand) 60%, black); --brand-800: color-mix(in oklch, var(--brand) 40%, black); --brand-900: color-mix(in oklch, var(--brand) 25%, black); } ``` Tints (mixing with white) 10% 25% 50% 75% Base Shades (mixing with black) Base 75% 50% 25% 10% Semi-transparent variants (mixing with transparent) 10% 25% 50% 75% 100% `} css={`.mix-demo { padding: 1.5rem; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 1.25rem; } .section h3 { font-size: 0.8rem; color: #555; margin: 0 0 0.5rem; font-weight: 600; } .row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; } .chip { height: 48px; border-radius: 8px; display: flex; align-items: center; justify-content: center; } .chip span { font-size: 0.75rem; font-weight: 600; color: #333; } .chip[style*="color: white"] span { color: white; } .checkerboard { background-image: repeating-conic-gradient(#e0e0e0 0% 25%, white 0% 50%); background-size: 16px 16px; padding: 0.5rem; border-radius: 8px; }`} height={320} /> ### 半透明バリアント `transparent` とミックスすることで、任意の色の半透明バージョンを作成できます。ホバー状態、オーバーレイ、背景に特に便利です: ```css :root { --brand: oklch(55% 0.25 264); /* Semi-transparent variants */ --brand-alpha-10: color-mix(in oklch, var(--brand) 10%, transparent); --brand-alpha-20: color-mix(in oklch, var(--brand) 20%, transparent); --brand-alpha-50: color-mix(in oklch, var(--brand) 50%, transparent); } .hover-card:hover { background-color: var(--brand-alpha-10); } .overlay { background-color: var(--brand-alpha-50); } .subtle-border { border-color: var(--brand-alpha-20); } ``` ### インタラクティブステートカラー ```css :root { --btn-bg: oklch(55% 0.22 264); --btn-hover: color-mix(in oklch, var(--btn-bg), white 15%); --btn-active: color-mix(in oklch, var(--btn-bg), black 15%); --btn-disabled: color-mix(in oklch, var(--btn-bg) 40%, oklch(70% 0 0)); } .button { background: var(--btn-bg); } .button:hover { background: var(--btn-hover); } .button:active { background: var(--btn-active); } .button:disabled { background: var(--btn-disabled); } ``` ### カラースペースの比較 ```css :root { --red: oklch(60% 0.25 30); --blue: oklch(55% 0.25 264); /* sRGB interpolation — can produce muddy, desaturated results */ --mix-srgb: color-mix(in srgb, var(--red), var(--blue)); /* oklch interpolation — maintains vibrancy through the blend */ --mix-oklch: color-mix(in oklch, var(--red), var(--blue)); } ``` ### 単一ベースカラーからの動的テーマ ```css :root { --base: oklch(55% 0.2 264); --surface: color-mix(in oklch, var(--base) 5%, white); --surface-raised: color-mix(in oklch, var(--base) 10%, white); --border: color-mix(in oklch, var(--base) 20%, oklch(80% 0 0)); --text: color-mix(in oklch, var(--base) 40%, black); --text-muted: color-mix(in oklch, var(--base) 30%, oklch(50% 0 0)); --accent: var(--base); --accent-hover: color-mix(in oklch, var(--base), white 20%); } ``` ### 隣接するカラートークンのブレンド ```css :root { --success: oklch(60% 0.2 145); --warning: oklch(70% 0.2 85); /* Blend between semantic colors for status transitions */ --status-improving: color-mix(in oklch, var(--warning) 60%, var(--success)); } ``` ## AIがよくやるミス - `color-mix()` で単一ベースからティントとシェードを導出する代わりに、すべての色シェードを個別の hex 値としてハードコードしている - `in oklch` や `in oklab` の方が視覚的に均一な結果を生むのに、ブレンドに `in srgb` を使っている — sRGBブレンドは特に彩度の高い色間でくすんだ中間点を作る - カラースペースパラメータを完全に省略している — これは仕様で必須です - パーセンテージの意味を混同している:`color-mix(in oklch, red 70%, blue)` は70%の赤と30%の青を意味し、青の70%を加えるという意味ではない - `color-mix()` がネイティブで処理できる色操作に JavaScript や CSSプリプロセッサ(`sass darken()`、`lighten()`)を使っている - `transparent` とミックスすることで色の半透明バージョンが作れることを知らない — AIエージェントは今でも `rgba()` や手動のアルファ値を使いがち - 明度を調整したシンプルな `oklch()` 値の方がより明確で保守しやすい場面で `color-mix()` を使っている ## 使い分け - **デザインシステム**: 単一のブランドカラーからシェードスケール全体を導出 - **インタラクティブステート**: hover、active、focus、disabled のカラーバリアントを体系的に生成 - **半透明オーバーレイ**: rgba 値をハードコードせずにセマンティックカラーのアルファバリアントを作成 - **テーマカスタマイズ**: ユーザーにベースカラーを選ばせ、そこからすべてのUIカラーを導出 - **セマンティックトークンのブレンド**: ステータス遷移のために success/warning/danger カラー間をミックス ### 使わない方がよい場面 - ベースに関連付ける必要のないシンプルな静的カラー — `oklch()` や `hex` を直接使いましょう - すべてのシェードが、白/黒との単純なミックスに従わない、デザイナーが正確に指定した値を必要とする場合 - 正確な仕様に一致する必要がある重要なブランドカラー(ミックス結果は補間カラースペースに依存します) ## 参考リンク - [MDN: color-mix()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/color-mix) - [CSS color-mix(): The Complete Guide — DevToolbox](https://devtoolbox.dedyn.io/blog/css-color-mix-complete-guide) - [Using color-mix() to create opacity variants — Una Kravets](https://una.im/color-mix-opacity/) - [Quick and Dirty Colour Palettes using color-mix() — Always Twisted](https://www.alwaystwisted.com/articles/quick-and-dirty-colour-palettes-using-color-mix) - [A deep dive into the CSS color-mix() function — DEV Community](https://dev.to/astrit/a-deep-dive-into-the-css-color-mix-function-and-future-of-colors-on-the-web-2pgi) --- # クリップパスとマスク > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/effects/clip-path-and-mask ## 問題 AIエージェントは `clip-path` やCSSマスクをほとんど使わず、デザインが斜めのエッジ、円形のリビール、フェードするボーダーを明らかに求めている場合でも矩形のレイアウトにデフォルトで落ち着きます。非矩形の形状を試みる場合、画像、SVG、または `overflow: hidden` と回転した擬似要素を使った追加のラッパー div に頼ります。これらはネイティブのCSS解決策よりもはるかに複雑で壊れやすい方法です。 ## 解決方法 CSSは非矩形レンダリングのための2つの補完的なツールを提供しています。 - **`clip-path`** — 幾何学的なシェイプ関数(`polygon()`、`circle()`、`ellipse()`、`inset()`)やSVGパスを使ったハードエッジのクリッピングです。クリップの外側のコンテンツは非表示かつインタラクション不可になります。 - **`mask-image`** — 画像やグラデーションを使ったソフトエッジのマスキングです。マスクが黒(または不透明)の部分では要素が表示され、透明な部分では要素が隠されます。グラデーションにより滑らかなフェード効果が作成できます。 ## コード例 ### clip-path: 基本シェイプ ```css /* Circle clip */ .avatar-circle { clip-path: circle(50%); } /* Ellipse */ .banner-ellipse { clip-path: ellipse(60% 40% at 50% 50%); } /* Inset with rounded corners */ .rounded-inset { clip-path: inset(10px round 16px); } ``` ### clip-path: ポリゴンシェイプ ```css /* Triangle */ .triangle { clip-path: polygon(50% 0%, 0% 100%, 100% 100%); } /* Angled section edge */ .angled-section { clip-path: polygon(0 0, 100% 0, 100% 85%, 0 100%); } /* Chevron / arrow */ .chevron { clip-path: polygon( 0% 0%, 75% 0%, 100% 50%, 75% 100%, 0% 100%, 25% 50% ); } /* Hexagon */ .hexagon { clip-path: polygon( 25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50% ); } ``` ### 斜めのヒーローセクション ```css .hero { background: linear-gradient(135deg, #1a1a2e, #16213e); padding: 80px 24px 120px; clip-path: polygon(0 0, 100% 0, 100% 85%, 0 100%); } .next-section { margin-top: -60px; /* overlap into the angled area */ position: relative; z-index: 1; } ``` ```html Angled Hero Content overlaps the angled edge. ``` ### clip-path のアニメーション clip-path のシェイプは、シェイプ関数の種類とポイント数が同じである限り、トランジションやアニメーションが可能です。 ```css .reveal-circle { clip-path: circle(0% at 50% 50%); transition: clip-path 0.6s ease-out; } .reveal-circle.is-visible { clip-path: circle(75% at 50% 50%); } ``` ```css /* Morphing between two polygons with the same number of points */ .morph { clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); /* diamond */ transition: clip-path 0.4s ease; } .morph:hover { clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); /* rectangle */ } ``` ### CSS マスク: グラデーションフェード マスクは輝度またはアルファ値で可視性を決定します。不透明から透明へのグラデーションで滑らかなフェードが作成できます。 ```css /* Fade out at the bottom */ .fade-bottom { mask-image: linear-gradient(to bottom, black 60%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%); } /* Fade both edges horizontally */ .fade-edges { mask-image: linear-gradient( to right, transparent, black 15%, black 85%, transparent ); -webkit-mask-image: linear-gradient( to right, transparent, black 15%, black 85%, transparent ); } ``` ```html ``` ### フェードするエッジを持つスクロール可能コンテナ ```css .scroll-fade { overflow-x: auto; mask-image: linear-gradient( to right, transparent, black 40px, black calc(100% - 40px), transparent ); -webkit-mask-image: linear-gradient( to right, transparent, black 40px, black calc(100% - 40px), transparent ); } ``` ### 放射状グラデーションマスク(スポットライト効果) ```css .spotlight { mask-image: radial-gradient( circle at var(--x, 50%) var(--y, 50%), black 0%, black 20%, transparent 60% ); -webkit-mask-image: radial-gradient( circle at var(--x, 50%) var(--y, 50%), black 0%, black 20%, transparent 60% ); } ``` JavaScriptと組み合わせて `mousemove` で `--x` と `--y` カスタムプロパティを動かすと、インタラクティブなスポットライト効果になります。 ### clip-path と mask の組み合わせ ```css /* Hard clip for overall shape, soft mask for edge fading */ .shaped-fade { clip-path: polygon(0 0, 100% 0, 100% 80%, 50% 100%, 0 80%); mask-image: linear-gradient(to bottom, black 70%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%); } ``` ### SVG画像によるマスク ```css .masked-image { mask-image: url("mask-shape.svg"); mask-size: contain; mask-repeat: no-repeat; mask-position: center; -webkit-mask-image: url("mask-shape.svg"); -webkit-mask-size: contain; -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; } ``` ## ライブプレビュー Angled Hero Sectionclip-path: polygon() creates the diagonal edgeContent flows beneath the angled edge.`} css={` .wrapper { width: 100%; height: 100%; font-family: system-ui, sans-serif; } .hero { background: linear-gradient(135deg, #1e3a5f, #3b82f6); padding: 40px 24px 80px; clip-path: polygon(0 0, 100% 0, 100% 75%, 0 100%); color: white; } .hero h1 { margin: 0 0 8px; font-size: 24px; } .hero p { margin: 0; font-size: 14px; opacity: 0.85; } .content { margin-top: -30px; padding: 0 24px 24px; position: relative; z-index: 1; } .content p { margin: 0; font-size: 14px; color: #334155; } `} height={260} /> `} css={` .demo { display: flex; gap: 24px; justify-content: center; align-items: center; height: 100%; background: #0f172a; padding: 24px; } .circle-clip { width: 120px; height: 120px; background: linear-gradient(135deg, #3b82f6, #ec4899); clip-path: circle(50%); } .hex { background: linear-gradient(135deg, #8b5cf6, #06b6d4); clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%); } .diamond { background: linear-gradient(135deg, #f59e0b, #ef4444); clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); } `} height={220} /> This content gradually fades out at the bottom using a CSS mask with a linear gradient. The mask transitions from fully opaque (black) to transparent, creating a smooth fade effect without any images.This second paragraph is partially hidden by the fade, demonstrating how mask-image works with gradient values.`} css={` .demo { display: flex; justify-content: center; align-items: flex-start; height: 100%; background: #0f172a; padding: 24px; font-family: system-ui, sans-serif; } .fade-box { background: linear-gradient(135deg, #3b82f6, #8b5cf6); border-radius: 12px; padding: 24px; max-width: 360px; color: white; mask-image: linear-gradient(to bottom, black 40%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%); } .fade-box p { margin: 0 0 12px; font-size: 14px; line-height: 1.6; } .fade-box p:last-child { margin-bottom: 0; } `} height={240} /> ## AIがよくやるミス - **clip-path を全く使わない** — 矩形レイアウトにデフォルトで落ち着き、デザインが求める斜め、円形、幾何学的なセクションエッジを無視する。 - **clip-path の代わりに回転した擬似要素を使う** — `::before`/`::after` に `transform: rotate()` と `overflow: hidden` を使って斜めエッジを作成するのは、脆くてメンテナンスが難しい方法です。 - **異なるシェイプ関数間でアニメーションする** — `clip-path` のトランジションは、開始値と終了値が同じ関数(例:両方とも `polygon()`)を使い、同じポイント数の場合にのみ機能します。 - **マスクプロパティの `-webkit-` プレフィックスを忘れる** — Safariでは `-webkit-mask-image`、`-webkit-mask-size` などが必要です。これがないと、Safariではマスクが表示されません。 - **`mask-image` と `mask` ショートハンドを誤って使う** — `mask` ショートハンドは複雑なサブプロパティの解析があります。明確さと信頼性のために、個別のプロパティ(`mask-image`、`mask-size`、`mask-repeat`)を使いましょう。 - **クリップされた領域がインタラクティビティを失うことを忘れる** — `clip-path` 領域外のコンテンツは、非表示になるだけでなくクリックやホバーもできなくなるため、期待されるインタラクション領域が壊れる可能性があります。 - **単純な幾何学的マスクに画像を使う** — `clip-path: polygon()` やグラデーションマスクでネイティブに表現できるシェイプに、外部画像をロードしてしまう。 ## 使い分け - **斜めのセクション区切り** — 対角線やカーブのカットを持つヒーローセクション、フィーチャーブロック、フッターのエッジ - **円形や幾何学的な画像クロップ** — 追加のマークアップなしでアバター、サムネイル、装飾的な画像シェイプを作る - **フェードアウト効果** — エッジでフェードするスクロール可能コンテナ、画像リビール、コンテンツプレビュー - **ページ遷移アニメーション** — ルート変更時のサークルワイプやポリゴンモーフのリビール - **装飾的なUIシェイプ** — 六角形カード、ダイヤモンドバッジ、矢印コールアウト、非矩形レイアウト ## 参考リンク - [clip-path — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/clip-path) - [mask-image — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/mask-image) - [Clipping and Masking in CSS — CSS-Tricks](https://css-tricks.com/clipping-masking-css/) - [The Modern Guide for Making CSS Shapes — Smashing Magazine](https://www.smashingmagazine.com/2024/05/modern-guide-making-css-shapes/) - [CSS Masking — Ahmad Shadeed](https://ishadeed.com/article/css-masking/) - [Fade Out Overflow Using CSS Mask-Image — PQINA](https://pqina.nl/blog/fade-out-overflow-using-css-mask-image/) --- # ボーダーテクニック > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/shadows-and-borders/border-techniques ## 問題 AIエージェントは通常 `border: 1px solid #ccc` で済ませ、CSSが提供するより豊富なボーダー機能を探ることはほとんどありません。グラデーションボーダー、ダブルボーダー効果、アウトラインのテクニック、`border-radius` と `overflow: hidden` の相互作用は、日常的に見落とされるか誤って実装されます。最大の落とし穴は `border-image` と `border-radius` の組み合わせです。これらは互換性がなく、AIエージェントが両方を組み合わせると壊れたコードを生成します。 ## 解決方法 CSSは基本的な `border` ショートハンドを超えた、複数のボーダーエフェクト用プロパティを提供しています。`border-image`、`outline`、`box-shadow`、背景ベースのグラデーションボーダーテクニックをいつ使うべきかを理解することで、一般的な互換性の問題を防げます。 ## コード例 ### border-image によるグラデーションボーダー グラデーションボーダーの最もシンプルな構文です。直線エッジの要素にのみ機能します。 ```css .gradient-border-straight { border: 4px solid; border-image: linear-gradient(135deg, #3b82f6, #8b5cf6) 1; } ``` 末尾の `1`(`border-image-slice: 1`)は、グラデーション画像全体をボーダーの塗りとして使うようブラウザに指示します。 ### border-radius 付きグラデーションボーダー(background-clip アプローチ) `border-image` は `border-radius` と併用できません。代わりに background-clip テクニックを使います。 ```css .gradient-border-rounded { border: 3px solid transparent; border-radius: 12px; background: linear-gradient(white, white) padding-box, linear-gradient(135deg, #3b82f6, #8b5cf6) border-box; } ``` 最初の背景が padding-box を白(または希望する内側の色)で塗り、2番目が border-box をグラデーションで塗ります。透明なボーダーが下のグラデーションを透かして見せます。 ```html Content with a rounded gradient border. ``` ### カスタム背景色のグラデーションボーダー ```css /* Works on any background color */ .gradient-border-dark { --bg-color: #1a1a2e; border: 2px solid transparent; border-radius: 8px; background: linear-gradient(var(--bg-color), var(--bg-color)) padding-box, linear-gradient(135deg, #3b82f6, #ec4899) border-box; } ``` ### outline と border の違い `outline` はレイアウトに影響を与えず、`border-radius` を尊重せず(古いブラウザの場合)、ボーダーの外側に描画されます。最新のブラウザではアウトラインも `border-radius` に従います。 ```css /* Outline for focus indicators — does not shift layout */ .input-focus:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } /* Border changes shift layout unless using box-sizing carefully */ .input-border:focus { border: 2px solid #3b82f6; } ``` ### outline-offset による間隔のあるリング `outline-offset` は要素とそのアウトラインの間にギャップを作成します。フォーカスインジケーターや装飾的なリングに便利です。 ```css .ring-effect { border: 2px solid #3b82f6; outline: 2px solid #3b82f6; outline-offset: 4px; border-radius: 8px; } ``` ### box-shadow によるダブルボーダー `box-shadow` は追加のボーダーをシミュレートできます。`inset` シャドウは `border-radius` に従い、レイアウトに影響しないためです。 ```css /* Double border effect */ .double-border { border: 2px solid #3b82f6; border-radius: 8px; box-shadow: 0 0 0 4px white, 0 0 0 6px #3b82f6; } /* Triple ring effect */ .triple-ring { border-radius: 50%; box-shadow: 0 0 0 4px #3b82f6, 0 0 0 8px white, 0 0 0 12px #8b5cf6; } ``` ### inset シャドウによる内側ボーダー ```css .inner-border { border-radius: 12px; box-shadow: inset 0 0 0 2px #3b82f6; } ``` これにより、外側の寸法を変えずに要素の内側にボーダーが作成されます。 ### border-radius と overflow: hidden の注意点 `border-radius` と独自の背景を持つ子要素を組み合わせる場合、親に `overflow: hidden` を設定しないと、子要素の角がはみ出します。 ```css /* Without overflow: hidden — child corners poke through */ .card-broken { border-radius: 12px; border: 1px solid #e2e8f0; } .card-broken img { width: 100%; /* Image corners are square, poking outside the rounded card */ } /* Fixed with overflow: hidden */ .card-fixed { border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; } .card-fixed img { width: 100%; /* Image corners are clipped to the card's border-radius */ } ``` ```html Card Title ``` **注意点**: `overflow: hidden` は box-shadow や要素の境界を超えて広がるコンテンツ(ツールチップ、ドロップダウン)もクリップします。意図的に使いましょう。 ### background-clip によるパディング効果付き border-radius ```css /* Visible gap between border and content using background-clip */ .padded-border { border: 4px solid #3b82f6; border-radius: 12px; padding: 4px; background: #3b82f6; background-clip: content-box; } ``` ## ライブプレビュー Gradient border using border-image. Works on straight edges only — no border-radius.`} css={` .demo { display: flex; justify-content: center; align-items: center; height: 100%; background: #f8fafc; padding: 24px; font-family: system-ui, sans-serif; } .gradient-border-straight { border: 4px solid; border-image: linear-gradient(135deg, #3b82f6, #8b5cf6) 1; padding: 24px; max-width: 320px; } .gradient-border-straight p { margin: 0; font-size: 14px; color: #334155; } `} height={200} /> Rounded gradient border using the background-clip technique.`} css={` .demo { display: flex; justify-content: center; align-items: center; height: 100%; background: #f8fafc; padding: 24px; font-family: system-ui, sans-serif; } .gradient-border-rounded { border: 3px solid transparent; border-radius: 16px; background: linear-gradient(white, white) padding-box, linear-gradient(135deg, #3b82f6, #ec4899) border-box; padding: 24px; max-width: 320px; } .gradient-border-rounded p { margin: 0; font-size: 14px; color: #334155; } `} height={200} /> Double border using box-shadow spread`} css={` .demo { display: flex; justify-content: center; align-items: center; height: 100%; background: #f8fafc; padding: 32px; font-family: system-ui, sans-serif; } .double-border { border: 2px solid #3b82f6; border-radius: 12px; box-shadow: 0 0 0 5px white, 0 0 0 7px #8b5cf6; padding: 24px; background: white; max-width: 320px; } .double-border p { margin: 0; font-size: 14px; color: #334155; } `} height={200} /> Ring effect with outline-offset`} css={` .demo { display: flex; justify-content: center; align-items: center; height: 100%; background: #f8fafc; padding: 32px; font-family: system-ui, sans-serif; } .ring-box { border: 2px solid #3b82f6; outline: 2px solid #8b5cf6; outline-offset: 4px; border-radius: 12px; padding: 24px; background: white; max-width: 320px; } .ring-box p { margin: 0; font-size: 14px; color: #334155; } `} height={200} /> ## AIがよくやるミス - **border-image と border-radius を組み合わせる** — これは最もよくある間違いです。`border-image` は `border-radius` を完全に無視し、radius を宣言しているにもかかわらず角が四角になります。 - **フォーカスインジケーターに border を使う** — ボーダーの変更はレイアウトをずらします。フォーカス状態には `outline` と `outline-offset` が正しいアプローチです。 - **角丸コンテナに overflow: hidden を忘れる** — これがないと、子の画像や色付きセクションが角丸からはみ出します。 - **マルチリング効果に box-shadow を使わない** — AIエージェントは装飾的なボーダーのために追加の div をネストしようとしますが、ブラーなしの `box-shadow` スプレッドでクリーンに処理できます。 - **border-image-slice を誤解する** — グラデーション使用時にスライス値の `1` を忘れ、空のボーダーになります。 - **グラデーションボーダーに複雑なラッパー div を使う** — `background-clip: padding-box, border-box` テクニックにより余分なマークアップは不要です。 ## 使い分け - **グラデーションボーダー** — フィーチャーカード、ハイライトされたセクション、視覚的に差をつけたいCTA - **outline + outline-offset** — レイアウトをずらさないアクセシブルなフォーカスインジケーター - **box-shadow リング** — アバターリング、ステータスインジケーター、装飾的なマルチボーダーエフェクト - **inset box-shadow** — 外側の寸法を変えない内側ボーダー - **角丸コンテナの overflow: hidden** — 画像や色付き子要素を持つ border-radius 付きのカードやコンテナ ## Tailwind CSS Tailwindはボーダーエフェクト用に `border-*`、`ring-*`、`outline-*`、`divide-*` ユーティリティを提供しています。`ring-*` ユーティリティは、レイアウトシフトなしのフォーカスインジケーターや装飾的なリングエフェクトに特に便利です。 ### ボーダーとリングエフェクト border-2 ring-2 ring + offset outline + offset `} height={240} /> ### Divide ユーティリティ First item Second item Third item Fourth item `} height={220} /> ### フォーカスリングパターン Focused Button Outlined Focus `} height={180} /> ## 参考リンク - [Gradient Borders in CSS — CSS-Tricks](https://css-tricks.com/gradient-borders-in-css/) - [border-image — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/border-image) - [Border with Gradient and Radius — Temani Afif](https://dev.to/afif/border-with-gradient-and-radius-387f) - [outline — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/outline) - [outline-offset — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/outline-offset) --- # 3層フォントサイズ戦略 > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/font-sizing/three-tier-font-size-strategy ## 問題 UI を構築する際、フォントサイズの値を直接使いたくなりがちです。あるコンポーネントでは `font-size: 1.25rem`、別のコンポーネントでは `font-size: 20px`、さらに別のコンポーネントでは `font-size: 1.3rem` と、すべて「やや大きいテキスト」を意味しています。これでは生の値がコードベース全体に散らばり、一元管理できるポイントがありません。 よくある最初の改善策は、**セマンティックトークン**を定義することです。`--font-heading`、`--font-body`、`--font-caption` のように名前を付けます。しかし、これは2つの関心事を1つのレイヤーに混在させています。生のサイズ値とそのセマンティックな役割です。見出しを 28px から 24px に縮小する場合、トークンを変更します。しかし、たまたま同じサイズだったために `--font-heading` を使っていたナビリンクも、意図せず縮小されてしまいます。 反対のアプローチとして、`text-lg` のような**抽象的なスケール**のみを使う方法があります。役割との結合問題は避けられますが、セマンティックな明確さが失われます。開発者はコードベース全体に散らばった `text-lg` を見て、それが見出しなのか、サブタイトルなのか、単に強調テキストなのかを判断しなければなりません。 どちらのアプローチも単独では不十分です。必要なのは、_どのくらいの大きさか_(生の値)と_何のためか_(セマンティックな役割)の分離です。 ## 解決方法 フォントサイズを**3つのティア**に整理し、それぞれに明確な目的を持たせます。 | ティア | 名前 | 目的 | 例 | | --- | --- | --- | --- | | 1 | **スケール** | 抽象的なサイズ値 — 利用可能なステップ | `--scale-lg` → `1.25rem` | | 2 | **テーマ** | セマンティックな役割 — 各サイズの_意味_ | `--font-heading` → `var(--scale-xl)` | | 3 | **コンポーネント** | スコープ付きオーバーライド — 1つのコンポーネント固有のサイズ | `--_card-title` → `var(--font-subheading)` | 重要なポイントは、**各ティアはその上のティアのみを参照する**ということです。コンポーネントはテーマトークンを使います。テーマトークンはスケール値を指します。スケールは実際の rem/px 値を保持します。 これは [3層カラー戦略](../../styling/color/three-tier-color-strategy/)(パレット → テーマ → コンポーネント)と同じアーキテクチャを、フォントサイズに適用したものです。 ## コード例 ### ティア 1: スケール スケールは素材そのものです。システムで利用可能なすべてのフォントサイズが含まれます。これらの値はコンポーネントで直接使いません。絵の具のチューブのようなものです。用意はしておきますが、計画なしにキャンバスに直接絞り出すことはしません。 Scale (Tier 1) These are the raw sizes. Components should not use these directly. Aa --scale-2xl 2.5rem (40px) Aa --scale-xl 1.75rem (28px) Aa --scale-lg 1.25rem (20px) Aa --scale-base 1rem (16px) Aa --scale-sm 0.875rem (14px) Aa --scale-xs 0.75rem (12px) `} css={`.scale-demo { padding: 1.25rem; font-family: system-ui, sans-serif; background: hsl(0 0% 99%); height: 100%; box-sizing: border-box; } .scale-demo__title { margin: 0 0 0.25rem; font-size: 0.85rem; font-weight: 700; color: hsl(222 47% 11%); } .scale-demo__desc { margin: 0 0 0.75rem; font-size: 0.72rem; color: hsl(215 16% 47%); } .scale-demo__rows { display: flex; flex-direction: column; gap: 6px; } .scale-demo__row { display: flex; align-items: center; gap: 12px; } .scale-demo__sample { width: 56px; flex-shrink: 0; font-weight: 700; color: hsl(222 47% 11%); text-align: right; } .scale-demo__info { display: flex; flex-direction: column; gap: 1px; } .scale-demo__name { font-size: 0.72rem; font-family: monospace; font-weight: 600; color: hsl(221 83% 53%); } .scale-demo__value { font-size: 0.65rem; color: hsl(215 16% 47%); }`} /> Tailwind プロジェクトでは、ティア 1 は `@theme` ブロックに配置します。 ```css @theme { --font-size-xs: 0.75rem; /* 12px */ --font-size-sm: 0.875rem; /* 14px */ --font-size-base: 1rem; /* 16px */ --font-size-lg: 1.25rem; /* 20px */ --font-size-xl: 1.75rem; /* 28px */ --font-size-2xl: 2.5rem; /* 40px */ } ``` Tailwind の `@theme` 設定の詳細(ペアとなる line-height や weight トークンを含む)については、[タイポグラフィトークンパターン](../../methodology/tight-token-strategy/typography-tokens/)を参照してください。 ### ティア 2: テーマ テーマトークンはスケール値に**セマンティックな意味**を与えます。コンポーネントから見ると「lg」ではなく「subheading」や「heading」になります。タイポグラフィの調整を容易にするレイヤーです。`--font-heading` を `--scale-xl` から `--scale-lg` に一箇所で変更すれば、プロジェクト内のすべての見出しが更新されます。 Scale → Theme Mapping --scale-2xl → --font-display --scale-xl → --font-heading --scale-lg → --font-subheading --scale-base → --font-body --scale-sm → --font-secondary --scale-xs → --font-caption Result: UI using Theme tokens App Dashboard Welcome back Your project overview All systems operational. Last deploy was 12 minutes ago with no issues reported. Updated 2 min ago View Details `} css={`:root { /* Tier 1: Scale */ --scale-xs: 0.75rem; --scale-sm: 0.875rem; --scale-base: 1rem; --scale-lg: 1.25rem; --scale-xl: 1.75rem; --scale-2xl: 2.5rem; /* Tier 2: Theme — semantic pointers to scale */ --font-display: var(--scale-2xl); --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } .theme-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1rem; font-family: system-ui, sans-serif; background: hsl(0 0% 99%); height: 100%; box-sizing: border-box; } .theme-demo__title { margin: 0 0 0.6rem; font-size: 0.72rem; font-weight: 700; color: hsl(215 16% 47%); text-transform: uppercase; letter-spacing: 0.04em; } .theme-demo__col { min-width: 0; } .mapping { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.4rem; } .mapping__from { font-size: 0.72rem; font-family: monospace; color: hsl(215 16% 47%); width: 90px; text-align: right; flex-shrink: 0; } .mapping__arrow { font-size: 0.75rem; color: hsl(215 16% 47%); } .mapping__to { font-size: 0.72rem; font-family: monospace; color: hsl(221 83% 53%); font-weight: 600; } .mini-ui { background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 10px; overflow: hidden; } .mini-ui__header { background: hsl(222 47% 11%); color: hsl(0 0% 100%); padding: 0.4rem 0.75rem; display: flex; align-items: center; gap: 0.75rem; } .mini-ui__logo { font-size: var(--font-secondary); font-weight: 700; } .mini-ui__nav { font-size: var(--font-caption); color: hsl(215 25% 70%); } .mini-ui__body { padding: 0.75rem; color: hsl(222 47% 11%); } .mini-ui__heading { font-size: var(--font-heading); font-weight: 700; line-height: 1.25; margin-bottom: 0.15rem; } .mini-ui__subheading { font-size: var(--font-subheading); font-weight: 500; color: hsl(215 16% 47%); margin-bottom: 0.5rem; } .mini-ui__text { font-size: var(--font-body); line-height: 1.6; color: hsl(222 47% 11%); margin-bottom: 0.5rem; } .mini-ui__footer { display: flex; align-items: center; justify-content: space-between; padding-top: 0.4rem; border-top: 1px solid hsl(214 32% 91%); } .mini-ui__caption { font-size: var(--font-caption); color: hsl(215 16% 47%); } .mini-ui__btn { font-size: var(--font-caption); font-weight: 600; background: hsl(221 83% 53%); color: hsl(0 0% 100%); border: none; border-radius: 6px; padding: 0.3rem 0.6rem; cursor: pointer; }`} /> CSS では、ティア 2 は `:root`(または共有スコープ)に配置します。 ```css :root { --font-display: var(--scale-2xl); --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } ``` ### ティア 3: コンポーネントスコープのサイズ コンポーネントによっては、グローバルテーマに収まらないフォントサイズの判断が必要になることがあります。テキストが小さいコンパクトなサイドバー、金額が特大の料金カード、密度の高い管理画面などです。これらが**ティア 3** の変数です。スコープが狭く、コンポーネント自体に定義され、テーマまたはスケールのトークンを参照します。 コンポーネントスコープのカスタムプロパティには、先頭にアンダースコア(`--_`)を付けてローカルスコープであることを示します。 ```css .pricing-card { --_card-amount: var(--scale-2xl); --_card-period: var(--font-caption); } ``` `--_` プレフィックスは「この変数はこのコンポーネントにローカルスコープされている」ことを読み手に伝えます。他の言語で `_privateMethod` がプライベートスコープを示すのと同様の規則です。これは CSS のルールではなく、プロジェクト内における命名規則の例です。 Tailwind + コンポーネントファーストのプロジェクト(React、Vue、Astro でユーティリティクラスを使用)では、ティア 3 のコンポーネントスコープ CSS カスタムプロパティが必要になることはほとんどありません。コンポーネントフレームワーク自体がスコープを提供するため、これらの変数を定義する別の CSS ファイルが存在しません。ティア 3 は主に一般的な CSS アプローチ(BEM、CSS Modules、バニラ CSS)で必要になります。 Standard $29 per month Perfect for small teams getting started with design tokens. Premium $99 per month For teams that need advanced theming and component controls. Navigation Main Dashboard Projects Settings Account Billing `} css={`:root { --scale-xs: 0.75rem; --scale-sm: 0.875rem; --scale-base: 1rem; --scale-lg: 1.25rem; --scale-xl: 1.75rem; --scale-2xl: 2.5rem; --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } .tier3-demo { display: flex; gap: 12px; padding: 1rem; font-family: system-ui, sans-serif; background: hsl(210 40% 96%); height: 100%; box-sizing: border-box; color: hsl(222 47% 11%); } /* Tier 3: Pricing card — local size variables */ .pricing-card { --_card-amount: var(--scale-2xl); --_card-period: var(--font-caption); --_card-desc: var(--font-secondary); --_card-badge: var(--font-caption); flex: 1; background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 10px; overflow: hidden; } .pricing-card--premium { --_card-amount: var(--scale-2xl); border-color: hsl(221 83% 53%); } .pricing-card__badge { font-size: var(--_card-badge); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.4rem 0.75rem; background: hsl(210 40% 96%); color: hsl(215 16% 47%); } .pricing-card--premium .pricing-card__badge { background: hsl(221 83% 53%); color: hsl(0 0% 100%); } .pricing-card__body { padding: 0.75rem; } .pricing-card__amount { font-size: var(--_card-amount); font-weight: 700; line-height: 1.1; } .pricing-card__period { font-size: var(--_card-period); color: hsl(215 16% 47%); margin-bottom: 0.4rem; } .pricing-card__desc { font-size: var(--_card-desc); color: hsl(215 16% 47%); line-height: 1.5; } /* Tier 3: Sidebar nav — compact local sizes */ .sidebar-nav { --_nav-title: var(--font-secondary); --_nav-category: var(--font-caption); --_nav-link: var(--font-secondary); width: 140px; flex-shrink: 0; background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 10px; padding: 0.6rem; } .sidebar-nav__title { font-size: var(--_nav-title); font-weight: 700; margin-bottom: 0.5rem; } .sidebar-nav__category { font-size: var(--_nav-category); font-weight: 600; color: hsl(215 16% 47%); text-transform: uppercase; letter-spacing: 0.04em; margin: 0.4rem 0 0.2rem; } .sidebar-nav__link { display: block; font-size: var(--_nav-link); color: hsl(221 83% 53%); padding: 0.15rem 0; text-decoration: none; cursor: pointer; }`} /> 各コンポーネントが独自のフォントサイズ変数を定義しつつ、テーマまたはスケールのティアを参照している点に注目してください。料金カードは `--_card-amount: var(--scale-2xl)`(スケール)と `--_card-desc: var(--font-secondary)`(テーマ)を使っています。サイドバーナビはすべてのサイズにテーマトークンを参照しています。グローバルなタイプスケールが変更されると、これらのコンポーネントも自動的に更新されます。 ### 3つのティアの連携 このデモは、CSS に3つのティアすべてが表示された完全なページレイアウトを示しています。ティア 1 が生のスケールを定義し、ティア 2 がそれをセマンティックな役割にマッピングし、ティア 3 が stat カードに独自のローカルサイズ変数を与えています。 AppName Docs Blog About Project Dashboard Overview of your active deployments All systems are running normally. The latest deployment completed successfully 12 minutes ago with zero errors across 3 services. 12 Projects 98% Uptime 3 Deploys Last updated 2 minutes ago `} css={`:root { /* ── Tier 1: Scale ── */ --scale-xs: 0.75rem; --scale-sm: 0.875rem; --scale-base: 1rem; --scale-lg: 1.25rem; --scale-xl: 1.75rem; --scale-2xl: 2.5rem; /* ── Tier 2: Theme ── */ --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } .page { font-family: system-ui, sans-serif; color: hsl(222 47% 11%); height: 100%; display: flex; flex-direction: column; } .page__header { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 1rem; background: hsl(222 47% 11%); color: hsl(0 0% 100%); } .page__logo { font-size: var(--font-body); font-weight: 700; } .page__nav { display: flex; gap: 1rem; } .page__nav-link { font-size: var(--font-caption); color: hsl(215 25% 70%); text-decoration: none; cursor: pointer; } .page__main { padding: 1.25rem 1rem; flex: 1; } .page__title { font-size: var(--font-heading); font-weight: 700; line-height: 1.25; margin: 0 0 0.15rem; } .page__subtitle { font-size: var(--font-subheading); font-weight: 500; color: hsl(215 16% 47%); margin: 0 0 0.6rem; } .page__body { font-size: var(--font-body); line-height: 1.6; margin: 0 0 1rem; max-width: 560px; } .page__timestamp { display: block; font-size: var(--font-caption); color: hsl(215 16% 47%); margin-top: 0.75rem; } /* ── Tier 3: Stat card ── */ .stat-grid { display: flex; gap: 0.75rem; } .stat-card { --_stat-value: var(--scale-xl); --_stat-label: var(--font-caption); background: hsl(210 40% 96%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; padding: 0.6rem 1rem; text-align: center; min-width: 80px; } .stat-card__value { display: block; font-size: var(--_stat-value); font-weight: 700; line-height: 1.2; } .stat-card__label { display: block; font-size: var(--_stat-label); color: hsl(215 16% 47%); }`} /> ### ティア 2 の威力: サイズテーマの切り替え このアーキテクチャの最も強力な特徴は、**同じマークアップがまったく異なるサイズテーマで動作する**ことです。ティア 2(セマンティックトークン)を再マッピングするだけで、UI 全体が調整されます。コンパクトモード、大きい/アクセシブルモード、密度設定などを、コンポーネントの CSS に一切触れずに実装できます。 Default Dashboard Project overview All systems operational. Last deploy was 12 minutes ago. Updated 2 min ago View Compact Dashboard Project overview All systems operational. Last deploy was 12 minutes ago. Updated 2 min ago View Large Dashboard Project overview All systems operational. Last deploy was 12 minutes ago. Updated 2 min ago View `} css={`:root { /* Tier 1: Scale — same for all themes */ --scale-xs: 0.75rem; --scale-sm: 0.875rem; --scale-base: 1rem; --scale-lg: 1.25rem; --scale-xl: 1.75rem; --scale-2xl: 2.5rem; } /* Default Tier 2 mapping */ .theme-col--default { --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } /* Compact Tier 2 — every role shifts one step down */ .theme-col--compact { --font-heading: var(--scale-lg); --font-subheading: var(--scale-base); --font-body: var(--scale-sm); --font-secondary: var(--scale-xs); --font-caption: var(--scale-xs); } /* Large Tier 2 — every role shifts one step up */ .theme-col--large { --font-heading: var(--scale-2xl); --font-subheading: var(--scale-xl); --font-body: var(--scale-lg); --font-secondary: var(--scale-base); --font-caption: var(--scale-sm); } .themes-demo { display: grid; grid-template-columns: 1fr 1fr 1fr; height: 100%; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .theme-col { background: hsl(210 40% 96%); padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; } .theme-col + .theme-col { border-left: 1px solid hsl(214 32% 91%); } .theme-col__label { margin: 0; font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); } .card { background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 10px; padding: 0.75rem; display: flex; flex-direction: column; gap: 0.3rem; flex: 1; } .card__heading { font-size: var(--font-heading); font-weight: 700; line-height: 1.25; } .card__subheading { font-size: var(--font-subheading); font-weight: 500; color: hsl(215 16% 47%); } .card__body { font-size: var(--font-body); line-height: 1.6; color: hsl(222 47% 11%); flex: 1; } .card__footer { display: flex; align-items: center; justify-content: space-between; padding-top: 0.4rem; border-top: 1px solid hsl(214 32% 91%); margin-top: 0.25rem; } .card__caption { font-size: var(--font-caption); color: hsl(215 16% 47%); } .card__btn { font-size: var(--font-caption); font-weight: 600; background: hsl(221 83% 53%); color: hsl(0 0% 100%); border: none; border-radius: 6px; padding: 0.25rem 0.5rem; cursor: pointer; }`} /> マークアップは3つのカラムすべてで同一です。変わるのはティア 2 のマッピングだけです。 - **Default**: heading → xl、body → base、caption → xs - **Compact**: heading → lg、body → sm、caption → xs(すべて1ステップ下にシフト) - **Large**: heading → 2xl、body → lg、caption → sm(すべて1ステップ上にシフト) ティア 1 とティア 3 はまったく同じです。変わるのはティア 2 だけです。 ### 完全な CSS コード構造 3つのティアが実際のプロジェクトでどのように組み合わさるかを示します。 ```css /* ── Tier 1: Scale ── */ /* In Tailwind, this goes in @theme */ :root { --scale-xs: 0.75rem; --scale-sm: 0.875rem; --scale-base: 1rem; --scale-lg: 1.25rem; --scale-xl: 1.75rem; --scale-2xl: 2.5rem; } /* ── Tier 2: Theme ── */ /* Semantic roles — change these to adjust the entire UI */ :root { --font-display: var(--scale-2xl); --font-heading: var(--scale-xl); --font-subheading: var(--scale-lg); --font-body: var(--scale-base); --font-secondary: var(--scale-sm); --font-caption: var(--scale-xs); } /* ── Tier 3: Component ── */ /* Scoped overrides — only when a component needs its own size logic */ .pricing-card { --_card-amount: var(--scale-2xl); --_card-label: var(--font-caption); } .sidebar-nav { --_nav-link: var(--font-secondary); --_nav-category: var(--font-caption); } ``` ### Tailwind CSS との統合 Tailwind v4 プロジェクトでは、3つのティアは既存のパターンに自然にマッピングされます。 **ティア 1** → Tailwind の `@theme` ブロック。制約付きスケールを定義する場所です。 ```css @theme { --font-size-xs: 0.75rem; --font-size-sm: 0.875rem; --font-size-base: 1rem; --font-size-lg: 1.25rem; --font-size-xl: 1.75rem; --font-size-2xl: 2.5rem; } ``` これにより `text-xs` から `text-2xl` のユーティリティが使えるようになります。ペアとなる line-height や weight トークンを含む完全な Tailwind 設定については、[タイポグラフィトークンパターン](../../methodology/tight-token-strategy/typography-tokens/)を参照してください。 **ティア 2** → `:root` 上の CSS カスタムプロパティ。Tailwind ユーティリティではなく、CSS で使用するセマンティックトークンです。 ```css :root { --font-heading: var(--font-size-xl); --font-subheading: var(--font-size-lg); --font-body: var(--font-size-base); --font-secondary: var(--font-size-sm); --font-caption: var(--font-size-xs); } ``` コンポーネントは CSS でこれらを参照します: `font-size: var(--font-heading)`。 **ティア 3** → コンポーネントスコープの CSS カスタムプロパティ。一般的なパターンと同じです。 ## AI がよくやるミス - **ティア 2 を省略する** — スケール値をコンポーネントで直接使う(`font-size: var(--scale-lg)` や `text-lg` を至るところで使う)と、セマンティックレイヤーがなくなります。見出しサイズの変更にすべてのコンポーネントの更新が必要になります - **ティア 1 にセマンティックな名前を使う** — `@theme` で `--font-size-heading` を定義すると、スケールが特定の役割に固定されます。見出し以外に同じサイズが必要な場合、トークン名が誤解を招きます - **スケールとテーマを分離しない** — `--font-heading: 1.75rem` のようにハードコードされた値で定義すると、スケール全体を比例的に調整できません。見出しサイズがタイプシステムの他の部分と切り離されてしまいます - **ティア 3 の変数が多すぎる** — コンポーネントが 10 個以上のローカルフォントサイズ変数を定義している場合、テーマレイヤーを再発明している可能性があります。ティア 2 に昇格させましょう - **ティア 1 が小さすぎる** — サイズが 3 段階しかないスケールでは、コンポーネントが独自の生の値(ハードコードされた rem 値のティア 3 変数)を発明せざるを得なくなり、システムが壊れます ## 使い分け - **コンポーネントが数個以上あるプロジェクト** — 一貫したタイポグラフィが必要になった時点で、3層構成のコストは回収できます - **マルチ密度またはアクセシビリティモード** — ティア 2 により、コンパクト/ゆったり/アクセシブルの切り替えが容易になります - **デザインシステムやコンポーネントライブラリ** — コンポーネントは生のスケール値ではなくテーマトークンを参照すべきです - **段階的な導入** — ティア 1 + 2 から始めて、コンポーネントにスコープ付きオーバーライドが必要になったらティア 3 を追加できます ### 3層構成が過剰なケース - タイプスケールが1つで密度バリエーションのないシングルページサイト - メンテナンス性よりスピードが重要なクイックプロトタイプ - CSS を1人の開発者だけが触るプロジェクト ## 関連記事 - [タイポグラフィトークンパターン](../../methodology/tight-token-strategy/typography-tokens/) — Tailwind でのティア 1 の実践的な `@theme` 設定 - [3層カラー戦略](../../styling/color/three-tier-color-strategy/) — 同じ3層アーキテクチャをカラーに適用したもの - [行の高さのベストプラクティス](../line-height-best-practices/) — タイプスケールに合わせた line-height の選び方 - [clamp()を使った流体フォントサイズ](../fluid-font-sizing/) — `clamp()` でティア 1 の値をレスポンシブにする方法 ## 参考資料 - [MDN: Using CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 @theme Directive](https://tailwindcss.com/docs/functions-and-directives#theme-directive) --- # バリアブルフォント > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/fonts/variable-fonts ## 問題 従来のWebタイポグラフィでは、ウェイト、幅、スタイルの組み合わせごとに別々のフォントファイルを読み込む必要がありました。一般的なプロジェクトでは、本文テキストだけでレギュラー、ボールド、イタリック、ボールドイタリックの4ファイルを読み込みます。AIエージェントは複数の静的フォントウェイト(300、400、500、600、700)をそれぞれ別の`@font-face`宣言で参照するCSSを生成しがちで、5つ以上のHTTPリクエストと大幅に増大したダウンロードサイズを招きます。バリアブルフォント(Variable Fonts)は、バリエーションの全範囲を1つのファイルにまとめることでこの問題を解決します。 ## 解決方法 バリアブルフォントには1つ以上の**バリエーション軸**が含まれています — ウェイト、幅、スラントなどのプロパティの連続的な範囲です。1つのバリアブルフォントファイルが複数の静的ファイルを置き換え、ネットワークリクエストを削減し、それらの軸に沿った任意の値間のスムーズな遷移を可能にします。CSSの`font-variation-settings`プロパティは低レベルの制御を提供し、標準CSSプロパティ(`font-weight`、`font-stretch`、`font-style`)は範囲を受け入れ、登録済み軸に直接マッピングされるようになりました。 ### 登録済み軸 | 軸タグ | CSSプロパティ | 説明 | 範囲の例 | | -------- | --------------------- | ------------------------------ | --------------- | | `wght` | `font-weight` | ウェイト(Thin〜Black) | 100–900 | | `wdth` | `font-stretch` | 幅(Condensed〜Expanded) | 75%–125% | | `slnt` | `font-style` | スラント角度 | -12deg–0deg | | `ital` | `font-style` | イタリック(バイナリトグル) | 0 or 1 | | `opsz` | `font-optical-sizing` | オプティカルサイズ調整 | 8–144 | ## コード例 ### 基本的なバリアブルフォントのセットアップ ```css @font-face { font-family: "Inter"; src: url("/fonts/Inter-Variable.woff2") format("woff2-variations"); font-weight: 100 900; /* ウェイト範囲全体を宣言 */ font-display: swap; } body { font-family: "Inter", system-ui, sans-serif; } h1 { font-weight: 750; /* 範囲内の任意の値 — 100刻みに限定されない */ } .light-text { font-weight: 350; } .bold-text { font-weight: 680; } ``` 100 ThinThe quick brown fox 250The quick brown fox 400 RegularThe quick brown fox 550The quick brown fox 700 BoldThe quick brown fox 850The quick brown fox 900 BlackThe quick brown fox `} css={`.vf-demo { padding: 1.5rem; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 0.5rem; } .weight-row { display: flex; align-items: baseline; gap: 1rem; padding: 0.4rem 0.75rem; background: #f8f9fa; border-radius: 6px; } .label { font-size: 0.75rem; color: #6c63ff; font-weight: 600; min-width: 100px; flex-shrink: 0; } .sample { font-size: 1.2rem; color: #1a1a2e; }`} height={320} /> ### 標準CSSプロパティの使用(推奨) ```css /* 正しい: 登録済み軸には標準CSSプロパティを使う */ h1 { font-weight: 800; font-stretch: 110%; font-style: oblique 8deg; } /* 避けるべき: 登録済み軸に低レベルのfont-variation-settingsを使う */ h1 { font-variation-settings: "wght" 800, "wdth" 110, "slnt" -8; } ``` 標準プロパティが推奨される理由は、カスケードが正しく動作し、`inherit`や`initial`と連携し、互いをオーバーライドしないからです。`font-variation-settings`では、1つの軸を設定すると他のすべてがデフォルトにリセットされます。 ### カスタム軸 カスタム軸(大文字のタグで識別)には`font-variation-settings`が必要です: ```css /* GRAD = Grade軸(カスタム)、幅を変えずにストロークウェイトを調整 */ .dark-bg-text { font-variation-settings: "GRAD" 150; } /* CASL = RecursiveフォントのCasual軸 */ .casual-text { font-variation-settings: "CASL" 1; } /* カスタム軸と標準プロパティの組み合わせ */ .display-text { font-weight: 700; font-variation-settings: "GRAD" 100, "CASL" 0.5; } ``` ### カスタムプロパティによるレスポンシブウェイト ```css :root { --heading-weight: 700; --body-weight: 400; } @media (max-width: 768px) { :root { --heading-weight: 600; /* 小さな画面では読みやすさのためにやや軽く */ --body-weight: 420; /* 小さな画面での視認性のためにやや重く */ } } h1, h2, h3 { font-weight: var(--heading-weight); } body { font-weight: var(--body-weight); } ``` ### フォントバリエーションのアニメーション ```css .hover-weight { font-weight: 400; transition: font-weight 0.3s ease; } .hover-weight:hover { font-weight: 700; } /* スムーズなウェイトアニメーション — 静的フォントでは不可能 */ @keyframes breathe { 0%, 100% { font-weight: 300; } 50% { font-weight: 700; } } .animated-text { animation: breathe 3s ease-in-out infinite; } ``` ### オプティカルサイジング ```css /* 自動オプティカルサイジング(フォントがサポートしている場合はデフォルトで有効) */ body { font-optical-sizing: auto; } /* 特定のケースでの手動制御 */ .small-caption { font-size: 0.75rem; font-optical-sizing: auto; /* 小さいサイズに合わせてストロークコントラストを調整 */ } .display-hero { font-size: 4rem; font-optical-sizing: auto; /* 大きなディスプレイサイズに合わせて調整 */ } ``` ### @supportsによるプログレッシブエンハンスメント ```css /* フォールバック: 静的フォントファイル */ @font-face { font-family: "MyFont"; src: url("/fonts/myfont-regular.woff2") format("woff2"); font-weight: 400; } @font-face { font-family: "MyFont"; src: url("/fonts/myfont-bold.woff2") format("woff2"); font-weight: 700; } /* 対応ブラウザでのバリアブルフォントオーバーライド */ @supports (font-variation-settings: normal) { @font-face { font-family: "MyFont"; src: url("/fonts/myfont-variable.woff2") format("woff2-variations"); font-weight: 100 900; } } ``` ### ダークモードのウェイト補正 ```css /* 暗い背景のテキストは太く見える — ウェイトを下げて補正 */ @media (prefers-color-scheme: dark) { body { font-weight: 350; /* ライトモードの400より軽く */ } h1 { font-weight: 650; /* ライトモードの700より軽く */ } } ``` ## AIがよくやるミス - 1つのバリアブルフォントファイルの代わりに複数の静的フォントファイル(レギュラー、ミディアム、セミボールド、ボールド)を読み込み、HTTPリクエストを不必要に増やす - 登録済み軸(ウェイト、幅、スラント)に標準CSSプロパティではなく`font-variation-settings`を使う — カスケードが壊れ、未指定の軸がリセットされる - `@font-face`でウェイト範囲を宣言しない(例: `font-weight: 100 900`)ため、ブラウザがデフォルトウェイトのみを使用する - バリアブルフォントのウェイトを静的フォントと同じように扱い、100刻みの値(400、500、600)しか使わない — 範囲内の任意の値が有効 - ダークモードでテキストが太く見えることを補正しない — バリアブルフォントなら30〜50ウェイトユニットを減らすのが簡単 - `font-variation-settings`の値はいずれか1つの軸を設定するとすべてリセットされることを忘れる — 各宣言に制御したいすべての軸を含める必要がある - `@font-face`の`src`ディスクリプタで`format("woff2-variations")`ではなく`format("woff2")`を使う。ただし、ほとんどのモダンブラウザはどちらも受け入れる ## 使い分け ### バリアブルフォントが最適な場合 - 同じフォントファミリーの3つ以上のウェイトを使うプロジェクト — 1ファイルで通常、複数の静的ファイルより小さくなる - きめ細かなウェイト制御が必要なデザイン(例: 350、450、550) - ウェイト、幅、スラントの変化を含むアニメーションやトランジション - ウェイト補正で可読性を向上させるダークモードデザイン - ビューポートサイズやコンテキストに応じてウェイトを調整するレスポンシブデザイン ### 静的フォントを使い続けるべき場合 - 1〜2ウェイトしか必要ない場合 — 1つの静的ファイルがバリアブル版より小さい場合がある - 選択した書体がバリアブルフォントとして提供されていない場合 - レガシーブラウザサポート(IE11)がハードな要件の場合 ## 参考リンク - [MDN: Variable fonts guide](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Fonts/Variable_fonts) - [MDN: font-variation-settings](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variation-settings) - [web.dev: Introduction to variable fonts](https://web.dev/articles/variable-fonts) - [Designing with Variable Fonts](https://variablefonts.io/about-variable-fonts/) - [Variable Fonts: Reduce Bloat And Fix Layout Shifts](https://inkbotdesign.com/variable-fonts/) - [A Variable Fonts Primer — Google Fonts](https://fonts.google.com/knowledge/introducing_type/introducing_variable_fonts) --- # バーティカルリズム > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/text-control/vertical-rhythm ## 問題 AIが生成したレイアウトは、適切なフォントサイズやカラーを使っていても「なんか違う」と感じることがよくあります。その根本原因は垂直方向のスペーシングの不一致です — マージン、パディング、line-heightが、互いに数学的な関係を持たない任意の値で設定されています。`margin-bottom: 12px`の見出しの後に`margin-top: 20px`の段落が続き、さらに`margin-bottom: 15px`のリストが続くと、視覚的なノイズが生まれます。人間はレイアウトがなぜ不自然に感じるのかを言語化できなくても、この不一致を感知します。 ## 解決方法 バーティカルリズム(Vertical Rhythm)とは、要素間の垂直方向のスペースを、1つの基本単位からすべてのスペーシングを導出することで一貫させる手法です。この基本単位は通常、本文テキストの`line-height`の計算値です。すべてのマージン、パディング、ギャップは、この基本単位の倍数(または端数)で設定します。 ### 基本原則 1. ベースのline-heightを定義する(例: `16px`フォントで`1.5` = `24px`のリズム単位) 2. すべての垂直スペーシングをその単位の倍数で設定する:`24px`、`48px`、`72px`(または`1×`、`2×`、`3×`) 3. より狭いスペーシングには端数の倍数を使う:`12px`(`0.5×`)、`6px`(`0.25×`) ## コード例 ### 基本的なバーティカルリズムシステム ```css :root { --font-size-base: 1rem; /* 16px */ --line-height-base: 1.5; /* 24pxのリズム単位 */ --rhythm: calc(var(--font-size-base) * var(--line-height-base)); /* 1.5rem = 24px */ } body { font-size: var(--font-size-base); line-height: var(--line-height-base); } /* すべてのスペーシングをリズム単位から導出 */ h1 { font-size: 2.5rem; line-height: 1.2; margin-block: calc(var(--rhythm) * 2) var(--rhythm); } h2 { font-size: 2rem; line-height: 1.25; margin-block: calc(var(--rhythm) * 2) var(--rhythm); } h3 { font-size: 1.5rem; line-height: 1.3; margin-block: var(--rhythm) calc(var(--rhythm) * 0.5); } p { margin-block-end: var(--rhythm); } ul, ol { margin-block-end: var(--rhythm); padding-inline-start: var(--rhythm); } li + li { margin-block-start: calc(var(--rhythm) * 0.25); } blockquote { margin-block: var(--rhythm); padding-block: calc(var(--rhythm) * 0.5); padding-inline-start: var(--rhythm); } ``` Consistent rhythm (24px base) Section Title First paragraph with body text. Vertical rhythm keeps spacing mathematically related to the base line-height unit. Second paragraph maintains the same spacing. Every gap is a multiple of the 24px rhythm unit. Another Section Content flows with predictable, harmonious spacing throughout the layout. Inconsistent spacing Section Title First paragraph with body text. The spacing here uses arbitrary values with no mathematical relationship. Second paragraph has different margin than the first. The gaps feel random and uneven. Another Section Content feels visually noisy even though the text is identical. `} css={`.vr-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .vr-column { background: #fafafa; border-radius: 8px; padding: 1rem; } .label { font-size: 0.8rem; color: #6c63ff; margin: 0 0 1rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } .consistent h2 { font-size: 1.4rem; line-height: 1.2; margin: 48px 0 24px; color: #1a1a2e; } .consistent h2:first-child { margin-top: 0; } .consistent p { font-size: 0.95rem; line-height: 1.5; margin: 0 0 24px; color: #444; } .inconsistent h2 { font-size: 1.4rem; line-height: 1.2; margin: 35px 0 10px; color: #1a1a2e; } .inconsistent h2:first-child { margin-top: 0; } .inconsistent p { font-size: 0.95rem; line-height: 1.5; color: #444; } .inconsistent p:nth-child(2) { margin: 0 0 18px; } .inconsistent p:nth-child(3) { margin: 0 0 30px; } .inconsistent p:nth-child(5) { margin: 0 0 12px; }`} height={400} /> ### リズム単位を使ったスペーシングスケール ```css :root { --rhythm: 1.5rem; /* 24pxの基本単位 */ --space-3xs: calc(var(--rhythm) * 0.125); /* 3px */ --space-2xs: calc(var(--rhythm) * 0.25); /* 6px */ --space-xs: calc(var(--rhythm) * 0.5); /* 12px */ --space-sm: calc(var(--rhythm) * 0.75); /* 18px */ --space-md: var(--rhythm); /* 24px */ --space-lg: calc(var(--rhythm) * 1.5); /* 36px */ --space-xl: calc(var(--rhythm) * 2); /* 48px */ --space-2xl: calc(var(--rhythm) * 3); /* 72px */ --space-3xl: calc(var(--rhythm) * 4); /* 96px */ } ``` ### Lobotomized Owlによる一貫したフロースペーシング 「Lobotomized Owl」セレクタ(`* + *`)は、隣接する兄弟要素間に一貫したスペーシングを適用します: ```css .flow > * + * { margin-block-start: var(--rhythm, 1.5rem); } /* 特定の要素に対するオーバーライド */ .flow > h2 + * { margin-block-start: calc(var(--rhythm) * 0.5); } .flow > * + h2 { margin-block-start: calc(var(--rhythm) * 2); } ``` ```html Section Title First paragraph gets half-rhythm spacing from heading. Subsequent paragraphs get standard rhythm spacing. Consistent spacing throughout the article. Next Section More content with predictable spacing. ``` ### グリッドを使ったバーティカルリズム ```css .content-grid { display: grid; row-gap: var(--rhythm); } .content-grid > h2 { margin-block-start: var(--rhythm); /* 見出し前に追加のスペース */ } ``` ## AIがよくやるミス - 数学的な関係のない任意のマージン/パディング値を使う(`margin: 10px`、`padding: 15px`、`gap: 22px`) - スペーシング単位を一貫なく混在させる — ある場所では`px`、別の場所では`rem`、また別の場所では`em` - 隣接する要素に`margin-top`と`margin-bottom`の両方を設定し、マージンの相殺(またはflex/gridコンテキストでの非相殺)によってスペースが倍増する - 同一の要素タイプ間で異なるスペーシングを使う — ある段落は`margin-bottom: 16px`、次の段落は`margin-bottom: 20px` - 見出しは上に余分なスペース、下には少ないスペースが必要で、後続のコンテンツとの視覚的な関連付けを作ることを無視する - スペーシングスケールを確立せず、各要素に対して場当たり的に値を選ぶ - flexやgridコンテナではマージンが相殺されないため、スペーシング戦略の調整が必要であることを忘れる ## 使い分け バーティカルリズムは、コンテンツの多いレイアウトに適用しましょう: - ブログ記事やアーティクルページ - ドキュメンテーションサイト - 混合コンテンツセクションのあるランディングページ - メールテンプレート - 複数のテキスト要素が縦に積み重なるあらゆるレイアウト リズムの厳密さは状況に応じて変えられます: - **厳密なリズム:** すべての要素がベースライングリッドに揃う。エディトリアル/出版デザインに最適 - **緩やかなリズム:** スペーシングは基本単位の倍数を使うが、ベースラインの整列は強制しない。コンポーネントの内部スペーシングが多様なWebアプリケーションに最適 密度の高いUIレイアウト(ダッシュボード、データテーブル、ツールバー)では、スペースの効率的な使用がバーティカルリズムより重要です。リズムの原則は主に読み物中心のコンテンツエリアに適用しましょう。 ## 参考リンク - [Why is Vertical Rhythm an Important Typography Practice? — Zell Liew](https://zellwk.com/blog/why-vertical-rhythms/) - [Mastering CSS: Vertical Rhythm — DEV Community](https://dev.to/adrianbdesigns/mastering-css-vertical-rhythm-om9) - [Baseline Grids in CSS — edg.design](https://edgdesign.co/blog/baseline-grids-in-css) - [Creating a Vertical Rhythm with CSS Grid — Aleksandr Hovhannisyan](https://www.aleksandrhovhannisyan.com/blog/vertical-rhythm-with-css-grid/) - [8-Point Grid: Vertical Rhythm — Built to Adapt](https://medium.com/built-to-adapt/8-point-grid-vertical-rhythm-90d05ad95032) --- # prefers-reduced-motion(モーション軽減設定) > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/forms-and-accessibility/prefers-reduced-motion ## 問題 アニメーションやトランジションは、前庭障害、モーション感度、特定の認知障害を持つユーザーに不快感、めまい、吐き気を引き起こす可能性があります。`prefers-reduced-motion` メディアクエリは、ユーザーがオペレーティングシステムの設定を通じてモーションの好みを伝えることを可能にします。AIエージェントは生成するコードにモーション設定の処理をほとんど含めず、含める場合でもすべてのモーションを完全に削除する傾向があります。しかし、これは有用な状態変化インジケーターまで削除してしまうため、逆にユーザビリティを損なう可能性があります。 ## 解決方法 `prefers-reduced-motion: reduce` の設定を尊重し、モーションを**削除**するのではなく**軽減**しましょう。大きく速いアニメーションやパララックススタイルのアニメーションを、微妙なフェードや即時の状態変化に置き換えます。フォーカスリングやローディング状態のような機能的なインジケーターはそのまま維持しましょう。 ### 2つのアプローチ 1. **モーション削除アプローチ**:通常通りアニメーションを書き、`prefers-reduced-motion: reduce` ブロック内で無効化します。 2. **ノーモーションファーストアプローチ**:デフォルトで静的なスタイルを書き、`prefers-reduced-motion: no-preference` ブロック内でアニメーションを追加します。この方が安全です。設定を指定していないユーザーでもモーションが軽減されるためです。 Full Motion Loading spinner Bouncing element Reduced Motion Pulsing indicator Gentle fade only `} css={` .demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 1.5rem; } .column { display: flex; flex-direction: column; gap: 0.75rem; } .col-title { font-size: 0.875rem; font-weight: 700; color: #1e293b; margin: 0; text-align: center; } .box { padding: 1rem; border-radius: 0.5rem; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: #475569; } .box-full { background: #eff6ff; border: 1px solid #bfdbfe; } .box-reduced { background: #f0fdf4; border: 1px solid #bbf7d0; } .spinner { width: 1.5rem; height: 1.5rem; border: 3px solid #e2e8f0; border-radius: 50%; } .spinner-full { border-top-color: #3b82f6; animation: spin 0.8s linear infinite; } .spinner-reduced { border-top-color: #22c55e; animation: pulse 1.5s ease-in-out infinite; } @keyframes spin { to { transform: rotate(360deg); } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } .bounce-box { animation: bounce-anim 1s ease-in-out infinite; } @keyframes bounce-anim { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } } .fade-box { animation: fade-anim 2s ease-in-out infinite; } @keyframes fade-anim { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } `} /> ## コード例 ### グローバルな軽減モーションリセット モーション軽減を希望するユーザー向けに、すべてのアニメーションを軽減する防御的リセットです: ```css @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; } } ``` これは大まかなツールです。ベースラインとして使用し、必要に応じて個々のコンポーネントを調整しましょう。 ### モーションをフェードに置き換える(より良いアプローチ) すべてのアニメーションを削除する代わりに、大きなモーションを微妙な透明度変化に置き換えます: ```css /* Default: slide-in animation */ .modal { animation: modal-enter 0.3s ease-out; } @keyframes modal-enter { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } /* Reduced motion: fade only, no spatial movement */ @media (prefers-reduced-motion: reduce) { .modal { animation: modal-fade-in 0.2s ease-out; } @keyframes modal-fade-in { from { opacity: 0; } to { opacity: 1; } } } ``` ### ノーモーションファーストアプローチ アニメーションなしの状態から始め、モーション設定がないユーザーにのみアニメーションを追加します: ```css /* Base: static, no animation */ .card { opacity: 1; transform: none; } /* Only animate for users without motion preference */ @media (prefers-reduced-motion: no-preference) { .card { animation: card-reveal 0.4s ease-out both; } @keyframes card-reveal { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } } ``` ### 安全なトランジション ```css .button { background-color: var(--color-primary); } /* Hover transition: only for no-preference users */ @media (prefers-reduced-motion: no-preference) { .button { transition: background-color 0.15s ease, transform 0.15s ease; } } @media (hover: hover) { .button:hover { background-color: var(--color-primary-dark); } } /* Reduced motion users still see the color change, just instantly */ ``` ### ローディングスピナーの代替 ```css .spinner { width: 2rem; height: 2rem; border: 3px solid var(--color-border); border-top-color: var(--color-primary); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Reduced motion: pulsing opacity instead of spinning */ @media (prefers-reduced-motion: reduce) { .spinner { animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } } ``` ### スクロール動作 ```css html { scroll-behavior: smooth; } @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } ``` ### パララックスとスクロール駆動アニメーション ```css .hero__background { animation: parallax linear; animation-timeline: scroll(); } @keyframes parallax { from { transform: translateY(-15%); } to { transform: translateY(15%); } } /* Disable parallax entirely for reduced motion */ @media (prefers-reduced-motion: reduce) { .hero__background { animation: none; transform: none; } } ``` ### JavaScriptでの検出 JavaScriptで制御されるアニメーションの場合: ```html const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)" ); function handleMotionPreference() { if (prefersReducedMotion.matches) { // Disable JS-driven animations document.documentElement.dataset.reducedMotion = "true"; } else { delete document.documentElement.dataset.reducedMotion; } } prefersReducedMotion.addEventListener("change", handleMotionPreference); handleMotionPreference(); ``` ```css /* Use the data attribute for JS-controlled animations */ [data-reduced-motion="true"] .js-animated { animation: none !important; transition: none !important; } ``` ### 維持すべきもの vs 軽減すべきもの ```css /* KEEP: Focus indicators (functional, not decorative) */ .button:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; /* No transition needed — instant is fine */ } /* KEEP: Color changes (not spatial motion) */ @media (prefers-reduced-motion: reduce) { .button:hover { /* Color change is fine, remove transform */ background-color: var(--color-primary-dark); transform: none; } } /* REDUCE: Large spatial movement */ @media (prefers-reduced-motion: reduce) { .slide-in-panel { /* Replace slide with fade */ animation: fade-in 0.15s ease; } } /* REMOVE: Parallax, background movement, continuous animations */ @media (prefers-reduced-motion: reduce) { .background-animation, .parallax-layer, .floating-element { animation: none; } } ``` ## AIがよくやるミス - **`prefers-reduced-motion` を一切含めない**:最もよくあるミスです。AIはモーション設定の処理なしでアニメーションを生成します。 - **一括ルールですべてのアニメーションを削除する**:すべてのアニメーションとトランジションを無効にすると、有用な状態インジケーターまで削除されます。モーションは削除ではなく軽減しましょう。 - **`scroll-behavior: auto` を忘れる**:軽減モーションユーザー向けのオプトアウトなしで `scroll-behavior: smooth` を設定してしまいます。 - **削除したアニメーションの代替を提供しない**:スライドインアニメーションを削除しながら、フェードの代替を提供せず、ユーザーに状態変化のインジケーターがなくなります。 - **CSSアニメーションのみ対応する**:JavaScriptで制御されるアニメーション(GSAP、Framer Motionなど)もこの設定を尊重する必要があることを忘れてしまいます。 - **デフォルト状態のみテストする**:軽減モーションを有効にした場合の体験を検証しません。Chrome DevToolsでエミュレートできます:Rendering パネル > Emulate CSS media feature > prefers-reduced-motion: reduce。 ## 使い分け - **アニメーションのあるすべてのプロジェクト**:アニメーションやトランジションを追加する場合は、必ず `prefers-reduced-motion` の処理を追加しましょう。 - **パララックスとスクロールエフェクト**:軽減モーションユーザーに対しては常に無効にすべきです。 - **自動再生アニメーション**:フローティング要素や背景エフェクトなどの継続的な装飾アニメーションは停止すべきです。 - **ページトランジション**:フルページのルートトランジションはシンプルなフェードに軽減するか、削除すべきです。 - **機能的なモーションは維持する**:ローディングインジケーター、フォーカスリング、状態変化インジケーターは保持しましょう(簡略化は可能ですが、削除は避けましょう)。 ## 参考リンク - [prefers-reduced-motion — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion) - [prefers-reduced-motion — CSS-Tricks](https://css-tricks.com/almanac/rules/m/media/prefers-reduced-motion/) - [Taking a No-Motion-First Approach — Tatiana Mac](https://www.tatianamac.com/posts/prefers-reduced-motion) - [C39: Using prefers-reduced-motion to Prevent Motion — W3C](https://www.w3.org/WAI/WCAG21/Techniques/css/C39) - [Design Accessible Animation and Movement — Pope Tech](https://blog.pope.tech/2025/12/08/design-accessible-animation-and-movement/) --- # オーバースクロールの挙動 > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/scroll/overscroll-behavior ## 問題 ネストされたスクロール可能領域 — モーダル、サイドバー、ドロップダウン、チャットパネル — の端までスクロールすると、ブラウザはスクロールイベントを最も近いスクロール可能な祖先に「チェーン」します。背景のページがオーバーレイの下で突然スクロールし始めます。これは**スクロールチェーン**と呼ばれ、ウェブアプリケーションで最もよく見られるUXバグの1つです。 `overscroll-behavior` が登場する前は、これを防ぐためにJavaScriptのスクロールロックハックが必要でした:wheelイベントのリスニング、スクロール位置の計算、適切なタイミングでの `preventDefault()` 呼び出しです。これらのソリューションは脆弱で、カクつきを引き起こし、タッチデバイスのスクロールを完全に壊すことが多かったです。モバイルでは、プルトゥリフレッシュのようなオーバースクロールエフェクトがカスタムスクロール領域内で予期せずトリガーされることもありました。 ## 解決方法 `overscroll-behavior` プロパティは、スクロールコンテナが境界に達した時の動作を1行のCSSで制御します。 - **`auto`**(デフォルト):通常の動作 — 親へのスクロールチェーンとネイティブオーバースクロールエフェクト(バウンス、プルトゥリフレッシュ)が有効です。 - **`contain`**:親へのスクロールチェーンを防止しますが、ネイティブオーバースクロールエフェクト(iOS/macOSのラバーバンドバウンスなど)は要素自体の中では依然として適用されます。 - **`none`**:スクロールチェーンとすべてのネイティブオーバースクロールエフェクトの両方を防止します。スクロールは単に停止します。 `overscroll-behavior-x` と `overscroll-behavior-y` で各軸を個別に制御することもできます。 ### 基本原則 #### auto(デフォルト) デフォルト値です。要素がスクロール境界に達すると親にチェーンします。メインページコンテンツでは通常問題ありませんが、オーバーレイ、モーダル、サイドバーでは問題を引き起こします。 #### contain 最もよく必要とされる値です。スクロールチェーンを防止し、スクロールが要素内に閉じ込められます。モーダル、サイドバー、チャットパネル、ドロップダウンメニューなど、背景をスクロールさせたくない独立したスクロール可能領域に使用しましょう。 #### none `contain` よりさらに進んで、ラバーバンドバウンスやプルトゥリフレッシュなどのネイティブオーバースクロールエフェクトも抑制します。境界でスクロールが視覚的フィードバックなしで完全に停止してほしい場合に使用します。埋め込みアプリライクなインターフェースに便利です。 Background Page This outer area is scrollable. Try scrolling inside the inner panel below until you reach its end — then keep scrolling. The outer page will start scrolling too. This is scroll chaining. Inner Scrollable Panel Item 1 — Scroll down inside this panel Item 2 — Keep scrolling past the end Item 3 — The outer page will start moving Item 4 — This is the scroll chaining problem Item 5 — No overscroll-behavior is set here Item 6 — So the browser chains the scroll Item 7 — To the nearest scrollable ancestor Item 8 — Which is the outer container Item 9 — Causing unexpected page movement Item 10 — This is a very common UX bug Item 11 — That frustrates users Item 12 — Especially on modal overlays More background content below the panel. If scroll chaining is happening, you will see this area scroll into view when the inner panel reaches its end. Even more content here to demonstrate the outer scroll area is tall enough to scroll. And even more content to make it clearly scrollable. Bottom of the outer scrollable area. `} css={` .outer-scroll { height: 100%; overflow-y: auto; padding: 1rem; background: hsl(210 20% 96%); } .page-header { font-size: 1.125rem; font-weight: 700; color: hsl(210 25% 25%); margin-bottom: 0.5rem; } .page-text { font-size: 0.8125rem; color: hsl(210 15% 40%); line-height: 1.5; margin: 0.5rem 0; } .inner-panel { background: hsl(0 0% 100%); border: 2px solid hsl(0 70% 60%); border-radius: 0.5rem; padding: 0.75rem; height: 150px; overflow-y: auto; margin: 0.75rem 0; /* No overscroll-behavior — scroll chains to parent */ } .panel-header { font-size: 0.875rem; font-weight: 700; color: hsl(0 70% 45%); margin-bottom: 0.5rem; } .inner-panel p { font-size: 0.75rem; color: hsl(210 10% 35%); padding: 0.25rem 0; margin: 0; border-bottom: 1px solid hsl(210 20% 92%); } `} /> Background Page This outer area is scrollable, just like before. But now the inner panel has overscroll-behavior: contain. Try scrolling inside the panel past its end — the outer page will NOT scroll. Inner Panel (contained) Item 1 — Scroll down inside this panel Item 2 — Keep scrolling past the end Item 3 — The outer page stays still Item 4 — overscroll-behavior: contain fixes it Item 5 — Scroll chaining is prevented Item 6 — The scroll stays trapped here Item 7 — No JavaScript required Item 8 — Just one line of CSS Item 9 — Works on all modern browsers Item 10 — Touch devices included Item 11 — Much better UX Item 12 — The background page is safe More background content. This time it will NOT scroll when you reach the end of the inner panel. You can still scroll the outer area by scrolling outside the inner panel. More outer content to demonstrate the page is scrollable on its own. And more content here. Bottom of the outer scrollable area. `} css={` .outer-scroll { height: 100%; overflow-y: auto; padding: 1rem; background: hsl(210 20% 96%); } .page-header { font-size: 1.125rem; font-weight: 700; color: hsl(210 25% 25%); margin-bottom: 0.5rem; } .page-text { font-size: 0.8125rem; color: hsl(210 15% 40%); line-height: 1.5; margin: 0.5rem 0; } .inner-panel { background: hsl(0 0% 100%); border: 2px solid hsl(140 60% 40%); border-radius: 0.5rem; padding: 0.75rem; height: 150px; overflow-y: auto; margin: 0.75rem 0; overscroll-behavior: contain; } .panel-header { font-size: 0.875rem; font-weight: 700; color: hsl(140 60% 30%); margin-bottom: 0.5rem; } .inner-panel p { font-size: 0.75rem; color: hsl(210 10% 35%); padding: 0.25rem 0; margin: 0; border-bottom: 1px solid hsl(210 20% 92%); } `} /> overscroll-behavior: contain Line 1 — Scroll to the bottom Line 2 — Native bounce effects still show Line 3 — on supported platforms (macOS/iOS) Line 4 — but scroll chaining is prevented Line 5 — The rubber-band effect still works Line 6 — within this element Line 7 — Only chaining to the parent Line 8 — is blocked by contain Line 9 — This is the most common value Line 10 — for modals and sidebars Line 11 — Use contain by default Line 12 — unless you need none overscroll-behavior: none Line 1 — Scroll to the bottom Line 2 — No bounce effects at all Line 3 — No pull-to-refresh Line 4 — No rubber-band overscroll Line 5 — Scroll just stops completely Line 6 — at the boundary Line 7 — No chaining either Line 8 — Like contain but stricter Line 9 — Good for app-like interfaces Line 10 — or embedded panels Line 11 — Use none when you want Line 12 — no overscroll feedback `} css={` .comparison { display: flex; gap: 0.75rem; height: 100%; padding: 0.75rem; background: hsl(210 20% 96%); } .column { flex: 1; display: flex; flex-direction: column; min-width: 0; } .column-label { font-size: 0.6875rem; font-weight: 700; padding: 0.375rem 0.5rem; border-radius: 0.375rem 0.375rem 0 0; text-align: center; } .contain-label { background: hsl(200 70% 50%); color: hsl(0 0% 100%); } .none-label { background: hsl(270 60% 50%); color: hsl(0 0% 100%); } .scroll-box { flex: 1; overflow-y: auto; background: hsl(0 0% 100%); padding: 0.5rem; border-radius: 0 0 0.375rem 0.375rem; } .contain-box { overscroll-behavior: contain; border: 2px solid hsl(200 70% 50%); border-top: none; } .none-box { overscroll-behavior: none; border: 2px solid hsl(270 60% 50%); border-top: none; } .scroll-box p { font-size: 0.6875rem; color: hsl(210 10% 35%); padding: 0.25rem 0; margin: 0; border-bottom: 1px solid hsl(210 20% 92%); } `} /> T Team Chat 3 members online Alice Hey team, has anyone looked at the new design specs? 10:02 AM Yes, I reviewed them this morning. The layout changes look good. 10:05 AM Bob I have a few concerns about the navigation redesign. Can we discuss? 10:08 AM Sure, let's set up a quick call after lunch. 10:10 AM Alice Sounds good. I'll send a calendar invite. Also, the client wants to see a progress update by Friday. 10:12 AM Bob That's tight. We should prioritize the landing page and dashboard components first. 10:14 AM Agreed. I'll focus on the dashboard today and we can review tomorrow morning. 10:15 AM Alice Perfect. I'll handle the landing page. Bob, can you update the shared components library? 10:17 AM Bob On it. I'll push the updates by end of day. 10:18 AM Great teamwork everyone. Let's keep this momentum going! 10:20 AM Type a message... Send `} css={` .chat-app { display: flex; flex-direction: column; height: 100%; background: hsl(210 20% 97%); font-family: system-ui, sans-serif; } .chat-header { display: flex; align-items: center; gap: 0.625rem; padding: 0.625rem 0.75rem; background: hsl(220 60% 50%); color: hsl(0 0% 100%); flex-shrink: 0; } .chat-avatar { width: 2rem; height: 2rem; border-radius: 50%; background: hsl(220 60% 70%); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; } .chat-info { display: flex; flex-direction: column; } .chat-name { font-weight: 700; font-size: 0.875rem; } .chat-status { font-size: 0.6875rem; opacity: 0.8; } .chat-messages { flex: 1; overflow-y: auto; padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; overscroll-behavior-y: contain; } .message { display: flex; flex-direction: column; max-width: 80%; } .message.sent { align-self: flex-end; } .message.received { align-self: flex-start; } .message-author { font-size: 0.625rem; font-weight: 600; color: hsl(220 50% 45%); margin-bottom: 0.125rem; padding-left: 0.5rem; } .message-bubble { padding: 0.5rem 0.75rem; border-radius: 1rem; font-size: 0.75rem; line-height: 1.4; } .message.sent .message-bubble { background: hsl(220 60% 50%); color: hsl(0 0% 100%); border-bottom-right-radius: 0.25rem; } .message.received .message-bubble { background: hsl(0 0% 100%); color: hsl(210 15% 25%); border-bottom-left-radius: 0.25rem; box-shadow: 0 1px 2px hsl(210 20% 85%); } .message-time { font-size: 0.5625rem; color: hsl(210 10% 60%); margin-top: 0.125rem; padding: 0 0.5rem; } .message.sent .message-time { align-self: flex-end; } .chat-input { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; background: hsl(0 0% 100%); border-top: 1px solid hsl(210 20% 90%); flex-shrink: 0; } .input-field { flex: 1; padding: 0.5rem 0.75rem; border-radius: 1.25rem; background: hsl(210 20% 96%); font-size: 0.75rem; color: hsl(210 10% 60%); } .send-btn { padding: 0.375rem 0.75rem; background: hsl(220 60% 50%); color: hsl(0 0% 100%); border-radius: 1.25rem; font-size: 0.75rem; font-weight: 600; cursor: pointer; } `} /> Navigation Dashboard Analytics Customers Products Orders Inventory Marketing Campaigns Reports Revenue Expenses Team Members Roles Permissions Settings Preferences Integrations API Keys Webhooks Notifications Billing Support Documentation Dashboard This is the main content area. Scroll inside the sidebar navigation on the left — even when you reach the end of the nav list, the main content will not scroll. The sidebar uses overscroll-behavior: contain to prevent scroll chaining to this main area. This pattern is essential for any application with a fixed sidebar that has enough nav items to be scrollable. Without overscroll-behavior, users accidentally scroll the main content when they reach the end of the navigation list. Monthly Revenue $48,250 Active Users 12,847 Conversion Rate 3.24% More dashboard content follows below. This area is scrollable on its own, but the sidebar scroll will never chain into it. Additional content to make the main area longer and clearly scrollable independently from the sidebar. `} css={` .app-layout { display: flex; height: 100%; font-family: system-ui, sans-serif; } .sidebar { width: 10rem; background: hsl(220 25% 18%); color: hsl(0 0% 100%); display: flex; flex-direction: column; flex-shrink: 0; } .sidebar-header { padding: 0.75rem; font-weight: 700; font-size: 0.8125rem; border-bottom: 1px solid hsl(220 20% 28%); flex-shrink: 0; } .nav-list { list-style: none; margin: 0; padding: 0.25rem 0; overflow-y: auto; flex: 1; overscroll-behavior: contain; } .nav-item { padding: 0.375rem 0.75rem; font-size: 0.6875rem; color: hsl(220 15% 75%); cursor: pointer; transition: background 0.15s; } .nav-item:hover { background: hsl(220 20% 25%); color: hsl(0 0% 100%); } .nav-item.active { background: hsl(220 60% 50%); color: hsl(0 0% 100%); font-weight: 600; } .main-content { flex: 1; padding: 1rem; overflow-y: auto; background: hsl(210 20% 97%); } .main-title { font-size: 1.125rem; font-weight: 700; color: hsl(210 25% 20%); margin: 0 0 0.75rem; } .main-text { font-size: 0.75rem; color: hsl(210 15% 40%); line-height: 1.6; margin: 0 0 0.75rem; } .main-text code { background: hsl(210 20% 92%); padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.6875rem; } .card { background: hsl(0 0% 100%); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 0.5rem; box-shadow: 0 1px 3px hsl(210 20% 85%); } .card-title { font-size: 0.6875rem; color: hsl(210 10% 55%); margin-bottom: 0.25rem; } .card-value { font-size: 1.25rem; font-weight: 700; color: hsl(210 25% 20%); } `} /> ## コード例 ### モーダルでのスクロールチェーン防止 ```css .modal-body { max-height: 80vh; overflow-y: auto; overscroll-behavior-y: contain; } ``` この1行で、モーダルコンテンツの端までスクロールした際に背景ページがスクロールするのを防止します。 ### ブラウザの戻るナビゲーションの誤発動防止 ```css .horizontal-scroller { overflow-x: auto; overscroll-behavior-x: contain; } ``` 一部のブラウザでは、水平オーバースクロールジェスチャーがブラウザの戻る/進むナビゲーションをトリガーします。水平スクロール領域に `overscroll-behavior-x: contain` を設定することで、この誤ナビゲーションを防止します。 ### コンテナを制御したアプリシェル ```css .app-shell { display: grid; grid-template-columns: 250px 1fr 300px; height: 100vh; } .sidebar-nav { overflow-y: auto; overscroll-behavior: contain; } .main-content { overflow-y: auto; } .detail-panel { overflow-y: auto; overscroll-behavior: contain; } ``` マルチパネルレイアウトでは、各セカンダリスクロールパネルに `overscroll-behavior: contain` を適用し、メインコンテンツ領域へのスクロールチェーンを防止しましょう。 ### プルトゥリフレッシュの無効化 ```css body { overscroll-behavior-y: none; } ``` モバイルブラウザでは、ページ上部で下に引くとリフレッシュがトリガーされます。body に `overscroll-behavior-y: none` を設定することでこの動作を無効にできます。独自のリフレッシュメカニズムを持つウェブアプリに便利です。 ## AIがよくやるミス - **`overscroll-behavior` を一切提案しない**:単一のCSSプロパティで解決できる問題にJavaScriptのスクロールロックライブラリや `event.preventDefault()` ハックをデフォルトで使用します。 - **間違った要素に適用する**:`overscroll-behavior` は**スクロールを持つ**要素(`overflow: auto` または `overflow: scroll`)に設定する必要がありますが、親やラッパーに設定してしまいます。 - **`contain` ではなく常に `none` を使用する**:`none` はすべてのオーバースクロールフィードバックを抑制するため、不自然に感じることがあります。バウンスエフェクトを特に抑制する必要がない限り、`contain` を優先しましょう。 - **軸固有のバリアントを忘れる**:水平スクローラーの `overscroll-behavior-x: contain` のように1軸のみ制御が必要な場合に `overscroll-behavior: contain` を使用します。 - **`overflow` と組み合わせない**:`overscroll-behavior` はスクロールコンテナにのみ効果を発揮します。要素に `overflow: auto` または `overflow: scroll` がない場合、このプロパティは効果がありません。 ## 使い分け - **モーダルとダイアログ**:モーダル内でスクロールした際の背景ページスクロールを防止します。 - **サイドバーとナビゲーションパネル**:サイドバーのスクロールをメインコンテンツから独立させます。 - **チャット・メッセージングパネル**:ユーザーがメッセージ履歴をスクロールする際のページスクロールを防止します。 - **ドロップダウンメニュー**:長いドロップダウンが背後のページをスクロールさせないようにします。 - **水平スクロール領域**:`overscroll-behavior-x: contain` でブラウザの戻る/進むナビゲーションの誤発動を防止します。 - **モバイルウェブアプリ**:アプリが独自のリフレッシュロジックを処理する場合、body に `overscroll-behavior-y: none` でプルトゥリフレッシュを無効にします。 ## 参考リンク - [overscroll-behavior — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior) - [overscroll-behavior — web.dev](https://developer.chrome.com/blog/overscroll-behavior) - [CSS overscroll-behavior — Ahmad Shadeed](https://ishadeed.com/article/css-overscroll-behavior) --- # 親要素の状態による子要素のスタイリング > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/selectors/parent-state-child-styling ## 問題 親のインタラクティブ状態(ホバー、フォーカス、チェックなど)に基づいて子要素をスタイリングすることは、最も一般的なUIパターンの1つです — カードをホバーするとタイトルがハイライトされたり、inputにフォーカスするとアイコンが変わったり、チェックボックスをオンにすると追加コンテンツが表示されたりします。AIエージェントはこれらのパターンにJavaScriptのイベントリスナーとクラストグルを使おうとしたり、各子要素に個別にホバースタイルを適用したりする傾向があります。Tailwind CSSは `group` と `group-hover:` ユーティリティでこれをエレガントに解決しましたが、基盤となるCSSパターンはシンプルで、多くの開発者が認識しているよりも強力です。 ## 解決方法 CSSは親の状態に基づく子スタイリングのための3つの補完的なメカニズムを提供しています: 1. **擬似クラスと子孫コンビネーター** — `.parent:hover .child` で親がホバーされた時に子をターゲットにします 2. **`:focus-within`** — 要素自体またはその子孫がフォーカスを持つ時にマッチします 3. **`:has()`** — 最も強力:任意の子の状態に基づいて親(およびその子)をスタイリングします これらを組み合わせることで、Tailwindの `group-*` ユーティリティが処理するすべてのシナリオ、さらにそれ以上をカバーします。 → Hover this card The icon shifts, the title changes color, and the background transitions — all from a single parent :hover and :focus-within. → Second card Each card is independent. Hovering one does not affect the other. `} css={` .demo { display: flex; gap: 1rem; padding: 1.5rem; } .card { flex: 1; padding: 1.5rem; border: 1px solid hsl(220 15% 85%); border-radius: 0.5rem; background: white; transition: background-color 0.2s ease, border-color 0.2s ease; } .card:hover, .card:focus-within { background: hsl(220 60% 97%); border-color: hsl(220 60% 70%); } .card:focus-visible { outline: 2px solid hsl(220 70% 50%); outline-offset: 2px; } .card__icon { font-size: 1.25rem; transition: transform 0.2s ease; } .card:hover .card__icon, .card:focus-within .card__icon { transform: translateX(4px); } .card__title { margin: 0.5rem 0 0.25rem; font-size: 1rem; color: hsl(220 15% 25%); transition: color 0.2s ease; } .card:hover .card__title, .card:focus-within .card__title { color: hsl(220 70% 50%); } .card__desc { margin: 0; font-size: 0.8rem; color: hsl(220 10% 50%); line-height: 1.5; } `} /> ## コード例 ### 基本:親のホバー → 子のスタイリング 最もシンプルなパターンです。親がホバーされると子が応答します。 ```css .card:hover .card__title { color: blue; } .card:hover .card__icon { transform: translateX(4px); } .card:hover .card__arrow { opacity: 1; } ``` これはTailwindの `group` / `group-hover:` がコンパイルされる先のCSSです。親が「グループ」で、子がその状態に反応します。 ### Focus-Within:キーボードアクセシブルなグループフォーカス `:focus-within` は要素自体または**任意の子孫**がフォーカスを持つ時にマッチします。キーボードアクセシビリティに不可欠で、`:hover` が親に対して提供するのと同じ連動スタイリングをフォーカスイベントに対して実現します。 ```css /* The search bar container highlights when its input is focused */ .search-bar:focus-within { border-color: hsl(220 70% 50%); box-shadow: 0 0 0 3px hsl(220 70% 50% / 0.15); } .search-bar:focus-within .search-bar__icon { color: hsl(220 70% 50%); } .search-bar:focus-within .search-bar__label { transform: translateY(-100%) scale(0.85); color: hsl(220 70% 50%); } ``` ⌕ Click the input or press Tab — the entire search bar highlights via :focus-within `} css={` .demo { padding: 2rem; } .hint { font-size: 0.75rem; color: hsl(220 10% 55%); font-style: italic; margin-top: 0.75rem; } .search-bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.625rem 1rem; border: 2px solid hsl(220 15% 80%); border-radius: 0.5rem; background: white; transition: border-color 0.2s ease, box-shadow 0.2s ease; } .search-bar:focus-within { border-color: hsl(220 70% 50%); box-shadow: 0 0 0 3px hsl(220 70% 50% / 0.15); } .search-bar__icon { font-size: 1.25rem; color: hsl(220 10% 60%); transition: color 0.2s ease; } .search-bar:focus-within .search-bar__icon { color: hsl(220 70% 50%); } .search-bar__input { border: none; outline: 2px solid transparent; font-size: 0.9rem; flex: 1; background: transparent; color: hsl(220 15% 20%); } .search-bar__input::placeholder { color: hsl(220 10% 65%); } `} /> ### :has() — 最も強力なグループパターン `:has()` はホバーやフォーカスを超えた能力を持ちます。**任意の子の状態**(チェックされたチェックボックス、入力されたinput、選択されたオプション、さらには構造的条件)に基づいて親(およびその子)をスタイリングできます。 ```css /* Highlight the form group when its checkbox is checked */ .option-group:has(input:checked) { background: hsl(220 60% 97%); border-color: hsl(220 70% 50%); } .option-group:has(input:checked) .option-group__label { color: hsl(220 70% 40%); font-weight: 600; } /* Reveal extra content when checked */ .option-group:has(input:checked) .option-group__details { display: block; } ``` Free Basic features, 1 project Pro Unlimited projects, priority support Team Everything in Pro, plus team management `} css={` .demo { display: flex; flex-direction: column; gap: 0.5rem; padding: 1.5rem; } .option-card { display: block; border: 2px solid hsl(220 15% 85%); border-radius: 0.5rem; cursor: pointer; transition: border-color 0.2s ease, background-color 0.2s ease; } .option-card:hover { border-color: hsl(220 30% 70%); } .option-card:has(input:checked) { border-color: hsl(220 70% 50%); background: hsl(220 60% 97%); } .option-card:has(input:focus-visible) { outline: 2px solid hsl(220 70% 50%); outline-offset: 2px; } .option-card__input { position: absolute; opacity: 0; pointer-events: none; } .option-card__content { display: flex; align-items: center; gap: 0.75rem; padding: 1rem; } .option-card__indicator { width: 1.25rem; height: 1.25rem; border-radius: 50%; border: 2px solid hsl(220 15% 70%); flex-shrink: 0; position: relative; transition: border-color 0.2s ease; } .option-card:has(input:checked) .option-card__indicator { border-color: hsl(220 70% 50%); } .option-card:has(input:checked) .option-card__indicator::after { content: ""; position: absolute; inset: 3px; border-radius: 50%; background: hsl(220 70% 50%); } .option-card__title { font-weight: 600; font-size: 0.95rem; color: hsl(220 15% 25%); transition: color 0.2s ease; } .option-card:has(input:checked) .option-card__title { color: hsl(220 70% 40%); } .option-card__desc { font-size: 0.8rem; color: hsl(220 10% 55%); margin-top: 0.125rem; } `} /> ### ホバーと Focus-Within の組み合わせ 堅牢なインタラクティブコンポーネントには、マウスとキーボードの両方のユーザーで動作するよう `:hover` と `:focus-within` の両方を組み合わせましょう。 ```css .nav-item:hover .nav-item__tooltip, .nav-item:focus-within .nav-item__tooltip { opacity: 1; transform: translateY(0); pointer-events: auto; } ``` Home Go to homepage Settings Manage your preferences Profile View your profile Hover or Tab to each button — the tooltip appears via :hover + :focus-within `} css={` .demo { padding: 2rem; } .hint { font-size: 0.75rem; color: hsl(220 10% 55%); font-style: italic; margin-top: 1.5rem; } .nav { display: flex; gap: 0.5rem; } .nav-item { position: relative; } .nav-item__btn { padding: 0.5rem 1rem; border: 1px solid hsl(220 15% 80%); border-radius: 0.375rem; background: white; font-size: 0.85rem; cursor: pointer; transition: background-color 0.15s ease, border-color 0.15s ease; } .nav-item:hover .nav-item__btn, .nav-item:focus-within .nav-item__btn { background: hsl(220 60% 97%); border-color: hsl(220 60% 70%); } .nav-item__btn:focus-visible { outline: 2px solid hsl(220 70% 50%); outline-offset: 2px; } .nav-item__tooltip { position: absolute; top: calc(100% + 0.5rem); left: 50%; transform: translateX(-50%) translateY(4px); padding: 0.375rem 0.75rem; background: hsl(220 20% 20%); color: white; font-size: 0.75rem; border-radius: 0.25rem; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.15s ease, transform 0.15s ease; } .nav-item:hover .nav-item__tooltip, .nav-item:focus-within .nav-item__tooltip { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; } `} /> ### :has(:checked) による表示切り替え クラシックなパターンです。チェックボックスやラジオの状態に基づいてコンテンツセクションの表示/非表示を切り替えます。JavaScriptは不要です。 What is parent-state styling? › Parent-state styling means applying CSS to child elements based on the parent's interactive state — hover, focus, checked, etc. This is what Tailwind's group/group-hover utilities compile to. Do I need JavaScript for this? › No. Descendant combinators with :hover, :focus-within, and :has(:checked) cover the vast majority of interactive parent-child patterns without any JavaScript. What about browser support? › :hover and :focus-within have universal support. :has() is supported in all major browsers since December 2023 (Chrome 105+, Safari 15.4+, Firefox 121+). `} css={` .accordion { padding: 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; } .accordion__item { border: 1px solid hsl(220 15% 85%); border-radius: 0.5rem; overflow: hidden; transition: border-color 0.2s ease; } .accordion__item:has(.accordion__trigger:checked) { border-color: hsl(220 60% 70%); } .accordion__item:has(.accordion__trigger:focus-visible) { outline: 2px solid hsl(220 70% 50%); outline-offset: 2px; } .accordion__trigger { position: absolute; opacity: 0; pointer-events: none; } .accordion__header { display: flex; align-items: center; justify-content: space-between; padding: 0.875rem 1rem; cursor: pointer; background: white; transition: background-color 0.15s ease; } .accordion__header:hover { background: hsl(220 30% 97%); } .accordion__title { font-size: 0.9rem; font-weight: 600; color: hsl(220 15% 25%); } .accordion__chevron { font-size: 1.25rem; color: hsl(220 10% 55%); transition: transform 0.2s ease; } .accordion__item:has(.accordion__trigger:checked) .accordion__chevron { transform: rotate(90deg); } .accordion__body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.25s ease; } .accordion__item:has(.accordion__trigger:checked) .accordion__body { grid-template-rows: 1fr; } .accordion__body-inner { overflow: hidden; } .accordion__body-inner > p { margin: 0; padding: 0 1rem 1rem; font-size: 0.85rem; color: hsl(220 10% 40%); line-height: 1.6; } `} /> ### ネストされたグループ:複数の祖先レベル 異なる祖先レベルが異なる子スタイルを駆動する必要がある場合、各レベルに個別のクラス名を使用しましょう。 ```css /* Outer group: the card */ .card:hover .card__badge { background: hsl(220 70% 50%); } /* Inner group: the card footer */ .card__footer:hover .card__footer-link { text-decoration: underline; } ``` これはTailwindの名前付きグループ(`group/card`、`group/footer`)のCSS等価物です。各祖先の状態がそれぞれの子孫を独立して制御します。 New Nested Group Demo Hover the card to highlight the badge. Then hover the footer area to see the link underline. Read more → `} css={` .demo { padding: 1.5rem; max-width: 320px; } .card { border: 1px solid hsl(220 15% 85%); border-radius: 0.5rem; padding: 1.25rem; background: white; transition: border-color 0.2s ease; position: relative; } .card:hover { border-color: hsl(220 50% 70%); } .card__badge { display: inline-block; padding: 0.2rem 0.5rem; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; border-radius: 0.25rem; background: hsl(220 15% 88%); color: hsl(220 15% 40%); transition: background-color 0.2s ease, color 0.2s ease; } /* Outer group: card hover changes badge */ .card:hover .card__badge { background: hsl(220 70% 50%); color: white; } .card__title { font-size: 1rem; margin: 0.75rem 0 0.25rem; color: hsl(220 15% 20%); } .card__desc { font-size: 0.8rem; color: hsl(220 10% 50%); line-height: 1.5; margin: 0; } .card__footer { margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid hsl(220 15% 90%); } .card__footer-link { font-size: 0.85rem; color: hsl(220 70% 50%); transition: text-decoration-color 0.15s ease; text-decoration: underline transparent; } .card__footer-link:focus-visible { outline: 2px solid hsl(220 70% 50%); outline-offset: 2px; border-radius: 2px; } /* Inner group: footer hover changes link */ .card__footer:hover .card__footer-link, .card__footer:focus-within .card__footer-link { text-decoration: underline hsl(220 70% 50%); } `} /> ## Tailwind group → CSS マッピング | Tailwind ユーティリティ | CSS 等価物 | | --- | --- | | `group` + `group-hover:text-blue` | `.parent:hover .child { color: blue; }` | | `group` + `group-focus:opacity-100` | `.parent:focus .child { opacity: 1; }` | | `group` + `group-focus-within:ring-2` | `.parent:focus-within .child { ... }` | | `group` + `group-active:scale-95` | `.parent:active .child { transform: scale(0.95); }` | | `group/name`(名前付きグループ) | 祖先レベルごとに個別のクラス名を使用 | | `group-has-[:checked]:bg-blue` | `.parent:has(:checked) { background: blue; }` | ## AIがよくやるミス - **ホバーエフェクトにJavaScriptでクラスをトグルする** — `.parent:hover .child` を使いましょう。イベントリスナーは不要です。 - **すべての子要素にホバースタイルを重複して書く** — 親に一度 `:hover` を適用し、そこからすべての子をスタイリングしましょう。 - **キーボードアクセシビリティを忘れる** — インタラクティブコンテナには常に `:hover` と `:focus-within` をペアにしましょう。ホバーのみのパターンはキーボードユーザーを排除します。 - **`:has()` を過度に使用する** — シンプルな `.parent:hover .child` で十分な場合に `:has()` を使わないでください。`:has()` は子の内部状態(checked、valid、empty)に反応する必要がある場合に使いましょう。 ## 使い分け - **カードのホバーエフェクト**:タイトル、アイコン、背景の連動変化に使います。 - **フォームグループ**:inputがフォーカスされた時や無効な時にグループ全体をハイライトする場合に使います。 - **ナビゲーションメニュー**:ホバー/フォーカス時にツールチップやドロップダウンを表示する場合に使います。 - **オプションセレクター**:ラジオ/チェックボックスの状態に基づいて選択されたオプションをスタイリングする場合に使います。 - **アコーディオン/ディスクロージャー**:`:has(:checked)` で表示を切り替える場合に使います。 - **Tailwindが `group` を使用するパターンすべて** — CSSは常に子孫コンビネーター + 擬似クラスです。 --- # ビュートランジション > Source: https://takazudomodular.com/pj/zcss/ja/docs/interactive/states-and-transitions/view-transitions ## 問題 ページの状態間やナビゲーション時にスムーズなアニメーショントランジションを作成するには、従来、複雑なJavaScriptアニメーションライブラリ、手動のDOM操作、またはReact Transition Groupのようなフレームワーク固有のソリューションが必要でした。ページナビゲーション(SPAとMPAの両方)では、視覚的な連続性のない唐突なコンテンツの切り替えが発生します。AIエージェントはJavaScriptに依存するアニメーションアプローチをデフォルトとし、View Transitions APIを提案することはほとんどありません。 ## 解決方法 View Transitions APIは、DOM状態間のアニメーショントランジションを作成するためのネイティブメカニズムを提供します。ブラウザが古い状態のスナップショットをキャプチャし、DOM更新を適用してから、CSSを使って古いスナップショットと新しいスナップショットの間をアニメーションします。同一ドキュメント(SPA)のトランジションには`document.startViewTransition()`を使用します。クロスドキュメント(MPA)のトランジションには`@view-transition` CSSアットルールを使って両方のページをオプトインします。 ## コード例 ### 同一ドキュメントのView Transition(SPA) ```css /* Default crossfade animation — works with no extra CSS */ ::view-transition-old(root) { animation: fade-out 0.3s ease-out; } ::view-transition-new(root) { animation: fade-in 0.3s ease-in; } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } ``` ```html function updateContent(newHTML) { if (!document.startViewTransition) { // Fallback: just update directly document.getElementById('content').innerHTML = newHTML; return; } document.startViewTransition(() => { document.getElementById('content').innerHTML = newHTML; }); } ``` ### 要素レベルのアニメーション用の名前付きView Transition `view-transition-name`を割り当てることで、特定の要素に独自のトランジションを持たせます。 ```css .product-image { view-transition-name: product-image; } .product-title { view-transition-name: product-title; } /* Customize the animation for the product image */ ::view-transition-old(product-image) { animation: scale-down 0.4s ease-in; } ::view-transition-new(product-image) { animation: scale-up 0.4s ease-out; } @keyframes scale-down { from { transform: scale(1); } to { transform: scale(0.8); opacity: 0; } } @keyframes scale-up { from { transform: scale(0.8); opacity: 0; } to { transform: scale(1); } } ``` ### クロスドキュメントのView Transition(MPA) `@view-transition`アットルールで両方のページをトランジションにオプトインします。 ```css /* Include this in BOTH the source and destination pages */ @view-transition { navigation: auto; } /* Shared element transitions across pages */ .hero-image { view-transition-name: hero; } /* Customize the cross-document transition */ ::view-transition-old(hero) { animation-duration: 0.4s; } ::view-transition-new(hero) { animation-duration: 0.4s; } ``` ### ページ間のスライドトランジション ```css @view-transition { navigation: auto; } @keyframes slide-from-right { from { transform: translateX(100%); } to { transform: translateX(0); } } @keyframes slide-to-left { from { transform: translateX(0); } to { transform: translateX(-100%); } } ::view-transition-old(root) { animation: slide-to-left 0.4s ease-in-out; } ::view-transition-new(root) { animation: slide-from-right 0.4s ease-in-out; } ``` ### `view-transition-class`によるグループアニメーション CSSを繰り返すことなく、複数の名前付きトランジションに同じアニメーションを適用します。 ```css .card-1 { view-transition-name: card-1; } .card-2 { view-transition-name: card-2; } .card-3 { view-transition-name: card-3; } /* Apply the same animation class to all cards */ .card-1, .card-2, .card-3 { view-transition-class: card; } /* One rule animates all card transitions */ ::view-transition-group(*.card) { animation-duration: 0.35s; animation-timing-function: ease-in-out; } ``` ### View Transition Typesによる条件付きトランジション ```html function navigateForward(updateFn) { const transition = document.startViewTransition({ update: updateFn, types: ['slide-forward'], }); } function navigateBack(updateFn) { const transition = document.startViewTransition({ update: updateFn, types: ['slide-back'], }); } ``` ```css /* Forward navigation */ :active-view-transition-type(slide-forward) { &::view-transition-old(root) { animation: slide-to-left 0.3s ease-in-out; } &::view-transition-new(root) { animation: slide-from-right 0.3s ease-in-out; } } /* Back navigation */ :active-view-transition-type(slide-back) { &::view-transition-old(root) { animation: slide-to-right 0.3s ease-in-out; } &::view-transition-new(root) { animation: slide-from-left 0.3s ease-in-out; } } ``` ### ユーザー設定の尊重 ```css @media (prefers-reduced-motion: reduce) { ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.01ms; } } ``` ## ブラウザサポート ### 同一ドキュメントのView Transitions - Chrome 111+ - Edge 111+ - Safari 18+ - Firefox 144+(2025年10月リリース — Interop 2025の一部) ### クロスドキュメントのView Transitions - Chrome 126+ - Edge 126+ - Safari 18.2+ - Firefox:未サポート 同一ドキュメントのトランジションは幅広いサポートがあります。クロスドキュメントのトランジションはChromiumとSafariでサポートされていますが、Firefoxではまだサポートされていません。常に`document.startViewTransition`のサポートを確認してから呼び出すようにフォールバックを提供しましょう。 ## AIがよくやるミス - View Transitions APIがネイティブに処理できるトランジションに対して、JavaScriptアニメーションライブラリ(GSAP、Framer Motion)を使用する - `document.startViewTransition`のサポートを呼び出し前に確認しない - クロスドキュメントトランジションで**両方**のページに`@view-transition { navigation: auto; }`を追加し忘れる - ページとは独立してアニメーションすべき共有要素に`view-transition-name`を割り当てない - 同じページ上で`view-transition-name`の値をユニークにしない(トランジション時に各名前はユニークでなければならない) - `prefers-reduced-motion`を尊重しない — モーションの軽減を好むユーザーのためにアニメーションを無効化または短縮する - 過剰なアニメーション:意味のある状態変化ではなく、すべての小さなUI更新にView Transitionsを使用する ## 使い分け - ページ間のナビゲーショントランジション(SPAとMPAの両方) - ページ内のコンテンツ更新(タブ切り替え、リストフィルタリング、詳細ビュー) - リストビューと詳細ビュー間の共有要素トランジション(例:商品サムネイル) - 視覚的な連続性がユーザーの変化の理解を助けるあらゆる状態変化 - 複雑なJavaScriptアニメーションの設定をネイティブブラウザ機能に置き換える ## ライブプレビュー Page A Original Content → Page B New Content How it works: The browser captures a snapshot of the old state, updates the DOM, then crossfades between old and new using ::view-transition-old and ::view-transition-new pseudo-elements. View transitions require JavaScript (document.startViewTransition()) to trigger — this static demo shows the visual pattern. `} css={` .demo { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; gap: 1.5rem; } .page { width: 140px; border-radius: 12px; overflow: hidden; border: 2px solid #e2e8f0; } .page-old { opacity: 0.5; border-color: #fca5a5; } .page-new { border-color: #86efac; box-shadow: 0 4px 12px rgba(34, 197, 94, 0.15); } .header { padding: 0.5rem 0.75rem; font-weight: 700; font-size: 0.8rem; } .page-old .header { background: #fef2f2; color: #dc2626; } .page-new .header { background: #f0fdf4; color: #16a34a; } .content { padding: 1.5rem 0.75rem; text-align: center; font-size: 0.8rem; color: #64748b; background: white; } .arrow { font-size: 2rem; color: #94a3b8; font-weight: 300; } .code-note { margin-top: 1rem; padding: 1rem; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; font-family: system-ui, sans-serif; font-size: 0.8rem; color: #64748b; line-height: 1.5; } .code-note p { margin: 0 0 0.5rem; } .code-note p:last-child { margin: 0; } code { background: #e2e8f0; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.75rem; } `} /> ## 参考リンク - [View Transition API - MDN](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - [Smooth transitions with the View Transition API - Chrome for Developers](https://developer.chrome.com/docs/web-platform/view-transitions) - [What's new in view transitions (2025 update) - Chrome for Developers](https://developer.chrome.com/blog/view-transitions-in-2025) - [@view-transition - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@view-transition) - [A Practical Guide to the CSS View Transition API - Cyd Stumpel](https://cydstumpel.nl/a-practical-guide-to-the-css-view-transition-api/) --- # サブグリッド > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/flexbox-and-grid/subgrid ## 問題 CSS Gridは直接の子要素のレイアウトに優れていますが、ネストされた要素は親グリッドのトラックサイジングに参加できません。これにより、兄弟コンポーネント間でコンテンツを揃えることが不可能になります — 例えば、カードの行全体でタイトル、説明、ボタンがすべて同じ垂直位置に揃うようにすることができません。AIエージェントが`subgrid`を使うことはほとんどなく、代わりに固定の高さ、JavaScriptベースの揃え、または動的コンテンツで壊れる複雑な回避策に頼ります。 ## 解決方法 `grid-template-columns`や`grid-template-rows`の`subgrid`値を使うと、ネストされたグリッドが親のトラック定義を継承できます。子グリッドは親のトラックサイジングに参加するため、兄弟グリッドアイテム間のコンテンツが固定の寸法やトラック定義の重複なしに自然に揃います。 ## コード例 ### 揃えられたコンテンツのカードレイアウト 最も一般的なsubgridのユースケース:カード間で見出し、コンテンツ、フッターが揃うようにします。 ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; } .card { display: grid; /* Inherit parent's row tracks for this card's internal layout */ grid-row: span 3; grid-template-rows: subgrid; gap: 0.75rem; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1.5rem; } .card h2 { /* Row 1: title — aligns across all cards */ align-self: start; } .card p { /* Row 2: description — aligns across all cards */ align-self: start; } .card .action { /* Row 3: button — pushed to bottom, aligned across all cards */ align-self: end; } ``` ```html Short Title Brief description. Read more A Much Longer Card Title That Wraps This card has a longer title, but the description and button still align with the other cards. Read more Medium Title Another card with varying content length. Read more ``` ### カラム用のSubgrid ネストされたフォームのラベルと入力フィールドを親グリッドのカラムに揃えます。 ```css .form-grid { display: grid; grid-template-columns: 120px 1fr; gap: 1rem; } .field-group { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: center; } .field-group label { grid-column: 1; } .field-group input { grid-column: 2; } ``` ```html Name Email Phone ``` ### 2次元のSubgrid 親グリッドから行とカラムの両方を継承します。 ```css .dashboard { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: auto 1fr auto; gap: 1rem; } .widget { display: grid; grid-column: span 1; grid-row: span 3; grid-template-columns: subgrid; grid-template-rows: subgrid; } .widget header { /* Aligns with other widgets' headers */ font-weight: bold; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; } .widget footer { /* Aligns with other widgets' footers */ font-size: 0.875rem; color: #6b7280; } ``` ### 名前付きラインを持つSubgrid 親グリッドの名前付きラインはsubgridに引き継がれます。 ```css .page-layout { display: grid; grid-template-columns: [full-start] 1fr [content-start] minmax(0, 65ch) [content-end] 1fr [full-end]; } .article { display: grid; grid-column: full-start / full-end; grid-template-columns: subgrid; } .article p { grid-column: content-start / content-end; } .article .wide-image { grid-column: full-start / full-end; } ``` ### gapを重複させないSubgrid subgridはデフォルトで親の`gap`を継承します。必要に応じてオーバーライドできます。 ```css .parent { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2rem; } .child { display: grid; grid-column: span 2; grid-template-columns: subgrid; /* Subgrid inherits parent's 2rem gap */ /* Override if needed: */ gap: 0.5rem; } ``` ## ブラウザサポート - Chrome 117+ - Edge 117+ - Safari 16+ - Firefox 71+ グローバルサポートは97%を超えています。Firefoxが2019年12月に最初にsubgridをリリースしました。Safariが2022年9月に続き、Chrome/Edgeが2023年9月にサポートを追加しました。Subgridはプロダクション利用に安全です。 ## AIがよくやるミス - `subgrid`をまったく使わない — ほとんどのAIエージェントは、揃えのためにトラック定義を重複させたり固定の高さを使ったりするグリッドレイアウトを生成する - 子グリッドで`subgrid`を使う代わりに`grid-template-columns`の値を重複させる - 兄弟グリッドアイテム間のコンテンツ揃えにJavaScriptや固定の寸法を使用する - subgridを適用する前に、子グリッドアイテムを正しい数の親トラックにまたがらせない - subgridが親の`gap`値を継承すること(オーバーライド可能)を忘れる - 子要素に最初に`display: grid`を設定せずに`subgrid`を適用する - ラベルと入力フィールドに一貫した揃えが必要なフォームレイアウトでsubgridを活用しない ## 使い分け - カード間でタイトル、コンテンツ、アクションを揃える必要があるカードグリッド - ラベルと入力フィールドが共有カラムトラックに揃うフォームレイアウト - 共通のヘッダー/コンテンツ/フッター構造を共有するダッシュボードウィジェット - ネストされた要素が親グリッドの名前付きラインを参照する必要があるフルブリードコンテンツレイアウト - ネストされた要素が親グリッドのトラックサイジングに参加する必要があるあらゆるレイアウト ## ライブプレビュー Short Title Brief description of this card. Read more → A Much Longer Card Title That Wraps to Multiple Lines Despite the longer title, the description and action button still align perfectly with other cards thanks to subgrid. Read more → Medium Title Another card with varying content length to show alignment. Read more → Notice how titles, descriptions, and buttons all align across cards — subgrid shares the parent's row tracks `} css={` .card-grid { font-family: system-ui, sans-serif; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; } .card { display: grid; grid-row: span 3; grid-template-rows: subgrid; gap: 0.5rem; border: 1px solid #e2e8f0; border-radius: 12px; padding: 1.25rem; background: #fff; } .card h2 { font-size: 1rem; color: #1e293b; margin: 0; align-self: start; } .card p { font-size: 0.875rem; color: #64748b; margin: 0; line-height: 1.5; align-self: start; } .action { align-self: end; display: inline-block; padding: 0.5rem 1rem; background: #3b82f6; color: white; text-decoration: none; border-radius: 6px; font-size: 0.85rem; font-weight: 600; text-align: center; transition: background 0.2s; } .action:hover { background: #2563eb; } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 1rem; } `} height={320} /> ## 参考リンク - [Subgrid - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Subgrid) - [CSS subgrid - web.dev](https://web.dev/articles/css-subgrid) - [Brand New Layouts with CSS Subgrid - Josh W. Comeau](https://www.joshwcomeau.com/css/subgrid/) - [CSS Subgrid - Can I Use](https://caniuse.com/css-subgrid) --- # スタッキングコンテキスト > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/positioning/stacking-context ## 問題 スタッキングコンテキスト(stacking context)はCSSで最も誤解されている概念です。AIエージェントは、要素が他の要素の後ろに表示される理由を理解せずに、`z-index` の値を途方もない数値(99999)に引き上げることを繰り返します。根本的な原因はほぼ常に、要素が異なるスタッキングコンテキストに属しているということであり、z-indexをいくら上げても親のスタッキングコンテキストから抜け出すことはできません。 ## 解決方法 スタッキングコンテキストは、独立したレイヤリンググループです。1つのスタッキングコンテキスト内の要素はz-orderの比較対象になりますが、異なるスタッキングコンテキストの要素とは比較されません。何がスタッキングコンテキストを作成するかを理解し、`isolation` プロパティを使って意図的に作成すれば、z-indexの戦いは解消されます。 ## スタッキングコンテキストを作るもの 以下のプロパティが要素に新しいスタッキングコンテキストを作成します: ### 常にスタッキングコンテキストを作成するもの - ルート要素(``) - `position: fixed` - `position: sticky` ### 条件を満たすと作成するもの - `position: relative` または `position: absolute` で **`z-index` が `auto` 以外の場合** - `opacity` が `1` 未満 - `transform` が `none` 以外の値 - `filter` が `none` 以外の値 - `backdrop-filter` が `none` 以外の値 - `perspective` が `none` 以外の値 - `clip-path` が `none` 以外の値 - `mask` / `mask-image` / `mask-border` が `none` 以外の値 - `mix-blend-mode` が `normal` 以外の値 - `isolation: isolate` - 上記のプロパティのいずれかを指定した `will-change` - `contain: layout`、`contain: paint`、または `contain: strict` ## コード例 ### 典型的な z-index バグ ```css .sidebar { position: relative; z-index: 1; } .sidebar .dropdown { position: absolute; z-index: 99999; /* Will NOT appear above .main-content */ } .main-content { position: relative; z-index: 2; } ``` この例では、`.dropdown` は `z-index: 99999` を持っていますが、`.main-content` の後ろに表示されます。これは `.sidebar` が `z-index: 1` でスタッキングコンテキストを作成しているためです。ドロップダウンはそのスタッキングコンテキスト内に閉じ込められています。`.main-content`(z-index: 2)の視点からは、`.sidebar` グループ全体のz-indexは1です。 Sidebar (z-index: 1) Dropdown z-index: 99999 — trapped behind main! Main Content (z-index: 2) — covers dropdown `} css={`.container { position: relative; height: 200px; font-family: system-ui, sans-serif; font-size: 14px; } .sidebar { position: relative; z-index: 1; background: #f59e0b; color: #fff; padding: 12px; width: 55%; border-radius: 8px; } .sidebar-label { font-weight: 600; margin-bottom: 8px; } .dropdown { position: absolute; top: 100%; left: 0; z-index: 99999; background: #ef4444; color: #fff; padding: 12px 16px; border-radius: 8px; width: 280px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .main-content { position: relative; z-index: 2; background: #3b82f6; color: #fff; padding: 16px; border-radius: 8px; margin-top: -8px; }`} height={220} /> ### 修正: 親のスタッキングコンテキストを取り除く ```css .sidebar { position: relative; /* Remove z-index to avoid creating a stacking context */ } .sidebar .dropdown { position: absolute; z-index: 10; } .main-content { position: relative; /* No z-index needed */ } ``` Sidebar (no z-index) Dropdown z-index: 10 — now appears on top! Main Content (no z-index) `} css={`.container { position: relative; height: 200px; font-family: system-ui, sans-serif; font-size: 14px; } .sidebar { position: relative; background: #f59e0b; color: #fff; padding: 12px; width: 55%; border-radius: 8px; } .sidebar-label { font-weight: 600; margin-bottom: 8px; } .dropdown { position: absolute; top: 100%; left: 0; z-index: 10; background: #22c55e; color: #fff; padding: 12px 16px; border-radius: 8px; width: 280px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .main-content { position: relative; background: #3b82f6; color: #fff; padding: 16px; border-radius: 8px; margin-top: -8px; }`} height={220} /> ### isolation プロパティ `isolation: isolate` はスタッキングコンテキストを作成する最もクリーンな方法です。新しいスタッキングコンテキストを作成するという1つのことだけを行います。副作用なし、視覚的な変化もありません。 ```css .card { isolation: isolate; /* Creates stacking context */ } .card .background { position: absolute; inset: 0; z-index: -1; /* Stays behind card content but inside card's context */ } .card .content { position: relative; /* z-index: auto, but still above .background due to DOM order */ } ``` ```html Card text ``` isolation: isolate The purple background uses z-index: -1 but stays inside the card thanks to isolation. This text is behind the card — z-index: -1 does not leak out `} css={`.demo-area { padding: 12px; background: #f1f5f9; border-radius: 8px; font-family: system-ui, sans-serif; font-size: 14px; } .card { isolation: isolate; position: relative; border-radius: 12px; padding: 20px; margin-bottom: 12px; overflow: hidden; } .card-bg { position: absolute; inset: 0; z-index: -1; background: linear-gradient(135deg, #8b5cf6, #6d28d9); } .card-content { position: relative; color: #fff; line-height: 1.5; } .behind-text { color: #64748b; padding: 8px; text-align: center; }`} /> ### コンポーネントの漏洩防止 コンポーネントのルート要素に `isolation: isolate` を使い、z-indexが外部に漏れるのを防ぎましょう。 ```css /* Each component is self-contained */ .modal-overlay { isolation: isolate; position: fixed; inset: 0; z-index: 100; } .tooltip { isolation: isolate; position: absolute; } .dropdown-menu { isolation: isolate; position: absolute; } ``` ### 意図しないスタッキングコンテキスト レイヤリングとは無関係に見えるプロパティが、サイレントにスタッキングコンテキストを作成することがあります。 ```css /* This creates a stacking context! */ .fading-element { opacity: 0.99; } /* This also creates a stacking context! */ .animated-element { transform: translateZ(0); /* Often added for "GPU acceleration" */ } /* This too! */ .blurred-element { filter: blur(0px); } ``` これらのいずれも、子要素のz-index値をその要素のスタッキングコンテキスト内に閉じ込めます。 ## スタッキングコンテキストの問題をデバッグする ### ステップバイステップの診断 1. 正しくレイヤリングされていない要素を特定します。 2. その要素からルートまでDOMツリーを上にたどります。 3. 各祖先について、スタッキングコンテキストを作成しているかを確認します(上記のリストを使用)。 4. レイヤリングが崩れるスタッキングコンテキストの境界を見つけます。 5. 不要なスタッキングコンテキストを取り除くか、既存のコンテキスト内で動作するようにz-index値を再構成します。 ### ブラウザ DevTools Chrome DevToolsでは、Layersパネル(More Tools > Layers)でスタッキングコンテキストを可視化できます。Firefox DevToolsではLayoutパネルにスタッキングコンテキスト情報が表示されます。 ## AIがよくやるミス - **z-indexの値をエスカレーションする。** 要素が最前面に表示されない場合、AIエージェントはz-indexを大きな数値に増やします。これはスタッキングコンテキストの問題を解決しません。修正するには、要素がどのスタッキングコンテキストに属しているかを理解する必要があります。 - **`opacity`、`transform`、`filter` がスタッキングコンテキストを作成することを知らない。** パフォーマンスのために `opacity: 0.99` や `transform: translateZ(0)` を追加すると、子要素のz-indexの動作が壊れる可能性があります。 - **位置指定されていない要素にz-indexを使う。** `z-index` は位置指定された要素(`relative`、`absolute`、`fixed`、`sticky`)とflex/gridアイテムにのみ作用します。静的に配置された要素には効果がありません。 - **コンポーネント境界に `isolation: isolate` を使わない。** 内部でz-indexを使う再利用可能なコンポーネントはすべて、z-index値が親コンテキストに漏れるのを防ぐために `isolation: isolate` を使うべきです。 - **一般的な「修正」として `position: relative; z-index: 1` を追加する。** これは新しいスタッキングコンテキストを作成し、目の前の問題は解決するかもしれませんが、すべての子孫のz-index値を閉じ込め、別の場所で新たな問題を引き起こします。 ## 使い分け ### `isolation: isolate` を使うべき場合 - 内部でz-indexを使う再利用可能なコンポーネントの構築 - 視覚的な副作用なしでスタッキングコンテキストが必要な場合 - `z-index: -1` の要素を親要素内に留めたい場合 - mix-blend-mode が透過するのを防ぎたい場合 ### `z-index` を使うべき場合 - 同じスタッキングコンテキスト内の位置指定された要素の順序を制御する場合 - ページレベルでオーバーレイ、モーダル、ドロップダウンをレイヤリングする場合 ### スタッキングコンテキストを監査すべき場合 - z-indexの値が「効かない」場合 - 高いz-indexを持つ要素が低いz-indexの要素の後ろに表示される場合 - `opacity`、`transform`、`filter` を追加して予期しないレイヤリングの変化が起きた場合 ## 参考リンク - [Stacking Context - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context) - [What The Heck, z-index?? - Josh W. Comeau](https://www.joshwcomeau.com/css/stacking-contexts/) - [isolation - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/isolation) - [Z-index and Stacking Contexts - web.dev](https://web.dev/learn/css/z-index) - [CSS isolation property - freeCodeCamp](https://www.freecodecamp.org/news/the-css-isolation-property/) --- # clamp() による流体サイジング > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/sizing/clamp-for-sizing ## 問題 従来のレスポンシブデザインは、特定のブレークポイントでサイズを変更するメディアクエリに依存しています。これは急激な変化を生みます。見出しがモバイルでは 2rem で、デスクトップでは突然 3rem になるといった具合です。AIエージェントはフォントサイズ、パディング、幅に対して複数のメディアクエリを生成しがちで、冗長で保守しにくいCSSになります。`clamp()` 関数は、1つの宣言でメディアクエリゼロのまま、最小値と最大値の間をスムーズに流体スケーリングすることでこの問題を解決します。 ## 解決方法 `clamp()` 関数は3つの値を取ります: ``` clamp(minimum, preferred, maximum) ``` - **minimum** — 値が取りうる最小値 - **preferred** — 流体的にスケールするビューポート相対の式 - **maximum** — 値が取りうる最大値 ブラウザは preferred 値を使用しますが、minimum と maximum の間に制約します。preferred 値が minimum より小さい場合は minimum が使われ、maximum より大きい場合は maximum が使われます。 ## コード例 ### 流体タイポグラフィ ```css h1 { font-size: clamp(1.75rem, 1rem + 2.5vw, 3rem); } h2 { font-size: clamp(1.375rem, 0.875rem + 1.5vw, 2rem); } p { font-size: clamp(1rem, 0.875rem + 0.4vw, 1.25rem); } ``` 見出しは狭いビューポートの 1.75rem から広いビューポートの 3rem までスムーズにスケールします。`1rem + 2.5vw` の preferred 値は固定のベースとビューポート相対の部分を組み合わせており、ユーザーがズームした場合でもテキストがスケールします。 ### rem + vw であって、vw だけではない理由 ```css /* Bad: pure vw ignores user zoom preferences */ h1 { font-size: clamp(1.75rem, 4vw, 3rem); } /* Good: rem + vw respects user zoom */ h1 { font-size: clamp(1.75rem, 1rem + 2.5vw, 3rem); } ``` preferred 値に `vw` だけを使うと、ユーザーがブラウザのズームレベルを変更してもテキストがスケールしません(WCAGアクセシビリティ要件に違反します)。`rem` + `vw` を組み合わせることで、テキストがビューポート幅とズーム設定の両方に応答します。 ### preferred 値の計算方法 最小ビューポートでの最小サイズから最大ビューポートでの最大サイズまで線形にスケールするには: ``` slope = (maxSize - minSize) / (maxViewport - minViewport) intercept = minSize - slope × minViewport preferred = intercept(rem) + slope × 100(vw) ``` 例:320px で 1.75rem から 1280px で 3rem にスケール: ``` slope = (3 - 1.75) / (80 - 20) = 1.25 / 60 = 0.02083 intercept = 1.75 - 0.02083 × 20 = 1.3334 preferred = 1.3334rem + 2.083vw ``` (ビューポート幅は16で割って rem に変換:320/16=20、1280/16=80) ```css h1 { font-size: clamp(1.75rem, 1.333rem + 2.083vw, 3rem); } ``` 実際には、手計算ではなく clamp 計算ツールを使いましょう。 ### 流体スペーシング `clamp()` はタイポグラフィだけでなく、padding、margin、gap にも使えます: ```css .section { padding-block: clamp(2rem, 1rem + 3vw, 5rem); } .card-grid { gap: clamp(1rem, 0.5rem + 1.5vw, 2.5rem); } .container { padding-inline: clamp(1rem, 0.5rem + 2vw, 4rem); } ``` width: clamp(200px, 60%, 500px) Resize with viewport buttons to see it adapt `} css={`.outer { padding: 16px; background: #f1f5f9; border-radius: 8px; font-family: system-ui, sans-serif; } .fluid-box { width: clamp(200px, 60%, 500px); background: #3b82f6; color: #fff; padding: 20px; border-radius: 8px; } .label { font-size: 16px; font-weight: 600; margin-bottom: 4px; } .sublabel { font-size: 13px; opacity: 0.85; }`} /> Section with fluid padding padding-block: clamp(1rem, 0.5rem + 3vw, 4rem) padding-inline: clamp(1rem, 0.5rem + 2vw, 3rem) Use viewport buttons to see padding change `} css={`.section { background: #f1f5f9; border-radius: 8px; font-family: system-ui, sans-serif; } .content { background: #8b5cf6; color: #fff; border-radius: 8px; padding-block: clamp(1rem, 0.5rem + 3vw, 4rem); padding-inline: clamp(1rem, 0.5rem + 2vw, 3rem); } .heading { font-size: 18px; font-weight: 600; margin-bottom: 8px; } .text { font-size: 14px; margin-bottom: 4px; font-family: monospace; } .hint { font-size: 13px; opacity: 0.8; margin-top: 8px; }`} /> ### 流体幅の制約 ```css .content { max-inline-size: clamp(30rem, 90%, 60rem); } ``` コンテンツエリアは最小 30rem、最大 60rem で、その間はコンテナの90%にスケールします。 ### 完全な流体タイポグラフィシステム ```css :root { --text-sm: clamp(0.875rem, 0.8rem + 0.2vw, 1rem); --text-base: clamp(1rem, 0.875rem + 0.4vw, 1.25rem); --text-lg: clamp(1.25rem, 1rem + 0.75vw, 1.75rem); --text-xl: clamp(1.5rem, 1.125rem + 1.25vw, 2.25rem); --text-2xl: clamp(1.875rem, 1.25rem + 2vw, 3rem); --text-3xl: clamp(2.25rem, 1.25rem + 3vw, 4rem); --space-sm: clamp(0.5rem, 0.375rem + 0.4vw, 0.75rem); --space-md: clamp(1rem, 0.75rem + 0.75vw, 1.5rem); --space-lg: clamp(1.5rem, 1rem + 1.5vw, 3rem); --space-xl: clamp(2rem, 1rem + 3vw, 5rem); } ``` ### メディアクエリの置き換え ```css /* Before: multiple breakpoints */ h1 { font-size: 1.75rem; } @media (min-width: 640px) { h1 { font-size: 2rem; } } @media (min-width: 768px) { h1 { font-size: 2.5rem; } } @media (min-width: 1024px) { h1 { font-size: 3rem; } } /* After: one line, smooth scaling */ h1 { font-size: clamp(1.75rem, 1rem + 2.5vw, 3rem); } ``` ## AIがよくやるミス - **preferred 値に `vw` だけを使っている。** `font-size: clamp(1rem, 3vw, 2rem)` はユーザーがブラウザをズームしてもスケールせず、WCAG 1.4.4に違反します。preferred 値では常に `rem` + `vw` を組み合わせましょう。 - **`clamp()` の方がシンプルなのに、フォントサイズに複数のメディアクエリを生成している。** 1つの `clamp()` 宣言で3〜4個のメディアクエリを置き換えられ、よりスムーズなスケーリングが実現できます。 - **min と max を逆にしている。** minimum 値が maximum より大きい場合、`clamp()` は常に minimum を返します。`min < max` であることを必ず確認しましょう。 - **min と max の値に `rem` ではなく `px` を使っている。** `rem` を使うことで、ユーザーのフォントサイズ設定やズーム設定が尊重されます。 - **スペーシングに `clamp()` を使っていない。** AIエージェントはフォントサイズには `clamp()` を使う一方で、padding、margin、gap にはまだメディアクエリを使うことがあります。これらすべてが流体スケーリングの恩恵を受けます。 - **preferred 値を複雑にしすぎている。** 式はピクセルパーフェクトである必要はありません。本文テキストには `clamp(1rem, 0.875rem + 0.5vw, 1.25rem)` で十分です。特定のビューポート幅間の正確な線形補間が必要になることはほとんどありません。 - **`clamp()` を不必要に `calc()` の中にネストしている。** `clamp()` はすでに内部で数式を評価します。`calc(clamp(...))` は冗長です。 ## 使い分け ### clamp() が最適な場面 - モバイルとデスクトップ間でスムーズにスケールすべきフォントサイズ - ビューポートに応じて大きくなるセクションの padding と margin - 柔軟な制約を持つコンテナ幅 - grid/flex レイアウトの gap 値 - 現在2つ以上のメディアクエリを使ってスケールしている値すべて ### メディアクエリを使い続ける場面 - プロパティをまったく別の値に変更する必要がある場合(スケールされた版ではなく) - サイズのスケーリングではなくレイアウトの切り替え(例:1カラムからサイドバーへの切り替え) - スケーリングが線形でない場合(例:特定のブレークポイントでサイズがジャンプすべき場合) ### アクセシビリティ要件 - preferred 値では常に `rem` + `vw` を使い、`vw` だけは避けましょう - ブラウザの200%ズームでテキストが読みやすいことをテストしましょう - ビューポート全体で本文テキストが45〜75文字の行長範囲に収まることを確認しましょう ## 参考リンク - [clamp() - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/clamp) - [Modern Fluid Typography Using CSS Clamp - Smashing Magazine](https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/) - [Linearly Scale font-size with CSS clamp() - CSS-Tricks](https://css-tricks.com/linearly-scale-font-size-with-css-clamp-based-on-the-viewport/) - [Fluid Typography Tool](https://fluidtypography.com/) - [CSS Clamp Calculator - modern.css](https://modern-css.com/playground/css-clamp-fluid-typography/) --- # ネガティブマージンによる拡張 + パディングで戻す > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/specialized/negative-margin-expand ## 問題 padding が設定されたコンテナの内部では、すべての子要素がコンテナの padding を尊重し、端から内側に配置されます。しかし、特定のセクションの背景色や画像、ボーダーをコンテナの端まで広げたい場合があります。テキストコンテンツはページの他の部分と揃えたまま、視覚的にはコンテナからはみ出させたいケースです。AIエージェントはこのような場面で、余分なラッパー div を追加したり、要素をコンテナの外に出したり、`100vw` ハックを使って水平スクロールバーを発生させたりしがちです。 ## 解決方法 負の水平マージンで要素をコンテナの padding の外側に引き出し、同じ値の正の padding でコンテンツを元の位置に戻します: ```css .container { max-width: 600px; margin: 0 auto; padding: 0 24px; } .full-bleed-section { margin-left: -24px; margin-right: -24px; padding-left: 24px; padding-right: 24px; background: hsl(220 80% 96%); } ``` 負のマージンと正のパディングは必ず同じ値にします。背景は広がりますが、テキストは元の位置のまま保たれます。 Normal paragraph inside padded container. Notice the gap on both sides. Full-bleed section. The background extends to the container edges, but the text stays aligned with the paragraph above. Another normal paragraph below. Text alignment is consistent throughout. `} css={` body { margin: 0; background: hsl(0 0% 96%); font-family: system-ui, sans-serif; } .container { max-width: 480px; margin: 24px auto; padding: 0 32px; background: white; border: 2px solid hsl(0 0% 85%); } .normal { font-size: 14px; line-height: 1.6; color: hsl(0 0% 30%); } .full-bleed { margin-left: -32px; margin-right: -32px; padding-left: 32px; padding-right: 32px; padding-top: 16px; padding-bottom: 16px; background: hsl(220 80% 96%); border-top: 1px solid hsl(220 60% 85%); border-bottom: 1px solid hsl(220 60% 85%); } .full-bleed p { font-size: 14px; line-height: 1.6; color: hsl(220 40% 30%); margin: 0; } `} height={320} /> ## 論理プロパティの使用 国際化対応を向上させるために、物理的な `left`/`right` の代わりに論理プロパティを使います: ```css .full-bleed-section { margin-inline: -24px; padding-inline: 24px; background: hsl(220 80% 96%); } ``` ## レスポンシブ対応 コンテナの padding がブレークポイントごとに変わる場合、拡張の値もそれに合わせます: ```css .container { padding-inline: 16px; } .full-bleed-section { margin-inline: -16px; padding-inline: 16px; } @media (min-width: 768px) { .container { padding-inline: 32px; } .full-bleed-section { margin-inline: -32px; padding-inline: 32px; } } @media (min-width: 1024px) { .container { padding-inline: 48px; } .full-bleed-section { margin-inline: -48px; padding-inline: 48px; } } ``` Article Title Regular content sits within the container padding. Key Takeaway This highlighted section breaks out to the container edges. Try switching viewport sizes to see the responsive padding adjust. Content continues aligned with the rest of the text. `} css={` body { margin: 0; font-family: system-ui, sans-serif; background: hsl(0 0% 95%); } .container { max-width: 100%; margin: 0 auto; padding: 16px; background: white; } .container h2 { font-size: 18px; margin-bottom: 8px; color: hsl(0 0% 20%); } .container p { font-size: 14px; line-height: 1.6; color: hsl(0 0% 35%); } .highlight { margin-inline: -16px; padding-inline: 16px; padding-top: 16px; padding-bottom: 16px; background: hsl(35 90% 95%); border-left: 4px solid hsl(35 80% 55%); } .highlight h3 { font-size: 15px; margin-bottom: 4px; color: hsl(35 60% 25%); } .highlight p { color: hsl(35 30% 30%); margin: 0; } @media (min-width: 500px) { .container { padding: 32px; } .highlight { margin-inline: -32px; padding-inline: 32px; } } `} height={300} /> ## 重要なルール 負のマージンは親コンテナの padding を**絶対に超えてはいけません**。超えるとページの端からはみ出し、水平スクロールバーが発生します。 ```css /* The container has 24px padding */ .container { padding-inline: 24px; } /* GOOD — matches the container padding */ .full-bleed { margin-inline: -24px; padding-inline: 24px; } /* BAD — exceeds the container padding, causes overflow */ .full-bleed-broken { margin-inline: -48px; padding-inline: 48px; } ``` ## AIがよくやるミス - **全幅表示に `width: 100vw` を使う** — ページに縦スクロールバーがある場合、`100vw` はスクロールバーの幅を含むため水平スクロールバーが発生します。ネガティブマージンのテクニックならこの問題を完全に回避できます。 - **padding とマージンの値を一致させない** — `margin-inline: -32px` を設定しても `padding-inline: 32px` を忘れると、テキストコンテンツが左右にずれてアライメントが崩れます。 - **コンテナの padding を超える値を指定する** — 負のマージンは親の padding の範囲までしか引き出せません。それ以上の値を指定するとオーバーフローが発生します。 - **親要素に `overflow: hidden` を使う** — 拡張した背景がクリップされます。親要素が別の理由で `overflow: hidden` を必要とする場合は、中間のラッパーコンテナを使いましょう。 ## 使用場面 - padding が設定された記事レイアウト内でのハイライトセクションやコールアウトブロック - 幅が制限されたコンテンツコンテナ内での全幅背景色や画像 - テキストの整列を保ちつつ、視覚的に目立たせたいセクション - シングルカラムレイアウト内での交互の背景帯 --- # CSS Modules 戦略 > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/architecture/css-modules-strategy ## 問題 CSS はデフォルトでグローバル名前空間で動作します。記述したすべてのクラス名がどこからでもアクセスできるため、あるスタイルシートが別のスタイルを意図せず上書きする可能性があります。プロジェクトが成長すると、名前の衝突は避けられなくなります。2人の開発者が独立して `.title` クラスを作成したり、新機能の `.container` が既存のものと競合したりします。使われていない CSS も蓄積されていきます。コードベース全体を検索して未使用であることを確認しないと、安全にクラスを削除できないからです。 AI エージェントにとって、グローバル CSS は特に危険です。`.card` や `.header` クラスを生成するエージェントには、それらの名前がプロジェクト内に既に存在するかどうかを知る方法がありません。その結果、診断が困難な意図しないスタイルのオーバーライドが発生します。 ## 解決方法 CSS Modules はグローバル名前空間の問題をビルド時に解決します。各 CSS ファイルはローカルスコープとして扱われ、クラス名は自動的にユニークな識別子(通常はハッシュを付加)に変換されるため、他のファイルのクラスと衝突することがありません。スタイルを JavaScript オブジェクトとしてインポートし、キーで参照します。 ```jsx function Button() { return Click me; } ``` ビルドツール(Webpack、Vite など)が `.primary` を `.Button_primary_x7f2a` のようなものに変換し、命名規約なしでユニーク性を保証します。作成した名前と生成された名前のマッピングは、インポートした `styles` オブジェクトを通じて自動的に処理されます。 ## コード例 ### クラス名のスコープの仕組み CSS Modules ファイルを記述すると、ビルドツールが各クラス名をユニークなハッシュ付きバージョンに変換します。記述する元の名前は可読性のためのもので、ブラウザが見るのは生成された名前だけです。 What you write (authored CSS) .title { color: #1e293b; font-size: 1.5rem; } .subtitle { color: #64748b; font-size: 1rem; } .highlight { background: #fef08a; padding: 2px 6px; } What the browser receives (generated CSS) .Card_title_x7f2a { color: #1e293b; font-size: 1.5rem; } .Card_subtitle_k9m3p { color: #64748b; font-size: 1rem; } .Card_highlight_q2w8r { background: #fef08a; padding: 2px 6px; } Title styled with scoped class Subtitle with its own scoped class Text with highlighted word `} css={`* { box-sizing: border-box; margin: 0; } .scope { padding: 16px; font-family: system-ui, sans-serif; } .scope__heading { font-size: 13px; font-weight: 600; color: #475569; text-transform: uppercase; letter-spacing: 0.05em; margin: 12px 0 6px; } .scope__heading:first-child { margin-top: 0; } .scope__code { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 12px; font-size: 13px; font-family: "SF Mono", Monaco, monospace; line-height: 1.6; overflow-x: auto; white-space: pre; } .scope__code--generated { background: #f0fdf4; border-color: #bbf7d0; } .scope__demo { margin-top: 12px; display: flex; flex-direction: column; gap: 4px; padding: 12px; background: #fff; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; } .title { color: #1e293b; font-size: 1.25rem; font-weight: 600; } .subtitle { color: #64748b; font-size: 0.875rem; } .highlight { background: #fef08a; padding: 2px 6px; border-radius: 3px; }`} height={370} /> ### 基本的な使い方: インポートと適用 CSS Modules のワークフローでは、CSS ファイルを JavaScript モジュールとしてインポートします。インポートされたオブジェクトは、作成したクラス名を生成されたユニークな名前にマッピングします。 ```jsx // Button.module.css // .primary { background: #3b82f6; color: #fff; } // .secondary { background: #e2e8f0; color: #1e293b; } function Button({ variant = 'primary', children }) { return ( {children} ); } ``` Primary Action Secondary Delete Each class is scoped: Button_primary_x7f2a instead of .primary `} css={`* { box-sizing: border-box; margin: 0; } .button-demo { padding: 20px; font-family: system-ui, sans-serif; } .button-demo__group { display: flex; gap: 12px; margin-bottom: 12px; } .button-demo__note { font-size: 13px; color: #64748b; } .button-demo__note code { background: #f1f5f9; padding: 2px 6px; border-radius: 3px; font-size: 12px; font-family: "SF Mono", Monaco, monospace; } .Button_primary_x7f2a { background: #3b82f6; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; } .Button_primary_x7f2a:hover { background: #2563eb; } .Button_secondary_k9m3p { background: #e2e8f0; color: #1e293b; border: none; padding: 10px 20px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; } .Button_secondary_k9m3p:hover { background: #cbd5e1; } .Button_danger_q2w8r { background: #ef4444; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; } .Button_danger_q2w8r:hover { background: #dc2626; }`} height={120} /> ### コンポーネント間で衝突しない CSS Modules の最大の利点は、異なるファイルで同じクラス名を使っても、生成される名前が異なることです。2つのコンポーネントがどちらも `.title` を使っても、衝突は起きません。 Card component Card Title Card description text styled by Card.module.css .Card_title_d4e5f Modal component Modal Title Modal description text styled by Modal.module.css .Modal_title_m3n4o `} css={`* { box-sizing: border-box; margin: 0; } .collision-demo { display: flex; gap: 16px; padding: 16px; font-family: system-ui, sans-serif; } .collision-demo__panel { flex: 1; display: flex; flex-direction: column; gap: 8px; } .collision-demo__label { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; } .collision-demo__class { font-size: 12px; font-family: "SF Mono", Monaco, monospace; color: #16a34a; background: #f0fdf4; padding: 4px 8px; border-radius: 4px; display: block; } /* Card component styles */ .Card_wrapper_a1b2c { padding: 16px; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; } .Card_title_d4e5f { font-size: 18px; font-weight: 600; color: #1e293b; margin-bottom: 4px; } .Card_text_g7h8i { font-size: 14px; color: #64748b; line-height: 1.5; } /* Modal component styles */ .Modal_wrapper_j1k2l { padding: 16px; background: #fefce8; border: 2px solid #facc15; border-radius: 8px; } .Modal_title_m3n4o { font-size: 18px; font-weight: 700; color: #854d0e; margin-bottom: 4px; } .Modal_text_p5q6r { font-size: 14px; color: #a16207; line-height: 1.5; }`} height={210} /> ### composes によるコンポジション CSS Modules は `composes` キーワードをサポートしており、同じファイルや他のファイルからクラスを組み合わせることができます。これは CSS Modules での共通スタイルの共有方法で、重複を避けることができます。 ```css /* shared.module.css */ .baseButton { border: none; padding: 10px 20px; border-radius: 6px; font-weight: 500; cursor: pointer; } /* Button.module.css */ .primary { composes: baseButton from './shared.module.css'; background: #3b82f6; color: #fff; } ``` Base style (shared.module.css) .baseButton { border: none; padding: 10px 20px; border-radius: 6px; font-weight: 500; cursor: pointer; } Composed variants (Button.module.css) .primary { composes: baseButton from './shared.module.css'; background: #3b82f6; color: #fff; } .outline { composes: baseButton from './shared.module.css'; background: transparent; color: #3b82f6; border: 2px solid #3b82f6; } Result: element receives both classes Primary Outline `} css={`* { box-sizing: border-box; margin: 0; } .compose-demo { padding: 16px; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 12px; } .compose-demo__section { display: flex; flex-direction: column; gap: 4px; } .compose-demo__label { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; } .compose-demo__code { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 10px 12px; font-size: 12px; font-family: "SF Mono", Monaco, monospace; line-height: 1.5; overflow-x: auto; white-space: pre; } .compose-demo__buttons { display: flex; gap: 12px; } .baseButton { border: none; padding: 10px 20px; border-radius: 6px; font-weight: 500; font-size: 14px; cursor: pointer; } .primary { background: #3b82f6; color: #fff; } .primary:hover { background: #2563eb; } .outline { background: transparent; color: #3b82f6; border: 2px solid #3b82f6; } .outline:hover { background: #eff6ff; }`} height={360} /> ### :global によるグローバルセレクタ ローカルスコープされていないクラス名をターゲットにする必要がある場合があります。たとえば、サードパーティライブラリのクラスや body レベルの状態クラスなどです。CSS Modules はこの目的で `:global()` を提供しています。 ```css /* デフォルトでローカルスコープ */ .container { padding: 16px; } /* 特定のセレクタでスコープを解除 */ :global(.ReactModal__Overlay) { background: rgba(0, 0, 0, 0.5); } /* ローカルとグローバルの混在 */ .container :global(.highlight) { background: yellow; } ``` .container → .App_container_f3g8k Locally scoped (default) .ReactModal__Overlay → .ReactModal__Overlay Global — keeps original name via :global() .App_container_f3g8k .highlight Mixed — local parent, global child This container is scoped. The highlighted text uses a global class inside it. `} css={`* { box-sizing: border-box; margin: 0; } .global-demo { padding: 16px; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 10px; } .global-demo__row { display: flex; align-items: center; gap: 12px; } .global-demo__tag { font-size: 12px; font-family: "SF Mono", Monaco, monospace; padding: 4px 10px; border-radius: 4px; white-space: nowrap; } .global-demo__tag--local { background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe; } .global-demo__tag--global { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; } .global-demo__tag--mixed { background: #fefce8; color: #a16207; border: 1px solid #fde68a; } .global-demo__desc { font-size: 13px; color: #64748b; } .global-demo__example { margin-top: 4px; } .App_container_f3g8k { background: #f8fafc; padding: 12px 16px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; color: #334155; line-height: 1.6; } .highlight { background: #fef08a; padding: 1px 6px; border-radius: 3px; font-weight: 500; }`} height={230} /> ## 他のアプローチとの比較 | アプローチ | スコープの仕組み | 命名 | ビルドツールが必要 | ランタイムコスト | |---|---|---|---|---| | **CSS Modules** | ビルド時のハッシュ | 作成者が選択、ローカルスコープ | はい | なし | | **BEM** | 手動の命名規約 | 手動の規律(`block__element--modifier`) | いいえ | なし | | **ユーティリティファースト** | カスタムクラス不要 | 定義済みのユーティリティ語彙 | 推奨 | なし | | **CSS-in-JS** | ランタイムまたはビルド時 | JS コンポーネントと同じ場所に配置 | 場合による | ランタイムコストあり | - **BEM との比較**: BEM は手動の命名規律で衝突回避を実現します。CSS Modules はそれを自動化します。`.title` と書くだけでツールがユニーク性を保証します。BEM は規約であり、CSS Modules は強制です。 - **ユーティリティファーストとの比較**: ユーティリティフレームワークはカスタムクラス名を完全に排除します。CSS Modules はセマンティックなクラス名を書きつつ自動的にスコープ化します。両者は共存可能で、コンポーネント固有のスタイルに CSS Modules、レイアウトにユーティリティを使うプロジェクトもあります。 - **CSS-in-JS との比較**: styled-components のようなライブラリは `` タグを注入してランタイムでスタイルをスコープ化します。CSS Modules はビルド時に同じスコープ化を実現し、ランタイムコストはゼロです。CSS-in-JS は props に基づく動的なスタイリングを提供しますが、CSS Modules では CSS カスタムプロパティまたは条件付きクラス名が必要です。 ## よくあるミス ### :global を多用しすぎる すべてを `:global` で囲むと、CSS Modules の目的が失われます。サードパーティのクラスや body レベルの状態をターゲットにする場合にのみ使い、利便性のために使うのは避けましょう。 ```css /* 誤り: 理由なくスコープを解除 */ :global(.card) { padding: 16px; } :global(.card-title) { font-size: 18px; } /* 正しい: デフォルトでローカルスコープを使用 */ .card { padding: 16px; } .cardTitle { font-size: 18px; } ``` ### composes を活用していない `composes` がまさにこの目的のために存在するのに、複数の `.module.css` ファイルで共通スタイルを重複させてしまうケースです。 ```css /* 誤り: ベーススタイルの重複 */ /* Button.module.css */ .primary { border: none; padding: 10px 20px; border-radius: 6px; background: #3b82f6; color: #fff; } .secondary { border: none; padding: 10px 20px; border-radius: 6px; background: #e2e8f0; color: #1e293b; } /* 正しい: 共通ベースを compose する */ .primary { composes: base from './shared.module.css'; background: #3b82f6; color: #fff; } .secondary { composes: base from './shared.module.css'; background: #e2e8f0; color: #1e293b; } ``` ### グローバルスタイルとモジュールスタイルを1つのコンポーネントで混在させる 同じコンポーネントでグローバルスタイルシートと CSS Module の両方をインポートすると、どのスタイルがスコープされていてどれがされていないか曖昧になります。 ```jsx // 誤り: スコープモデルの混在 // 正しい: コンポーネントごとに1つのアプローチを一貫して使用 ``` ### JavaScript でケバブケースのクラス名を使う CSS Modules はクラス名をオブジェクトのプロパティとしてエクスポートします。ケバブケースの名前はブラケット記法が必要になり、扱いにくくなります。 ```jsx // 扱いにくい: ブラケット記法が必要 // 改善: .module.css でキャメルケースを使用 // .cardTitle { font-size: 18px; } ``` ## 使い分け CSS Modules が適している場面: - **React、Vue、その他のフレームワークプロジェクト**: ビルドツール(Webpack、Vite)が既に導入されている場合 - **コンポーネントライブラリ**: コンポーネントごとのスタイル分離が重要な場合 - **グローバル CSS からの移行プロジェクト**: CSS-in-JS やユーティリティフレームワークを採用せずにスコープ化したい場合 - **標準的な CSS を書きたいチーム**: 自動的な衝突防止が必要だが、従来の CSS の書き方を維持したい場合 CSS Modules が適さない場面: - **ビルドツールが利用できない場合** — CSS Modules はクラス名を変換するバンドラーが必要です - **ユーティリティファーストのスタイリングを使いたい場合** — Tailwind や UnoCSS はカスタムクラス名の必要性を完全に排除します - **高度に動的なスタイルが必要な場合** — スタイルが多くのコンポーネント props に依存する場合、CSS-in-JS の方が使いやすいことがあります ## 参考リンク - [CSS Modules — GitHub Repository](https://github.com/css-modules/css-modules) - [css-loader CSS Modules — Webpack Documentation](https://webpack.js.org/loaders/css-loader/#modules) - [CSS Modules — Vite Documentation](https://vite.dev/guide/features.html#css-modules) - [Adding a CSS Modules Stylesheet — Create React App](https://create-react-app.dev/docs/adding-a-css-modules-stylesheet/) --- # 高度なカスタムプロパティ > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/design-systems/custom-properties-advanced ## 問題 CSSカスタムプロパティ(変数)は広く使われていますが、ほとんどの開発者やAIエージェントは表面をなぞるだけにとどまります — `:root`にシンプルな色やサイズのトークンを定義し、`var()`で参照するだけです。スペーストグルトリック、フォールバックチェーン、計算によるプロパティの関連付けといった高度なパターンはほとんど活用されず、条件付きスタイリングに不要なJavaScript、硬直的なテーマシステム、重複した宣言につながります。 ## 解決方法 CSSカスタムプロパティは単純な変数の置換よりもはるかに強力です。カスケードに参加し、任意の要素にスコープでき、多段階のフォールバックチェーンをサポートし、スペーストグルトリックと組み合わせることでブーリアンのような条件ロジックを作成できます — すべて純粋なCSSでJavaScriptなしで実現できます。 ## コード例 ### スペーストグルトリック スペーストグルは`var()`のフォールバックの仕組みを利用します。カスタムプロパティを単一のスペース(` `)に設定すると有効で「パススルー」し、`initial`に設定するとフォールバック値が使用されます。 ```css /* The toggle: space = ON, initial = OFF */ .card { --is-featured: initial; /* OFF by default */ /* When ON, value becomes " blue"; when OFF, fallback "gray" is used */ background: var(--is-featured) blue, gray; color: var(--is-featured) white, #333; border-width: var(--is-featured) 3px, 1px; } .card.featured { --is-featured: ; /* ON (single space) */ } ``` ### ダークモード用のスペーストグル ```css :root { --dark: initial; /* Light mode by default */ } @media (prefers-color-scheme: dark) { :root { --dark: ; /* Enable dark mode */ } } body { background: var(--dark) #1a1a2e, #ffffff; color: var(--dark) #e0e0e0, #1a1a2e; } .card { background: var(--dark) #2d2d44, #f5f5f5; border-color: var(--dark) #444, #ddd; } ``` ### テーマ用のフォールバックチェーン コンポーネントが段階的に広いデフォルトを確認するレイヤード設定システムを作成します。これは[3層カラー戦略](../../../styling/color/three-tier-color-strategy)の背後にあるメカニズムです — コンポーネントトークンはテーマトークンにフォールバックし、テーマトークンはパレット値にフォールバックします。 ```css .button { /* Check component-specific → theme-level → hardcoded default */ background: var(--button-bg, var(--accent-color, #2563eb)); color: var(--button-color, var(--accent-contrast, white)); padding: var(--button-padding, var(--spacing-sm, 0.5rem 1rem)); border-radius: var(--button-radius, var(--radius, 4px)); } /* Theme-level override: changes all components using --accent-color */ .theme-warm { --accent-color: #ea580c; --accent-contrast: white; } /* Component-specific override: changes only buttons */ .cta-section { --button-bg: #16a34a; --button-color: white; } ``` ### コンポーネントバリアント用のスコープ付きカスタムプロパティ すべてのバリアントに個別のクラスを作成する代わりに、カスタムプロパティをスタイリングAPIとして使用します。 ```css .badge { --_bg: var(--badge-bg, #e5e7eb); --_color: var(--badge-color, #374151); --_size: var(--badge-size, 0.75rem); background: var(--_bg); color: var(--_color); font-size: var(--_size); padding: 0.25em 0.75em; border-radius: 999px; font-weight: 600; } /* Variants set only the custom properties */ .badge-success { --badge-bg: #dcfce7; --badge-color: #166534; } .badge-error { --badge-bg: #fee2e2; --badge-color: #991b1b; } ``` ### `calc()`による計算された関係 ```css .fluid-type { --min-size: 1; --max-size: 1.5; --min-width: 320; --max-width: 1200; font-size: calc( (var(--min-size) * 1rem) + (var(--max-size) - var(--min-size)) * (100vw - var(--min-width) * 1px) / (var(--max-width) - var(--min-width)) ); } h1 { --min-size: 1.5; --max-size: 3; } h2 { --min-size: 1.25; --max-size: 2; } ``` ### CSSとJavaScript間の状態共有 ```css .progress-bar { --progress: 0; width: calc(var(--progress) * 1%); background: hsl(calc(var(--progress) * 1.2) 70% 50%); transition: width 0.3s, background 0.3s; } ``` ```html // Update from JavaScript — CSS handles the visual mapping element.style.setProperty('--progress', newValue); ``` ### プライベートカスタムプロパティの命名規則 `--`の後にアンダースコアを付けて、コンシューマーが設定すべきでない「内部」プロパティであることを示します。 ```css .tooltip { /* Public API */ --tooltip-bg: var(--surface-inverse, #1f2937); --tooltip-color: var(--text-inverse, white); /* Private (internal computation) */ --_arrow-size: 6px; --_offset: calc(100% + var(--_arrow-size) + 4px); background: var(--tooltip-bg); color: var(--tooltip-color); transform: translateY(calc(-1 * var(--_offset))); } ``` ## ブラウザサポート - Chrome 49+ - Firefox 31+ - Safari 9.1+ - Edge 15+ カスタムプロパティはほぼユニバーサルなサポート(98%以上)があります。スペーストグルトリックで使用される`initial`キーワードの動作は、カスタムプロパティをサポートするすべてのブラウザで動作します。パフォーマンスを最適にするために、3レベルを超えるフォールバックチェーンは避け、`setProperty()`の呼び出しは`:root`ではなく最も具体的な要素にスコープしましょう。 ## AIがよくやるミス - スペーストグルトリックで処理できるビジュアル状態のトグルにJavaScriptを使用する - カスタムプロパティをコンポーネントにスコープする代わりに`:root`にのみ定義する - テーマ可能なコンポーネントAPIにフォールバックチェーンを活用しない - カスタムプロパティベースのバリアントを使う代わりに、すべてのバリアントに個別のCSSクラスを作成する - `calc()`内でカスタムプロパティなしの生の値を使用し、値間の関係を不透明にする - フォールバックチェーンを深くネストする(4レベル以上)ことで解決のオーバーヘッドを増やす - パブリックとプライベートのカスタムプロパティを区別するための命名規則(`--_`プレフィックスなど)を使用しない ## 使い分け - フォールバックチェーンによるコンポーネントレベルのオーバーライドを持つテーマシステム - スペーストグルトリックによるブーリアンのような条件付きスタイリング(ダークモード、フィーチャーフラグ) - コンシューマーがプロパティを設定して外観をカスタマイズするコンポーネントバリアントAPI - 値間の計算された関係(レスポンシブサイジング、カラーパレット) - クラストグルなしのCSSとJavaScriptの状態橋渡し ## ライブプレビュー Regular Card Default styling — --is-featured is off (initial) Featured Card Featured styling — --is-featured is on (space value) Regular Card Back to default styling The .featured class sets --is-featured to a space value, toggling multiple properties at once with zero JavaScript `} css={` .cards { font-family: system-ui, sans-serif; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; } .card { --is-featured: initial; background: var(--is-featured) linear-gradient(135deg, #fbbf24, #f59e0b), #f8fafc; color: var(--is-featured) #78350f, #334155; border: var(--is-featured) 2px solid #f59e0b, 1px solid #e2e8f0; border-radius: 12px; padding: 1.25rem; transition: transform 0.2s; } .card.featured { --is-featured: ; transform: scale(1.05); box-shadow: 0 8px 24px rgba(245, 158, 11, 0.25); } .card h3 { margin: 0 0 0.5rem; font-size: 1rem; } .card p { margin: 0; font-size: 0.85rem; opacity: 0.8; line-height: 1.5; } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 1rem; } code { background: #f1f5f9; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.75rem; } `} /> Default Success Error Info Each variant sets only custom properties — the base .badge rule handles all rendering `} css={` .demo { font-family: system-ui, sans-serif; display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; } .badge { --_bg: var(--badge-bg, #f1f5f9); --_color: var(--badge-color, #475569); --_border: var(--badge-border, #e2e8f0); background: var(--_bg); color: var(--_color); border: 1px solid var(--_border); padding: 0.35rem 1rem; border-radius: 999px; font-weight: 600; font-size: 0.875rem; } .badge-success { --badge-bg: #dcfce7; --badge-color: #166534; --badge-border: #86efac; } .badge-error { --badge-bg: #fee2e2; --badge-color: #991b1b; --badge-border: #fca5a5; } .badge-info { --badge-bg: #dbeafe; --badge-color: #1e40af; --badge-border: #93c5fd; } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 1rem; } `} /> ## 詳細解説 - [パターンカタログ](./pattern-catalog) — インタラクティブなデモ付きのCSSカスタムプロパティパターンの包括的なコレクション - [テーマレシピ](./theming-recipes) — ライト/ダークモード、ブランドテーマ、コンポーネントライブラリのための完全なテーマシステムレシピ ## 参考リンク - [Using CSS custom properties (variables) - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Cascading_variables/Using_custom_properties) - [The CSS Custom Property Toggle Trick - CSS-Tricks](https://css-tricks.com/the-css-custom-property-toggle-trick/) - [The --var: ; hack to toggle multiple values with one custom property - Lea Verou](https://lea.verou.me/blog/2020/10/the-var-space-hack-to-toggle-multiple-values-with-one-custom-property/) - [A Complete Guide to Custom Properties - CSS-Tricks](https://css-tricks.com/a-complete-guide-to-custom-properties/) - [Cyclic Dependency Space Toggles - kizu.dev](https://kizu.dev/cyclic-toggles/) --- # マルチネームスペーストークン戦略 > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/design-systems/multi-namespace-token-strategy ## 問題 [タイトトークン戦略](./tight-token-strategy/)では、プロジェクト固有のセマンティックトークンを定義します。例えば、ブログ中心のウェブサイトでは、記事コンテンツに最適化されたスペーシングとフォントサイズのトークンを `myweb-` というネームスペースで定義します: ```css @theme { /* myweb namespace — article-focused tokens */ --spacing-myweb-content-gap: 32px; --spacing-myweb-section-gap: 48px; --spacing-myweb-reading-padding: 24px; --font-size-myweb-body: 1.125rem; --font-size-myweb-heading: 2rem; } ``` サイトが成長するにつれて、新しい機能が追加されていきます。ログイン画面、管理ダッシュボード、設定パネル — これらの UI は記事コンテンツとは根本的に異なるデザインルールを必要とします。管理画面はよりタイトなスペーシング、小さなフォントサイズ、密度の高いレイアウトを求めます。 1つのネームスペースにすべてを押し込むとどうなるか: ```css @theme { /* myweb namespace — growing out of control */ --spacing-myweb-content-gap: 32px; --spacing-myweb-content-gap-dense: 16px; /* admin用に追加 */ --spacing-myweb-section-gap: 48px; --spacing-myweb-section-gap-compact: 24px; /* admin用に追加 */ --spacing-myweb-reading-padding: 24px; --spacing-myweb-panel-padding: 12px; /* admin用に追加 */ --font-size-myweb-body: 1.125rem; --font-size-myweb-body-dense: 0.875rem; /* admin用に追加 */ --font-size-myweb-heading: 2rem; --font-size-myweb-heading-compact: 1.25rem; /* admin用に追加 */ } ``` トークンが増えるたびに `-dense`、`-compact`、`-small` といった接尾辞が付きます。トークンセットは肥大化し、どのトークンがどのコンテキストに属するのか分からなくなります。タイトトークン戦略の本来の目的 — 「少ない選択肢で明確なルールを作る」— が損なわれます。 ## 解決方法 デザインコンテキストごとに**別のネームスペース**を使います: - `myweb-` — 記事・コンテンツのデザインルール - `myadmin-` — 管理画面・ダッシュボードのデザインルール ```css @theme { /* ── myweb namespace: article/content ── */ --spacing-myweb-content-gap: 32px; --spacing-myweb-section-gap: 48px; --spacing-myweb-reading-padding: 24px; --font-size-myweb-body: 1.125rem; --font-size-myweb-heading: 2rem; /* ── myadmin namespace: admin/dashboard ── */ --spacing-myadmin-cell-gap: 8px; --spacing-myadmin-section-gap: 16px; --spacing-myadmin-panel-padding: 12px; --font-size-myadmin-body: 0.875rem; --font-size-myadmin-heading: 1.25rem; } ``` 各ネームスペースは独立したデザインコンテキストです。`myweb-` のトークンは読みやすさを最適化し、`myadmin-` のトークンは情報密度を最適化します。`-dense` や `-compact` のような接尾辞は不要です — トークン名のネームスペースプレフィックスが、そのトークンの適用先を明確にします。 ### ネームスペース分割の判断基準 ネームスペースを分ける必要があるのは、2つのコンテキストが**異なるデザイン原則**を持つ場合です: | シグナル | 例 | | --- | --- | | スペーシングの基準が異なる | 記事は余白が広い / 管理画面はタイト | | フォントサイズの基準が異なる | 記事は読みやすさ重視 / ダッシュボードは密度重視 | | 同じ目的のトークンに異なる値が必要 | 「ボディテキスト」が記事では 18px、管理画面では 14px | | `-dense`/`-compact` 接尾辞が増え始めた | 1つのネームスペースに収まらないサイン | 逆に、以下の場合は分割不要です: | シグナル | 理由 | | --- | --- | | レスポンシブで値が変わるだけ | メディアクエリやコンテナクエリで対応 | | テーマ(ライト/ダーク)の切り替え | カラースキームで対応 | | 微妙なバリエーション | 既存トークンのスケール(sm/md/lg)で対応 | ## コード例 ### 単一ネームスペース(肥大化した状態) ```css @theme { /* すべてが myweb- に押し込まれている */ --spacing-myweb-content-gap: 32px; --spacing-myweb-content-gap-dense: 16px; --spacing-myweb-section-gap: 48px; --spacing-myweb-section-gap-compact: 24px; --spacing-myweb-reading-padding: 24px; --spacing-myweb-panel-padding: 12px; --font-size-myweb-body: 1.125rem; --font-size-myweb-body-dense: 0.875rem; --font-size-myweb-heading: 2rem; --font-size-myweb-heading-compact: 1.25rem; } ``` トークンの数が多く、どれが記事用でどれが管理画面用かが名前から推測しにくい。 ### マルチネームスペース(分割後) ```css @theme { /* ── myweb: 記事コンテンツ ── */ --spacing-myweb-content-gap: 32px; --spacing-myweb-section-gap: 48px; --spacing-myweb-reading-padding: 24px; --font-size-myweb-body: 1.125rem; --font-size-myweb-heading: 2rem; /* ── myadmin: 管理ダッシュボード ── */ --spacing-myadmin-cell-gap: 8px; --spacing-myadmin-section-gap: 16px; --spacing-myadmin-panel-padding: 12px; --font-size-myadmin-body: 0.875rem; --font-size-myadmin-heading: 1.25rem; } ``` 各ネームスペースのトークン数は少なく、適用先がプレフィックスから明確です。 ### コンポーネントでの使い方 ```html Article Title Long-form content with generous reading spacing... Dashboard Row 1 Row 2 ``` Tailwind クラスのネームスペースプレフィックスが、各コンポーネントのデザインコンテキストを即座に伝えます。 ### デモ:単一ネームスペース vs マルチネームスペース Single namespace (bloated) myweb-content-gap myweb-content-gap-dense myweb-section-gap myweb-section-gap-compact myweb-reading-padding myweb-panel-padding myweb-body myweb-body-dense myweb-heading myweb-heading-compact 10 tokens — mixed contexts, suffix pollution Multi namespace (separated) myweb- myweb-content-gap myweb-section-gap myweb-reading-padding myweb-body myweb-heading myadmin- myadmin-cell-gap myadmin-section-gap myadmin-panel-padding myadmin-body myadmin-heading 5 + 5 tokens — clear ownership, no suffixes `} css={`.demo { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 16px; font-family: system-ui, sans-serif; font-size: 13px; color: hsl(222 47% 11%); } .demo__col { display: flex; flex-direction: column; gap: 8px; } .demo__label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .demo__tokens { display: flex; flex-direction: column; gap: 3px; } .demo__token { font-size: 0.75rem; padding: 3px 8px; border-radius: 3px; } .demo__token--warn { background: hsl(40 90% 93%); color: hsl(30 70% 35%); border: 1px solid hsl(40 60% 80%); } .demo__token--good { background: hsl(142 50% 93%); color: hsl(142 50% 30%); border: 1px solid hsl(142 40% 78%); } .demo__group { display: flex; flex-direction: column; gap: 3px; } .demo__group-label { font-size: 0.75rem; font-weight: 700; color: hsl(142 50% 30%); padding-left: 4px; } .demo__verdict { font-size: 0.75rem; padding: 6px 8px; border-radius: 4px; line-height: 1.4; } .demo__verdict--bad { background: hsl(40 90% 93%); color: hsl(30 70% 35%); border-left: 3px solid hsl(30 70% 50%); } .demo__verdict--ok { background: hsl(142 50% 93%); color: hsl(142 50% 30%); border-left: 3px solid hsl(142 50% 40%); }`} /> 左側は単一ネームスペースにすべてを押し込んだ状態です。`-dense`、`-compact` といった接尾辞でトークンが増殖し、どのトークンがどのコンテキスト向けか判別しにくくなっています。右側はネームスペースで分割した状態です。各ネームスペースのトークン数は半分になり、プレフィックスが適用先を明示しています。 ### デモ:記事コンテンツ vs 管理ダッシュボード 以下のデモは、同じウェブサイト内の2つの異なるデザインコンテキストを示しています。左の記事 UI は広いスペーシングと大きなフォントを使い、右の管理 UI はタイトなスペーシングと小さなフォントを使っています。 myweb- (article context) Understanding CSS Grid CSS Grid provides a two-dimensional layout system. It handles both columns and rows, making complex layouts straightforward. CSS Layout content-gap: 32px body: 1.125rem myadmin- (admin context) User Management Name Role Status AliceAdminActive BobEditorActive CarolViewerInactive cell-gap: 8px body: 0.875rem `} css={`.ctx-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 16px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .ctx-demo__col { display: flex; flex-direction: column; gap: 8px; } .ctx-demo__label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } /* ── Article context (myweb-) ── */ .ctx-demo__article { background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; padding: 24px; display: flex; flex-direction: column; gap: 32px; } .ctx-demo__article-heading { font-size: 1.125rem; font-weight: 700; line-height: 1.3; } .ctx-demo__article-body { font-size: 1.125rem; line-height: 1.7; color: hsl(215 25% 35%); } .ctx-demo__article-meta { display: flex; gap: 8px; } .ctx-demo__article-tag { font-size: 0.75rem; background: hsl(210 40% 96%); color: hsl(221 83% 53%); padding: 4px 10px; border-radius: 4px; } /* ── Admin context (myadmin-) ── */ .ctx-demo__admin { background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; padding: 12px; display: flex; flex-direction: column; gap: 8px; } .ctx-demo__admin-heading { font-size: 0.875rem; font-weight: 700; } .ctx-demo__table { width: 100%; border-collapse: collapse; font-size: 0.75rem; } .ctx-demo__table th { text-align: left; padding: 4px 8px; border-bottom: 2px solid hsl(214 32% 91%); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } .ctx-demo__table td { padding: 4px 8px; border-bottom: 1px solid hsl(214 32% 95%); } .ctx-demo__status--active { color: hsl(142 50% 35%); } .ctx-demo__status--inactive { color: hsl(0 50% 50%); } /* ── Spacing notes ── */ .ctx-demo__spacing-note { display: flex; gap: 12px; font-size: 0.75rem; color: hsl(215 16% 47%); font-style: italic; }`} /> 記事コンテキストでは `myweb-content-gap`(32px)の広い垂直ギャップと `myweb-body`(1.125rem)の大きなフォントで読みやすさを重視しています。管理コンテキストでは `myadmin-cell-gap`(8px)のタイトなスペーシングと `myadmin-body`(0.875rem)の小さなフォントで情報密度を最大化しています。 ### デモ:3つのネームスペースを持つ大規模サイト サイトの規模が大きくなると、3つ以上のネームスペースが必要になることもあります。以下は記事、管理画面、マーケティングページの3コンテキストの例です。 myweb- Article / content body1.125rem section-gap48px reading-padding24px Optimized for readability myadmin- Admin / dashboard body0.875rem section-gap16px panel-padding12px Optimized for density mymarketing- Landing / marketing body1.25rem section-gap80px hero-padding64px Optimized for impact `} css={`.ns-demo { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; padding: 16px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); } .ns-demo__ns { background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; } .ns-demo__header { padding: 8px 12px; font-size: 0.8rem; font-weight: 700; font-family: monospace; color: hsl(0 0% 100%); } .ns-demo__header--web { background: hsl(221 83% 53%); } .ns-demo__header--admin { background: hsl(262 60% 50%); } .ns-demo__header--mkt { background: hsl(340 65% 50%); } .ns-demo__desc { font-size: 0.75rem; color: hsl(215 16% 47%); padding: 8px 12px 0; } .ns-demo__tokens { padding: 8px 12px; display: flex; flex-direction: column; gap: 4px; flex: 1; } .ns-demo__row { display: flex; justify-content: space-between; font-size: 0.75rem; } .ns-demo__key { color: hsl(215 16% 47%); } .ns-demo__val { font-weight: 600; font-family: monospace; font-size: 0.75rem; } .ns-demo__char { font-size: 0.75rem; color: hsl(215 16% 47%); font-style: italic; padding: 8px 12px; border-top: 1px solid hsl(214 32% 95%); background: hsl(210 40% 98%); }`} /> 各ネームスペースが独立したデザイン原則を持っています。`myweb-` は読みやすさ、`myadmin-` は情報密度、`mymarketing-` はインパクトに最適化されています。同じプロパティ名(`body`、`section-gap`)がネームスペースごとに異なる値を持つことで、コンテキストに応じた最適なデザインが実現されます。 ## クイックリファレンス | シナリオ | アプローチ | | --- | --- | | サイト全体が同じデザインルール | 単一ネームスペースで十分 | | 記事コンテンツ + 管理画面 | 2つのネームスペースに分割 | | `-dense`/`-compact` 接尾辞が増殖 | ネームスペース分割のサイン | | レスポンシブで値が変わるだけ | メディアクエリで対応(分割不要) | | ライト/ダークテーマの切り替え | カラースキームで対応(分割不要) | | 3つ以上の明確に異なるデザインコンテキスト | 3つ以上のネームスペースも可 | ## AI がよくやるミス - **すべてのコンテキストを1つのネームスペースに押し込む** — `-dense`、`-compact`、`-small` 接尾辞でトークンを区別しようとする。ネームスペースプレフィックスで分離すべき - **ネームスペースを細かく分けすぎる** — ページごとにネームスペースを作るのは過剰。デザイン原則が根本的に異なるコンテキスト間でのみ分割する - **共有トークンを各ネームスペースに複製する** — ブランドカラーやボーダー半径など、すべてのコンテキストで共通のトークンはプレフィックスなしの共有トークンとして定義する - **レスポンシブバリエーションをネームスペースで分ける** — モバイル用/デスクトップ用でネームスペースを分けるのは誤り。レスポンシブはメディアクエリやコンテナクエリで対応する - **ネームスペースプレフィックスを省略する** — `content-gap` と `cell-gap` では、どのコンテキスト用か分からない。必ず `myweb-content-gap`、`myadmin-cell-gap` のようにプレフィックスを付ける ## いつ使うか ### 適しているケース - **サイトが複数の明確に異なるデザインコンテキストを持つ** — 記事サイト + 管理画面、ECサイト + 管理ダッシュボードなど - **1つのトークンセットに `-dense`/`-compact` 接尾辞が増え始めた** — ネームスペース分割のタイミング - **異なるチームが異なる画面を担当している** — 各チームが自分のネームスペースを管理できる ### 不要なケース - **サイト全体が同じデザインルールで統一されている** — 単一ネームスペースのタイトトークン戦略で十分 - **管理画面が別のアプリケーション** — 別プロジェクトなら別の `@theme` を持てばよい - **小規模サイト** — トークン数が少なければ分割の必要はない ### 他のトークン戦略との関係 マルチネームスペーストークン戦略はタイトトークン戦略の拡張です。まずタイトトークン戦略でデフォルトをリセットし、その上で複数のネームスペースを定義します: | 戦略 | 役割 | | --- | --- | | [タイトトークン戦略](./tight-token-strategy/) | デフォルトをリセットし、セマンティックトークンのみ定義 | | **マルチネームスペーストークン戦略** | デザインコンテキストごとにトークンを分離 | | [2層サイズ戦略](./two-tier-size-strategy/) | 幅・高さのサイジングルール | ## 参考リンク - [Tailwind CSS v4 Theme Configuration](https://tailwindcss.com/docs/theme) - [Tailwind CSS v4 @theme Directive](https://tailwindcss.com/docs/functions-and-directives#theme-directive) --- # トークンプレビュー > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/design-systems/tight-token-strategy/token-preview タイトトークン戦略で利用可能なすべてのトークンのビジュアルリファレンスです。スペーシング、カラー、タイポグラフィの値を選ぶ際のクイックチートシートとして使いましょう。 ## スペーシングトークン ### 水平スペーシング(hsp) hsp-2xs 5px hsp-xs 12px hsp-sm 20px hsp-md 40px hsp-lg 60px hsp-xl 100px hsp-2xl 250px `} css={`.token-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 10px; } .token-row { display: flex; align-items: center; gap: 12px; } .token-name { font-size: 13px; font-family: monospace; font-weight: 600; color: hsl(221 83% 53%); width: 72px; flex-shrink: 0; } .token-value { font-size: 12px; color: hsl(215 16% 47%); width: 40px; flex-shrink: 0; text-align: right; } .token-bar { height: 24px; background: hsl(221 83% 53% / 0.2); border-left: 3px solid hsl(221 83% 53%); border-radius: 0 4px 4px 0; }`} /> ### 垂直スペーシング(vsp) vsp-2xs 4px vsp-xs 8px vsp-sm 20px vsp-md 35px vsp-lg 50px vsp-xl 65px vsp-2xl 80px `} css={`.token-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 8px; } .token-row { display: flex; align-items: flex-start; gap: 16px; } .token-info { display: flex; align-items: baseline; gap: 8px; width: 130px; flex-shrink: 0; padding-top: 2px; } .token-name { font-size: 13px; font-family: monospace; font-weight: 600; color: hsl(142 71% 35%); } .token-value { font-size: 12px; color: hsl(215 16% 47%); } .token-bar-wrap { flex: 1; } .token-bar { width: 100%; background: hsl(142 71% 45% / 0.2); border-top: 3px solid hsl(142 71% 45%); border-radius: 0 0 4px 4px; }`} /> ## カラートークン ### ブランドカラー Primary primary-light hsl(217 91% 60%) primary hsl(221 83% 53%) primary-dark hsl(224 76% 48%) Secondary secondary-light hsl(250 80% 68%) secondary hsl(252 78% 60%) secondary-dark hsl(255 70% 52%) Accent accent-light hsl(38 95% 64%) accent hsl(33 95% 54%) accent-dark hsl(28 90% 46%) `} css={`.swatch-grid { padding: 16px; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 12px; } .swatch-group-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); margin-bottom: 6px; } .swatch-row { display: flex; gap: 6px; } .swatch { flex: 1; padding: 12px 10px; border-radius: 8px; color: hsl(210 40% 98%); display: flex; flex-direction: column; gap: 2px; } .swatch.accent { color: hsl(222 47% 11%); } .swatch-name { font-size: 12px; font-weight: 600; } .swatch-val { font-size: 10px; opacity: 0.8; font-family: monospace; }`} /> ### ステートカラー success hsl(142 71% 45%) warning hsl(38 92% 50%) error hsl(0 84% 60%) info hsl(199 89% 48%) `} css={`.swatch-row { display: flex; gap: 6px; padding: 16px; font-family: system-ui, sans-serif; } .swatch { flex: 1; padding: 14px 12px; border-radius: 8px; color: hsl(210 40% 98%); display: flex; flex-direction: column; gap: 2px; } .swatch.dark-text { color: hsl(222 47% 11%); } .swatch-name { font-size: 13px; font-weight: 600; } .swatch-val { font-size: 10px; opacity: 0.8; font-family: monospace; }`} /> ### サーフェス、テキスト、ボーダーカラー Surface surface hsl(0 0% 100%) surface-alt hsl(210 40% 96%) surface-inverse hsl(222 47% 11%) Text The quick brown fox text — hsl(222 47% 11%) The quick brown fox text-muted — hsl(215 16% 47%) The quick brown fox text-inverse — hsl(210 40% 98%) Border border — hsl(214 32% 91%) border-focus — hsl(221 83% 53%) `} css={`.token-section { padding: 16px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 16px; } .group-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(215 16% 47%); margin-bottom: 6px; } .swatch-row { display: flex; gap: 6px; } .swatch { flex: 1; padding: 14px 12px; border-radius: 8px; color: hsl(210 40% 98%); display: flex; flex-direction: column; gap: 2px; } .swatch.surface-light { color: hsl(222 47% 11%); border: 1px solid hsl(214 32% 91%); } .swatch-name { font-size: 12px; font-weight: 600; } .swatch-val { font-size: 10px; opacity: 0.7; font-family: monospace; } /* Text samples */ .text-samples { display: flex; flex-direction: column; gap: 4px; } .text-sample { display: flex; align-items: baseline; gap: 12px; padding: 8px 12px; border-radius: 6px; background: hsl(0 0% 100%); border: 1px solid hsl(214 32% 91%); } .text-sample.dark { background: hsl(222 47% 11%); } .text-preview { font-size: 14px; font-weight: 500; flex-shrink: 0; } .text-meta { font-size: 10px; font-family: monospace; color: hsl(215 16% 47%); margin-left: auto; } .text-sample.dark .text-meta { color: hsl(215 25% 65%); } /* Border samples */ .border-samples { display: flex; gap: 12px; } .border-sample { flex: 1; display: flex; align-items: center; gap: 10px; } .border-box { width: 60px; height: 36px; border: 2px solid; border-radius: 6px; flex-shrink: 0; } .border-meta { font-size: 10px; font-family: monospace; color: hsl(215 16% 47%); }`} /> ## タイポグラフィトークン ### フォントサイズ caption 0.75rem (12px) The quick brown fox jumps over the lazy dog body 1rem (16px) The quick brown fox jumps over the lazy dog subheading 1.25rem (20px) The quick brown fox jumps over the lazy dog heading 1.75rem (28px) The quick brown fox jumps display 2.5rem (40px) The quick brown fox `} css={`.type-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 8px; } .type-row { display: flex; flex-direction: column; gap: 2px; padding: 6px 0; border-bottom: 1px solid hsl(214 32% 91%); } .type-row:last-child { border-bottom: none; } .type-meta { display: flex; align-items: baseline; gap: 8px; } .type-name { font-size: 11px; font-family: monospace; font-weight: 600; color: hsl(221 83% 53%); } .type-val { font-size: 10px; color: hsl(215 16% 47%); font-family: monospace; } .type-sample { line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }`} /> ### フォントウェイト The quick brown fox jumps over the lazy dog normal — 400 The quick brown fox jumps over the lazy dog medium — 500 The quick brown fox jumps over the lazy dog bold — 700 `} css={`.weight-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 10px; } .weight-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } .weight-sample { font-size: 16px; line-height: 1.4; } .weight-meta { font-size: 11px; font-family: monospace; color: hsl(221 83% 53%); font-weight: 600; flex-shrink: 0; }`} /> ### 行の高さ tight 1.25 Typography tokens constrain the type scale to a small set of intentional values. This eliminates drift and keeps the interface consistent across teams. normal 1.5 Typography tokens constrain the type scale to a small set of intentional values. This eliminates drift and keeps the interface consistent across teams. relaxed 1.75 Typography tokens constrain the type scale to a small set of intentional values. This eliminates drift and keeps the interface consistent across teams. `} css={`.lh-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 12px; } .lh-row { display: flex; gap: 16px; align-items: flex-start; } .lh-meta { width: 80px; flex-shrink: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 2px; } .lh-name { font-size: 12px; font-family: monospace; font-weight: 600; color: hsl(221 83% 53%); } .lh-val { font-size: 10px; color: hsl(215 16% 47%); font-family: monospace; } .lh-sample { font-size: 13px; margin: 0; flex: 1; color: hsl(222 47% 11%); background: hsl(210 40% 96%); padding: 8px 12px; border-radius: 6px; }`} /> ### フォントファミリー sans The quick brown fox jumps over the lazy dog — 0123456789 "Inter", system-ui, sans-serif mono The quick brown fox jumps — 0123456789 "JetBrains Mono", ui-monospace, monospace `} css={`.ff-list { padding: 20px; font-family: system-ui, sans-serif; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 10px; } .ff-row { display: flex; flex-direction: column; gap: 2px; padding: 8px 12px; border: 1px solid hsl(214 32% 91%); border-radius: 6px; } .ff-name { font-size: 11px; font-family: monospace; font-weight: 600; color: hsl(221 83% 53%); } .ff-sample { font-size: 15px; line-height: 1.4; } .ff-val { font-size: 10px; color: hsl(215 16% 47%); font-family: monospace; }`} /> --- # css-wisdom スキル > Source: https://takazudomodular.com/pj/zcss/ja/docs/overview/css-wisdom-skill `css-wisdom` スキルは、[Claude Code](https://docs.anthropic.com/en/docs/claude-code) のスキルで、このドキュメントサイト内のすべての CSS ベストプラクティス記事をインデックス化します。AI コーディングエージェントが開発中に関連する CSS パターンやテクニックをすばやく参照できるようにするためのものです。 ## 機能 このスキルは、CSS の概念をドキュメント記事にマッピングするトピックインデックスを管理します。呼び出されると、関連する記事を読み取り、推奨パターンを適用します。 トピックインデックスは、`src/content/docs/` 配下のすべての MDX 記事(`overview/` と `inbox/` カテゴリを除く)から生成され、`.claude/skills/css-wisdom/descriptions.json` のキュレーションされた説明文と組み合わされます。 ## インストール このスキルをグローバルの Claude Code スキルディレクトリにシンボリックリンクする必要があります。 ```bash pnpm run setup:symlink ``` このコマンドはトピックインデックスの生成を実行し、このリポジトリの `.claude/skills/css-wisdom/` を指すシンボリックリンクを `~/.claude/skills/css-wisdom` に作成します。 ## 使い方 任意の Claude Code セッションで、トピックキーワードを指定してスキルを呼び出します。 ``` /css-wisdom flexbox /css-wisdom dark mode /css-wisdom centering ``` スキルはトピックインデックスから関連する記事を見つけ、それを読み取り、コードを書く際に CSS パターンを適用します。 ## トピックインデックスの再生成 記事が追加または削除された場合は、トピックインデックスを再生成してください。 ```bash pnpm run generate:css-wisdom ``` ジェネレータースクリプト(`scripts/generate-css-wisdom.js`)は `src/content/docs/` 配下のすべての MDX ファイルを読み取り、`descriptions.json` で説明文を参照し、`SKILL.md` トピックインデックスを生成します。 新しい記事を追加する場合は、ジェネレーターを実行する前に `.claude/skills/css-wisdom/descriptions.json` に説明文を追加してください。 ## スキルの構成 ``` .claude/skills/css-wisdom/ SKILL.md # Generated topic index (do not edit manually) descriptions.json # Curated article descriptions (edit this) ``` --- # レスポンシブ画像 > Source: https://takazudomodular.com/pj/zcss/ja/docs/responsive/responsive-images ## 問題 画像はレイアウトの問題やパフォーマンス低下の最も一般的な原因の一つです。AIエージェントは固定幅の `` タグを出力し、`object-fit` を忘れ(画像が引き伸ばされたり潰れたりする)、`aspect-ratio` を省略し(レイアウトシフトが発生する)、適切な `srcset`/`sizes` 属性や `` 要素によるアートディレクションもほとんど生成しません。 ## 解決方法 レスポンシブ画像には、視覚的な表示のためのCSSテクニック(`object-fit`、`aspect-ratio`)と、パフォーマンスとアートディレクションのためのHTML属性(`srcset`、`sizes`、``)の両方が必要です。 cover Fills the box, cropping to maintain ratio contain Fits entirely inside, letterboxing if needed fill Stretches to fill — distorts the image `} css={` .fit-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1rem; padding: 1rem; } .fit-item { text-align: center; } .fit-label { font-size: 0.8125rem; font-weight: 700; color: #1e293b; margin-bottom: 0.5rem; font-family: monospace; background: #f1f5f9; padding: 0.25rem 0.5rem; border-radius: 0.25rem; display: inline-block; } .fit-image { width: 100%; height: 120px; border-radius: 0.375rem; border: 2px solid #e2e8f0; background-image: linear-gradient(135deg, #ef4444 0%, #f59e0b 25%, #22c55e 50%, #3b82f6 75%, #8b5cf6 100%); background-size: 200% 200%; } .fit-cover { object-fit: cover; background-size: cover; } .fit-contain { background-size: contain; background-repeat: no-repeat; background-position: center; } .fit-fill { background-size: 100% 100%; } .fit-desc { font-size: 0.75rem; color: #64748b; margin: 0.5rem 0 0; line-height: 1.4; } `} /> ## コード例 ### object-fit で画像の引き伸ばしを防ぐ `object-fit` プロパティは、背景画像における `background-size` と同様に、画像がコンテナをどのように満たすかを制御します。 ```css /* Fills the container, cropping to maintain aspect ratio */ .image-cover { width: 100%; height: 300px; object-fit: cover; } /* Fits entirely within the container, letterboxing if needed */ .image-contain { width: 100%; height: 300px; object-fit: contain; } ``` ### object-position でトリミング位置を制御する ```css /* Focus on the top of the image when cropping */ .image-top { width: 100%; height: 200px; object-fit: cover; object-position: center top; } /* Focus on a specific area */ .image-focal { width: 100%; height: 200px; object-fit: cover; object-position: 30% 20%; } ``` ### aspect-ratio でレイアウトシフトを防ぐ ```css .card__image { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } .avatar { width: 3rem; aspect-ratio: 1; object-fit: cover; border-radius: 50%; } .hero-image { width: 100%; aspect-ratio: 21 / 9; object-fit: cover; } ``` ### max-width による基本的なレスポンシブ画像 ```css img { max-width: 100%; height: auto; } ``` これはすべてのプロジェクトが画像に適用すべき最低限のCSSです。アスペクト比を維持しながら、画像がコンテナからはみ出すのを防ぎます。 ### srcset と sizes による解像度の切り替え `srcset` を使って複数の画像サイズを提供し、`sizes` でさまざまなビューポート幅での画像の表示幅をブラウザに伝えます。 ```html ``` - `srcset` は利用可能な画像ファイルとその固有の幅をリストします。 - `sizes` はさまざまなビューポート幅でのレイアウト上の画像幅を記述します。 - ブラウザはビューポート幅とデバイスピクセル比に基づいて最適なファイルを選択します。 - `width` と `height` 属性は、画像がロードされる前のアスペクト比計算のための固有の寸法を提供します。 ### picture 要素によるアートディレクション 異なるビューポート幅で完全に異なる画像(異なるトリミング、異なるコンテンツ)を提供する必要がある場合は `` を使いましょう。 ```html ``` ### picture によるフォーマットの切り替え モダンフォーマットをフォールバック付きで提供します: ```html ``` ### 完全なレスポンシブ画像パターン すべてのテクニックを組み合わせた例: ```html ``` ```css picture img { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; } ``` ## AIがよくやるミス - **`object-fit` を忘れる**: `object-fit: cover` なしで画像に固定の `width` と `height` を設定し、画像が引き伸ばされたり潰れたりしてしまいます。 - **`aspect-ratio` がない**: `aspect-ratio` を省略すると、画像のロード時にレイアウトシフト(CLS)が発生します。 - **`width` と `height` 属性がない**: これらのHTML属性により、ブラウザは画像がロードされる前にアスペクト比を計算でき、レイアウトシフトを防ぎます。 - **`srcset` や `sizes` がない**: すべてのデバイスに単一の大きな画像ファイルを配信すると、モバイルで帯域幅を無駄にします。 - **`sizes` の値が不正確**: 画像がビューポートの一部しか占めないのに `sizes="100vw"` を使うと、ブラウザが過大なファイルをダウンロードしてしまいます。 - **コンテンツ画像に `background-image` を使う**: コンテンツ画像はアクセシビリティ(alt テキスト)とパフォーマンス(遅延読み込み)のために `` を使うべきです。`background-image` は装飾的な画像に使いましょう。 - **`loading="lazy"` を忘れる**: ファーストビュー外の画像には `loading="lazy"` を使って読み込みを遅延させましょう。ただし、LCP(Largest Contentful Paint)画像(通常はヒーロー画像)を遅延読み込みしてはいけません。 ## 使い分け - **`object-fit: cover`**: カードサムネイル、ヒーロー画像、アバターなど、固定サイズの画像コンテナに使いましょう。 - **`aspect-ratio`**: 画像が固定コンテナを持ち、レイアウトシフトを防ぐ必要がある場合に使いましょう。 - **`srcset` + `sizes`**: 複数のビューポートコンテキストで提供される画像に使いましょう。これは本番画像の標準です。 - **``**: アートディレクション(異なるサイズでの異なるトリミング)やフォーマット切り替え(AVIF/WebP と JPEG フォールバック)に使いましょう。 - **`loading="lazy"`**: ファーストビュー外のすべての画像に使いましょう。 ## 参考リンク - [Responsive Images — web.dev](https://web.dev/learn/design/responsive-images) - [Responsive Images in HTML — MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Responsive_images) - [HTML Responsive Images Guide — CSS-Tricks](https://css-tricks.com/a-guide-to-the-responsive-images-syntax-in-html/) - [Responsive Images Best Practices in 2025 — DEV Community](https://dev.to/razbakov/responsive-images-best-practices-in-2025-4dlb) --- # currentColor パターン > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/color/currentcolor-patterns ## 問題 AIエージェントはすべてのプロパティに色値をハードコードしがちです。`color`、`border-color`、`box-shadow`、`outline`、SVGの `fill` に個別の hex 値を設定し、これらすべてが要素のテキストカラーと一致すべき場合でもそうします。これはメンテナンスの問題を生みます:コンポーネントの色を変更する場合、複数の宣言を更新する必要があります。さらに悪いことに、親コンポーネントがテキストカラーを変更した場合(ホバー状態やテーマ切り替えなど)、ボーダー、アイコン、シャドウは特定の hex 値に固定されているため追従しません。 ## 解決方法 `currentColor` は、要素の計算された `color` 値に解決されるCSSキーワードです。ボーダー、シャドウ、アウトライン、SVGのフィル、その他のカラープロパティがテキストカラーに自動的に追従するようになります。これはCSSで最も活用されていないパターンの1つで、CSS3から対応しておりすべてのブラウザで動作します。 ### 主な動作 - `currentColor` はカスケードを通じて継承されます — 要素が独自の `color` を設定していない場合、親から継承します - `border-color` はデフォルトで暗黙的に `currentColor` ですが、他のプロパティでは明示的に指定する必要があります - CSSカスタムプロパティや `color-mix()` と組み合わせて使えます ## コード例 ### テキストカラーに追従するボーダー ```css /* VERBOSE: separate color declarations */ .card { color: #1a365d; border: 1px solid #1a365d; } .card:hover { color: #2b6cb0; border-color: #2b6cb0; /* Must update separately */ } /* BETTER: currentColor keeps them in sync */ .card { color: #1a365d; border: 1px solid currentColor; } .card:hover { color: #2b6cb0; /* Border color updates automatically */ } ``` Indigo Component Border, icon, and shadow all use currentColor — they match the text automatically. Teal Component Same CSS — only the parent color changed. Everything else followed via currentColor. Red Component One color declaration controls text, border, icon stroke, and box shadow together. `} css={`.cc-demo { padding: 1.5rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; font-family: system-ui, sans-serif; } .card { border: 2px solid currentColor; border-radius: 10px; padding: 1rem; box-shadow: 0 4px 12px color-mix(in srgb, currentColor 20%, transparent); background: white; } .card svg { margin-bottom: 0.5rem; } .card h4 { margin: 0 0 0.4rem; font-size: 1rem; } .card p { margin: 0; font-size: 0.85rem; line-height: 1.5; opacity: 0.8; }`} height={260} /> ### テキストカラーに一致するSVGアイコン `currentColor` が最も価値を発揮する場面です。`fill="currentColor"` を持つインラインSVGは、親のテキストカラーを自動的に採用します: ```html Add to favorites ``` ```css .btn-primary { color: white; background: oklch(50% 0.22 264); } .btn-primary:hover { color: oklch(90% 0.05 264); /* SVG fill automatically updates to the new color */ } ``` ### ボックスシャドウ ```css .tag { color: oklch(45% 0.2 264); border: 1px solid currentColor; box-shadow: 0 1px 3px currentColor; } /* Semi-transparent shadow using color-mix with currentColor */ .card { color: oklch(30% 0.05 264); box-shadow: 0 4px 12px color-mix(in oklch, currentColor 25%, transparent); } ``` ### アウトラインとフォーカスリング ```css /* Focus ring that matches the element's text color */ .input:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } /* Link focus ring that matches the link color */ a:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; } ``` ### テキストデコレーション ```css /* Underline color automatically matches text */ a { color: oklch(50% 0.2 264); text-decoration-color: currentColor; } /* Subtle underline using semi-transparent currentColor */ a { color: oklch(50% 0.2 264); text-decoration-color: color-mix(in oklch, currentColor 40%, transparent); } a:hover { text-decoration-color: currentColor; /* Full opacity on hover */ } ``` ### マルチカラーコンポーネントバリアント ```css .badge { color: var(--badge-color, oklch(45% 0.15 264)); border: 1px solid currentColor; background: color-mix(in oklch, currentColor 10%, transparent); } /* All color properties follow from one custom property */ .badge--success { --badge-color: oklch(45% 0.15 145); } .badge--warning { --badge-color: oklch(55% 0.18 85); } .badge--danger { --badge-color: oklch(50% 0.2 25); } ``` ```html Active Pending Failed ``` ### ディバイダーとセパレーター ```css .divider { border: none; border-block-start: 1px solid currentColor; opacity: 0.2; } /* The divider inherits the section's text color */ .dark-section { color: white; } .light-section { color: #333; } ``` ### CSSカスタムプロパティとの組み合わせ ```css :root { --link-color: oklch(50% 0.2 264); } a { color: var(--link-color); text-decoration-color: color-mix(in oklch, currentColor 50%, transparent); transition: color 0.2s; } a:hover { color: oklch(40% 0.25 264); /* text-decoration-color updates through currentColor */ } a svg { fill: currentColor; /* Icon follows link color */ } ``` ## AIがよくやるミス - `currentColor` で同期を保つ代わりに、`color`、`border-color`、`box-shadow`、SVGの `fill` に同じ色値をハードコードしている - インラインSVGアイコンに `fill="currentColor"` を設定せず、状態変更時にSVGの色を変更するためにJavaScriptを書いている - `currentColor` を使えば1つの `color` 更新で変更が伝播するのに、ボーダー、シャドウ、アイコンの色を個別に更新する `:hover` ルールを書いている - 要素の `color` が遠い祖先から継承されており予期せず変わる可能性がある場面で `currentColor` を使っている — どの要素が `color` を設定するか意図的に考えましょう - `border-color` がすでにデフォルトで `currentColor` であることを忘れている — `border-color: currentColor` の明示的な設定は有効ですが冗長です - `color-mix()` と `currentColor` を組み合わせた半透明バリアント(例:`color-mix(in oklch, currentColor 25%, transparent)` のさりげないシャドウ)を活用していない - `background-color` に `currentColor` を使い、テキストを自身の背景に対して見えなくしている — `currentColor` はアクセントカラー用であり、背景用ではありません ## 使い分け - **ボタンやリンク内のSVGアイコン**: すべての状態(デフォルト、ホバー、アクティブ、無効、フォーカス)でアイコンの色がテキストカラーに自動的に追従する - **コンポーネントのボーダー**: 1つの `color` 変更でテキストとボーダーが一緒に更新される - **フォーカスインジケーター**: `outline: 2px solid currentColor` で要素のコンテキストに常に合うフォーカスリングが作られる - **色調整されたコンポーネント**: バッジ、タグ、アラートで、ボーダー、背景ティント、テキストが1つの色から導出されるべき場面 - **テキストデコレーション**: テキストカラーに一致する、または部分的に一致するさりげないアンダーライン ### 使わない方がよい場面 - **背景**: `background: currentColor` はテキストが見えなくなります(子要素に別の `color` を設定しない限り) - **テキストと一緒に変わるべきでない色**: ロゴ、ブランドマーク、コンテキストに関係なく固定色が必要なアイコン - **複雑なマルチカラーコンポーネント**: ボーダー、アイコン、テキストが意図的に異なる色を使う場合 ## 参考リンク - [MDN: currentColor](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/currentcolor) - [CSS-Tricks: currentColor](https://css-tricks.com/currentcolor/) - [currentColor and SVGs — Go Make Things](https://gomakethings.com/currentcolor-and-svgs/) - [Using the currentColor CSS Property with SVG — Echobind](https://echobind.com/post/currentcolor-css-property-with-svg) --- # ブレンドモード > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/effects/blend-modes ## 問題 AIエージェントは、デザインが明らかにブレンドモード(blend mode)を求めている場合でも、CSSブレンドモードに手を伸ばすことがほとんどありません。画像の上のテキストにはブレンドモードではなく不透明なオーバーレイ `div` を使い、画像の色調補正には `background-blend-mode` の方がシンプルなのに `filter` で行います。ブレンドモードを使う場合でも、`isolation` プロパティを忘れて、DOM上位の関係ない背景と意図せずブレンドしてしまうことがよくあります。 ## 解決方法 CSSには2つのブレンドモードプロパティがあります。 - **`mix-blend-mode`** — スタッキング順序において、要素の色がその直後のコンテンツとどのようにブレンドされるかを制御します - **`background-blend-mode`** — 要素自身の複数の背景レイヤー同士がどのようにブレンドされるかを制御します 両方とも同じブレンドモード値のセット(`multiply`、`screen`、`overlay`、`darken`、`lighten`、`color-dodge`、`color-burn`、`difference`、`exclusion`、`soft-light` など)を受け付けます。コンテナに `isolation: isolate` を設定すると、子要素のブレンドモードが親の背景に漏れ出すのを防げます。 ## コード例 ### 画像を暗くするオーバーレイ 最も一般的なユースケース:写真の上に読みやすいテキストを表示する方法です。半透明の黒いオーバーレイの代わりに、`multiply` を使ってコントラストを保ちながら画像を暗くします。 ```css .hero-overlay { position: relative; background: url("hero.jpg") center / cover; } .hero-overlay::before { content: ""; position: absolute; inset: 0; background: hsl(220deg 60% 20%); mix-blend-mode: multiply; } .hero-content { position: relative; z-index: 1; color: white; } ``` ```html Hero Title Text is readable without a flat black overlay. ``` `multiply` は画像のコントラストと色のバリエーションを保ちながら明るい部分を暗くします。すべてを均一にフラットにしてしまう `rgba(0,0,0,0.5)` オーバーレイとは異なります。 ### background-blend-mode による画像のカラーティンティング ```css /* Duotone effect */ .duotone { background: url("photo.jpg") center / cover, linear-gradient(#1a1a2e, #3b82f6); background-blend-mode: luminosity; } /* Warm tint */ .warm-tint { background: url("photo.jpg") center / cover, hsl(30deg 80% 50%); background-blend-mode: overlay; } /* Cool monochrome */ .cool-mono { background: url("photo.jpg") center / cover, hsl(220deg 80% 30%); background-blend-mode: color; } ``` ### 背景に適応するテキスト テキストに `mix-blend-mode: difference` を設定すると、背景に対してテキストの色が反転し、ライト・ダークどちらの領域でも読みやすくなります。 ```css .adaptive-text { color: white; mix-blend-mode: difference; font-size: 3rem; font-weight: 700; } ``` ### ノックアウトテキスト効果 `screen` を使って、テキストが背景を「切り抜いて」見せるエフェクトを作成します。 ```css .knockout-container { background: url("texture.jpg") center / cover; isolation: isolate; } .knockout-text { background: black; color: white; mix-blend-mode: screen; font-size: 4rem; font-weight: 900; padding: 20px; } ``` `screen` は黒い部分を透明に、白い部分を不透明にします。そのため白いテキストからテクスチャが透けて見え、黒い背景は消えます。 ### `isolation` プロパティ `isolation: isolate` がないと、ブレンドモードはDOMを上に伝播し、関係のない背景を含む要素の背後にあるすべてのコンテンツとブレンドしてしまいます。コンテナに `isolation: isolate` を設定すると、新しいスタッキングコンテキストが作成され、ブレンドが子要素内に限定されます。 ```css /* Without isolation — text blends with page background too */ .card-broken { background: white; } .card-broken .blend-text { mix-blend-mode: multiply; /* Multiplies with everything behind, including page background */ } /* With isolation — blending stops at the card */ .card-fixed { background: white; isolation: isolate; } .card-fixed .blend-text { mix-blend-mode: multiply; /* Only multiplies with the card's white background */ } ``` ### ブレンドされた背景パターン ```css /* Plaid pattern using blended gradients */ .plaid { background: repeating-linear-gradient( 0deg, hsl(220deg 80% 60% / 0.3) 0px, hsl(220deg 80% 60% / 0.3) 20px, transparent 20px, transparent 40px ), repeating-linear-gradient( 90deg, hsl(350deg 80% 60% / 0.3) 0px, hsl(350deg 80% 60% / 0.3) 20px, transparent 20px, transparent 40px ), hsl(0deg 0% 95%); background-blend-mode: multiply; } ``` ### ブレンドモードによるホバーエフェクト ```css .image-card { position: relative; overflow: hidden; } .image-card img { width: 100%; display: block; transition: filter 0.3s ease; } .image-card::after { content: ""; position: absolute; inset: 0; background: hsl(220deg 80% 50%); mix-blend-mode: soft-light; opacity: 0; transition: opacity 0.3s ease; } .image-card:hover::after { opacity: 1; } ``` ### クイックリファレンス:よく使うブレンドモード ```css /* Darken family — result is darker than both layers */ .darken { mix-blend-mode: darken; } .multiply { mix-blend-mode: multiply; } /* most useful */ .color-burn { mix-blend-mode: color-burn; } /* Lighten family — result is lighter than both layers */ .lighten { mix-blend-mode: lighten; } .screen { mix-blend-mode: screen; } /* most useful */ .color-dodge { mix-blend-mode: color-dodge; } /* Contrast family — darkens darks, lightens lights */ .overlay { mix-blend-mode: overlay; } /* most useful */ .soft-light { mix-blend-mode: soft-light; } /* subtle version */ .hard-light { mix-blend-mode: hard-light; } /* Difference family — inverts based on brightness */ .difference { mix-blend-mode: difference; } .exclusion { mix-blend-mode: exclusion; } /* softer version */ ``` ## ライブプレビュー BLEND`} css={` .demo { width: 100%; height: 100%; font-family: system-ui, sans-serif; } .blend-container { width: 100%; height: 100%; background: linear-gradient(135deg, #3b82f6 0%, #ec4899 50%, #f59e0b 100%); display: flex; justify-content: center; align-items: center; isolation: isolate; } .blend-text { font-size: 80px; font-weight: 900; color: white; mix-blend-mode: difference; margin: 0; letter-spacing: 8px; } `} height={250} /> luminosity blendno blend (normal)`} css={` .demo { display: flex; gap: 16px; height: 100%; padding: 16px; background: #0f172a; font-family: system-ui, sans-serif; } .duotone, .no-blend { flex: 1; border-radius: 12px; background: linear-gradient(135deg, #ec4899, #8b5cf6, #3b82f6, #06b6d4), linear-gradient(45deg, #000 0%, #fff 100%); background-size: cover; display: flex; align-items: flex-end; padding: 12px; position: relative; } .duotone { background-blend-mode: luminosity; } .no-blend { background-blend-mode: normal; } .label { background: hsl(0deg 0% 0% / 0.5); color: white; padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 500; } `} height={250} /> Without isolation — blend leaks to page backgroundWith isolation: isolate — blend contained to card`} css={` .demo { display: flex; gap: 20px; justify-content: center; align-items: center; height: 100%; background: repeating-linear-gradient( 45deg, #e2e8f0 0px, #e2e8f0 10px, #f1f5f9 10px, #f1f5f9 20px ); padding: 24px; font-family: system-ui, sans-serif; } .card { position: relative; width: 200px; background: white; border-radius: 12px; overflow: hidden; padding-bottom: 16px; } .with-isolate { isolation: isolate; } .overlay { height: 80px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); mix-blend-mode: multiply; } .card p { font-size: 12px; color: #334155; padding: 0 12px; line-height: 1.5; margin: 8px 0 0; } `} height={240} /> ## AIがよくやるミス - **ブレンドモードの代わりに `rgba(0,0,0,0.5)` オーバーレイを使う** — 半透明の黒いオーバーレイは画像を均一にフラットにします。`multiply` はコントラストと色のバリエーションを保持します。 - **`isolation: isolate` を忘れる** — これがないと、ブレンドモードがDOMを上に伝播し、関係のない背景とブレンドして予期しない結果を生みます。 - **`mix-blend-mode` と `background-blend-mode` を混同する** — `mix-blend-mode` は要素をその背後にあるものとブレンドします。`background-blend-mode` は要素自身の背景レイヤー同士をブレンドします。 - **見えない要素にブレンドモードを使う** — `opacity: 0` や `display: none` の要素に `mix-blend-mode` を設定しても効果はなく、無駄なコードになります。 - **テキストの可読性を考慮しない** — `difference` や `exclusion` のようなブレンドモードはテキストのコントラストが予測できません。常に背景のライト領域とダーク領域の両方でテストしましょう。 - **インタラクティブ要素にブレンドモードを適用する** — ブレンドモードにより、ボタンやリンクの色が背景によって予測不能になり、アクセシビリティを損なう可能性があります。 ## 使い分け - **画像オーバーレイ** — コントラストをフラットにせずにヒーロー画像を暗くしたり色調補正する場合 - **デュオトーンとカラーエフェクト** — `background-blend-mode` で色と `luminosity` または `color` モードを使い、Instagramのような画像処理を行う場合 - **ノックアウトテキスト** — `screen` を使ってテキスト形状から背景テクスチャを透かして見せる場合 - **適応テキスト** — `difference` を使って、画像のライト・ダーク両方の領域で読みやすいテキストを表示する場合 - **装飾的な背景** — 画像なしで複数のグラデーションレイヤーをブレンドしてリッチなパターンを作る場合 ## 参考リンク - [mix-blend-mode — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/mix-blend-mode) - [background-blend-mode — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/background-blend-mode) - [Blending Modes in CSS — Ahmad Shadeed](https://ishadeed.com/article/blending-modes-css/) - [Blend Modes — web.dev](https://web.dev/learn/css/blend-modes) - [Creative Text Styling with mix-blend-mode — LogRocket](https://blog.logrocket.com/creative-text-styling-with-the-css-mix-blend-mode-property/) --- # スムーズなシャドウトランジション > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/shadows-and-borders/smooth-shadow-transitions ## 問題 カードのホバーエフェクトでは、`box-shadow` を変更して要素を浮き上がらせるのが一般的です。AIエージェントは素朴に `transition: box-shadow 0.3s ease` と書きますが、これは視覚的には機能するものの、アニメーションの各フレームでコストの高いリペイントを発生させます。`box-shadow` はコンポジターフレンドリーなプロパティではないため、ブラウザはフレームごとにシャドウのピクセルを再計算してリペイントする必要があります。インタラクティブなカードが多いページでは、特に低スペックのデバイスで目に見えるフレーム落ちやジャンクを引き起こします。 ## 解決方法 `box-shadow` を直接トランジションする代わりに、重いシャドウを擬似要素(`::after`)に配置し、その `opacity` のみをトランジションします。`opacity` はレイアウトやペイントを発生させずにGPUコンポジターで完全に処理されるため、シャドウの複雑さに関係なくアニメーションは滑らかな60 FPSで動作します。 個々のシャドウパラメータ(ブラー、スプレッド、色)を独立してアニメーションする必要がある場合は、`@property` ルールを使うことで、ブラウザにカスタムプロパティを型付きのアニメーション可能な値として扱わせることができます。 ## コード例 ### 素朴な(コストの高い)アプローチ これは機能しますが、`box-shadow` の変更がフレームごとにペイントを発生させるため、パフォーマンスが悪いです。 ```css /* Avoid this for performance-critical animations */ .card-naive { box-shadow: 0 1px 2px hsl(0deg 0% 0% / 0.1); transition: box-shadow 0.3s ease; } .card-naive:hover { box-shadow: 0 4px 8px hsl(0deg 0% 0% / 0.1), 0 16px 32px hsl(0deg 0% 0% / 0.08); } ``` ### パフォーマンスの良いアプローチ:擬似要素のオパシティ ```css .card { position: relative; border-radius: 12px; background: white; /* Base shadow — always visible */ box-shadow: 0 1px 2px hsl(0deg 0% 0% / 0.1); } .card::after { content: ""; position: absolute; inset: 0; border-radius: inherit; /* Hover shadow — pre-rendered but invisible */ box-shadow: 0 4px 8px hsl(220deg 60% 50% / 0.08), 0 12px 24px hsl(220deg 60% 50% / 0.06), 0 24px 48px hsl(220deg 60% 50% / 0.04); opacity: 0; transition: opacity 0.3s ease; /* Keep pseudo-element behind content */ z-index: -1; } .card:hover::after { opacity: 1; } ``` ```html Performant Shadow Hover Shadow transitions via opacity, not box-shadow. ``` ブラウザは両方のシャドウを一度だけレンダリングし(完全な値で)、ホバー時に擬似要素のオパシティをフェードするだけです。リペイントは不要で、コンポジティングのみです。 ### リフトエフェクト付きの完全なカード 擬似要素のシャドウと微妙な `transform: translateY()` を組み合わせると、説得力のある「ページから浮き上がる」ホバーエフェクトになります。 ```css .lift-card { position: relative; border-radius: 12px; background: white; box-shadow: 0 1px 1px hsl(220deg 60% 50% / 0.06), 0 2px 4px hsl(220deg 60% 50% / 0.06); transition: transform 0.3s ease; } .lift-card::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 2px 4px hsl(220deg 60% 50% / 0.05), 0 8px 16px hsl(220deg 60% 50% / 0.05), 0 16px 32px hsl(220deg 60% 50% / 0.05), 0 32px 64px hsl(220deg 60% 50% / 0.04); opacity: 0; transition: opacity 0.3s ease; z-index: -1; } .lift-card:hover { transform: translateY(-4px); } .lift-card:hover::after { opacity: 1; } ``` `transform` と `opacity` はどちらもコンポジターフレンドリーなため、このホバーエフェクト全体がレイアウトやペイントなしで動作します。 ### @property トリックによる個別シャドウパラメータ きめ細かい制御が必要な場合 — 例えば、シャドウのブラーや色だけを独立してアニメーションする場合 — `@property` を使って型付きのカスタムプロパティを作成し、ブラウザが補間できるようにします。 ```css @property --shadow-blur { syntax: ""; inherits: false; initial-value: 2px; } @property --shadow-y { syntax: ""; inherits: false; initial-value: 1px; } @property --shadow-color { syntax: ""; inherits: false; initial-value: hsl(220deg 60% 50% / 0.1); } .card-property { box-shadow: 0 var(--shadow-y) var(--shadow-blur) var(--shadow-color); transition: --shadow-blur 0.3s ease, --shadow-y 0.3s ease, --shadow-color 0.5s ease; } .card-property:hover { --shadow-blur: 24px; --shadow-y: 12px; --shadow-color: hsl(220deg 60% 50% / 0.2); } ``` これにより各シャドウパラメータが独自のタイミングでトランジションします。ブラウザはフレームごとにリペイントしますが(素朴なアプローチと同様)、個々のパラメータに対する精密な制御が得られます。パフォーマンスよりもクリエイティブな制御が重要な場合、例えば単一のヒーロー要素などに使いましょう。 ### 3つのアプローチの比較 ```css /* 1. Naive — simple but expensive */ .approach-naive { box-shadow: 0 2px 4px hsl(0deg 0% 0% / 0.1); transition: box-shadow 0.3s; } /* 2. Pseudo-element opacity — performant */ .approach-pseudo { position: relative; box-shadow: 0 2px 4px hsl(0deg 0% 0% / 0.1); } .approach-pseudo::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 12px 24px hsl(0deg 0% 0% / 0.15); opacity: 0; transition: opacity 0.3s; z-index: -1; } /* 3. @property — creative control, moderate performance */ @property --blur { syntax: ""; inherits: false; initial-value: 4px; } .approach-property { box-shadow: 0 2px var(--blur) hsl(0deg 0% 0% / 0.1); transition: --blur 0.3s; } .approach-property:hover { --blur: 24px; } ``` ## ライブプレビュー Naive ApproachTransitions box-shadow directly (triggers repaint every frame)Hover to see effectPerformant ApproachTransitions pseudo-element opacity (GPU compositing only)Hover to see effect`} css={` .demo { display: flex; gap: 24px; justify-content: center; align-items: center; height: 100%; background: #f1f5f9; padding: 24px; font-family: system-ui, sans-serif; } .card { padding: 24px; border-radius: 12px; background: white; width: 220px; cursor: pointer; } .card h3 { font-size: 15px; font-weight: 600; margin: 0 0 8px; color: #0f172a; } .card p { font-size: 13px; color: #64748b; margin: 0 0 12px; line-height: 1.5; } .hint { font-size: 12px; color: #3b82f6; font-weight: 500; } .naive { box-shadow: 0 1px 3px hsl(0deg 0% 0% / 0.1); transition: box-shadow 0.3s ease; } .naive:hover { box-shadow: 0 4px 8px hsl(0deg 0% 0% / 0.1), 0 16px 32px hsl(0deg 0% 0% / 0.08); } .performant { position: relative; box-shadow: 0 1px 3px hsl(0deg 0% 0% / 0.1); transition: transform 0.3s ease; } .performant::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 4px 8px hsl(220deg 60% 50% / 0.08), 0 12px 24px hsl(220deg 60% 50% / 0.06), 0 24px 48px hsl(220deg 60% 50% / 0.04); opacity: 0; transition: opacity 0.3s ease; z-index: -1; } .performant:hover { transform: translateY(-2px); } .performant:hover::after { opacity: 1; } `} height={280} /> ## AIがよくやるミス - **常に `transition: box-shadow` を使う** — これは最もよくある間違いです。視覚的には機能しますが、ホバー可能なカードが多いページではブラウザがフレームごとにシャドウをリペイントするためジャンクが発生します。 - **親に `position: relative` を忘れる** — 擬似要素はアンカーとなる位置指定された親が必要です。これがないと、シャドウが予期しない位置にレンダリングされます。 - **`border-radius: inherit` がない** — 擬似要素はデフォルトでは border-radius を継承しません。これがないと、ホバーシャドウは角が四角で、カードは角丸になります。 - **擬似要素に `z-index: -1` を忘れる** — 負の z-index がないと、擬似要素のシャドウがカードコンテンツの上に表示され、テキストの選択やクリックイベントをブロックします。 - **`will-change: box-shadow` を使う** — これは助けになりません。`will-change` は要素を独自のコンポジターレイヤーに昇格させますが、`box-shadow` の変更はそのレイヤー内でリペイントが必要です。擬似要素のオパシティトリックが正しい解決策です。 - **型付きカスタムプロパティに `@property` を使わない** — 標準の `--custom-properties` は文字列として扱われ、補間できません。アニメーション可能なカスタムプロパティには、明示的な `syntax` を持つ `@property` が必要です。 - **頻繁にアニメーションされる要素に過度に複雑なシャドウを使う** — オパシティトリックを使っても、多くの要素で同時に非常に複雑なシャドウ(6レイヤー以上)をレンダリングすると、初期ペイントのパフォーマンスに影響する可能性があります。 ## 使い分け - **カードのホバーエフェクト** — ホバー可能なカードグリッドには擬似要素のオパシティトリックが標準アプローチ - **インタラクティブなリストやテーブル** — パフォーマンスコストなしでシャドウを変更する行のホバーエフェクト - **クリエイティブなシャドウを持つヒーロー要素** — 精密なパラメータアニメーションが重要な単一要素には `@property` トリック - **`transition: box-shadow` を持つ任意の要素** — パフォーマンスが重要なコンテキストでは擬似要素テクニックに置き換えましょう ## 参考リンク - [How to Animate Box-Shadow with Silky Smooth Performance — Tobias Ahlin](https://tobiasahlin.com/blog/how-to-animate-box-shadow/) - [Box-Shadow Transition Performance — Cloud 66](https://blog.cloud66.com/box-shadow-transition-performance) - [Exploring @property and Its Animating Powers — CSS-Tricks](https://css-tricks.com/exploring-property-and-its-animating-powers/) - [The Times You Need a Custom @property Instead of a CSS Variable — Smashing Magazine](https://www.smashingmagazine.com/2024/05/times-need-custom-property-instead-css-variable/) - [box-shadow — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow) --- # 画面幅ベースのフォントサイズ定義 > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/font-sizing/screen-width-based-font-size ## 問題 基本的な`clamp()`タイポグラフィは、ビューポート範囲**全体**にわたってフォントサイズを線形にスケーリングします。シンプルなケースではこれで十分ですが、特定のブレークポイント範囲内でテキストのスケーリングを精密に制御したい場合には不十分です。たとえば、サイトロゴに次のような要件があるとします: - `lg`ブレークポイント範囲(1024-1280px)では15-24px - `xl`範囲(1280-1536px)では24-30px - `2xl`範囲(1536-1920px)では30-36px 単一の`clamp()`ではこの区間ごとのスケーリングを表現できません。メディアクエリで固定サイズを指定すると急激な変化が生じ、単一のclampでは各ブレークポイント境界で正確なサイズを実現できません。 ## 解決方法 **ブレークポイントごとのメディアクエリ**と**範囲ごとの`clamp()`値**を組み合わせます。各ブレークポイントが独自の`clamp()`を持ち、その範囲内で滑らかにスケーリングします。範囲同士は、一つの範囲の最大値が次の範囲の最小値と一致するようにつなぎ合わせます。 この記事は[clamp()を使った流体フォントサイズ](../fluid-font-sizing)の内容を前提としています。基本的な`clamp()`の使い方を理解してから読み進めてください。 ### 計算式 ブレークポイント範囲`startVw`から`endVw`で、フォントサイズを`minSize`から`maxSize`にスケーリングする場合: ``` slope = (maxSize - minSize) / (endVw - startVw) intercept = minSize - slope × startVw ``` これにより次のように記述できます: ```css font-size: clamp(minSize, calc(intercept + slope × 1vw), maxSize); ``` たとえば、1024px-1280pxの範囲で15px→24pxにスケーリングする場合: ``` slope = (24 - 15) / (1280 - 1024) = 9 / 256 ≈ 0.03516 (3.516vw) intercept = 15 - 0.03516 × 1024 = 15 - 36 ≈ -21px ``` 結果:`clamp(15px, calc(-21px + 3.516vw), 24px)` **検算:** 1024pxのとき → `calc(-21 + 0.03516 × 1024)` = `calc(-21 + 36)` = 15px。1280pxのとき → `calc(-21 + 0.03516 × 1280)` = `calc(-21 + 45)` = 24px。 ## コード例 ### 基本的な区間別フォントサイズ ```css .site-title { /* Base: fixed size for small screens */ font-size: 15px; } /* lg: 1024px – 1280px → scale 15px to 24px */ @media (min-width: 1024px) { .site-title { font-size: clamp(15px, calc(-21px + 3.516vw), 24px); } } /* xl: 1280px – 1536px → scale 24px to 30px */ @media (min-width: 1280px) { .site-title { font-size: clamp(24px, calc(-6px + 2.344vw), 30px); } } /* 2xl: 1536px – 1920px → scale 30px to 36px */ @media (min-width: 1536px) { .site-title { font-size: clamp(30px, calc(6px + 1.563vw), 36px); } } ``` 注意:以下のデモではプレビューiframe内で流体スケーリングが見えるよう、縮小したブレークポイント(320px / 500px / 768px)を使用しています。上記のコード例は本番環境に適したブレークポイントを示しています。 Site Title (segmented clamp) Takazudo Modular This title uses different clamp() values at each breakpoint range, giving precise control over scaling behavior. Base (<320px) 14px fixed 320–500px 14px → 20px 500–768px 20px → 28px 768px+ 28px → 36px `} css={`.seg-demo { padding: 2rem; font-family: system-ui, sans-serif; } .seg-demo__label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(0 0% 40%); margin-bottom: 0.25rem; } .seg-demo__title { font-size: 14px; font-weight: 700; line-height: 1.2; margin: 0 0 1rem; color: hsl(220 30% 15%); } @media (min-width: 320px) { .seg-demo__title { font-size: clamp(14px, calc(3.3px + 3.333vw), 20px); } } @media (min-width: 500px) { .seg-demo__title { font-size: clamp(20px, calc(5.1px + 2.985vw), 28px); } } @media (min-width: 768px) { .seg-demo__title { font-size: clamp(28px, calc(4px + 3.125vw), 36px); } } .seg-demo__info { margin-bottom: 1.5rem; } .seg-demo__info p { font-size: 0.875rem; line-height: 1.6; color: hsl(0 0% 35%); margin: 0; } .seg-demo__info code { background: hsl(220 15% 94%); padding: 0.15em 0.35em; border-radius: 3px; font-size: 0.85em; } .seg-demo__comparison { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 0.75rem; } .seg-demo__card { background: hsl(220 15% 96%); border-radius: 6px; padding: 0.75rem 1rem; } .seg-demo__card-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; color: hsl(0 0% 40%); margin-bottom: 0.25rem; } .seg-demo__card-value { font-size: 0.9rem; font-weight: 600; color: hsl(220 50% 40%); }`} height={320} /> ### デザイントークンシステムによる区間別スケーリング 各フォントサイズステップがブレークポイントごとに独立してスケーリングする、再利用可能なトークンシステムを定義します: ```css :root { /* Small screens: fixed base sizes */ --font-size-display: 24px; --font-size-title: 20px; --font-size-heading: 18px; --font-size-body: 16px; } /* lg: 1024px – 1280px */ @media (min-width: 1024px) { :root { --font-size-display: clamp(28px, calc(-4px + 3.125vw), 36px); --font-size-title: clamp(22px, calc(-2px + 2.344vw), 28px); --font-size-heading: clamp(18px, calc(2px + 1.563vw), 22px); --font-size-body: clamp(16px, calc(8px + 0.781vw), 18px); } } /* xl: 1280px – 1536px */ @media (min-width: 1280px) { :root { --font-size-display: clamp(36px, calc(-24px + 4.688vw), 48px); --font-size-title: clamp(28px, calc(-12px + 3.125vw), 36px); --font-size-heading: clamp(22px, calc(2px + 1.563vw), 26px); --font-size-body: clamp(18px, calc(8px + 0.781vw), 20px); } } ``` --font-size-display Display Text --font-size-title Title Text --font-size-heading Heading Text --font-size-body Body text that uses the base font size token for comfortable reading across all viewport sizes. `} css={`:root { --font-size-display: 24px; --font-size-title: 20px; --font-size-heading: 18px; --font-size-body: 16px; } @media (min-width: 320px) { :root { --font-size-display: clamp(24px, calc(10.7px + 4.167vw), 32px); --font-size-title: clamp(20px, calc(11.1px + 2.778vw), 26px); --font-size-heading: clamp(18px, calc(12.7px + 1.667vw), 22px); --font-size-body: clamp(16px, calc(13.3px + 0.833vw), 18px); } } @media (min-width: 500px) { :root { --font-size-display: clamp(32px, calc(-0.9px + 6.343vw), 50px); --font-size-title: clamp(26px, calc(3.8px + 4.478vw), 38px); --font-size-heading: clamp(22px, calc(7.1px + 2.985vw), 30px); --font-size-body: clamp(18px, calc(11.3px + 1.493vw), 22px); } } .token-demo { padding: 2rem; font-family: system-ui, sans-serif; } .token-demo__scale { display: flex; flex-direction: column; gap: 1.25rem; } .token-demo__item { display: flex; flex-direction: column; gap: 0.25rem; padding-bottom: 1.25rem; border-bottom: 1px solid hsl(0 0% 90%); } .token-demo__item:last-child { border-bottom: none; padding-bottom: 0; } .token-demo__label { font-size: 0.75rem; font-family: monospace; color: hsl(220 50% 50%); letter-spacing: 0.02em; } .token-demo__text { color: hsl(220 30% 15%); font-weight: 600; line-height: 1.3; } .token-demo__text--display { font-size: var(--font-size-display); } .token-demo__text--title { font-size: var(--font-size-title); } .token-demo__text--heading { font-size: var(--font-size-heading); } .token-demo__text--body { font-size: var(--font-size-body); font-weight: 400; line-height: 1.6; }`} height={400} /> ### 任意の範囲に対するclamp()値の計算方法 任意のビューポート範囲に対する`clamp()`のpreferred値を計算するステップバイステップの例です。 **目標:** ビューポート1024pxで22px、1280pxで28pxにフォントをスケーリングする。 ``` Step 1: Calculate the slope slope = (28 - 22) / (1280 - 1024) = 6 / 256 ≈ 0.02344 Step 2: Calculate the intercept (base offset in px) intercept = minSize - slope × startVw = 22 - 0.02344 × 1024 ≈ 22 - 24 = -2px Step 3: Assemble the clamp() font-size: clamp(22px, calc(-2px + 2.344vw), 28px); ``` **検算:** ビューポート1024pxのとき → `calc(-2 + 0.02344 × 1024)` = `calc(-2 + 24)` = 22px。1280pxのとき → `calc(-2 + 0.02344 × 1280)` = `calc(-2 + 30)` = 28px。 ### ページヘッダーの実例 サイトヘッダーのロゴテキストが全ブレークポイントで滑らかにスケーリングする実践的な例です: Project Dashboard Docs Blog About Welcome The header logo text above scales smoothly within each breakpoint range using segmented clamp() values. Try switching between Mobile and Full viewports to see the difference. `} css={`.header-demo__inner { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.5rem; background: hsl(220 25% 18%); font-family: system-ui, sans-serif; } .header-demo__logo { display: flex; align-items: center; gap: 0.5rem; } .header-demo__icon { width: 1.5em; height: 1.5em; color: hsl(35 90% 55%); flex-shrink: 0; } .header-demo__title { font-size: 14px; font-weight: 700; color: hsl(0 0% 100%); white-space: nowrap; } @media (min-width: 320px) { .header-demo__title { font-size: clamp(14px, calc(3.3px + 3.333vw), 20px); } } @media (min-width: 500px) { .header-demo__title { font-size: clamp(20px, calc(5.1px + 2.985vw), 28px); } } @media (min-width: 768px) { .header-demo__title { font-size: clamp(28px, calc(4px + 3.125vw), 36px); } } .header-demo__nav { display: flex; gap: 1rem; } .header-demo__link { font-size: 0.875rem; color: hsl(0 0% 75%); text-decoration: none; } .header-demo__link:hover { color: hsl(0 0% 100%); } .header-demo__main { padding: 2rem 1.5rem; font-family: system-ui, sans-serif; } .header-demo__heading { font-size: 20px; font-weight: 700; color: hsl(220 30% 15%); margin: 0 0 0.75rem; } @media (min-width: 500px) { .header-demo__heading { font-size: clamp(20px, calc(5.1px + 2.985vw), 28px); } } .header-demo__body { font-size: 1rem; line-height: 1.6; color: hsl(0 0% 35%); margin: 0; max-width: 60ch; }`} height={280} /> ## AIがよくやるミス - ブレークポイントごとの精密な制御が必要な場面で、ビューポート範囲全体に単一の`clamp()`を使ってしまう -- 範囲ごとにスケーリング速度を調整できません - ある範囲の`max`と次の範囲の`min`を揃え忘れ、ブレークポイント境界で目に見えるジャンプが発生する(例:lgの最大値が24pxなのにxlの最小値が26pxだと2pxのジャンプが生じます) - 計算式を間違える -- 分母は**ビューポート範囲**`(endVw - startVw)`であり、`(endVw - minSize)`ではありません。ビューポート幅とフォントサイズを混同すると、傾きが不正確になります - `calc()`オフセットなしで`vw`のみを使う -- `font-size: 2vw`はビューポート1000pxで20pxになりますが、開始位置と停止位置を制御できません - 本文テキストや小さなUIラベルに区間別clampを適用する -- このテクニックはディスプレイテキストやタイトルに最適であり、単一の`clamp()`や固定サイズで十分な場合には不要です - 計算を検算しない -- `calc(intercept + slope × startVw)`が意図した最小サイズと等しくなることを必ず確認してください ## 使用場面 - **サイトロゴやブランドテキスト** -- 特定のブレークポイントで正確なサイズにする必要がある場合 - **デザイントークンのフォントサイズ定義** -- 各トークンステップが独立したスケーリング動作を必要とする場合 - **ディスプレイ見出し** -- ヒーローセクションでビューポート範囲ごとに異なるスケーリング速度が必要な場合 - **あらゆる要素** -- 単一の`clamp()`ではスケーリングカーブを十分に制御できない場合 以下の場合は単一の`clamp()`([clamp()を使った流体フォントサイズ](../fluid-font-sizing)を参照)を使いましょう: - スケーリング範囲がシンプルな場合(全ビューポートで一つのmin→一つのmax) - ブレークポイント境界でのピクセル単位の精密な制御が不要な場合 - シンプルなレスポンシブテキストに最小限のCSSで対応したい場合 ## 参考リンク - [MDN: clamp()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/clamp) - [Linearly Scale font-size with CSS clamp() -- CSS-Tricks](https://css-tricks.com/linearly-scale-font-size-with-css-clamp-based-on-the-viewport/) - [Modern Fluid Typography Using CSS Clamp -- Smashing Magazine](https://www.smashingmagazine.com/2022/01/modern-fluid-typography-css-clamp/) - [Fluid Type Scale Calculator](https://www.fluid-type-scale.com/) --- # 日本語フォントファミリーの指定 > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/fonts/japanese-font-family ## 問題 日本語Webサイトの`font-family`指定は、ラテン文字サイトと比べて格段に複雑です。`sans-serif`と書くだけでは不十分で、ブラウザはOSによって大きく異なるシステムのデフォルトにフォールバックし、古いOSではビットマップフォントで日本語が表示されることもあります。主な課題は以下の通りです: 1. **プラットフォームの分断**: macOSにはヒラギノフォントが、Windows 10以降には游ゴシックが、Linux/Androidにはほぼ Noto Sans CJK が搭載されています。良い表示結果を得るには、各プラットフォームのフォント名を明示的に指定する必要があります。 2. **日本語フォントにはラテングリフが含まれる**: 日本語フォントにはすべてASCII文字の独自グリフが含まれています。日本語フォントを先に指定すると、英数字や記号がすべて日本語フォントのラテン字形でレンダリングされます。これは専用のラテン書体と比べて一般的に幅が広く、精度も劣ります。 3. **游ゴシックのウェイト表示バグ**: Windows上の游ゴシックは、Windowsのフォントマッピングの仕様により`font-weight: normal`で極細のストロークウェイトでレンダリングされます。回避策なしでは、日本語テキストが不自然に細く表示されます。 4. **Webフォントのファイルサイズ**: 日本語の文字セットには数千のグリフが含まれます。Noto Sans JPは非圧縮で約9MB(TTF)にもなります。WOFF2でも全文字セットは数MB以上になり、サブセット化やバリアブルフォント戦略なしでは実用的なWebフォントとして使えません。 ## 解決方法 現代的なアプローチでは、順番を慎重に検討したシステムフォントスタックを使います。順序の原則は次の通りです: 1. 欧文Webフォント(ある場合)を先頭に — 英語テキストが意図した書体で表示されるよう 2. 日本語システムフォントをプラットフォームごとに 3. 最終フォールバックとして`sans-serif` ### プラットフォーム別フォント一覧 | プラットフォーム | 推奨フォント | 備考 | |---|---|---| | macOS / iOS | `"Hiragino Sans"` | macOS 10.11 El Capitan以降のデフォルト。10段階のウェイト | | macOS(互換用) | `"Hiragino Kaku Gothic ProN"` | 古いが広くサポートされている。2ウェイトのみ | | Windows 10+ | `"Yu Gothic UI"` | ウェイトマッピングが正しいUIバリアント | | Windows 10(フォールバック) | `"Yu Gothic"` | オリジナル。デフォルトウェイトで細く表示される | | Windows Vista〜8.1 | `"Meiryo"` | レガシー。現在はデフォルトではないため、古いOS対応用として含める | | Linux / Android | `"Noto Sans CJK JP"` | オープンソース。ほとんどのLinuxディストリビューションに収録 | | Android(Google Fonts) | `"Noto Sans JP"` | Android上やGoogle Fonts経由で使われるサブセット版 | ### 推奨スタック ```css body { font-family: /* 1. 欧文Webフォント(@font-faceやGoogle Fonts経由で読み込んでいる場合) */ 'Inter', /* 2. macOS / iOS — モダンなヒラギノ(10ウェイト) */ 'Hiragino Sans', /* 3. Windows 10+ — "Yu Gothic UI"がウェイト表示バグを修正 */ 'Yu Gothic UI', 'Yu Gothic', /* 4. 古いmacOS / iOSの互換用 */ 'Hiragino Kaku Gothic ProN', /* 5. Linux / Android */ 'Noto Sans CJK JP', 'Noto Sans JP', /* 6. 最終フォールバック */ sans-serif; } ``` ### 欧文Webフォントを使わない場合 欧文Webフォントを読み込まない場合は、ステップ1を省略します。ブラウザは最初に解決された日本語フォントのラテングリフを使用します。これはブランドタイポグラフィとして理想的ではありませんが、許容範囲内です: ```css body { font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', 'Noto Sans JP', sans-serif; } ``` ### 游ゴシックのウェイト修正 `"Yu Gothic UI"`はWindows 10以降に搭載された游ゴシックのUI最適化バリアントです。`font-weight`の値を正しくマッピングするため、`font-weight: 400`で読みやすいストロークウェイトでレンダリングされます。オリジナルの`"Yu Gothic"`は700未満のウェイトをすべて最も細いバリアントにマッピングするため、本文テキストが極細になってしまいます。 スタックでは必ず`"Yu Gothic UI"`を`"Yu Gothic"`より前に記述してください: ```css /* 誤り — WindowsでYu Gothicがウェイト400で細く表示される */ font-family: 'Hiragino Sans', 'Yu Gothic', sans-serif; /* 正しい — Yu Gothic UIはウェイトマッピングが正確 */ font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', sans-serif; ``` ## コード例 ### 推奨スタックの動作確認 font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Noto Sans CJK JP', sans-serif 日本語のWebサイトにおけるフォント指定は、OSによって使用できるフォントが異なるため、複数のフォントをフォールバックとして指定する必要があります。 Mixed text: CSS font-family for Japanese (日本語) websites in 2025. Latin only: The quick brown fox jumps over the lazy dog. 0123456789. `} css={`.font-demo { padding: 1.5rem; font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', 'Noto Sans JP', sans-serif; line-height: 1.8; } .font-demo__label { font-size: 0.7rem; color: hsl(220, 10%, 55%); background: hsl(220, 15%, 95%); border-radius: 4px; padding: 0.5rem 0.75rem; margin: 0 0 1rem; font-family: monospace; line-height: 1.4; } .font-demo__ja { font-size: 1rem; color: hsl(220, 20%, 15%); margin: 0 0 0.75rem; } .font-demo__mix { font-size: 1rem; color: hsl(220, 20%, 25%); margin: 0 0 0.75rem; } .font-demo__en { font-size: 1rem; color: hsl(220, 20%, 35%); margin: 0; }`} /> ### 欧文フォントの順序: 日本語フォントより前か後か 日本語フォントには独自のラテングリフが含まれています。日本語フォントを欧文フォントより前に指定すると、すべてのASCII文字が日本語フォントのラテン字形でレンダリングされます。これは一般的に幅が広く、スペーシングやストロークコントラストも異なります。欧文フォントは必ず先頭に置いてください。 Wrong order Hiragino Sans, system-ui, sans-serif CSS font-family 2025 ABCDEFabcdef 0123456789 日本語テキスト Latin characters rendered in Japanese font's Latin variant — wider proportions, different stroke contrast Correct order system-ui, Hiragino Sans, sans-serif CSS font-family 2025 ABCDEFabcdef 0123456789 日本語テキスト Latin characters use system-ui (San Francisco / Segoe UI), Japanese uses Hiragino `} css={`.ordering-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1.25rem; } .ordering-demo__col { border-radius: 8px; padding: 1rem; } .ordering-demo__col--wrong { background: hsl(0, 15%, 96%); border: 1px solid hsl(0, 25%, 88%); } .ordering-demo__col--correct { background: hsl(140, 15%, 95%); border: 1px solid hsl(140, 25%, 82%); } .ordering-demo__badge { display: inline-block; font-size: 0.7rem; font-weight: 700; padding: 0.2rem 0.5rem; border-radius: 3px; margin-bottom: 0.5rem; font-family: system-ui, sans-serif; } .ordering-demo__badge--wrong { background: hsl(0, 70%, 90%); color: hsl(0, 60%, 35%); } .ordering-demo__badge--correct { background: hsl(140, 55%, 85%); color: hsl(140, 50%, 28%); } .ordering-demo__stack-label { font-size: 0.65rem; font-family: monospace; color: hsl(220, 10%, 50%); margin: 0 0 0.75rem; line-height: 1.4; } .ordering-demo__text { font-size: 1.1rem; line-height: 1.9; color: hsl(220, 20%, 15%); margin: 0 0 0.75rem; } .ordering-demo__text--wrong-order { font-family: 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', system-ui, sans-serif; } .ordering-demo__text--correct-order { font-family: system-ui, -apple-system, 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Noto Sans CJK JP', sans-serif; } .ordering-demo__note { font-size: 0.72rem; color: hsl(220, 15%, 50%); line-height: 1.5; margin: 0; font-family: system-ui, sans-serif; }`} /> ### 日本語テキストのフォントウェイトバリエーション 日本語フォントは欧文フォントと同じ`font-weight`スケールをサポートしますが、動作はOSのフォントと利用可能なウェイトによって異なります。ヒラギノSansは10段階のウェイト(W0〜W9)を提供しますが、ヒラギノ角ゴシックProNは2段階(W3、W6)のみです。 300 フォントウェイト Light — The quick brown fox jumps over the lazy dog 400 フォントウェイト Regular — The quick brown fox jumps over the lazy dog 500 フォントウェイト Medium — The quick brown fox jumps over the lazy dog 600 フォントウェイト SemiBold — The quick brown fox jumps over the lazy dog 700 フォントウェイト Bold — The quick brown fox jumps over the lazy dog 900 フォントウェイト Black — The quick brown fox jumps over the lazy dog `} css={`.weight-demo { padding: 1rem 1.5rem; font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Noto Sans CJK JP', 'Noto Sans JP', sans-serif; } .weight-demo__row { display: flex; align-items: baseline; gap: 1rem; border-bottom: 1px solid hsl(220, 15%, 92%); padding: 0.4rem 0; } .weight-demo__row:last-child { border-bottom: none; } .weight-demo__label { font-size: 0.7rem; font-family: monospace; color: hsl(220, 15%, 55%); min-width: 2.5rem; flex-shrink: 0; } .weight-demo__text { font-size: 0.95rem; line-height: 1.5; color: hsl(220, 20%, 15%); margin: 0; }`} /> ### 包括的なシステムスタック vs 最小限のフォールバック このデモは、包括的なシステムスタックと`sans-serif`だけに頼った場合の違いを示します。ほとんどのモダンデスクトップブラウザでは結果が似て見えることもありますが、CJKフォントの設定が不十分なLinuxシステムでは、`sans-serif`がビットマップや形の悪いフォントにフォールバックすることがあります。 Recommended stack 日本語のWebサイトでは、各OSに対応したフォントを明示的に指定することが重要です。 春はあけぼの。やうやう白くなりゆく山際、少し明かりて、紫だちたる雲の細くたなびきたる。 sans-serif only 日本語のWebサイトでは、各OSに対応したフォントを明示的に指定することが重要です。 春はあけぼの。やうやう白くなりゆく山際、少し明かりて、紫だちたる雲の細くたなびきたる。 `} css={`.stack-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: hsl(220, 15%, 88%); } .stack-compare__col { background: hsl(0, 0%, 100%); padding: 1.25rem; } .stack-compare__header { font-size: 0.7rem; font-weight: 700; font-family: monospace; color: hsl(220, 30%, 50%); margin-bottom: 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid hsl(220, 15%, 90%); } .stack-compare__text { font-size: 1rem; line-height: 1.8; color: hsl(220, 20%, 15%); margin: 0 0 0.75rem; } .stack-compare__text--full { font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', 'Noto Sans JP', sans-serif; } .stack-compare__text--minimal { font-family: sans-serif; } .stack-compare__subtext { font-size: 0.85rem; line-height: 1.8; color: hsl(220, 15%, 45%); margin: 0; font-family: 'Hiragino Sans', 'Yu Gothic UI', 'Yu Gothic', 'Hiragino Kaku Gothic ProN', 'Noto Sans CJK JP', 'Noto Sans JP', sans-serif; } .stack-compare__subtext--minimal { font-family: sans-serif; }`} /> ## AIがよくやるミス - **日本語システムフォントをまったく省略する** — `system-ui, sans-serif`のみを使い、日本語フォント名をまったく指定しない。フォント選択がブラウザのデフォルトマッピングに完全に委ねられ、プラットフォーム間で一貫性のない結果になる - **日本語フォントを欧文フォントより前に並べる** — 英語テキスト、数字、記号が意図したラテン書体ではなく日本語フォントのラテングリフでレンダリングされる - **`"Yu Gothic UI"`なしで`"Yu Gothic"`を使う** — Windows 10で通常ウェイトのテキストが極細になる。`"Yu Gothic UI"`を必ずスタックの先に置かなければならない - **`"Meiryo"`をWindowsのメインフォントとして含める** — MeiryoはWindows VistaとWindows 7のデフォルトだったが、Windows 10以降は游ゴシックがデフォルト。Meiryoは互換フォールバックとして游ゴシックの後に置くべき - **サブセット化なしで日本語Webフォントを読み込む** — すべてのページでNoto Sans JPなど(9MB以上)をフルでダウンロードする。システムフォントならダウンロードコストゼロで同等の表示が得られる - **日本語テキストに`italic`スタイルを使う** — 日本語フォントには真のイタリック体がない。ブラウザはCSSのobliqueトランスフォームを適用するため、文字が斜めに傾いて不自然に見える - **ヒラギノ角ゴシックProNをmacOSの現行フォントと見なす** — `"Hiragino Sans"`(macOS 10.11で追加)は10段階のウェイトを持つ現代的な後継フォント。ProNの2ウェイトより優れており、常にヒラギノSansを先に並べるべき ## 使い分け **フルシステムフォントスタック**は、次のような日本語Webサイトやアプリケーションで使います: - ページに日本語テキスト(本文、見出し、UIラベル)が含まれる - macOS、Windows、Linux間で一貫したレンダリングが必要 - 日本語Webフォントを読み込まない(パフォーマンスを考慮した一般的なケース) **欧文Webフォントを先頭に含める**のは: - ブランドが特定のラテン書体(Inter、Noto Sans、Robotoなど)を使っている - プラットフォーム間で欧文の一貫したレンダリングが重要 - 他の理由(バリアブルフォントなど)ですでにWebフォントを読み込んでいる **日本語Webフォント**(Noto Sans JP、BIZ UDPGothicなど)を読み込むのは: - プリント品質のデザインシステムなど、クロスプラットフォームでの完全な視覚的一貫性が必要 - ファイルサイズを管理するためのフォントサブセット化またはプログレッシブローディング戦略を採用している - システムフォントでは利用できない特定のグリフスタイルをデザインが要求している ## 参考リンク - [Best Japanese CSS font-family in 2025 — Bloomstreet Japan](https://www.bloomstreetjapan.com/best-japanese-font-setting-for-websites/) - [Japanese web safe fonts and how to use them — JStockMedia](https://jstockmedia.com/blog/japanese-web-safe-fonts-and-how-to-use-them-in-web-design/) - [system-fonts/modern-font-stacks — GitHub](https://github.com/system-fonts/modern-font-stacks) - [Japanese typography on the web — Pavel Laptev / Medium](https://pavellaptev.medium.com/japanese-typography-on-the-web-tips-and-tricks-981f120ad20e) - [MDN: font-family](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family) - [MDN: font-variant-east-asian](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-east-asian) --- # text-wrap: balanceとpretty > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/text-control/text-wrap-balance-pretty ## 問題 見出しや短いテキストブロックは、最終行に1つの単語だけが取り残される(オーファン)ような、不自然な行分割になることがよくあります。開発者は従来、手動の` `文字、明示的な``タグ、またはテキスト幅を計測してリフローするJavaScriptベースの対策で回避してきました。これらのアプローチは脆弱で、ビューポートサイズ、フォントサイズ、言語が異なると壊れます。現在ではCSSが`text-wrap`プロパティでこの問題をネイティブに処理します。 ## 解決方法 `text-wrap`プロパティは、デフォルトの`wrap`に加えて、テキストの改行を細かく制御する3つの値を提供します: - **`balance`** — テキストをすべての行に均等に分配し、特定の行だけが極端に長かったり短かったりしないようにします。見出し、ラベル、短いテキストブロックに最適です。 - **`pretty`** — 段落の最終行にオーファン(孤立した単語)が残るのを防ぎます。ブラウザが前の行の改行位置を調整して、最終行に少なくとも2つの単語が残るようにします。本文テキスト向けに設計されています。 - **`stable`** — 編集可能なコンテンツが変更された際に、すでにレイアウトされた行が再折り返しされないようにします。`contenteditable`領域やライブ編集インターフェースに便利です。 ### 基本原則 #### 見出しや短いテキストには`balance`を使う `text-wrap: balance`は、すべての行をほぼ同じ幅にすることで動作します。ブラウザが最適な改行位置を計算し、最も短い行ができるだけ長くなるようにします。これにより、見出し、プルクオート、キャプションに最適な、視覚的に中央寄りで均等なテキストブロックが作られます。 ただし、パフォーマンス上の理由から、ブラウザはバランシングを約6行までに制限しています。そのしきい値を超えると、テキストは通常の折り返しにフォールバックします。 #### 本文テキストには`pretty`を使う `text-wrap: pretty`は最終行に特化しています。前の行の改行位置を調整して、最終行に1つの単語だけが残ることを防ぎます。`balance`とは異なり、すべての行を均等にしようとはせず、末尾がきれいに見えることだけを保証します。 #### 編集可能なコンテンツには`stable`を使う `text-wrap: stable`は、新しいコンテンツがその後に入力された際に、以前レイアウトされた行がシフトするのを防ぎます。これにより、`contenteditable`要素やテキストエディタでの不快な再折り返し効果を回避できます。 ## コード例 ### 基本的な使い方 ```css h1, h2, h3 { text-wrap: balance; } p { text-wrap: pretty; } ``` ### 本番タイポグラフィシステム ```css /* すべての見出しレベルをバランス */ h1, h2, h3, h4, h5, h6 { text-wrap: balance; } /* 本文テキストのオーファンを防止 */ p, li, blockquote { text-wrap: pretty; } /* 編集可能な領域にstableラッピング */ [contenteditable] { text-wrap: stable; } ``` Default wrapping Designing Accessible User Interfaces for Modern Web Applications text-wrap: balance Designing Accessible User Interfaces for Modern Web Applications `} css={`.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .column { background: hsl(220 30% 96%); border-radius: 8px; padding: 1.25rem; } .label { display: inline-block; font-size: 0.75rem; font-weight: 600; color: hsl(220 60% 50%); background: hsl(220 60% 94%); padding: 0.2rem 0.6rem; border-radius: 4px; margin-bottom: 0.75rem; } .heading-default { font-size: 1.35rem; line-height: 1.3; color: hsl(220 20% 20%); margin: 0; text-wrap: wrap; } .heading-balanced { font-size: 1.35rem; line-height: 1.3; color: hsl(220 20% 20%); margin: 0; text-wrap: balance; }`} /> Default wrapping Performance optimization requires a careful balance between load time and interactivity. Users expect pages to render within two seconds, and every additional resource increases the risk of abandonment. Prioritize critical rendering paths and defer non-essential assets to improve the perceived speed of your application and keep users engaged. text-wrap: pretty Performance optimization requires a careful balance between load time and interactivity. Users expect pages to render within two seconds, and every additional resource increases the risk of abandonment. Prioritize critical rendering paths and defer non-essential assets to improve the perceived speed of your application and keep users engaged. `} css={`.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .column { background: hsl(220 30% 96%); border-radius: 8px; padding: 1.25rem; } .label { display: inline-block; font-size: 0.75rem; font-weight: 600; color: hsl(260 60% 50%); background: hsl(260 60% 94%); padding: 0.2rem 0.6rem; border-radius: 4px; margin-bottom: 0.75rem; } .body-default { font-size: 0.9rem; line-height: 1.6; color: hsl(220 10% 30%); margin: 0; text-wrap: wrap; } .body-pretty { font-size: 0.9rem; line-height: 1.6; color: hsl(220 10% 30%); margin: 0; text-wrap: pretty; }`} /> 4 lines — balance works Building responsive layouts that adapt gracefully to any screen size is one of the most important skills in modern front-end development. Container queries and fluid typography make this easier than ever before. 10+ lines — balance stops working Building responsive layouts that adapt gracefully to any screen size is one of the most important skills in modern front-end development. Container queries and fluid typography make this easier than ever before. Developers should consider the full range of devices their users might have, from narrow mobile screens to ultra-wide desktop monitors. A robust layout system uses relative units, logical properties, and flexible grids rather than fixed pixel values. Testing across multiple viewport sizes during development catches layout issues early and ensures a consistent user experience. Progressive enhancement means the core content remains accessible even when advanced layout features are not supported by the browser. `} css={`.limit-demo { display: flex; flex-direction: column; gap: 1.5rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .block { background: hsl(160 30% 96%); border-radius: 8px; padding: 1.25rem; } .label { display: inline-block; font-size: 0.75rem; font-weight: 600; color: hsl(160 60% 35%); background: hsl(160 50% 90%); padding: 0.2rem 0.6rem; border-radius: 4px; margin-bottom: 0.75rem; } .balanced-text { font-size: 0.9rem; line-height: 1.6; color: hsl(160 10% 25%); margin: 0; text-wrap: balance; }`} /> Getting Started with CSS Container Queries for Responsive Components Container queries let you style elements based on the size of their parent container rather than the viewport. This enables truly reusable, context-aware components. Why Container Queries Matter Traditional media queries respond to the viewport width, which means the same component can look different depending on where it sits in the page layout. A card component in a narrow sidebar needs different styles than the same card in a wide content area, but viewport-based breakpoints cannot distinguish between the two contexts. Container queries solve this by letting the component respond to its own container size. This makes components self-contained and portable across different layout contexts without any modification. `} css={`.article { max-width: 600px; margin: 0 auto; padding: 1.5rem; font-family: system-ui, sans-serif; } .article-title { font-size: 1.5rem; line-height: 1.25; color: hsl(220 25% 15%); margin: 0 0 0.75rem; text-wrap: balance; } .article-lead { font-size: 1.05rem; line-height: 1.5; color: hsl(220 15% 40%); margin: 0 0 1.5rem; text-wrap: pretty; border-left: 3px solid hsl(220 60% 60%); padding-left: 1rem; } .article-heading { font-size: 1.15rem; line-height: 1.3; color: hsl(220 25% 20%); margin: 1.25rem 0 0.5rem; text-wrap: balance; } .article-body { font-size: 0.9rem; line-height: 1.65; color: hsl(220 10% 30%); margin: 0 0 0.75rem; text-wrap: pretty; }`} /> This heading uses text-wrap balance for even line distribution In browsers that do not support text-wrap: balance or pretty, these properties are simply ignored. The text renders with default wrapping behavior — no errors, no broken layouts, just the normal line-breaking you would get without the property. `} css={`.enhancement-demo { padding: 1.5rem; font-family: system-ui, sans-serif; } .card { background: hsl(40 40% 96%); border-radius: 8px; padding: 1.25rem; border: 1px solid hsl(40 30% 88%); } .card-heading { font-size: 1.15rem; line-height: 1.3; color: hsl(40 50% 25%); margin: 0 0 0.75rem; /* Graceful degradation: ignored in unsupported browsers */ text-wrap: balance; } .card-body { font-size: 0.9rem; line-height: 1.6; color: hsl(40 10% 30%); margin: 0; /* Graceful degradation: ignored in unsupported browsers */ text-wrap: pretty; }`} /> ## CJKと日本語テキストにおける注意点 `text-wrap: balance`と`text-wrap: pretty`はアルファベット言語を前提に設計されており、日本語や他のCJK(中国語・日本語・韓国語)テキストには予期しない動作をします。見出しや段落にこれらのプロパティをグローバルに適用すると、日本語テキストが不自然な位置で改行され、右側に大きな余白が生じることがよくあります。 ### なぜ崩れるのか アルファベット言語では、ブラウザはスペースや句読点を使って改行位置を分散させる際の単語境界を識別します。日本語には単語間のスペースがなく、すべての文字位置が有効な改行ポイントになります。`text-wrap: balance`が行の長さを均等にしようとする際、自然な文節の途中で任意の文字位置に改行が入ってしまいます。その結果、見出しの最初の行が文節の途中で終わり、右側に目立つ大きな空白が生じます。 English Default Designing Accessible User Interfaces for Modern Web Applications text-wrap: balance Designing Accessible User Interfaces for Modern Web Applications 日本語 Default モダンウェブ開発のためのアクセシブルなユーザーインターフェース設計 text-wrap: balance モダンウェブ開発のためのアクセシブルなユーザーインターフェース設計 `} css={`.lang-comparison { display: flex; flex-direction: column; gap: 1rem; padding: 1rem 1.25rem; font-family: system-ui, sans-serif; } .lang-block { background: hsl(220 20% 97%); border-radius: 8px; padding: 0.75rem 1rem; } .lang-label { display: inline-block; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; color: hsl(220 50% 45%); background: hsl(220 60% 92%); padding: 0.15rem 0.5rem; border-radius: 3px; margin-bottom: 0.6rem; } .lang-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } .lang-col { background: hsl(0 0% 100%); border-radius: 6px; padding: 0.75rem; border: 1px solid hsl(220 20% 90%); } .variant-label { display: block; font-size: 0.68rem; font-weight: 600; color: hsl(220 30% 55%); margin-bottom: 0.4rem; font-family: monospace; } .heading { font-size: 1rem; line-height: 1.4; color: hsl(220 20% 20%); margin: 0; } .heading--default { text-wrap: wrap; } .heading--balanced { text-wrap: balance; }`} /> 日本語でbalanceを適用したバージョンは、ブラウザが日本語の単語境界を認識しないため、「の」や「な」などの助詞の後など不自然な位置で改行されることがよくあります。改行が恣意的に感じられ、最初の行の右余白が目立つほど大きくなります。 ### word-break: auto-phraseによる回避策 Chrome 119+では`word-break: auto-phrase`が導入されました。これは[BudouX](https://github.com/google/budoux)という機械学習エンジンを使って日本語テキストの自然な文節境界を識別します。`text-wrap: balance`と組み合わせると、ブラウザは任意の文字位置ではなく、その文節境界を基準にバランシングを行います。 ```css h1, h2, h3 { text-wrap: balance; word-break: auto-phrase; /* Chrome 119+ — 日本語の改行を改善 */ } ``` balance only パフォーマンス最適化のためのレンダリングパイプラインの理解と改善手法 balance + auto-phrase パフォーマンス最適化のためのレンダリングパイプラインの理解と改善手法 `} css={`.autophrase-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1.25rem; font-family: system-ui, sans-serif; } .autophrase-col { background: hsl(260 20% 97%); border-radius: 8px; padding: 1rem; border: 1px solid hsl(260 20% 90%); } .autophrase-label { display: block; font-size: 0.7rem; font-weight: 600; color: hsl(260 50% 45%); font-family: monospace; margin-bottom: 0.5rem; } .heading-balance { font-size: 1.05rem; line-height: 1.5; color: hsl(260 20% 20%); margin: 0; text-wrap: balance; } .heading-autophrase { font-size: 1.05rem; line-height: 1.5; color: hsl(260 20% 20%); margin: 0; text-wrap: balance; word-break: auto-phrase; }`} /> `word-break: auto-phrase`が日本語の文節検出を有効にするには、要素または親要素に`lang="ja"`が必要です。`lang`属性がないとこのプロパティは効果を持ちません。FirefoxはまだAuto-phraseをサポートしていません。Safariのサポートも限定的です。利用前に現在のブラウザ互換性を確認してください。 ### ロケールスコープのフォールバックパターン 日本語または多言語サイトでは、`lang`属性セレクターを使って日本語コンテンツの`text-wrap`をリセットするのが最も安全なアプローチです: ```css /* グローバルにbalanceを適用 */ h1, h2, h3 { text-wrap: balance; word-break: auto-phrase; /* Chromeで効果あり */ } /* auto-phraseのサポートなしにbalanceが崩れる日本語向けにリセット */ [lang="ja"] :is(h1, h2, h3) { text-wrap: initial; } /* auto-phraseをサポートするChromeでbalanceを再有効化 */ @supports (word-break: auto-phrase) { [lang="ja"] :is(h1, h2, h3) { text-wrap: balance; } } ``` lang="en" Designing Accessible User Interfaces for Modern Applications Container queries let you style elements based on their parent size. This enables truly context-aware components that adapt to any layout without viewport-based media queries. lang="ja" モダンアプリケーションのためのアクセシブルなインターフェース設計 コンテナクエリを使用すると、ビューポートではなく親要素のサイズに基づいてスタイルを適用できます。これにより、どのレイアウトにも適応できる、真にコンテキストに対応したコンポーネントが実現します。 `} css={`.locale-demo { display: flex; flex-direction: column; gap: 1rem; padding: 1.25rem; font-family: system-ui, sans-serif; } .locale-section { background: hsl(220 20% 97%); border-radius: 8px; padding: 1rem 1.25rem; border: 1px solid hsl(220 20% 91%); } .locale-badge { display: inline-block; font-size: 0.68rem; font-weight: 700; font-family: monospace; padding: 0.15rem 0.5rem; border-radius: 3px; margin-bottom: 0.6rem; } .locale-badge--en { color: hsl(200 60% 35%); background: hsl(200 60% 90%); } .locale-badge--ja { color: hsl(20 60% 35%); background: hsl(20 60% 90%); } .article-heading { font-size: 1.1rem; line-height: 1.4; color: hsl(220 20% 20%); margin: 0 0 0.5rem; } .article-heading[lang="en"] { text-wrap: balance; } .article-heading[lang="ja"] { text-wrap: initial; word-break: auto-phrase; } @supports (word-break: auto-phrase) { .article-heading[lang="ja"] { text-wrap: balance; } } .article-body { font-size: 0.88rem; line-height: 1.65; color: hsl(220 10% 35%); margin: 0; text-wrap: pretty; }`} /> ### まとめ | プロパティ | 英語 | 日本語 | |---|---|---| | `text-wrap: balance` | うまく動作する | 任意の文字位置で改行される | | `text-wrap: pretty` | うまく動作する | ほぼ無害だが、オーファンの概念がCJKにはあまり当てはまらない | | `word-break: auto-phrase` | 効果なし | 文節を考慮した改行を改善する(Chrome 119+のみ) | **推奨**: 日本語や多言語プロジェクトでは、見出しに`text-wrap: balance`を無条件に適用しないようにしましょう。日本語コンテンツでは省略するか、上記のロケールスコープの`@supports`パターンを使うか、またはChromeでのみ改善が見られることを受け入れるかのいずれかを選択してください。 ## ブラウザサポート - **`text-wrap: balance`** — Chrome 114+、Edge 114+、Firefox 121+、Safari 17.5+でサポートされています。2025年時点で広くサポートされています。 - **`text-wrap: pretty`** — Chrome 117+、Edge 117+、Safari 17.4+でサポートされています。Firefoxは2026年初頭時点でまだ対応待ちです。 - **`text-wrap: stable`** — Chrome 120+、Edge 120+、Firefox 121+でサポートされています。Safariは対応待ちです。 3つの値すべてがグレースフルにデグレードします — 未対応のブラウザはデフォルトの`text-wrap: wrap`動作を使用するだけなので、今すぐ適用してもリスクはありません。 ## AIがよくやるミス - `text-wrap: balance`を長い本文の段落に適用する — 約6行以下のブロックでのみ動作し、適用されたとしても不自然に狭いテキストブロックになる - `text-wrap: balance`がネイティブに解決する問題を、JavaScriptで行分割を計算して``タグを挿入する方法で対処する - オーファンを防ぐために最後の2つの単語の間に` `を挿入する — ビューポート幅が変わると壊れる脆弱な方法。代わりに`text-wrap: pretty`を使いましょう - `text-wrap: balance`と`text-align: center`を混同する — balanceは行の長さを均等にするために改行位置を調整するもので、配置を変更するものではない - 見出し要素にデフォルトで`text-wrap: balance`を設定しない — すべての見出しレベルのベースラインスタイルとして設定すべき - ナビゲーション項目やバッジなど、特定の幅が必要な要素に`text-wrap: balance`を適用する — balanceは要素の固有の幅を予期しない形で変更する可能性がある ## 使い分け ### text-wrap: balance - 見出し(`h1`〜`h6`) - プルクオートやblockquoteテキスト - キャプションやfigcaption - カードタイトルやヒーローテキスト - 視覚的な対称性が重要な短いテキストブロック(6行以下) ### text-wrap: pretty - 本文の段落 - 折り返しのあるリスト項目 - 説明文やサマリー - 最終行に1つの単語だけが残るのを避けたい長文テキスト ### text-wrap: stable - `contenteditable`要素 - ライブテキストエディタや入力エリア - チャットメッセージの作成フィールド - テキストが活発に編集されており、再折り返しが妨げになるコンテキスト ## 参考リンク - [MDN: text-wrap](https://developer.mozilla.org/en-US/docs/Web/CSS/text-wrap) - [Chrome Developers: CSS text-wrap: balance](https://developer.chrome.com/blog/css-text-wrap-balance) - [Chrome Developers: CSS text-wrap: pretty](https://developer.chrome.com/blog/css-text-wrap-pretty) - [web.dev: CSS text-wrap: balance](https://web.dev/articles/css-text-wrap-balance) - [Ahmad Shadeed: CSS text-wrap balance](https://ishadeed.com/article/css-text-wrap-balance) --- # gap と margin の使い分け > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/flexbox-and-grid/gap-vs-margin ## 問題 要素間のスペーシングは最もよくあるCSSタスクの1つですが、AIエージェントは flex や grid コンテナ内でも `gap` が正しいツールであるにもかかわらず、あらゆる状況で `margin` を使いがちです。margin ベースのスペーシングにはいくつかの問題があります:アイテムが隣接する場所でマージンが二重になる、first/last child のワークアラウンドが必要、ブロックフローでのマージンの相殺、アイテムが折り返す際の不均一なスペーシングなどです。`gap` プロパティは flexbox、grid、マルチカラムレイアウトで利用可能で、これらの問題をすべて解決します。 ## 解決方法 flex、grid、またはマルチカラムコンテナ内の兄弟アイテム**間**のスペーシングには `gap` を使いましょう。要素の**周囲**のスペーシングや、同じレイアウトコンテナ内の兄弟ではない要素間のスペーシングには `margin` を使いましょう。 重要な違い:`gap` はアイテム間にのみスペースを作り、最初のアイテムの前や最後のアイテムの後にはスペースを作りません。margin はすべての要素の周囲にスペースを作るため、二重スペーシングや端のスペーシングを避けるためのワークアラウンドが必要です。 ## コード例 ### margin の問題 ```css /* Margin creates spacing issues */ .list-item { margin-bottom: 1rem; } /* Problem 1: Last item has unwanted bottom margin */ /* Fix attempt: */ .list-item:last-child { margin-bottom: 0; } ``` ```css /* Horizontal layout with margin */ .tag { margin-right: 0.5rem; } /* Problem 2: Last item has unwanted right margin */ .tag:last-child { margin-right: 0; } ``` ### gap による解決 ```css /* Gap only creates space BETWEEN items */ .tag-list { display: flex; flex-wrap: wrap; gap: 0.5rem; } /* No :last-child workaround needed. No double spacing. */ ``` ```html CSS Grid Flexbox ``` With margin (extra space on last item): CSS Grid Flexbox Layout Each item has margin-right — last item pushes container edge With gap (clean spacing): CSS Grid Flexbox Layout gap only adds space between items — no edge overflow`} css={`.label { font-family: system-ui, sans-serif; font-size: 14px; font-weight: 600; color: #334155; margin-bottom: 8px; } .note { font-family: system-ui, sans-serif; font-size: 12px; color: #64748b; margin-bottom: 16px; } .row-margin { display: flex; background: #fee2e2; padding: 8px; border-radius: 8px; margin-bottom: 4px; } .row-gap { display: flex; gap: 10px; background: #dcfce7; padding: 8px; border-radius: 8px; margin-bottom: 4px; } .tag { padding: 8px 16px; border-radius: 6px; font-family: system-ui, sans-serif; font-size: 14px; color: #fff; } .margin-tag { background: #ef4444; margin-right: 10px; } .gap-tag { background: #22c55e; }`} /> ### ブロックフローでのマージンの相殺 ブロック要素間の垂直マージンは相殺されます。ブラウザは合計ではなく、大きい方のマージンを使用します。 ```css .heading { margin-bottom: 1rem; } .paragraph { margin-top: 1.5rem; } /* The space between heading and paragraph is 1.5rem, not 2.5rem */ ``` これは仕様通りの動作ですが、AIエージェントを混乱させることが多く、「足りない」と感じたスペースを補うために任意のマージン増加を行うことがあります。 ### マージンの相殺が起こらない場合 マージンの相殺は通常のブロックフローでのみ発生します。以下の場合には発生**しません**: - flex コンテナ内 - grid コンテナ内 - フロートされた要素 - absolute positioned の要素 - 親が `visible` 以外の `overflow` を持つ場合 - 親が相殺される辺に padding または border を持つ場合 ```css /* No margin collapse: items are flex children */ .flex-container { display: flex; flex-direction: column; } .flex-container > .item { margin-block: 1rem; /* Adjacent margins DO NOT collapse. Total space = 2rem between items. */ } ``` ### グリッドレイアウトでの gap ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 1.5rem; } ``` すべてのカードは水平・垂直方向にぴったり 1.5rem のスペースを持ちます。margin のワークアラウンドは不要です。 Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} css={`.card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 8px; } .card { background: #8b5cf6; color: #fff; padding: 20px 16px; border-radius: 8px; text-align: center; font-family: system-ui, sans-serif; font-size: 16px; }`} /> ### 行と列で異なる gap ```css .layout { display: grid; grid-template-columns: 1fr 1fr 1fr; row-gap: 2rem; column-gap: 1rem; /* Or shorthand: gap: 2rem 1rem; */ } ``` ### flex レイアウトでの gap ```css .toolbar { display: flex; align-items: center; gap: 0.75rem; } ``` ```html Save Cancel Delete ``` すべてのアイテムは隣のアイテムとの間にぴったり 0.75rem のスペースを持ちます。margin ルールも `:first-child` や `:last-child` のオーバーライドも不要です。 ### gap と margin の組み合わせ gap と margin は異なる目的を持ち、一緒に使えます: ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 1.5rem; /* Space between cards */ margin-block-end: 3rem; /* Space after the entire grid */ } .card { padding: 1.5rem; /* Space inside each card */ /* No margin needed — gap handles inter-card spacing */ } ``` ### margin が正しい選択である場合 ```css /* Centering a block element */ .container { max-inline-size: 1200px; margin-inline: auto; } /* Space between unrelated sections */ .section + .section { margin-block-start: 4rem; } /* Space between elements that are NOT siblings in a flex/grid container */ .page-title { margin-block-end: 2rem; } ``` Block flow (margins collapse): margin-bottom: 20px margin-top: 30px Gap = 30px (not 50px) — margins collapsed Flex column (margins do NOT collapse): margin-bottom: 20px margin-top: 30px Gap = 50px — both margins apply `} css={`.demo { font-family: system-ui, sans-serif; font-size: 13px; padding: 8px; } .label { font-weight: 600; font-size: 14px; color: #334155; margin-bottom: 8px; } .block-flow { background: #fef3c7; padding: 12px; border-radius: 8px; margin-bottom: 16px; } .flex-flow { display: flex; flex-direction: column; background: #dbeafe; padding: 12px; border-radius: 8px; } .box { padding: 10px 14px; border-radius: 6px; color: #fff; font-size: 13px; } .box.a { background: #f59e0b; margin-bottom: 20px; } .box.b { background: #f59e0b; margin-top: 30px; } .box.c { background: #3b82f6; margin-bottom: 20px; } .box.d { background: #3b82f6; margin-top: 30px; } .annotation { font-size: 12px; color: #64748b; margin-top: 8px; text-align: center; }`} /> ## AIがよくやるミス - **コンテナに `gap` を使う代わりに、flex/grid の子要素に `margin` を使っている。** これが最もよくあるミスです。flex や grid コンテナ内のアイテムには、`gap` の方がシンプルで予測可能であり、二重スペーシングの問題を避けられます。 - **マージンの相殺を理解していない。** AIエージェントは隣接する要素に `margin-top: 1rem` と `margin-bottom: 1rem` を追加し、2rem のスペースを期待します。ブロックフローでは 1rem に相殺されます。これは混乱したデバッグや任意のマージン増加につながります。 - **gap の問題を「修正」するためにネガティブマージンを使っている。** よくあるアンチパターン:アイテムの `margin: 0.5rem` を打ち消すためにコンテナに `margin: -0.5rem` を適用する方法です。`gap` はこれを完全に不要にします。 - **`:last-child { margin: 0 }` のワークアラウンドを追加している。** first child や last child のマージンを削除している場合、代わりに `gap` を使うべきです。 - **flex/grid 以外のコンテナで `gap` を使っている。** `gap` は `display: flex`、`display: grid`、`display: multi-column` のコンテナでのみ機能します。通常のブロック要素では効果がありません。 - **flex/grid コンテナではマージンの相殺が無効になることを忘れている。** 要素をブロックから flex/grid に切り替えると、相殺が起こらなくなるため、既存のマージンベースのスペーシングが二倍になる可能性があります。 - **コンポーネント内のスペーシングに `margin` を使い、コンポーネント間には `gap` を使う(またはその逆)。** 一貫性を保ちましょう:レイアウトコンテナ(flex/grid)内では `gap` を、セクション間や無関係な要素間の外部スペーシングには `margin` を使いましょう。 ## 使い分け ### gap を使う場面 - flex コンテナの子要素間のスペーシング(ナビゲーションアイテム、ボタン、タグ) - grid コンテナの子要素間のスペーシング(カードグリッド、フォームレイアウト) - first/last child のワークアラウンドなしで均一なスペーシングが必要な場合 - アイテムが折り返す可能性があり、すべての行で均一な gap が必要な場合 ### margin を使う場面 - ブロック要素のセンタリング(`margin-inline: auto`) - flex/grid の兄弟ではないセクション間や無関係な要素間のスペーシング - コンポーネントの周囲のコンテンツとのスペーシング - マージンの相殺動作を理解し意図的に使いたい通常のブロックフローでの作業 ### padding を使う場面 - 要素内部のスペース(コンテンツとボーダーの間)の作成 - スペースを要素の背景やクリック可能なエリアの一部にする必要がある場合 ## Tailwind CSS Tailwind は flex や grid コンテナの `gap-*` ユーティリティで、`gap` を自然な選択にしています。 ### flex の gap Flex with gap-2.5: CSS Grid Flexbox Layout `} height={110} /> ### grid の gap Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} /> ## 参考リンク - [gap - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/gap) - [CSS gap property vs. margin property - LogRocket Blog](https://blog.logrocket.com/css-gap-vs-margin/) - [The Rules of Margin Collapse - Josh W. Comeau](https://www.joshwcomeau.com/css/rules-of-margin-collapse/) - [Spacing in CSS - Ahmad Shadeed](https://ishadeed.com/article/spacing-in-css/) - [Spacing - web.dev](https://web.dev/learn/css/spacing) --- # アンカーポジショニング > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/positioning/anchor-positioning ## 問題 要素を他の要素に対して相対的に配置すること(ツールチップ、ポップオーバー、ドロップダウンメニュー、ラベルなど)は、これまで常にJavaScriptが必要でした。Floating UI(Popper.js)のようなライブラリが位置を計算し、ビューポートのオーバーフローを処理し、スクロールやリサイズ時に再配置します。これによりバンドルサイズ、複雑さ、フレームレートの問題が生じます。AIエージェントは、純粋なCSS Anchor Positioningの方がパフォーマンスが良く保守しやすい場合でも、一貫してJavaScriptの配置ライブラリを推奨します。 ## 解決方法 CSS Anchor Positioningを使うと、純粋なCSSで要素を「アンカー」要素に対して宣言的に配置できます。アンカー関係(`anchor-name` / `position-anchor`)、配置(`position-area`または`anchor()`関数)、そしてビューポートからはみ出した場合の自動フォールバック再配置(`position-try-fallbacks`)を処理します。Popover APIと組み合わせることで、ほとんどのJavaScriptツールチップ/ドロップダウンライブラリを置き換えられます。 ## コード例 ### 基本的なツールチップ ```css .anchor-button { anchor-name: --my-tooltip-anchor; } .tooltip { /* Attach to the anchor */ position: fixed; position-anchor: --my-tooltip-anchor; /* Position above the anchor, centered */ position-area: top center; /* Spacing from the anchor */ margin-bottom: 8px; /* Styling */ background: #1f2937; color: white; padding: 0.5rem 0.75rem; border-radius: 6px; font-size: 0.875rem; white-space: nowrap; } ``` ```html Hover me Helpful tooltip text ``` ### ドロップダウンメニュー ```css .menu-trigger { anchor-name: --menu-anchor; } .dropdown-menu { position: fixed; position-anchor: --menu-anchor; /* Below the trigger, left-aligned */ position-area: bottom span-right; margin-top: 4px; background: white; border: 1px solid #e5e7eb; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); padding: 0.25rem; min-width: 180px; } ``` ```html Menu Option 1 Option 2 Option 3 ``` ### 自動フォールバック配置 配置された要素がビューポートからはみ出す場合、自動的に代替位置を試みます。 ```css .tooltip { position: fixed; position-anchor: --tooltip-anchor; position-area: top center; margin: 8px; /* If top overflows, try bottom, then right, then left */ position-try-fallbacks: flip-block, flip-inline; } ``` ### `@position-try`によるカスタムフォールバック位置 ```css @position-try --below { position-area: bottom center; margin-top: 8px; } @position-try --right { position-area: right center; margin-left: 8px; } @position-try --left { position-area: left center; margin-right: 8px; } .tooltip { position: fixed; position-anchor: --tooltip-anchor; /* Default: above */ position-area: top center; margin-bottom: 8px; /* Try these in order if default overflows */ position-try-fallbacks: --below, --right, --left; } ``` ### `anchor()`関数による精密な配置 `position-area`より細かい制御が必要な場合、insetプロパティ内で`anchor()`関数を使用します。 ```css .popover { position: fixed; position-anchor: --trigger; /* Position the popover's top-left corner at the anchor's bottom-left corner */ top: anchor(bottom); left: anchor(left); /* Or center horizontally relative to anchor */ left: anchor(center); translate: -50% 0; } ``` ### ラベルとフォームフィールドの接続 ```css .form-field { anchor-name: --field; } .field-hint { position: fixed; position-anchor: --field; position-area: right center; margin-left: 12px; font-size: 0.75rem; color: #6b7280; max-width: 200px; } ``` ```html We'll never share your email. ``` ### 複数要素での動的アンカーと`anchor-name` ```css .list-item { anchor-name: --item; } .list-item:hover { /* The detail panel follows whichever item is hovered */ } .detail-panel { position: fixed; position-anchor: --item; position-area: right center; margin-left: 1rem; } ``` ### Popover APIとの組み合わせ ```css [popovertarget] { anchor-name: --popover-trigger; } [popover] { position: fixed; position-anchor: --popover-trigger; position-area: bottom center; margin-top: 4px; /* Automatic fallback */ position-try-fallbacks: flip-block; /* Styling */ border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); } ``` ```html Info Additional Information This popover is positioned and managed entirely in CSS/HTML. ``` ## ブラウザサポート - Chrome 125+ - Edge 125+ - Safari 26+(Technology Preview、2025-2026年に正式リリース予定) - Firefox 145+(フラグ付き)、Firefox 150+で完全サポート 2025年後半の時点で、Anchor Positioningはすべての主要ブラウザで利用可能です。古いブラウザには、OddBirdによる[CSS anchor positioning polyfill](https://github.com/nicejose/css-anchor-positioning)がChrome 51+、Firefox 54+、Safari 10+をサポートしています。常に合理的な非配置フォールバックを提供しましょう。 ## AIがよくやるミス - CSS Anchor Positioningでネイティブに処理できるツールチップやポップオーバーに対して、JavaScriptの配置ライブラリ(Floating UI、Popper.js)を推奨する - CSS Anchor Positioningの存在を知らない - `position-area`の代わりに`position: absolute`と手動の`top`/`left`計算を使用する - アンカーされた要素に`position: fixed`を忘れる(Anchor Positioningが機能するために必要) - ビューポートのオーバーフロー処理に`position-try-fallbacks`を使用しない — 要素がクリップされる - 古いプロパティ名`inset-area`と現在の`position-area`を混同する(Chrome 129でリネーム) - アクセシブルなディスクロージャーパターンのために、Anchor PositioningとPopover APIを組み合わせない ## 使い分け - トリガーに対して相対的に配置されるツールチップ、ポップオーバー、情報パネル - ビューポートの端付近で再配置が必要なドロップダウンメニュー - 入力フィールドの横に配置されるフォームフィールドのヒントやバリデーションメッセージ - フローティングラベルやアノテーションマーカー - これまでJavaScriptで要素を別の要素に対して配置していたあらゆるUIパターン ## ライブプレビュー Anchor Button I'm positioned with CSS anchor-name and position-anchor! How it works: The button declares anchor-name: --btn-anchor. The tooltip uses position-anchor: --btn-anchor and position-area: top center to sit above the button. Note: CSS Anchor Positioning requires Chrome 125+, Edge 125+, or Firefox 150+. If the tooltip appears below the button instead of above it, your browser may not support this feature yet. `} css={` .demo { font-family: system-ui, sans-serif; padding: 3rem 1rem 1rem; } .button-row { display: flex; justify-content: center; position: relative; min-height: 100px; } .anchor-btn { anchor-name: --btn-anchor; padding: 0.75rem 1.5rem; background: #3b82f6; color: white; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; align-self: flex-end; } .tooltip { position: fixed; position-anchor: --btn-anchor; position-area: top center; margin-bottom: 8px; background: #1e293b; color: #f8fafc; padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.8rem; max-width: 240px; text-align: center; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } .tooltip::after { content: ""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 6px solid transparent; border-top-color: #1e293b; } .code-note { margin-top: 1.5rem; padding: 1rem; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; font-size: 0.8rem; color: #64748b; line-height: 1.5; } .code-note p { margin: 0 0 0.5rem; } .code-note p:last-child { margin: 0; } code { background: #e2e8f0; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.75rem; } `} height={340} /> ## 参考リンク - [CSS anchor positioning - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Anchor_positioning) - [CSS Anchor Positioning Guide - CSS-Tricks](https://css-tricks.com/css-anchor-positioning-guide/) - [The CSS anchor positioning API - Chrome for Developers](https://developer.chrome.com/docs/css-ui/anchor-positioning-api) - [Anchor positioning - web.dev](https://web.dev/learn/css/anchor-positioning) - [CSS Anchor Positioning - Can I Use](https://caniuse.com/css-anchor-positioning) --- # 論理プロパティ(Logical Properties) > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/sizing/logical-properties ## 問題 CSSは当初、物理的な方向(`top`、`right`、`bottom`、`left`)で設計されました。これは左から右(LTR)の言語では機能しますが、アラビア語やヘブライ語のような右から左(RTL)の言語や、日本語のような縦書きモードでは崩れます。AIエージェントは論理的な代替手段がある場合でも、ほぼ常に物理プロパティ(`margin-left`、`padding-right`、`border-top`)を生成します。これにより、国際化(i18n)のために手動でミラーリングが必要なレイアウトが作られ、エラーが起きやすく重複したCSSが生まれます。 ## 解決方法 CSS論理プロパティは、物理的な画面方向ではなくコンテンツの流れに基づいてスペーシング、サイジング、ポジショニングを定義します: - **インライン軸(Inline axis)** — テキストが流れる方向(LTR/RTLでは水平、縦書きモードでは垂直) - **ブロック軸(Block axis)** — ブロックが積み重なる方向(LTR/RTLでは垂直、縦書きモードでは水平) `margin-inline`、`padding-block`、`border-inline-start` などのプロパティを使うことで、レイアウトはどの書字方向にも自動的に適応します。 ## コード例 ### 物理プロパティと論理プロパティの対応 ```css /* Physical (LTR-only) */ .card-physical { margin-left: 1rem; margin-right: 1rem; padding-top: 2rem; padding-bottom: 2rem; border-left: 3px solid blue; } /* Logical (all writing directions) */ .card-logical { margin-inline: 1rem; padding-block: 2rem; border-inline-start: 3px solid blue; } ``` LTRでは同じ結果になります。RTLでは、論理バージョンはボーダーを自動的に右側(RTLでのインライン軸の開始側)に配置します。 margin-left + margin-right (physical): margin-left: 40px; margin-right: 40px margin-inline: 40px (logical): margin-inline: 40px margin-inline in RTL (auto-adapts): margin-inline: 40px (RTL text here) `} css={`.demo { font-family: system-ui, sans-serif; font-size: 13px; padding: 8px; } .label { font-weight: 600; font-size: 14px; color: #334155; margin-bottom: 6px; } .outer { background: #f1f5f9; border-radius: 8px; padding: 8px 0; margin-bottom: 12px; } .box { padding: 12px 16px; border-radius: 6px; color: #fff; font-size: 13px; } .physical { background: #3b82f6; margin-left: 40px; margin-right: 40px; } .logical { background: #22c55e; margin-inline: 40px; } .rtl-box { background: #8b5cf6; }`} /> ### 完全なプロパティ対応表 | 物理プロパティ | 論理プロパティ | | --- | --- | | `margin-top` | `margin-block-start` | | `margin-bottom` | `margin-block-end` | | `margin-left` | `margin-inline-start` | | `margin-right` | `margin-inline-end` | | `padding-top` | `padding-block-start` | | `padding-bottom` | `padding-block-end` | | `padding-left` | `padding-inline-start` | | `padding-right` | `padding-inline-end` | | `width` | `inline-size` | | `height` | `block-size` | | `min-width` | `min-inline-size` | | `max-height` | `max-block-size` | | `top` | `inset-block-start` | | `bottom` | `inset-block-end` | | `left` | `inset-inline-start` | | `right` | `inset-inline-end` | | `border-top` | `border-block-start` | | `border-bottom` | `border-block-end` | | `text-align: left` | `text-align: start` | | `text-align: right` | `text-align: end` | | `float: left` | `float: inline-start` | ### ショートハンドプロパティ ```css .box { /* Sets both inline-start and inline-end */ margin-inline: 1rem; /* same as margin-left + margin-right in LTR */ /* Sets both block-start and block-end */ padding-block: 2rem; /* same as padding-top + padding-bottom in LTR */ /* Two-value syntax: start and end */ margin-inline: 1rem 2rem; /* inline-start: 1rem, inline-end: 2rem */ padding-block: 1rem 0; /* block-start: 1rem, block-end: 0 */ } ``` ### margin-inline: auto でのセンタリング ```css .centered-block { max-inline-size: 800px; margin-inline: auto; } ``` これは `max-width: 800px; margin-left: auto; margin-right: auto` の論理的な等価物です。すべての書字モードで正しく機能します。 ### 論理スペーシングによるナビゲーション ```css .nav-item { padding-inline: 1rem; padding-block: 0.5rem; border-inline-end: 1px solid #e5e7eb; } .nav-item:last-child { border-inline-end: none; } ``` LTRではボーダーが各アイテムの右側に表示されます。RTLでは自動的に左側に表示されます。 ### 論理マージン付きアイコン ```css .button-icon { margin-inline-end: 0.5rem; } ``` ```html Submit ``` LTRではアイコンにテキストと区切るための右マージンがあります。RTLではマージンが自動的に左側に移動します。 ### inset による論理ポジショニング ```css .dropdown { position: absolute; inset-block-start: 100%; inset-inline-start: 0; } ``` これはドロップダウンを親の下(LTRでは `top: 100%`)に配置し、開始端に揃えます(LTRでは `left: 0`、RTLでは `right: 0`)。 ### inline-size と block-size の使用 ```css .sidebar { inline-size: 250px; /* width in horizontal writing modes */ block-size: 100%; /* height in horizontal writing modes */ } .hero { min-block-size: 100vh; /* min-height in horizontal writing modes */ inline-size: 100%; /* width in horizontal writing modes */ } ``` padding-block: 2rem (top and bottom padding) padding-inline: 2rem (left and right padding) padding-block: 1rem; padding-inline: 2rem `} css={`.demo { display: flex; flex-direction: column; gap: 12px; padding: 8px; font-family: system-ui, sans-serif; font-size: 13px; } .box { background: #dbeafe; border-radius: 8px; padding-block: 2rem; } .box.inline { background: #dcfce7; padding-block: 0; padding-inline: 2rem; } .box.both { background: #fef3c7; padding-block: 1rem; padding-inline: 2rem; } .inner { background: #3b82f6; color: #fff; padding: 8px 12px; border-radius: 4px; font-family: monospace; font-size: 13px; }`} /> ## AIがよくやるミス - **常に物理プロパティを使っている。** AIエージェントは、すでに論理プロパティを使用しているプロジェクトやi18nサポートが必要なプロジェクトでも、`margin-left`、`padding-top`、`border-right` などをデフォルトで使います。プロジェクトが論理プロパティを採用したら、新しいCSSはすべて一貫させるべきです。 - **同じ要素で物理プロパティと論理プロパティを混在させている。** これは混乱しやすく、保守が難しいCSSを作ります。`margin-inline` を使うなら、同じ要素で `margin-left` も使ってはいけません。 - **センタリングに `margin-inline: auto` を使っていない。** `margin: 0 auto` は副作用として垂直方向のマージンを0にリセットします。`margin-inline: auto` はインライン軸のみに作用し、既存のブロック軸マージンを保持します。 - **`text-align: start` の代わりに `text-align: left` を使っている。** RTLコンテキストでは `left` は反転しません。フロー相対の配置には `start` と `end` を使いましょう。 - **論理プロパティを「i18n専用」として扱っている。** LTR限定のプロジェクトでも、論理プロパティはより読みやすく将来性があります。`padding-block` は水平書字モードにおいて「垂直方向のパディング」を、読者が「top と bottom」を頭の中で対応させる必要なく明確に伝えます。 - **`inline-size` と `block-size` がより明確な場合に `width` と `height` を使っている。** コンテンツの流れに関連するレイアウトプロパティでは、論理的な等価物の方が意図をよく伝えます。 ## 使い分け ### 常に論理プロパティを優先すべき場面 - プロジェクトがRTL言語(アラビア語、ヘブライ語、ペルシャ語)をサポートしている、またはサポートする可能性がある場合 - プロジェクトが縦書きモードを使用している場合(CJKコンテンツ) - すでに論理プロパティを使用しているプロジェクトで新しいCSSを書く場合 - auto マージンでのセンタリング(`margin-inline: auto` は `margin: 0 auto` よりクリーン) ### 物理プロパティで問題ない場面 - プロパティが物理的な画面位置に本当に関連している場合(例:画面の視覚的な上部に固定された fixed 要素) - プロジェクトにi18n要件がなく、チームがまだ論理プロパティを採用していない場合 - 物理的なポジショニングが意図的な `position: fixed` コンテキストでの `top`/`left` の使用 ### 段階的に採用する - 新しいコンポーネントから始めて、既存のコンポーネントは時間をかけてリファクタリング - Stylelintルール(例:`liberty/use-logical-spec`)を使って新しいコードで論理プロパティを強制 - 優先順位:使用頻度の高いコンポーネント、ナビゲーション、フォーム、デザインシステムトークン ## 参考リンク - [CSS Logical Properties and Values - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values) - [Logical Properties - web.dev](https://web.dev/learn/css/logical-properties) - [Logical properties for margins, borders, and padding - MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values/Margins_borders_padding) - [From margin-left to margin-inline: Why Logical CSS Properties Matter - Brett Dorrans](https://lapidist.net/articles/2025/from-margin-left-to-margin-inline-why-logical-css-properties-matter/) --- # テーブルセルの幅制御 > Source: https://takazudomodular.com/pj/zcss/ja/docs/layout/specialized/table-cell-width-control ## 問題 テキスト列と画像列、ステータスバッジ、アクションボタンが混在するテーブルは、幅が予測しにくいレンダリングになりがちです。ブラウザの自動テーブルレイアウトアルゴリズムはコンテンツに基づいてスペースを配分するため、テキストが多い列は利用可能なスペースを埋めるように広がり、画像列は必要以上のスペースを取ってしまいます。AI エージェントはこれを `` 要素に `width` を設定して修正しようとしますが、ブラウザはそれをあくまで「目安」として扱い、頻繁に無視します。結果として、列が広すぎたり狭すぎたりし、「この列はコンテンツ幅ぴったりにしたい」「最低でも 200px、でも最大はそれ以上不要」といったことを確実に指定する手段がなくなってしまいます。 ## 解決方法 テーブルの列幅を精密に制御するために、互いに補完し合う 2 つのテクニックがあります。 ### テクニック 1: `width: 0` でコンテンツ幅に縮小するトリック セルの幅を `0`(または `1px` / `0.1%`)に設定すると、ブラウザに「この列をできるだけ狭くしてほしい」と伝えます。すると列はコンテンツにぴったり合わせて縮小されます。画像、アイコン、バッジ、アクションボタンなど、余白をなくしたいコンテンツに最適です。 ```css /* 列がコンテンツ幅にぴったり縮小される */ td.shrink { width: 0; white-space: nowrap; /* コンテンツの折り返しをなくしてさらなる縮小を防ぐ */ } ``` ### テクニック 2: 最小幅を確保するインナー要素 最小幅を強制したい場合は、セル内に固定 `width` を持つ `` を配置します。セル自体の `width: 0` と組み合わせることで「最小幅」の効果が生まれます。列はインナーの `` の幅以上になりますが、コンテンツが必要とする幅を超えて広がることはありません。 ```css /* セルはできるだけ小さくなろうとする */ td.min-width-cell { width: 0; } /* インナー div が最小幅を強制する */ td.min-width-cell .cell-inner { width: 200px; /* 列は最低でも 200px になる */ } ``` ### テクニック 3: 完全な制御のための `table-layout: fixed` 最大限の制御が必要な場合は `table-layout: fixed` を使います。このプロパティを指定すると、ブラウザは最初の行のみから列幅を決定し(後続の行のコンテンツは無視)、セルや `` 要素に設定した明示的な `width` の値を尊重します。 ```css table { table-layout: fixed; width: 100%; } ``` トレードオフとして、`table-layout: fixed` はレンダリングが速い(ブラウザがすべての行をスキャンする必要がない)一方で、すべての列を明示的にサイズ指定しなければなりません。サイズ未指定の列は残りのスペースを均等に分け合います。 ## コード例 ### コンテンツ幅に縮小する列 最も一般的なパターンです。画像、ステータス、アクションなどの特定の列をコンテンツ幅に縮小し、テキスト列が残りのスペースを取るようにします。 Avatar Name Description Status Alice Johnson Frontend developer working on the design system and component library Active Bob Chen Backend engineer focused on API performance and database optimization Away Carol Martinez Product manager coordinating the Q1 roadmap and sprint planning Active `} css={`.demo-table { width: 100%; border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 0.85rem; } .demo-table th, .demo-table td { padding: 0.6rem 0.75rem; border-bottom: 1px solid hsl(220 20% 90%); text-align: left; vertical-align: middle; } .demo-table th { font-weight: 600; color: hsl(220 20% 40%); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; background: hsl(220 20% 97%); } .col-shrink { width: 0; white-space: nowrap; } .avatar { width: 36px; height: 36px; border-radius: 50%; display: block; } .badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 10px; font-size: 0.72rem; font-weight: 600; } .badge--active { background: hsl(145 60% 90%); color: hsl(145 60% 30%); } .badge--away { background: hsl(40 80% 90%); color: hsl(40 60% 30%); }`} /> Avatar 列と Status 列はそれぞれの内容(画像とバッジ)にぴったり縮小され、Name 列と Description 列が残りのスペースを自然に分け合っています。 ### インナー要素で最小幅を確保 名前列のように常に 180px 以上の幅を保って読みやすくしたい場合など、最小幅を保証したい列には、固定幅を持つインナー `` を使います。 Product Description Price Image Wireless Keyboard Compact Bluetooth keyboard with backlit keys and rechargeable battery. Compatible with macOS and Windows. $79.99 USB-C Hub 7-in-1 hub with HDMI, SD card, USB-A ports, and 100W pass-through charging for laptops. $49.99 Noise Cancelling Headphones Over-ear headphones with adaptive noise cancellation and 30-hour battery life. $299.99 `} css={`.demo-table { width: 100%; border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 0.85rem; } .demo-table th, .demo-table td { padding: 0.6rem 0.75rem; border-bottom: 1px solid hsl(220 20% 90%); text-align: left; vertical-align: middle; } .demo-table th { font-weight: 600; color: hsl(220 20% 40%); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; background: hsl(220 20% 97%); } .col-min-width { width: 0; } .cell-sizer { /* この div が最小列幅を強制する */ /* セルは width:0 で縮もうとするが、この div の幅より小さくなれない */ } .col-shrink { width: 0; white-space: nowrap; } .product-img { width: 60px; height: 40px; object-fit: cover; border-radius: 4px; display: block; }`} /> Product 列は(ヘッダーの `cell-sizer` div によって設定された)常に 180px 以上の幅を保ち、Price 列と Image 列はコンテンツ幅に縮小されます。Description 列は残りのスペースを埋めます。 ### 組み合わせパターン: 固定幅 + 縮小 + 可変幅 すべてのテクニックを組み合わせた現実的なデータテーブルです。固定幅の ID 列、アバターとアクション用の縮小列、可変のテキスト列を含んでいます。 # Avatar Name Role Actions 001 Alice Johnson Senior Frontend Developer — Design System Lead Edit Del 002 Bob Chen Backend Engineer — API & Infrastructure Edit Del 003 Carol Martinez Product Manager — Platform & Growth Edit Del `} css={`.demo-table { width: 100%; border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 0.85rem; } .demo-table th, .demo-table td { padding: 0.6rem 0.75rem; border-bottom: 1px solid hsl(220 20% 90%); text-align: left; vertical-align: middle; } .demo-table th { font-weight: 600; color: hsl(220 20% 40%); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; background: hsl(220 20% 97%); } .col-min-width { width: 0; } .cell-sizer { /* 最小列幅を強制する */ } .col-shrink { width: 0; white-space: nowrap; } .cell-mono { font-family: ui-monospace, monospace; color: hsl(220 15% 50%); } .avatar { width: 32px; height: 32px; border-radius: 50%; display: block; } .btn { padding: 0.25rem 0.5rem; border: 1px solid hsl(220 20% 85%); border-radius: 4px; background: hsl(0 0% 100%); font-size: 0.72rem; font-weight: 500; cursor: pointer; } .btn--edit { color: hsl(220 60% 50%); } .btn--del { color: hsl(0 60% 50%); }`} /> ### table-layout: fixed を使った代替手法 `table-layout: fixed` を使うとすべての列を完全に制御できますが、すべての列のサイズを指定する必要があります。`` 要素を使って幅を宣言的に設定します。 ID Name Description Status 001 Wireless Keyboard Compact Bluetooth keyboard with backlit keys and rechargeable battery In Stock 002 USB-C Hub 7-in-1 hub with HDMI and USB-A ports for laptops Low 003 Monitor Stand Adjustable aluminum stand with built-in USB hub and cable management In Stock `} css={`.fixed-table { table-layout: fixed; width: 100%; border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 0.85rem; } .fixed-table th, .fixed-table td { padding: 0.6rem 0.75rem; border-bottom: 1px solid hsl(220 20% 90%); text-align: left; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .fixed-table th { font-weight: 600; color: hsl(220 20% 40%); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; background: hsl(220 20% 97%); } .cell-mono { font-family: ui-monospace, monospace; color: hsl(220 15% 50%); } .badge { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 10px; font-size: 0.72rem; font-weight: 600; } .badge--active { background: hsl(145 60% 90%); color: hsl(145 60% 30%); } .badge--away { background: hsl(40 80% 90%); color: hsl(40 60% 30%); }`} /> `table-layout: fixed` を使うと、Description 列の長いテキストは `text-overflow: ellipsis` で切り詰められます。列幅は厳格に適用されます。 ### 適切なアプローチの選び方 | アプローチ | 使うべき場面 | トレードオフ | |---|---|---| | セルに `width: 0` | 列をコンテンツ幅に縮小する(画像、バッジ、ボタン) | コンテンツが折り返さないようにするか、`white-space: nowrap` を追加する必要がある | | 固定幅の インナー `` | 縮小しつつも最小幅を確保したい | 追加のマークアップが必要 | | `table-layout: fixed` + `` | すべての列を完全に制御したい | すべての列のサイズを指定する必要がある。長いコンテンツは切り詰められる | | `table-layout: auto`(デフォルト) | ブラウザにコンテンツに基づいて判断させる | 予測しにくく、制御が難しい | ## AIがよくやるミス - `` に `width: 200px` を設定して適用されることを期待する — `table-layout: auto`(デフォルト)では、ブラウザはこれをルールではなく目安として扱う - テーブルセルに `min-width` を使って期待通りに動くと思う — `min-width` はほとんどのブラウザで `` 要素には効果が薄い。代わりにインナー `` のテクニックを使う - テーブル自体に `width` を設定せずに `table-layout: fixed` を適用する — テーブル自体に明示的な幅がないと `table-layout: fixed` は効果がない - 縮小列に `white-space: nowrap` を忘れる — これがないとセルのコンテンツが折り返してさらに幅が狭くなり、目的が達成できない - 列にパーセント幅を使ってそれらが正しく合算されると期待する — パーセント幅はテーブル幅に対する相対値であり、固定幅の列と組み合わせると予期しない結果になることがある - 固定レイアウトの列に `overflow: hidden; text-overflow: ellipsis` を追加しない — これがないとコンテンツがセルの境界からはみ出たり、水平スクロールが発生したりする ## 使い分け ### width: 0 でコンテンツ幅に縮小 - 画像・アバター列 - ステータスバッジやタグの列 - アクションボタン列 - アイコン列 - コンテンツが自然な固定サイズを持つ列全般 ### インナー div で最小幅を確保 - 読みやすい最小幅が必要な名前・ラベル列 - コンテンツ長がばらつくが最小幅を保証したい列 - 一定のサイズに揃えたい ID 列 ### table-layout: fixed - 厳密な制御が必要な多列のデータテーブル - 長いテキストを切り詰めるべきテーブル - 何百行もあってパフォーマンスが重要なテーブル(固定レイアウトはレンダリングが速い) - コンテンツに関係なく列幅を変えたくないダッシュボードのテーブル ## 参考リンク - [MDN: table-layout](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/table-layout) - [CSS-Tricks: Faking Min Width on a Table Column](https://css-tricks.com/faking-min-width-on-a-table-column/) - [CSS-Tricks: Fixed Table Layouts](https://css-tricks.com/fixing-tables-long-strings/) - [CSS-Tricks: table-layout](https://css-tricks.com/almanac/properties/t/table-layout/) --- # カスケードレイヤー > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/architecture/cascade-layers ## 問題 CSSの詳細度(specificity)の競合は、バグやフラストレーションの最も一般的な原因の一つです。開発者は、詳細度の競合を管理するために、過剰に具体的なセレクター、`!important`、またはBEMのような命名規則に頼ります。サードパーティCSS(デザインシステム、コンポーネントライブラリ、リセット)を統合する際、どのスタイルが優先されるかの制御はますます困難になります。AIエージェントは、詳細度の競合を引き起こすCSSを生成したり、修正として`!important`を使用したりすることがよくあります。 ## 解決方法 `@layer`アットルールは、カスケードに対する明示的な制御を提供します。レイヤーの優先度はセレクターの詳細度*より前に*評価されます — 後で宣言されたレイヤーのシンプルなセレクターは、先に宣言されたレイヤーの複雑なセレクターに常に勝ちます。これにより、設計上の詳細度の競合が解消されます。 完全なカスケード評価順序は、origin/importance > インラインスタイル > cascade layers > 詳細度 > ソース順序です。 ## コード例 ### レイヤー順序の宣言 レイヤーが最初に宣言される順序が優先度を決定します。宣言リストの最後のレイヤーが最も高い優先度を持ちます。 ```css /* Declare layer order upfront — this is the recommended pattern */ @layer reset, base, components, utilities; /* Now populate layers in any order */ @layer reset { *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } } @layer base { body { font-family: system-ui, sans-serif; line-height: 1.6; } a { color: #2563eb; } } @layer components { .button { padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; } .button-primary { background: #2563eb; color: white; } } @layer utilities { .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } .text-center { text-align: center; } } ``` ### サードパーティCSSのレイヤーへのインポート ```css /* Put third-party styles in a low-priority layer */ @import url("normalize.css") layer(reset); @import url("some-library.css") layer(vendor); @layer reset, vendor, base, components, utilities; ``` ### レイヤーの優先度が詳細度を上書きする ```css @layer base, components; @layer base { /* High specificity: (0, 2, 1) */ nav ul li.active a.nav-link { color: black; } } @layer components { /* Low specificity: (0, 1, 0) — but this WINS because 'components' is declared after 'base' */ .nav-link { color: blue; } } ``` ### ネストされたレイヤー ```css @layer components { @layer card { .card { border: 1px solid #e5e7eb; border-radius: 8px; } } @layer button { .button { padding: 0.5rem 1rem; } } } /* Reference nested layers with dot notation */ @layer components.card { .card { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } } ``` ### `revert-layer`の使用 `revert-layer`キーワードは、プロパティ値を前のレイヤーで設定された値にロールバックします。 ```css @layer base, theme, overrides; @layer base { a { color: blue; text-decoration: underline; } } @layer theme { a { color: #8b5cf6; text-decoration: none; } } @layer overrides { /* Roll back to the base layer value */ .classic-link { color: revert-layer; text-decoration: revert-layer; /* Result: color is #8b5cf6 from theme? No — revert-layer goes to the PREVIOUS layer. Actually: color reverts to theme's value first, then theme could revert-layer to base's value. */ } } ``` `revert-layer`の実用的なユースケース: ```css @layer defaults, theme; @layer defaults { button { background: #e5e7eb; color: #1f2937; } } @layer theme { button { background: #2563eb; color: white; } /* Opt specific buttons out of theming */ button.no-theme { background: revert-layer; /* Falls back to #e5e7eb */ color: revert-layer; /* Falls back to #1f2937 */ } } ``` ### レイヤーに属さないスタイルが最も高い優先度を持つ どのレイヤーにも属さないスタイルは、常にレイヤー内のスタイルに勝ちます。 ```css @layer base { p { color: gray; } } /* Unlayered — this wins */ p { color: black; } ``` ## ブラウザサポート - Chrome 99+ - Firefox 97+ - Safari 15.4+ - Edge 99+ グローバルサポートは96%を超えています。 ## AIがよくやるミス - レイヤーで優先度を管理する代わりに、スタイルのオーバーライドに`!important`を使用する - レイヤー順序を事前に宣言せず、最初に出現した順序に基づく予測不可能な優先度になる - サードパーティ/ベンダーCSSをレイヤーの外に配置する(最も高いレイヤー外の優先度を与えてしまう) - レイヤーに属さないスタイルがすべてのレイヤー内スタイルに勝つことを知らない - `revert-layer`と`revert`を混同する — `revert`はユーザーエージェントスタイルシートにロールバックし、`revert-layer`は前のカスケードレイヤーにロールバックする - レイヤーの順序付けで優先度の問題を解決できるのに、過剰に具体的なセレクターを生成する ## 使い分け - 複数のスタイルソースを持つ大規模なコードベースでの詳細度の管理 - 詳細度の競合なしにサードパーティCSSを統合する - 明確なCSSアーキテクチャの確立(reset、base、components、utilities、overrides) - `!important`の使用を構造化されたレイヤー優先度に置き換える - コンシューマーがコンポーネントスタイルを予測可能にオーバーライドする必要があるデザインシステムの構築 ## ライブプレビュー This text is styled by two layers with conflicting colors. base layer says: red theme layer says: blue — wins (declared later) `} css={` @layer base, theme; @layer base { .text { color: #ef4444; font-size: 1.25rem; font-weight: 700; padding: 1rem; background: #fef2f2; border-left: 4px solid #ef4444; border-radius: 6px; } } @layer theme { .text { color: #3b82f6; background: #eff6ff; border-left-color: #3b82f6; } } .demo { font-family: system-ui, sans-serif; } .legend { margin-top: 1rem; font-size: 0.85rem; color: #64748b; display: flex; flex-direction: column; gap: 0.35rem; } .legend div { display: flex; align-items: center; gap: 0.5rem; } .swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; } code { background: #f1f5f9; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.8rem; } `} /> Home About Contact The components layer uses a simple .nav-link selector, but it wins over the high-specificity base layer selector because it is declared later. `} css={` @layer base, components; @layer base { /* High specificity: (0, 2, 3) */ nav ul li.active a.nav-link { color: #ef4444; text-decoration: line-through; } } @layer components { /* Low specificity: (0, 1, 0) — but WINS */ .nav-link { color: #2563eb; text-decoration: none; font-weight: 600; padding: 0.5rem 1rem; border-radius: 6px; transition: background 0.2s; } .nav-link:hover { background: #eff6ff; } } nav { font-family: system-ui, sans-serif; } ul { list-style: none; display: flex; gap: 0.25rem; padding: 0; margin: 0; } .note { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #64748b; margin-top: 1rem; line-height: 1.5; } code { background: #f1f5f9; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.75rem; } `} /> ## 参考リンク - [@layer - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@layer) - [Cascade layers - MDN Learn](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Styling_basics/Cascade_layers) - [Cascade Layers Guide - CSS-Tricks](https://css-tricks.com/css-cascade-layers/) - [revert-layer - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/revert-layer) - [Hello, CSS Cascade Layers - Ahmad Shadeed](https://ishadeed.com/article/cascade-layers/) --- # コンポーネントトークンと任意の値 > Source: https://takazudomodular.com/pj/zcss/ja/docs/methodology/design-systems/tight-token-strategy/component-tokens ## 問題 タイトトークン戦略でコンポーネントを構築する際、チームは繰り返し同じ疑問に直面します:**「必要な値がトークンセットにない — 新しいトークンを追加すべきか?」** この疑問は常に発生します。ボタンのアイコン用にちょうど28pxの幅が必要です。グリッドレイアウトに `120px` と `1fr` のカラムが必要です。装飾的なグラデーションがブランドカラーに合わない特定の赤を使います。答えが常に「トークンを追加する」であれば、トークンセットはもはやタイトではなくなるまで膨らんでいき、この戦略が防ごうとしていた制約のない混乱に戻ってしまいます。 しかし、答えが常に「既存のトークンのみを使う」であれば、開発者は合わない値の使用を強いられ、視覚的に不自然なコンポーネントが生まれます。どちらの極端もうまくいきません。足りないのは、値がシステムに属するのかコンポーネントに属するのかを判断するための明確なフレームワークです。 ## 解決方法 タイトトークン戦略は**コンポーネント中心の哲学**に基づいています。これは Tailwind CSS 自体のアプローチと密接に一致しています: 1. **デザイントークンはシステムの語彙を定義する** — すべてのコンポーネントにわたる一貫性を確保するスペーシング、カラー、タイポグラフィスケール 2. **コンポーネントはそれらのトークンを使って構築される** — 標準的で再利用可能なスペーシングとカラーのために 3. **しかし、すべてがシステムに収まるわけではない** — コンポーネント固有の詳細には任意の値を使用する 重要なポイント:**トークンの追加はシステムレベルの判断であり、コンポーネントレベルの判断ではありません。** 特定の値が1つのコンポーネントのレイアウトや装飾に固有のものである場合、トークンセットに昇格させるのではなく、任意の値(Tailwind の `w-[28px]` のようなブラケット構文)として残すべきです。 ### 任意の値を使うべき場合 個々のコンポーネントの構造的な詳細であり、システム全体のパターンではない値には、Tailwind のブラケット構文を使いましょう: - **コンポーネント固有のサイジング** — 視覚的なバランスのためにちょうど `w-[28px] p-[6px]` が必要な小さなアイコンボタン - **グリッドテンプレートカラム** — 1つのコンポーネントの構造を定義する `grid-cols-[120px_1fr]` のようなレイアウト - **固有のアイコン寸法** — そのコンテキスト内で光学的に揃えるために `w-[18px]` にサイズ調整されたアイコン - **一回限りの装飾的な値** — 1つのヒーローセクションだけで使われるグラデーション `from-[hsl(0_60%_20%)] to-[hsl(0_60%_8%)]` - **数学的に計算された値** — 精密なポジショニングのためのオフセット `top-[calc(100%-2px)]` ### システムトークンを使うべき場合 共有されたデザイン判断を表す値には、プロジェクトの `@theme` 定義のトークンを使いましょう: - **標準的なコンポーネントスペーシング** — カードのパディングやセクションマージンの `px-hsp-sm py-vsp-xs` - **セマンティックな意味を持つカラー** — ブランドとテキスト用の `bg-primary text-text-muted` - **繰り返し要素間のギャップ** — グリッドや flex レイアウト用の `gap-x-hsp-xs gap-y-vsp-sm` - **デザイン仕様で名前で参照される値** — デザイナーが「セクションギャップを使って」と言えば、それはトークンです ## 判断フレームワーク トークンと任意の値のどちらを使うか判断する際、このデシジョンツリーを使いましょう: | 状況 | アクション | | --- | --- | | 値がデザイン上の判断を表している(例:「セクションギャップ」「アイコンサイズ」) | システムトークンを追加する | | 値が1つのコンポーネントの構造的な詳細である(例:グリッドカラム、ボタンのパディング) | 任意の値を使う | | 値が数学的に計算されたものか装飾的なもの | 任意の値を使う | トークンを作るかどうかは**アーキテクチャとデザインの判断**であり、使用回数の閾値ではありません。3つのコンポーネントが値を使うまで待ってからトークン化するのではなく、その値が意図的なデザイン上の判断を表しているかを問いましょう。コンテンツカラム幅 800px は、「レイアウトは800px幅にする」と決めた時点でトークン化する価値があります。たとえ今日それを使うのが1つのページテンプレートだけでも。これは「この関数をユーティリティに切り出すべきか?」という判断と同じです。答えはコールサイトの数ではなく、その概念がシステム内で名前を持つに値するかどうかで決まります。 ### トークンを追加すべきサイン - デザイナーがその値を名前付きステップとして参照している(スペーシングレベル、アイコンサイズ、レイアウト幅) - その値がデザイン上の判断を表している — UI の構成に関する意図的な選択 - その値を変更するとシステム全体が更新されるべきであり、1つのコンポーネントだけではない ### トークンを追加すべきでないサイン - 1つのコンポーネントの内部配置に固有の構造的またはレイアウト的な詳細である - 追加してもトークンセットが煩雑になるだけで明確さが増さない - その値はコンポーネントのコンテキスト外では意味を持たない(例:位置揃えのための `calc()` オフセット) ## コンポーネントファーストプロジェクト コンポーネントフレームワーク(React、Vue、Svelte)と Tailwind CSS を組み合わせたプロジェクトでは、コンポーネント層の CSS カスタムプロパティは不要です。コンポーネントアーキテクチャ自体がスコーピングを提供します — 各コンポーネントのテンプレートがスタイリングを直接含んでいるためです: ```html ``` `--card-sidebar-width: 80px` や `--card-avatar-size: 64px` を定義する別の CSS ファイルは存在しません。任意の値は JSX 内にブラケット構文として記述され、コンポーネントファイルの境界が必要なスコーピングをすべて提供します。 コンポーネントスコープの CSS カスタムプロパティが関係するのは、**一般的な CSS アプローチ** — BEM、CSS Modules、またはコンポーネントがマークアップとは別の専用 CSS ファイルを持つアーキテクチャです。この記事のコンポーネントレベル変数に関するガイダンスは、それらのコンテキストに特化しています。 ## コンポーネントスコープ変数の命名規則 一般的な CSS でコンポーネントスコープの CSS カスタムプロパティを定義する際、ローカルスコープを示すためにアンダースコアプレフィックスを使います: ```css .accordion { --_accordion-max-height: 200px; --_accordion-speed: 300ms; max-height: var(--_accordion-max-height); transition: max-height var(--_accordion-speed); } .dialog { --_dialog-side-spacing: 24px; padding-inline: var(--_dialog-side-spacing); } ``` 先頭のアンダースコア(`--_`)は「この変数はこのコンポーネントにローカルスコープされている」ことを読者に伝えます。`--color-primary` や `--spacing-sm` のようなグローバルテーマトークンと区別するためです。 シングルアンダースコア(`--_`)とダブルアンダースコア(`--__`)の両方の規則が存在します。1つを選び、プロジェクト全体で一貫して適用してください。シングルアンダースコア(`--_`)がより一般的です。 ```css /* シングルアンダースコア — より一般的 */ .menu { --_menu-gap: 8px; } /* ダブルアンダースコア — これも有効 */ .menu { --__menu-gap: 8px; } ``` ## デモ ### システムトークンと任意の値の混合 このカードコンポーネントは、標準的なスペーシングとカラーにシステムトークンを使い、コンポーネント固有のグリッドレイアウトと装飾的なアイコンサイジングに任意の値を使っています。コメントでトークンシステムからの値と任意の値を区別しています。 Project Dashboard Overview Analytics Settings 2,847 Active users 94.2% Uptime System tokens: padding, gaps, colors Arbitrary values: icon size, grid columns, stat font `} css={`.card-demo { font-family: system-ui, sans-serif; font-size: 14px; color: hsl(222 47% 11%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; overflow: hidden; } /* ── Header: system tokens for padding, arbitrary for icon ── */ .card-demo__header { display: flex; align-items: center; gap: 12px; /* hsp-xs (system token) */ padding: 8px 20px; /* vsp-xs / hsp-sm (system tokens) */ background: hsl(210 40% 96%); border-bottom: 1px solid hsl(214 32% 91%); } .card-demo__icon { width: 18px; /* ARBITRARY: icon-specific size */ height: 18px; /* ARBITRARY: icon-specific size */ color: hsl(221 83% 53%); flex-shrink: 0; } .card-demo__icon svg { width: 100%; height: 100%; } .card-demo__title { font-weight: 700; font-size: 15px; } /* ── Body: system tokens for padding ── */ .card-demo__body { padding: 20px; /* vsp-sm / hsp-sm (system tokens) */ } /* ── Grid: ARBITRARY column template ── */ .card-demo__grid { display: grid; grid-template-columns: 120px 1fr; /* ARBITRARY: component layout */ gap: 20px; /* hsp-sm (system token) */ } /* ── Sidebar nav ── */ .card-demo__sidebar { display: flex; flex-direction: column; gap: 4px; /* vsp-2xs (system token) */ } .card-demo__nav-item { padding: 4px 12px; /* vsp-2xs / hsp-xs (system tokens) */ border-radius: 4px; font-size: 13px; color: hsl(215 16% 47%); cursor: default; } .card-demo__nav-item--active { background: hsl(221 83% 53%); color: hsl(0 0% 100%); } /* ── Stats ── */ .card-demo__content { display: flex; flex-direction: column; gap: 8px; /* vsp-xs (system token) */ } .card-demo__stat { padding: 8px 12px; /* vsp-xs / hsp-xs (system tokens) */ background: hsl(210 40% 96%); border-radius: 4px; } .card-demo__stat-value { font-size: 22px; /* ARBITRARY: decorative display size */ font-weight: 700; line-height: 1.2; color: hsl(222 47% 11%); } .card-demo__stat-label { font-size: 12px; color: hsl(215 16% 47%); } /* ── Annotations ── */ .card-demo__annotations { display: flex; gap: 20px; /* hsp-sm */ padding: 8px 20px; /* vsp-xs / hsp-sm */ border-top: 1px solid hsl(214 32% 91%); background: hsl(210 40% 96%); font-size: 12px; color: hsl(215 16% 47%); } .card-demo__annotation { display: flex; align-items: center; gap: 6px; } .card-demo__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .card-demo__dot--token { background: hsl(142 71% 45%); } .card-demo__dot--arbitrary { background: hsl(33 95% 54%); }`} height={320} /> コードの中で、システムトークンはプロジェクトのスペーシング語彙に従う値の場所に現れます — 水平パディングの `hsp-sm`(20px)、垂直パディングの `vsp-xs`(8px)、ギャップの `hsp-xs`(12px)。任意の値はコンポーネント固有の詳細に現れます:アイコンは光学的バランスのために `18px`、グリッドはサイドバー幅に `120px`、統計数値は視覚的インパクトのために `22px` です。これらの任意の値はこのコンポーネント内でのみ意味を持つため、システムトークンセットに属しません。 ### トークン昇格:ビフォー・アフター 同じ任意の値が複数のコンポーネントで繰り返し現れる場合、それをシステムトークンに昇格させるシグナルです。このデモは、頻繁に使用されるカード幅が任意の値から名前付きトークンに昇格されたプライシングレイアウトを示しています。 Before: arbitrary values everywhere Basic $9 For individuals Pro $29 For teams Enterprise $99 For organizations gap: 15px; padding: 18px 22px; font-size: 28px; Each dev picked different values After: promoted to system tokens Basic $9 For individuals Pro $29 For teams Enterprise $99 For organizations gap: hsp-sm; padding: vsp-sm hsp-sm; font-size: heading; Consistent tokens across all cards `} css={`.promo-demo { font-family: system-ui, sans-serif; font-size: 14px; color: hsl(222 47% 11%); display: flex; flex-direction: column; gap: 24px; padding: 16px; } .promo-demo__section { display: flex; flex-direction: column; gap: 8px; } .promo-demo__label { font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); } /* ── Card grids ── */ .promo-demo__cards { display: flex; gap: 12px; } .promo-demo__card { flex: 1; border-radius: 8px; text-align: center; border: 1px solid hsl(214 32% 91%); } /* Before: inconsistent arbitrary padding */ .promo-demo__card--before:nth-child(1) { padding: 18px 22px; /* dev A */ } .promo-demo__card--before:nth-child(2) { padding: 14px 20px; /* dev B */ } .promo-demo__card--before:nth-child(3) { padding: 16px 24px; /* dev C */ } /* After: consistent system tokens */ .promo-demo__card--after { padding: 20px 20px; /* vsp-sm / hsp-sm — system tokens */ } .promo-demo__card-name { font-weight: 700; font-size: 13px; color: hsl(215 16% 47%); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; } /* Before: inconsistent font sizes */ .promo-demo__card-price--before { font-weight: 700; line-height: 1.2; } .promo-demo__card--before:nth-child(1) .promo-demo__card-price--before { font-size: 28px; /* dev A */ } .promo-demo__card--before:nth-child(2) .promo-demo__card-price--before { font-size: 32px; /* dev B */ } .promo-demo__card--before:nth-child(3) .promo-demo__card-price--before { font-size: 26px; /* dev C */ } /* After: consistent token-based size */ .promo-demo__card-price--after { font-size: 28px; /* heading token (1.75rem = 28px) */ font-weight: 700; line-height: 1.2; } .promo-demo__card-desc { font-size: 12px; color: hsl(215 16% 47%); margin-top: 4px; } /* ── Code callouts ── */ .promo-demo__code { padding: 8px 12px; background: hsl(210 40% 96%); border-radius: 4px; font-size: 12px; } .promo-demo__code code { font-family: ui-monospace, monospace; color: hsl(222 47% 11%); } .promo-demo__code-note { color: hsl(215 16% 47%); margin-top: 2px; font-size: 11px; }`} height={420} /> 「Before」バージョンでは、3人の開発者が同じプライシングカードコンポーネントに対してそれぞれ微妙に異なるパディングとフォントサイズを選びました。カードは微妙に不揃いに見えます — 垂直パディングが `18px` vs `14px` vs `16px`、価格のフォントサイズが `28px` vs `32px` vs `26px`。チームがこのパターンがプライシング、フィーチャーカード、テスティモニアルカードにわたって繰り返されていることに気づいた後、値をシステムトークンに昇格させました:パディングに `vsp-sm` / `hsp-sm`、価格のフォントサイズに `heading`。これですべてのカードが自動的に一貫性を保ちます。 ### 実際のレイアウトでのシステムトークンと任意の値の混合 このデモは、プロダクションプロジェクトの現実的なコンポーネントをシミュレートしています。システムトークンが標準的なスペーシング、カラー、タイポグラフィをすべて処理します。任意の値はコンポーネント固有のレイアウトグリッドと装飾的な詳細を処理します。 Section with system token spacing Jane Smith Admin Active Premium Email jane@example.com Joined Mar 2024 View Profile `} css={`.prod-demo { font-family: system-ui, sans-serif; font-size: 14px; color: hsl(222 47% 11%); border: 1px solid hsl(214 32% 91%); border-radius: 8px; overflow: hidden; } /* ── Section: system token padding ── */ .prod-demo__section { padding: 20px; /* vsp-sm / hsp-sm */ } .prod-demo__section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: hsl(215 16% 47%); margin-bottom: 8px; /* vsp-xs */ } /* ── Grid: ARBITRARY column template ── */ .prod-demo__grid { display: grid; grid-template-columns: 80px 1fr; /* ARBITRARY: profile layout */ gap: 20px; /* hsp-sm (system token) */ } /* ── Sidebar: ARBITRARY avatar size ── */ .prod-demo__sidebar { text-align: center; } .prod-demo__avatar { width: 64px; /* ARBITRARY: avatar specific */ height: 64px; /* ARBITRARY: avatar specific */ border-radius: 50%; background: linear-gradient( 135deg, hsl(221 83% 53%), hsl(250 80% 68%) ); margin: 0 auto 8px; /* vsp-xs */ } .prod-demo__user-name { font-weight: 700; font-size: 13px; } .prod-demo__user-role { font-size: 11px; color: hsl(215 16% 47%); } /* ── Tags: system token padding ── */ .prod-demo__row { display: flex; gap: 8px; /* vsp-xs */ margin-bottom: 8px; /* vsp-xs */ } .prod-demo__tag { padding: 4px 12px; /* vsp-2xs / hsp-xs (system tokens) */ border-radius: 4px; font-size: 12px; font-weight: 500; } .prod-demo__tag--success { background: hsl(142 71% 92%); color: hsl(142 71% 30%); } .prod-demo__tag--info { background: hsl(221 83% 92%); color: hsl(221 83% 40%); } /* ── Detail rows ── */ .prod-demo__details { display: flex; flex-direction: column; gap: 4px; /* vsp-2xs */ } .prod-demo__detail-row { display: flex; gap: 12px; /* hsp-xs */ font-size: 13px; } .prod-demo__detail-label { color: hsl(215 16% 47%); min-width: 50px; } .prod-demo__detail-value { color: hsl(222 47% 11%); } /* ── Footer: system token padding, ARBITRARY button sizing ── */ .prod-demo__footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; /* vsp-xs */ padding: 8px 20px; /* vsp-xs / hsp-sm (system tokens) */ border-top: 1px solid hsl(214 32% 91%); background: hsl(210 40% 96%); } .prod-demo__btn { border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; } .prod-demo__btn--icon { width: 32px; /* ARBITRARY: icon button size */ height: 32px; /* ARBITRARY: icon button size */ padding: 6px; /* ARBITRARY: icon inner spacing */ background: hsl(210 40% 96%); color: hsl(215 16% 47%); display: flex; align-items: center; justify-content: center; } .prod-demo__btn-svg { width: 16px; /* ARBITRARY: icon size */ height: 16px; /* ARBITRARY: icon size */ } .prod-demo__btn--primary { padding: 8px 20px; /* vsp-xs / hsp-sm (system tokens) */ background: hsl(221 83% 53%); color: hsl(0 0% 100%); }`} height={280} /> 実際の Tailwind プロジェクトでは、このコンポーネントのクラスは次のようになります: ```html Active Edit View Profile ``` ## AIがよくやるミス ### ミス1:すべての値にトークンを作る ```html ``` 一回限りの値はトークンセットを煩雑にします。`64px` のアバターサイズはプロフィールコンポーネント内でのみ意味があります。トークンに昇格させると、他の開発者が無関係な目的に使う可能性があり、その意図が希薄化します。 ### ミス2:すべてに任意の値を使う ```html ``` トークンセットに値が存在する場合は、常にトークンを使いましょう。任意の値はトークンセットが本当にニーズをカバーしていない場合にのみ使うべきです。 ### ミス3:先行的にトークンを追加する ```html ``` この区別は使用回数の問題ではなく、その値が**デザイン上の判断**なのか**構造的な詳細**なのかの問題です。デザインが「アイコンは18pxにする」と定めているなら、1つのコンポーネントしか使っていなくても初日からトークンです。18px がたまたま特定のボタンのバランスを整えるパディングに過ぎないなら、永久に任意の値のままです。 これはアプリケーション設計における「この関数をユーティリティに切り出すべきか?」という判断と同じです。答えはコールサイトの数ではなく、その概念がシステム内で名前を持つに値するかどうかで決まります。 ## 使い分け このコンポーネント中心のアプローチは、タイトトークン戦略で作業する際にいつでも適用できます: - **新しいコンポーネントの構築** — スペーシングとカラーにはシステムトークンをデフォルトとし、グリッドカラムや装飾的な値などのレイアウト固有の詳細には任意の値を使う - **コンポーネントコードのレビュー** — 任意の値がトークンにすべきデザイン上の判断を表しているのか、ローカルに留めるべき構造的な詳細なのかを確認する - **トークンセットの定義** — トークンはデザイン上の判断(アイコンサイズ、レイアウト幅、アバターの寸法)から生まれるものであり、何個のコンポーネントが値を共有しているかを数えて決めるものではない - **既存コンポーネントのリファクタリング** — 既存のトークンに一致するハードコードされた値を探して置き換える 目標は、小さく意味のあるトークンセットを維持することです。すべてのトークンは本当のデザイン上の判断を表すことによってその存在を正当化すべきです。それ以外はすべて任意の値としてコンポーネントレベルに留めます。 width/height のサイジングにおける具体的な適用方法 — 抽象スケール層を意図的にスキップするケース — については、[Two-Tier Size Strategy](../../two-tier-size-strategy/) を参照してください。 --- # メディアクエリのベストプラクティス > Source: https://takazudomodular.com/pj/zcss/ja/docs/responsive/media-query-best-practices ## 問題 AIエージェントはメディアクエリ(media query)をレスポンシブ対応のデフォルト(かつ唯一の)ツールとして使います。任意のデバイスベースのブレークポイントを多用し、ユーザー設定クエリ(`prefers-reduced-motion`、`prefers-color-scheme`、`prefers-contrast`)を無視し、機能クエリ(`@supports`)も使いません。また、デスクトップファーストのスタイルを書いてからモバイル向けにすべてをオーバーライドする傾向があり、CSSが肥大化します。 ## 解決方法 メディアクエリはレスポンシブデザインのための多くのツールの一つとして使うべきです。ページレベルのレイアウト変更やユーザー設定の検出に使いましょう。デバイス固有のブレークポイントよりもコンテンツ駆動のブレークポイントを優先し、モバイルファーストのアプローチを採用しましょう。 Header Navigation Main Content Area Sidebar Footer `} css={` /* Mobile-first: base styles for small screens */ .layout { display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem; min-height: 300px; } .layout__header, .layout__nav, .layout__main, .layout__sidebar, .layout__footer { padding: 1rem; border-radius: 0.375rem; font-size: 0.875rem; font-weight: 600; display: flex; align-items: center; justify-content: center; } .layout__header { background: #3b82f6; color: white; } .layout__nav { background: #8b5cf6; color: white; } .layout__main { background: #22c55e; color: white; flex: 1; min-height: 100px; } .layout__sidebar { background: #f59e0b; color: white; } .layout__footer { background: #64748b; color: white; } /* Tablet: two-column layout */ @media (min-width: 600px) { .layout { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto auto 1fr auto; } .layout__header { grid-column: 1 / -1; } .layout__nav { grid-column: 1 / -1; } .layout__footer { grid-column: 1 / -1; } } /* Desktop: sidebar layout */ @media (min-width: 900px) { .layout { grid-template-columns: 1fr 200px; grid-template-rows: auto auto 1fr auto; } .layout__header { grid-column: 1 / -1; } .layout__nav { grid-column: 1 / -1; } .layout__main { grid-column: 1; } .layout__sidebar { grid-column: 2; } .layout__footer { grid-column: 1 / -1; } } `} height={350} /> ## コード例 ### モバイルファースト vs デスクトップファースト モバイルファーストは `min-width` クエリを使い、最小の画面から始めて複雑さを追加していきます: ```css /* Mobile-first: base styles are for small screens */ .layout { display: flex; flex-direction: column; gap: 1rem; } @media (min-width: 48rem) { .layout { flex-direction: row; } } @media (min-width: 64rem) { .layout { max-width: 75rem; margin-inline: auto; } } ``` デスクトップファーストは `max-width` クエリを使い、最大の画面から始めて機能を削除していきます: ```css /* Desktop-first: more overrides needed */ .layout { display: flex; flex-direction: row; max-width: 75rem; margin-inline: auto; } @media (max-width: 63.999rem) { .layout { max-width: none; } } @media (max-width: 47.999rem) { .layout { flex-direction: column; } } ``` モバイルファーストの方がCSS全体が少なくなります。ビューポートが大きくなるにつれてスタイルを追加するのであって、削除するのではないからです。 ### コンテンツ駆動のブレークポイント 特定のデバイスをターゲットにするのではなく、レイアウトが崩れる箇所にブレークポイントを追加しましょう: ```css /* Let the content dictate the breakpoint */ .article { max-width: 65ch; /* Optimal reading width */ margin-inline: auto; padding-inline: 1rem; } .article-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; } /* Add a second column when there is enough room */ @media (min-width: 55rem) { .article-grid { grid-template-columns: 1fr 20rem; } } ``` ### ユーザー設定: prefers-color-scheme ```css :root { --color-text: #1a1a1a; --color-bg: #ffffff; --color-surface: #f5f5f5; --color-border: #e0e0e0; } @media (prefers-color-scheme: dark) { :root { --color-text: #e0e0e0; --color-bg: #1a1a1a; --color-surface: #2a2a2a; --color-border: #3a3a3a; } } body { color: var(--color-text); background-color: var(--color-bg); } ``` data 属性を使って手動オーバーライドを可能にします: ```css [data-theme="light"] { --color-text: #1a1a1a; --color-bg: #ffffff; --color-surface: #f5f5f5; --color-border: #e0e0e0; } [data-theme="dark"] { --color-text: #e0e0e0; --color-bg: #1a1a1a; --color-surface: #2a2a2a; --color-border: #3a3a3a; } ``` ### ユーザー設定: prefers-reduced-motion ```css /* Remove transitions and animations for users who prefer 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; } } ``` より細やかなアプローチについては、専用の [prefers-reduced-motion](../interactive/forms-and-accessibility/prefers-reduced-motion.mdx) ページを参照してください。 ### ユーザー設定: prefers-contrast ```css @media (prefers-contrast: more) { :root { --color-text: #000000; --color-bg: #ffffff; --color-border: #000000; } .button { border: 2px solid currentColor; } } @media (prefers-contrast: less) { :root { --color-text: #333333; --color-bg: #fafafa; --color-border: #cccccc; } } ``` ### インタラクションメディアクエリ: hover と pointer ```css /* Only apply hover styles on devices that support hover */ @media (hover: hover) { .card { transition: box-shadow 0.2s ease; } .card:hover { box-shadow: 0 4px 12px rgb(0 0 0 / 0.15); } } /* Increase touch targets on coarse pointer devices */ @media (pointer: coarse) { .nav-link { min-height: 44px; padding-block: 0.75rem; } } ``` ### @supports による機能クエリ ```css /* Base layout */ .grid { display: flex; flex-wrap: wrap; gap: 1rem; } .grid > * { flex: 1 1 300px; } /* Enhanced layout for browsers with grid subgrid support */ @supports (grid-template-columns: subgrid) { .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } .grid > * { display: grid; grid-template-rows: subgrid; grid-row: span 3; } } ``` ### クエリの組み合わせ ```css /* Dark mode + reduced motion */ @media (prefers-color-scheme: dark) and (prefers-reduced-motion: reduce) { .notification { background-color: var(--color-surface); /* No entrance animation, just appear */ } } ``` ## AIがよくやるミス - **デバイス固有のブレークポイント**: 「iPadの幅だから」という理由で `@media (max-width: 768px)` を使ってしまいます。ブレークポイントはデバイスカタログではなくコンテンツに基づくべきです。 - **デスクトップファーストのアプローチ**: まず完全なデスクトップスタイルを書いてからモバイル向けに削っていき、不要なオーバーライドを生み出してしまいます。 - **ユーザー設定を無視する**: `prefers-color-scheme`、`prefers-reduced-motion`、`prefers-contrast` クエリを一切含めません。 - **コンポーネントレイアウトにメディアクエリを使う**: `@container` の方が適切な場面で `@media` を使ってしまいます。メディアクエリはページレベルのレイアウト用、コンテナクエリはコンポーネントレベルの適応用です。 - **`@media (hover: hover)` を忘れる**: タッチデバイスでスティッキーなホバー状態を引き起こす `:hover` スタイルを追加してしまいます。 - **`@supports` を使わない**: フォールバックなし、サポートチェックなしでモダンなCSS機能を書いてしまいます。 - **ブレークポイントに `px` を使う**: ピクセルのブレークポイントはユーザーのフォントサイズ設定に応じてスケールしません。`rem` 値を使いましょう(例:`768px` ではなく `48rem`)。 - **ブレークポイントが多すぎる**: `clamp()` や固有のサイジングでフルイドな範囲を処理できるのに、5つ以上のブレークポイントを作成してしまいます。 ## 使い分け - **ページレベルのレイアウト変更**: シングルカラムとマルチカラムのページレイアウトの切り替えに使いましょう。 - **ユーザー設定の検出**: `prefers-color-scheme`、`prefers-reduced-motion`、`prefers-contrast` の検出に使いましょう。 - **入力モダリティの適応**: 入力タイプに合わせたインタラクション調整に `hover`、`pointer` を使いましょう。 - **機能検出**: 新しいCSS機能のプログレッシブエンハンスメントに `@supports` を使いましょう。 - **コンポーネントレイアウトには不向き**: 代わりにコンテナクエリを使いましょう。 - **フルイドサイジングには不向き**: ブレークポイントでのジャンプではなく `clamp()` を使いましょう。 ## 参考リンク - [Using Media Queries — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Using) - [@media — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media) - [CSS Media Queries Complete Guide for 2026 — DevToolbox](https://devtoolbox.dedyn.io/blog/css-media-queries-complete-guide) - [Solving Sticky Hover States with @media (hover: hover) — CSS-Tricks](https://css-tricks.com/solving-sticky-hover-states-with-media-hover-hover/) --- # ダークモード戦略 > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/color/dark-mode-strategies ## 問題 AIエージェントはダークモードを実装する際、スタイルシート全体を複製したり、JavaScriptを使ってすべての色宣言をオーバーライドするクラスを切り替えたりすることが多いです。結果として、冗長で壊れやすく、保守が難しいコードになります。よくあるミスとして、色を素朴に反転させる(白が黒になり、ブランドカラーはそのまま)、テキストとサーフェスの知覚的な明るさを調整しない、ダークモードで眼精疲労を引き起こすきついコントラストを作るなどがあります。 ## 解決方法 モダンCSSはダークモードに対して階層的なアプローチを提供します: 1. **`color-scheme`** — ブラウザにUA スタイル要素(フォームコントロール、スクロールバー)をライトまたはダークに調整するよう指示する 2. **`prefers-color-scheme`** — ユーザーのOS レベルのテーマ設定を検出するメディアクエリ 3. **`light-dark()`** — アクティブなカラースキームに応じて2つの色値のいずれかを返すCSS関数(Baseline 2024) 4. **CSSカスタムプロパティ** — テーマ設定の基盤で、1セットのプロパティ宣言でカラートークンを切り替えられる ## コード例 ### color-scheme: ブラウザダークモードへのオプトイン ```css /* Tell the browser this page supports both light and dark */ :root { color-scheme: light dark; } ``` この1行で、フォームコントロール、スクロールバー、その他のブラウザスタイル要素が自動的に適応します。これがないと、``、``、`` はページの背景がダークでもライトテーマのままです。 ### prefers-color-scheme メディアクエリ ```css :root { --color-bg: oklch(99% 0.005 264); --color-surface: oklch(97% 0.01 264); --color-text: oklch(20% 0.02 264); --color-text-muted: oklch(40% 0.02 264); --color-border: oklch(85% 0.01 264); --color-primary: oklch(55% 0.22 264); } @media (prefers-color-scheme: dark) { :root { --color-bg: oklch(15% 0.01 264); --color-surface: oklch(20% 0.015 264); --color-text: oklch(90% 0.01 264); --color-text-muted: oklch(65% 0.01 264); --color-border: oklch(30% 0.015 264); --color-primary: oklch(70% 0.18 264); /* Lighter primary for dark bg */ } } ``` ### light-dark() 関数 `light-dark()` は、1つの宣言に両方の色値をインラインで記述することでダークモードを簡素化します。`color-scheme` の設定が必要です。 ```css :root { color-scheme: light dark; --color-bg: light-dark(oklch(99% 0.005 264), oklch(15% 0.01 264)); --color-surface: light-dark(oklch(97% 0.01 264), oklch(20% 0.015 264)); --color-text: light-dark(oklch(20% 0.02 264), oklch(90% 0.01 264)); --color-text-muted: light-dark(oklch(40% 0.02 264), oklch(65% 0.01 264)); --color-border: light-dark(oklch(85% 0.01 264), oklch(30% 0.015 264)); --color-primary: light-dark(oklch(55% 0.22 264), oklch(70% 0.18 264)); } ``` 最初の引数がライトモードで使われ、2番目がダークモードで使われます。メディアクエリは不要です。 Light Theme Card Title Body text on a light surface. The primary color is adjusted for sufficient contrast on the light background. Primary Action Muted helper text Dark Theme Card Title Body text on a dark surface. Brand colors are lighter and less saturated to reduce eye strain and maintain contrast. Primary Action Muted helper text `} css={`.theme-demo { display: grid; grid-template-columns: 1fr 1fr; font-family: system-ui, sans-serif; } .panel { padding: 1.5rem; } .panel h3 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 1rem; font-weight: 600; } .light-theme { --bg: oklch(99% 0.005 264); --surface: oklch(97% 0.01 264); --text: oklch(20% 0.02 264); --text-muted: oklch(45% 0.02 264); --border: oklch(85% 0.01 264); --primary: oklch(55% 0.22 264); --primary-text: white; background: var(--bg); color: var(--text); } .dark-theme { --bg: oklch(15% 0.01 264); --surface: oklch(20% 0.015 264); --text: oklch(90% 0.01 264); --text-muted: oklch(60% 0.015 264); --border: oklch(30% 0.015 264); --primary: oklch(70% 0.18 264); --primary-text: oklch(15% 0.01 264); background: var(--bg); color: var(--text); } .light-theme h3 { color: oklch(50% 0.15 264); } .dark-theme h3 { color: oklch(70% 0.12 264); } .component { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; } .component h4 { margin: 0 0 0.5rem; font-size: 1.1rem; } .component p { margin: 0 0 1rem; font-size: 0.9rem; line-height: 1.5; } .btn { background: var(--primary); color: var(--primary-text); border: none; padding: 0.5rem 1.25rem; border-radius: 6px; font-size: 0.85rem; font-weight: 600; cursor: pointer; margin-right: 0.75rem; } .muted { font-size: 0.8rem; color: var(--text-muted); }`} height={300} /> ### JavaScript テーマトグル ユーザー制御のテーマ切り替え(OS設定のオーバーライド): ```html Toggle theme ``` ```css :root { color-scheme: light dark; } :root[data-theme="light"] { color-scheme: light; } :root[data-theme="dark"] { color-scheme: dark; } /* Custom properties using light-dark() respond to color-scheme */ :root { --color-bg: light-dark(oklch(99% 0.005 264), oklch(15% 0.01 264)); --color-text: light-dark(oklch(20% 0.02 264), oklch(90% 0.01 264)); } ``` ```html const toggle = document.getElementById("theme-toggle"); const root = document.documentElement; // Check for saved preference, fallback to OS preference const saved = localStorage.getItem("theme"); if (saved) { root.dataset.theme = saved; } toggle.addEventListener("click", () => { const current = root.dataset.theme; const next = current === "dark" ? "light" : current === "light" ? "dark" : window.matchMedia("(prefers-color-scheme: dark)").matches ? "light" : "dark"; root.dataset.theme = next; localStorage.setItem("theme", next); }); ``` ### 完全なダークモードトークンシステム ```css :root { color-scheme: light dark; /* Surfaces */ --surface-0: light-dark(oklch(100% 0 0), oklch(13% 0.01 264)); --surface-1: light-dark(oklch(97% 0.005 264), oklch(18% 0.012 264)); --surface-2: light-dark(oklch(94% 0.008 264), oklch(22% 0.015 264)); --surface-3: light-dark(oklch(90% 0.01 264), oklch(27% 0.018 264)); /* Text */ --text-primary: light-dark(oklch(20% 0.02 264), oklch(92% 0.01 264)); --text-secondary: light-dark(oklch(40% 0.015 264), oklch(70% 0.01 264)); --text-disabled: light-dark(oklch(60% 0.01 264), oklch(45% 0.01 264)); /* Borders */ --border-default: light-dark(oklch(85% 0.01 264), oklch(30% 0.015 264)); --border-strong: light-dark(oklch(70% 0.015 264), oklch(45% 0.02 264)); /* Brand */ --brand: light-dark(oklch(55% 0.22 264), oklch(72% 0.17 264)); --brand-hover: light-dark(oklch(48% 0.22 264), oklch(78% 0.15 264)); /* Feedback */ --success: light-dark(oklch(48% 0.15 145), oklch(70% 0.15 145)); --warning: light-dark(oklch(58% 0.18 85), oklch(75% 0.15 85)); --danger: light-dark(oklch(52% 0.2 25), oklch(70% 0.18 25)); } ``` ### ダークモードでの画像とメディア ```css /* Reduce brightness and increase contrast for images in dark mode */ @media (prefers-color-scheme: dark) { img:not([src*=".svg"]) { filter: brightness(0.9) contrast(1.05); } /* Invert dark-on-light diagrams and illustrations */ img.invertible { filter: invert(1) hue-rotate(180deg); } } ``` ### 誤ったテーマのフラッシュ(FOWT)の防止 ```html (function () { const saved = localStorage.getItem("theme"); if (saved) { document.documentElement.dataset.theme = saved; } })(); ``` ## AIがよくやるミス - `:root` に `color-scheme: light dark` を設定せず、ページがダークでもフォームコントロールやスクロールバーがライトモードのままになる - CSSカスタムプロパティを切り替える代わりに、ダークモード用にスタイルシート全体を複製している - `color-scheme` を宣言せずに `light-dark()` を使っている — `color-scheme` が設定されていないと関数はデフォルトで最初の(ライト)値を返す - 明度レベルを調整する代わりに色を素朴に反転させている(`white` ↔ `black`)— ダークモードの背景はダークグレー(純粋な黒ではない)で、テキストはオフホワイト(純粋な白ではない)であるべき - 両方のモードで同じブランドカラーを維持している — ダーク背景上の彩度の高い色は過度に鮮やかに見えるため、彩度を下げ明度を上げる必要がある - ダークモードでフォントウェイトを減らしていない — ダーク背景上のテキストは知覚的に太く見えるため、`font-weight` を30〜50単位減らすと読みやすさが向上する - ページ全体に `filter: invert(1)` を適用して「ダークモード」にしている — これは画像、動画、意図的な色を持つすべての要素を壊す - `localStorage` の代わりにJavaScript のステートにテーマ設定を保存し、ページリロード時に誤ったテーマがフラッシュする - `:root` のカスタムプロパティを活用する代わりに、JavaScriptで個々の要素の `.dark-mode` クラスを切り替えている ## 使い分け ### prefers-color-scheme - 手動トグルなしでOS設定を尊重する最もシンプルなアプローチ - 静的サイト、ブログ、ドキュメント ### light-dark() - 可読性のために両方の色値を同じ宣言に並置したい場合 - `color-scheme` を使って(`:root` または特定の要素で)モードを制御する場合 ### カスタムプロパティ + data 属性 - ユーザーが手動テーマトグルを必要とする場合 - アプリが2つ以上のテーマをサポートする場合(ライト、ダーク、ハイコントラストなど) - SPA や Web アプリケーション - これらのトークンをパレット、テーマ、コンポーネントの各レイヤーに整理する方法については、[Three-Tier Color Strategy](../three-tier-color-strategy) を参照してください ### color-scheme のみ - カスタムカラー変更なしで、ブラウザネイティブ要素のテーマ設定(フォーム、スクロールバー)のみが必要なページ ## Tailwind CSS Tailwind の `dark:` バリアントにより、ダークモードのスタイリングが簡単になります。`class` ストラテジーでは、親要素に `dark` クラスを追加すると、その中のすべての `dark:` ユーティリティがアクティブになります。 tailwind.config = { darkMode: 'class' } Light Mode Card Title Body text on a light surface with appropriate contrast. Action Muted text Dark Mode Card Title The same markup with dark: variants applied. Action Muted text `} height={280} /> ## 参考リンク - [MDN: prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-color-scheme) - [MDN: light-dark()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/light-dark) - [MDN: color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/color-scheme) - [CSS color-scheme-dependent colors with light-dark() — web.dev](https://web.dev/articles/light-dark) - [Dark Mode in CSS Guide — CSS-Tricks](https://css-tricks.com/a-complete-guide-to-dark-mode-on-the-web/) - [The ultimate guide to coding dark mode layouts in 2025 — Bootcamp](https://medium.com/design-bootcamp/the-ultimate-guide-to-implementing-dark-mode-in-2025-bbf2938d2526) --- # フィルターエフェクト > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/effects/filter-effects ## 問題 AIエージェントはCSSフィルターを活用できていないことが多く、CSS `filter` でネイティブに処理できるエフェクトに対して画像編集ツールやJavaScriptに頼ってしまいます。フィルターを使う場合でも、`filter: drop-shadow()` と `box-shadow` を混同したり、`backdrop-filter: blur()` のつもりで要素に `blur()` を適用したり、複数のフィルター関数を1つの宣言でチェーンできることを忘れたりします。`drop-shadow()` と `box-shadow` の決定的な違い — シェイプ認識 — はほとんど考慮されません。 ## 解決方法 CSS `filter` プロパティは、要素とそのすべてのコンテンツにグラフィカルなエフェクトを適用します。スペース区切りのフィルター関数リストを受け付け、順番に適用されます。主な関数には `blur()`、`brightness()`、`contrast()`、`saturate()`、`grayscale()`、`sepia()`、`hue-rotate()`、`invert()`、`opacity()`、`drop-shadow()` があります。 最も重要な区別は、`filter`(要素自体に影響)と `backdrop-filter`(要素の背後に影響)です。`filter` 内では、`drop-shadow()` が要素のアルファチャンネルの形状に追従するのに対し、`box-shadow` は常に矩形としてレンダリングされます。 ## コード例 ### 個別のフィルター関数 ```css /* Blur */ .blurred { filter: blur(4px); } /* Brightness — 1 is normal, >1 is brighter, <1 is darker */ .bright { filter: brightness(1.3); } .dimmed { filter: brightness(0.6); } /* Contrast — 1 is normal, >1 is more contrast */ .high-contrast { filter: contrast(1.5); } /* Saturate — 1 is normal, 0 is grayscale, >1 is oversaturated */ .vivid { filter: saturate(1.8); } .desaturated { filter: saturate(0.3); } /* Grayscale — 0 is normal, 1 is fully gray */ .gray { filter: grayscale(1); } /* Sepia — vintage photo tint */ .vintage { filter: sepia(0.8); } /* Hue-Rotate — shifts all colors around the color wheel */ .hue-shifted { filter: hue-rotate(90deg); } /* Invert — negative image */ .inverted { filter: invert(1); } ``` ### 複数フィルターのチェーン フィルターは左から右へ適用されます。順序が重要です。`brightness` を `contrast` の前に置く場合と `contrast` を `brightness` の前に置く場合では結果が異なります。 ```css /* Vibrant, slightly warm look */ .photo-enhance { filter: contrast(1.1) saturate(1.3) brightness(1.05); } /* Muted vintage effect */ .photo-vintage { filter: sepia(0.4) contrast(0.9) brightness(1.1) saturate(0.8); } /* Dramatic noir */ .photo-noir { filter: grayscale(1) contrast(1.4) brightness(0.9); } ``` ### drop-shadow() と box-shadow の違い `box-shadow` は要素のバウンディングボックスの背後に矩形のシャドウをレンダリングします。`drop-shadow()` は透明な部分を含む要素の実際の形状に追従します(PNG/SVG画像のアルファチャンネル)。 ```css /* box-shadow — rectangle behind the entire element */ .icon-box-shadow { box-shadow: 4px 4px 8px hsl(0deg 0% 0% / 0.3); } /* drop-shadow — follows the icon's shape */ .icon-drop-shadow { filter: drop-shadow(4px 4px 8px hsl(0deg 0% 0% / 0.3)); } ``` ```html ``` 最初の画像は矩形のシャドウを持ちます。2番目の画像は星のアウトラインに沿ったシャドウを持ちます。 #### drop-shadow の構文の違い ```css /* drop-shadow does NOT support: */ /* - inset keyword */ /* - spread radius */ /* Syntax: drop-shadow(offset-x offset-y blur-radius color) */ .shadow { /* Valid */ filter: drop-shadow(2px 4px 6px hsl(0deg 0% 0% / 0.2)); /* Invalid — no spread value allowed */ /* filter: drop-shadow(2px 4px 6px 2px black); */ /* Invalid — no inset allowed */ /* filter: drop-shadow(inset 2px 4px 6px black); */ } ``` ### フィルターによるホバーエフェクト ```css .image-hover { transition: filter 0.3s ease; } /* Brighten on hover */ .image-hover:hover { filter: brightness(1.15); } ``` ```css /* Color to grayscale on idle, full color on hover */ .team-photo { filter: grayscale(1); transition: filter 0.4s ease; } .team-photo:hover { filter: grayscale(0); } ``` ```css /* Subtle zoom + brightness for image cards */ .card-image { overflow: hidden; } .card-image img { transition: filter 0.3s ease, transform 0.3s ease; } .card-image:hover img { filter: brightness(1.1) saturate(1.2); transform: scale(1.03); } ``` ### フィルターによる無効化状態 ```css .disabled { filter: grayscale(1) opacity(0.5); pointer-events: none; } ``` ### クリップされた要素の drop-shadow `drop-shadow()` は `clip-path` を尊重しますが、`box-shadow` は常に矩形としてレンダリングされます。 ```css .clipped-with-shadow { clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); filter: drop-shadow(4px 4px 8px hsl(0deg 0% 0% / 0.3)); /* Shadow follows the diamond clip shape */ } ``` 注意:`filter` は要素自体に設定する必要があります。シャドウが clip-path でカットされる場合は、要素をコンテナでラップし、`filter` をコンテナに適用しましょう。 ```css /* Shadow wrapper pattern */ .shadow-wrapper { filter: drop-shadow(4px 4px 8px hsl(0deg 0% 0% / 0.3)); } .shadow-wrapper .clipped-element { clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); background: white; } ``` ```html Diamond with shadow ``` ### ダークモード用の画像フィルターショートカット ```css /* Quick dark mode adaptation for decorative images */ @media (prefers-color-scheme: dark) { .decorative-image { filter: brightness(0.85) contrast(1.1); } } ``` ## ライブプレビュー Originalblur(4px)brightness(1.5)contrast(1.8)saturate(2)hue-rotate(90deg)`} css={` .demo { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; align-items: center; height: 100%; background: #0f172a; padding: 16px; font-family: system-ui, sans-serif; } .item { display: flex; flex-direction: column; align-items: center; gap: 6px; } .box { width: 100px; height: 80px; border-radius: 8px; background: linear-gradient(135deg, #ef4444 0%, #f59e0b 25%, #22c55e 50%, #3b82f6 75%, #8b5cf6 100%); } .blur { filter: blur(4px); } .brightness { filter: brightness(1.5); } .contrast { filter: contrast(1.8); } .saturate { filter: saturate(2); } .hue-rotate { filter: hue-rotate(90deg); } .item span { font-size: 11px; color: #94a3b8; font-family: monospace; } `} height={250} /> box-shadow (rectangular)drop-shadow (shape-aware)`} css={` .demo { display: flex; gap: 40px; justify-content: center; align-items: center; height: 100%; background: #f8fafc; padding: 24px; font-family: system-ui, sans-serif; } .col { display: flex; flex-direction: column; align-items: center; gap: 12px; } .triangle-wrapper { width: 120px; height: 120px; display: flex; justify-content: center; align-items: center; } .triangle { width: 0; height: 0; border-left: 50px solid transparent; border-right: 50px solid transparent; border-bottom: 86px solid #3b82f6; } .box-shadow-demo .triangle { box-shadow: 6px 6px 12px hsl(0deg 0% 0% / 0.3); } .drop-shadow-demo { filter: drop-shadow(6px 6px 12px hsl(0deg 0% 0% / 0.3)); } .col span { font-size: 12px; color: #475569; font-weight: 500; } `} height={250} /> ## AIがよくやるミス - **透明画像に `box-shadow` を使う** — シャドウが矩形として表示され、画像の形状が無視されます。`filter: drop-shadow()` はアルファチャンネルに追従します。 - **`filter: blur()` と `backdrop-filter: blur()` を混同する** — `filter: blur()` は要素とそのすべてのコンテンツ(テキスト含む)をブラーします。`backdrop-filter: blur()` は要素の背後にあるものだけをブラーします。 - **`drop-shadow()` にスプレッド値を追加する** — `drop-shadow()` はスプレッドパラメータをサポートしていません。AIエージェントが `box-shadow` の構文をそのまま `drop-shadow()` にコピーして無効なCSSを生成します。 - **複数の個別の `filter` 宣言を適用する** — 最後の `filter` 宣言だけが有効になります。1つの宣言で関数をチェーンしましょう:`filter: blur(2px) brightness(1.2)`。 - **フィルターの順序を考慮しない** — `brightness(0.5) contrast(2)` と `contrast(2) brightness(0.5)` は異なる結果になります。チェーンされた関数の順序は重要です。 - **`opacity` プロパティで十分なのに `filter: opacity()` を使う** — `filter: opacity()` 関数は他のフィルターとのチェーン用に存在しますが、単独のオパシティには `opacity` プロパティの方がシンプルで同等のパフォーマンスです。 - **クリップされた要素に直接 `filter: drop-shadow()` を適用する** — シャドウもクリップされる可能性があります。代わりにラッピングコンテナにフィルターを適用しましょう。 ## 使い分け - **シェイプ認識シャドウ** — 透明PNG、SVGアイコン、`clip-path` でクリップされた要素に `drop-shadow()` を使う場合 - **画像処理** — 画像編集なしでのグレースケールのチーム写真、ヴィンテージフィルター、明るさ調整 - **ホバーエフェクト** — インタラクション時に画像を明るくしたり、彩度を上げたり下げたりする場合 - **無効化状態** — `grayscale(1) opacity(0.5)` を汎用的な無効化表現として使う場合 - **ダークモード調整** — ダークテーマでの画像の明るさ/コントラストの簡易調整 - **フィルターチェーン** — CSSで直接Instagramのようなエフェクトを組み合わせる場合 ## Tailwind CSS Tailwindは一般的なCSSフィルター関数のユーティリティクラスを提供しています:`blur-*`、`brightness-*`、`contrast-*`、`grayscale`、`sepia`、`hue-rotate-*`、`invert`。これらは1つの要素で組み合わせることができます。 ### フィルターエフェクトギャラリー Original blur-sm brightness-150 contrast-200 saturate-200 grayscale sepia hue-rotate-90 `} height={270} /> ### ホバーフィルターエフェクト Grayscale → Color Brighten on hover Disabled → Active `} height={220} /> ## 参考リンク - [filter — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/filter) - [drop-shadow() — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/drop-shadow) - [CSS Filter Effects — Can I Use](https://caniuse.com/css-filters) - [CSS Image Filters: The Ultimate Guide — DEV Community](https://dev.to/satyam_gupta_0d1ff2152dcc/css-image-filters-the-ultimate-guide-to-stunning-visual-effects-in-2025-2mc4) - [filter — CSS-Tricks](https://css-tricks.com/almanac/properties/f/filter/) --- # line-heightのベストプラクティス > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/font-sizing/line-height-best-practices ## 問題 line-heightは、CSSで最も誤解されているプロパティの一つです。AIエージェントは単位付きの値(`line-height: 24px`や`line-height: 1.5em`)を頻繁に使用しますが、これは子要素のフォントサイズが異なる場合に継承の問題を引き起こします。その結果、見出し・本文・小さなテキストの間でフォントサイズが異なるコンポーネントにおいて、テキストが詰まりすぎたり間延びしたりします。 ## 解決方法 単位なしの`line-height`値を使いましょう。単位なしの値は比率として継承されるため、各要素が自身のフォントサイズに基づいて実際のline-heightを再計算します。これにより、親要素のピクセル単位で計算された`line-height`が異なるフォントサイズの子要素に継承されるという一般的なバグを防げます。 ### 単位なしと単位付きの継承の仕組み ```css /* 問題あり: 単位付きのline-height */ .parent { font-size: 16px; line-height: 24px; /* 計算値: 24px */ } .parent h2 { font-size: 32px; /* line-height: 24pxを継承 — テキストが重なる! */ } /* 正しい: 単位なしのline-height */ .parent { font-size: 16px; line-height: 1.5; /* 計算値: 24px (16 × 1.5) */ } .parent h2 { font-size: 32px; /* line-height比率1.5を継承 → 計算値: 48px (32 × 1.5) */ } ``` ## コード例 ### 要素タイプ別の推奨値 ```css :root { /* 本文テキストのベースline-height */ line-height: 1.5; } /* 本文テキスト: 最適な可読性のために1.5〜1.6 */ p, li, dd, blockquote { line-height: 1.5; } /* 見出し: 大きなテキストはleadingが少なくて済むためタイトに */ h1 { line-height: 1.1; } h2 { line-height: 1.2; } h3 { line-height: 1.3; } h4, h5, h6 { line-height: 1.4; } /* 小さなテキスト / キャプション: 可読性のためにやや広めのline-height */ small, .caption, .footnote { line-height: 1.6; } /* コードブロック: コンパクトに保つためタイトに */ pre, code { line-height: 1.4; } ``` line-height: 1.2 Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing, and letter spacing. line-height: 1.5 Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing, and letter spacing. line-height: 2.0 Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing, and letter spacing. `} css={`.lh-demo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; padding: 1.5rem; font-family: system-ui, sans-serif; } .lh-column { background: #f8f9fa; border-radius: 8px; padding: 1rem; } .lh-column h3 { font-size: 0.85rem; color: #6c63ff; margin: 0 0 0.75rem; font-weight: 600; } .lh-column p { font-size: 0.95rem; color: #333; margin: 0; } .lh-tight { line-height: 1.2; } .lh-normal { line-height: 1.5; } .lh-loose { line-height: 2.0; }`} height={320} /> ### 流体タイポグラフィとの組み合わせ ```css :root { --line-height-tight: 1.1; --line-height-snug: 1.3; --line-height-normal: 1.5; --line-height-relaxed: 1.6; --line-height-loose: 1.8; } h1 { font-size: clamp(2rem, 1.5rem + 2.5vw, 3.5rem); line-height: var(--line-height-tight); } p { font-size: clamp(1rem, 0.95rem + 0.25vw, 1.125rem); line-height: var(--line-height-normal); } ``` ### スペーシングに`lh`単位を使う `lh`単位は要素の計算されたline-heightを表し、テキストのリズムに合ったスペーシングを実現します。 ```css p { line-height: 1.5; margin-block-end: 1lh; /* マージンがテキスト1行分に等しい */ } blockquote { line-height: 1.5; padding-block: 0.5lh; /* テキスト半行分のパディング */ border-inline-start: 0.125lh solid currentColor; } ``` ## AIがよくやるミス - 単位なしの`1.5`ではなく`line-height: 24px`や`line-height: 1.5em`を使い、継承のバグを引き起こす - 見出しと本文テキストに同じ`line-height`を適用する — 見出しにはよりタイトな値(1.1〜1.3)が必要 - 本文テキストに`line-height: 1`(または`normal`キーワード)を設定する。`normal`キーワードはフォントによって通常約1.2に解決され、WCAGの推奨値を下回るため可読性に問題がある - 本文テキストに`line-height: 2`以上を使い、過剰なスペースで段落がバラバラに見える - `line-height`がインライン要素やリンクのクリッカブルエリアに影響することを忘れる - フォントの組み込みメトリクスを考慮しない — 一部のフォント(特に装飾系)は標準的なサンセリフフォントとは異なるline-height値が必要 ## 使い分け すべてのテキスト要素には意図的な`line-height`値を設定しましょう。一般的なガイドラインは次のとおりです: - **本文テキスト(段落、リスト):** 1.5〜1.6 — WCAG 1.4.12(テキストスペーシング)の要件を満たす - **見出し:** 1.1〜1.3 — 大きなテキストはタイトなleadingでも読みやすい - **ディスプレイ / ヒーローテキスト:** 1.0〜1.15 — 非常に大きなテキストはさらにタイトにできる - **小さなテキスト / キャプション:** 1.5〜1.7 — 小さなテキストは余白があると読みやすい - **UI要素(ボタン、バッジ):** 1〜1.2 — 読みやすさよりも垂直方向の中央揃えが重要な場合 ## 参考リンク - [MDN: line-height](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/line-height) - [Why should line-height be unitless in CSS?](https://www.30secondsofcode.org/css/s/unitless-line-height/) - [How to Tame Line Height in CSS — CSS-Tricks](https://css-tricks.com/how-to-tame-line-height-in-css/) - [Deep dive CSS: font metrics, line-height and vertical-align](https://iamvdo.me/en/blog/css-font-metrics-line-height-and-vertical-align) - [Line-height tricks with the lh unit — Dan Burzo](https://danburzo.ro/line-height-lh/) --- # Noto Sans Webフォントガイド > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/fonts/noto-sans-webfont-guide ## 問題 Noto Sans(特に日本語コンテンツ向けのNoto Sans JP)を読み込む際、AIエージェントはいくつかの典型的なミスを犯しがちです。実際には2〜3ウェイトしか使わないのに9ウェイトすべてを読み込む、`font-display: swap`を省略する、サブセット化しないなど、その結果としてメガバイト単位のフォントデータがページレンダリングをブロックしてしまいます。日本語の場合、デフォルトのNoto Sans JPファイルは数MBにもなり、サブセット化なしではページ上で最も大きなアセットになることすらあります。 より微妙な問題もあります。AIエージェントはデフォルトで見出しに`font-weight: bold`、本文に`font-weight: normal`を使いがちです。しかし、Noto Sansの広いウェイト幅が可能にする、より洗練された階層構造を見逃しています。例えば、本文をLight(300)にして見出しをRegular(400)にする、または本文をRegular(400)にして見出しをMedium(500)にするといったアプローチは、太字と通常体のコントラストだけでなく、ウェイトの差による視覚的な差別化を生み出します。 ## 解決方法 Noto Sansは、慎重なウェイト選択、適切なサブセット化、`font-display: swap`を使って読み込みます。フォントスタックをCSSカスタムプロパティとして定義して一貫して再利用し、単に太字と通常体を使うのではなく、目指す階層を実現するウェイトを選びましょう。 ## コード例 ### Google Fonts CDN経由での読み込み 最もシンプルな方法です。Google Fontsはサブセット化を自動的に処理し、リクエストで検出された言語に必要なグリフだけを返します。 ```html ``` ```css :root { --font-noto: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, -apple-system, 'Hiragino Sans', sans-serif; } body { font-family: var(--font-noto); font-weight: 300; /* Light — 本文テキストに余白感を生む */ } h1, h2, h3 { font-family: var(--font-noto); font-weight: 400; /* Regular — 本文より目立ちつつ太字になりすぎない */ } ``` ### @fontsource経由での読み込み(セルフホスト) @fontsourceパッケージを使うとサブセット化してセルフホストできます。必要なウェイトだけをインストールしましょう: ```bash # Noto Sans JPをインストール — ウェイト300と400のみ npm install @fontsource/noto-sans-jp ``` ```css /* 実際に使うウェイトだけをインポートする */ @import '@fontsource/noto-sans-jp/300.css'; @import '@fontsource/noto-sans-jp/400.css'; @import '@fontsource/noto-sans-jp/500.css'; ``` ### Next.jsの`next/font/google`経由での読み込み Next.jsの`next/font/google`はフォントを自動的に最適化します。セルフホスト化し、外部リクエストをなくし、クリティカルなCSSをインライン化します。 ```tsx const notoSans = Noto_Sans_JP({ weight: ['300', '400', '500'], // 使うものだけ読み込む subsets: ['latin'], variable: '--font-noto', display: 'swap', }); return ( {children} ); } ``` ```css body { font-family: var(--font-noto), 'Hiragino Sans', sans-serif; } ``` ### Docusaurusでの読み込み Docusaurusでは、フォントリンクを`docusaurus.config.ts`に追加し、`custom.css`で適用します: ```ts // docusaurus.config.ts const config = { headTags: [ { tagName: 'link', attributes: { rel: 'preconnect', href: 'https://fonts.googleapis.com', }, }, { tagName: 'link', attributes: { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: 'anonymous', }, }, { tagName: 'link', attributes: { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;500&display=swap', }, }, ], }; ``` ```css /* src/css/custom.css */ :root { --ifm-font-family-base: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, -apple-system, 'Hiragino Sans', sans-serif; --ifm-font-weight-base: 300; } h1, h2, h3, h4, h5, h6 { font-weight: 400; } ``` ### フォントウェイト階層のパターン 見出し:Noto Sans JP で読みやすいタイポグラフィ 本文テキスト:ウェイト300(Light)を使うと、文章が軽やかに見えます。長い段落でも目が疲れにくく、読みやすい印象を与えます。見出しはウェイト400(Regular)で、本文との差をつけています。 セクション見出し(Regular / 400) このパターンは、コンテンツが多いサイトや、長文記事に適しています。ウェイトの差が控えめなので、洗練された印象を与えます。The quick brown fox jumps over the lazy dog. `} css={`@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400&display=swap'); .article { padding: 2rem; max-width: 600px; font-family: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, sans-serif; background: hsl(0 0% 99%); border-radius: 8px; } .article__title { font-size: 1.5rem; font-weight: 400; line-height: 1.4; margin: 0 0 1rem; color: hsl(220 20% 15%); } .article__heading { font-size: 1.1rem; font-weight: 400; margin: 1.5rem 0 0.5rem; color: hsl(220 20% 15%); } .article__body { font-size: 1rem; font-weight: 300; line-height: 1.75; color: hsl(220 15% 30%); margin: 0 0 1rem; }`} height={380} /> 見出し:Noto Sans JP で読みやすいタイポグラフィ 本文テキスト:ウェイト400(Regular)は最も標準的な本文ウェイトです。見出しにウェイト500(Medium)を使うと、太字ほど主張せずに、しっかりした階層を表現できます。 セクション見出し(Medium / 500) このパターンは、ダッシュボードやUIが多いアプリに適しています。情報密度が高い画面でも、見出しが適切に目立ちます。The quick brown fox jumps over the lazy dog. `} css={`@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500&display=swap'); .article { padding: 2rem; max-width: 600px; font-family: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, sans-serif; background: hsl(220 20% 97%); border-radius: 8px; } .article__title { font-size: 1.5rem; font-weight: 500; line-height: 1.4; margin: 0 0 1rem; color: hsl(220 20% 15%); } .article__heading { font-size: 1.1rem; font-weight: 500; margin: 1.5rem 0 0.5rem; color: hsl(220 20% 15%); } .article__body { font-size: 1rem; font-weight: 400; line-height: 1.75; color: hsl(220 15% 30%); margin: 0 0 1rem; }`} height={380} /> ### 全ウェイトのウェイト比較 100 Thin Typography · タイポグラフィ · 활자 200 ExtraLight Typography · タイポグラフィ · 활자 300 Light Typography · タイポグラフィ · 활자 400 Regular Typography · タイポグラフィ · 활자 500 Medium Typography · タイポグラフィ · 활자 600 SemiBold Typography · タイポグラフィ · 활자 700 Bold Typography · タイポグラフィ · 활자 800 ExtraBold Typography · タイポグラフィ · 활자 900 Black Typography · タイポグラフィ · 활자 `} css={`@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100;200;300;400;500;600;700;800;900&display=swap'); .weight-showcase { padding: 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; font-family: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, sans-serif; background: hsl(0 0% 99%); } .weight-row { display: flex; align-items: baseline; gap: 1.25rem; padding: 0.4rem 0.75rem; border-radius: 6px; background: hsl(220 15% 96%); } .weight-row__label { font-size: 0.7rem; color: hsl(260 60% 55%); font-weight: 600; min-width: 120px; flex-shrink: 0; letter-spacing: 0.02em; } .weight-row__sample { font-size: 1.05rem; color: hsl(220 20% 15%); }`} height={480} /> ### CSS変数を使ったフォントスタック管理 Noto Sans JP 本文テキストのサンプルです。CSS変数を使うことで、フォントファミリーをまとめて管理できます。The quick brown fox. font-family: var(--font-sans) Monospace Fallback コードブロックや等幅表示には別のCSS変数を使います。Consistent spacing in code examples. font-family: var(--font-mono) `} css={`@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;500&display=swap'); :root { --font-sans: 'Noto Sans JP', 'Noto Sans', ui-sans-serif, system-ui, -apple-system, 'Hiragino Sans', sans-serif; --font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace; --font-weight-body: 300; --font-weight-heading: 400; } .theme-demo { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; background: hsl(0 0% 99%); } .theme-demo__card { padding: 1.25rem; border-radius: 8px; border-left: 4px solid hsl(260 60% 55%); background: hsl(220 15% 96%); } .theme-demo__card--mono { border-left-color: hsl(200 70% 50%); } .theme-demo__card-title { margin: 0 0 0.5rem; font-size: 0.85rem; font-weight: 600; color: hsl(260 60% 50%); font-family: var(--font-sans); } .theme-demo__card--mono .theme-demo__card-title { color: hsl(200 70% 45%); } .theme-demo__card-body { margin: 0 0 0.75rem; font-size: 1rem; line-height: 1.7; font-family: var(--font-sans); font-weight: var(--font-weight-body); color: hsl(220 15% 25%); } .theme-demo__card--mono .theme-demo__card-body { font-family: var(--font-mono); font-weight: 400; } .theme-demo__card-code { display: block; font-family: var(--font-mono); font-size: 0.8rem; padding: 0.4rem 0.6rem; background: hsl(220 15% 88%); border-radius: 4px; color: hsl(220 15% 35%); }`} height={340} /> ### パフォーマンス:日本語フォントのサブセット化 Noto Sans JPはすべての日本語文字をカバーしているため、フルフォントファイルは大きくなります(数MB)。Google Fontsはページ上のテキストに基づいて自動的にサブセット化して処理します。セルフホストのフォントには、`unicode-range`を使ってスクリプト別にファイルを分割できます: ```css /* ラテン文字 — 小さいサブセット */ @font-face { font-family: 'Noto Sans JP'; src: url('/fonts/noto-sans-jp-latin.woff2') format('woff2'); font-weight: 300; font-display: swap; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215; } /* 日本語文字 — 大きいファイル。日本語テキストがある場合のみ読み込まれる */ @font-face { font-family: 'Noto Sans JP'; src: url('/fonts/noto-sans-jp-japanese.woff2') format('woff2'); font-weight: 300; font-display: swap; unicode-range: U+3000-9FFF, U+F900-FAFF, U+FF00-FFEF; } ``` `display=swap`がすでに付いたGoogle FontsのURLを使う場合、このURLのアプローチが自動的に処理してくれるため、`unicode-range`を手動で管理する必要はありません。 ## AIがよくやるミス - デザインで2〜3ウェイトしか使わないのに9ウェイトすべてを読み込む(`wght@100;200;300;400;500;600;700;800;900`)— 帯域を無駄にし、日本語フォントではページの読み込みが大幅に遅くなる - Google FontsのURLに`display=swap`を指定しない — 低速な接続でFOIT(Flash of Invisible Text)が発生する - 本文が`font-weight: normal`(400)なのに見出しに`font-weight: bold`(700)を使う — 大きな段差が生まれる。300/400や400/500のように、より小さいウェイト差を使う方が洗練されている - Google Fontsの``の前に`preconnect`ヒントを含めない — フォントリクエストへの不要なレイテンシが生じる - 日本語テキストがあるページで、JPバリアントなしで`'Noto Sans'`だけを参照する — ベースのNoto Sansには日本語グリフが含まれていない - CSS変数を使わずフォントファミリーの文字列をフラットに書く — コードベース全体でフォントスタックを一貫して変更しにくくなる - サブセット化せずにNoto Sans JPをセルフホストする — ウェイトあたり4〜8MBになることがあり、サブセット化処理なしではセルフホストは現実的でない ## 使い分け ### Light本文(300) + Regular見出し(400) - 長文コンテンツ、エディトリアルサイト、ドキュメント - エレガントで洗練された外観を目指すデザイン - 明るい背景に暗い文字のカラースキームで視覚疲労を軽減したい場合 ### Regular本文(400) + Medium見出し(500) - UIが多いアプリケーション、ダッシュボード、管理画面 - 情報密度が高く、明確な階層が重要なデザイン - 本文と見出しの両方を小さいサイズでも読みやすくしたいページ ### Noto Sans JPを特に使う場合 - 日本語、韓国語、CJK文字を含むページ - スクリプト間で一貫したレンダリングが必要な多言語サイト - 個性的なデザインの声よりも、中立的で高い可読性を持つ書体を好むプロジェクト ## 参考リンク - [Noto Sans — Google Fonts](https://fonts.google.com/noto/specimen/Noto+Sans+JP) - [Optimize WebFont loading — web.dev](https://web.dev/articles/optimize-webfont-loading) - [Font best practices — web.dev](https://web.dev/articles/font-best-practices) - [@fontsource/noto-sans-jp — npm](https://www.npmjs.com/package/@fontsource/noto-sans-jp) - [next/font — Next.js docs](https://nextjs.org/docs/app/api-reference/components/font) --- # Prose Heading Spacing > Source: https://takazudomodular.com/pj/zcss/ja/docs/typography/text-control/prose-heading-spacing ## 問題 Markdown から HTML へのコンバーターは、段落、リスト、テーブル、見出しといったブロック要素をフラットに並べた構造を出力します。よくあるスペーシング戦略は、すべてのブロック要素に同じ bottom margin を設定して均一なリズムを作り、見出しにはさらに大きな top margin を加えてセクション境界を読者に認識させるというものです。 この方法は、見出しが連続するまではうまく機能します。 ```html Getting Started Prerequisites Node.js Version Install Node.js 20 or later. ``` 各見出しがそれぞれ大きな top margin を持つため、連続するとスペースが蓄積され、ページ上のどの余白よりもはるかに大きな視覚的な隙間が生まれます。flex や grid コンテナ内ではマージンの相殺が起きないため、問題はさらに悪化します。 MDX ではコンポーネントラッパーやアドモニションが見出しとコンテンツの想定されるフローを中断する可能性があり、固定的なマージンルールが壊れやすくなります。 Normal content flow Section Title Paragraph text with standard spacing below. The rhythm feels consistent and readable. Another paragraph maintains the same spacing. Next Section The extra space above the heading creates clear section separation. Consecutive headings Getting Started Prerequisites Node.js Version Each heading added its own large top margin, creating a gap that looks broken. Next Section Overview The problem repeats every time headings stack. `} css={` .prose-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .prose-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } .prose-normal h2, .prose-broken h2 { font-size: 1.3rem; line-height: 1.3; margin: 2.5rem 0 0.75rem; color: hsl(220, 25%, 15%); } .prose-normal h2:first-child, .prose-broken h2:first-child { margin-top: 0; } .prose-normal h3, .prose-broken h3 { font-size: 1.1rem; line-height: 1.3; margin: 2rem 0 0.5rem; color: hsl(220, 20%, 25%); } .prose-normal h4, .prose-broken h4 { font-size: 0.95rem; line-height: 1.3; margin: 1.5rem 0 0.5rem; color: hsl(220, 15%, 35%); } .prose-normal p, .prose-broken p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1rem; color: hsl(220, 10%, 35%); } `} height={380} /> ## 解決方法 スペーシングを個々の要素から切り離します。各要素が自分自身のマージンを持つのではなく、**兄弟要素間の関係**としてスペーシングを定義します。これが「フロー」パターンです。親コンテナのルールで隣接する子要素間のギャップを制御し、見出しがそのギャップをオーバーライドしてセクションの区切りを作ります。 連続する見出しの問題は、1 つのオーバーライドで解決できます。見出しの直後に別の見出しが続く場合、スペーシングを縮めるだけです。 本番で使える 3 つの戦略があり、それぞれトレードオフが異なります。 ## コード例 ### 戦略 1: フローユーティリティと見出しオーバーライド フローユーティリティは、コンテナにスコープされたロボトミーアウルセレクタ(`* + *`)を使います。各見出しがより大きなフロースペースを設定し、1 つのルールで連続する見出しを詰めます。 ```css .prose > * + * { margin-block-start: var(--flow-space, 1em); } /* Headings create section separation */ .prose :where(h2) { --flow-space: 2.5em; } .prose :where(h3) { --flow-space: 2em; } .prose :where(h4) { --flow-space: 1.5em; } /* Consecutive headings: tighten spacing */ .prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } .prose > :first-child { margin-block-start: 0; } ``` これが最も堅牢なアプローチです。スペーシングは一方向のマージン(`margin-block-start`)のみで制御されるため、block flow、flex、grid のいずれでも同じ動作になります。マージンの相殺に依存しません。 Without fix (flow only) Getting Started Prerequisites Required Tools Paragraph after consecutive headings. Second paragraph with normal spacing. Installation Content with standard flow spacing. With consecutive heading fix Getting Started Prerequisites Required Tools Paragraph after consecutive headings. Second paragraph with normal spacing. Installation Content with standard flow spacing. `} css={` .flow-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .flow-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } /* --- No fix version --- */ .flow-no-fix > * + * { margin-block-start: var(--flow-space, 1em); } .flow-no-fix :where(h2) { --flow-space: 2.5em; } .flow-no-fix :where(h3) { --flow-space: 2em; } .flow-no-fix :where(h4) { --flow-space: 1.5em; } .flow-no-fix > :first-child { margin-block-start: 0; } /* --- Fixed version --- */ .flow-fixed > * + * { margin-block-start: var(--flow-space, 1em); } .flow-fixed :where(h2) { --flow-space: 2.5em; } .flow-fixed :where(h3) { --flow-space: 2em; } .flow-fixed :where(h4) { --flow-space: 1.5em; } .flow-fixed :where(h2, h3, h4, h5) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } .flow-fixed > :first-child { margin-block-start: 0; } /* Shared heading/paragraph styles */ .flow-no-fix h2, .flow-fixed h2 { font-size: 1.3rem; line-height: 1.3; color: hsl(220, 25%, 15%); margin: 0; } .flow-no-fix h3, .flow-fixed h3 { font-size: 1.1rem; line-height: 1.3; color: hsl(220, 20%, 25%); margin: 0; } .flow-no-fix h4, .flow-fixed h4 { font-size: 0.95rem; line-height: 1.3; color: hsl(220, 15%, 35%); margin: 0; } .flow-no-fix p, .flow-fixed p { font-size: 0.9rem; line-height: 1.6; color: hsl(220, 10%, 35%); margin: 0; } `} height={380} /> ### 戦略 2: Tailwind Typography スタイル Tailwind Typography は異なるアプローチを取ります。見出しが top と bottom の両方のマージンを持ち、ワイルドカードルールで見出しの直後の要素の top margin をゼロにします。 ```css .prose h2 { margin-block: 2em 1em; } .prose h3 { margin-block: 1.6em 0.6em; } .prose h4 { margin-block: 1.5em 0.5em; } .prose p, .prose ul, .prose ol, .prose table, .prose pre { margin-block-end: 1em; } /* Zero out next sibling's top margin after any heading */ .prose :is(h2, h3, h4) + * { margin-block-start: 0; } ``` `h2` の後に `h3` が来ると、`h3` の top margin が `0` になります。残るのは `h2` の bottom margin(`1em`)のみです。これにより、明示的なペアルールなしで連続する見出しを処理できます。 トレードオフとして、見出しの後の**あらゆる**要素の top margin がゼロになります。つまり、見出し直後の最初の段落は常に見出しに密着します。これは通常望ましい動作ですが、そのギャップを個別に微調整する余地がなくなります。 Without heading + * rule Getting Started Prerequisites Required Tools Paragraph spacing controlled by own margins. Consistent bottom margin on paragraphs. Installation Heading top margins accumulate. With heading + * rule Getting Started Prerequisites Required Tools Top margin zeroed — heading's bottom margin controls gap. Consistent bottom margin on paragraphs. Installation Clean spacing everywhere. `} css={` .tw-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .tw-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } /* --- No fix --- */ .tw-no-fix h2 { font-size: 1.3rem; line-height: 1.3; margin: 2em 0 1em; color: hsl(220, 25%, 15%); } .tw-no-fix h2:first-child { margin-top: 0; } .tw-no-fix h3 { font-size: 1.1rem; line-height: 1.3; margin: 1.6em 0 0.6em; color: hsl(220, 20%, 25%); } .tw-no-fix h4 { font-size: 0.95rem; line-height: 1.3; margin: 1.5em 0 0.5em; color: hsl(220, 15%, 35%); } .tw-no-fix p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1em; color: hsl(220, 10%, 35%); } /* --- Fixed --- */ .tw-fixed h2 { font-size: 1.3rem; line-height: 1.3; margin: 2em 0 1em; color: hsl(220, 25%, 15%); } .tw-fixed h2:first-child { margin-top: 0; } .tw-fixed h3 { font-size: 1.1rem; line-height: 1.3; margin: 1.6em 0 0.6em; color: hsl(220, 20%, 25%); } .tw-fixed h4 { font-size: 0.95rem; line-height: 1.3; margin: 1.5em 0 0.5em; color: hsl(220, 15%, 35%); } .tw-fixed p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1em; color: hsl(220, 10%, 35%); } .tw-fixed :is(h2, h3, h4) + * { margin-block-start: 0; } `} height={380} /> ### 戦略 3: `:has()` — 先行する見出しをターゲットにする `:has()` セレクタを使うと、親側からの制御が可能になります。別の見出しが後に続く場合に、先行する見出しの bottom margin を縮めます。 ```css :is(h2, h3, h4):has(+ :is(h2, h3, h4, h5, h6)) { margin-block-end: 0.25em; } ``` これは連続するペアの**最初**の見出しをターゲットにし、その bottom margin を縮小します。2 番目の見出しは通常の top margin を維持します。合計すると、ギャップは妥当なサイズに収まります。 このアプローチは正確です。見出しペア間のスペーシングのみを調整し、見出しからコンテンツへのスペーシングには影響しません。ブラウザサポートはモダンブラウザで広く対応しています(Chrome 105+、Firefox 121+、Safari 15.4+)。 トレードオフとして、この戦略は両方の見出しがそれぞれマージン(top と bottom)を持つことに依存しています。flex や grid コンテナではマージンが相殺されないため、連続する見出し間のギャップは block flow よりも大きくなります。prose コンテナが flex や grid のカラムになる可能性がある場合は、フローユーティリティ(戦略 1)または Tailwind スタイル(戦略 2)を使いましょう。 Without :has() rule Getting Started Prerequisites Required Tools Paragraph after headings with default spacing. Installation Quick Setup Another section with consecutive headings. With :has() rule Getting Started Prerequisites Required Tools Paragraph spacing is unchanged — only heading pairs tightened. Installation Quick Setup Precise control over heading-to-heading gaps. `} css={` .has-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .has-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } /* Shared base styles */ .has-no-fix h2, .has-fixed h2 { font-size: 1.3rem; line-height: 1.3; margin: 2em 0 1em; color: hsl(220, 25%, 15%); } .has-no-fix h2:first-child, .has-fixed h2:first-child { margin-top: 0; } .has-no-fix h3, .has-fixed h3 { font-size: 1.1rem; line-height: 1.3; margin: 1.6em 0 0.6em; color: hsl(220, 20%, 25%); } .has-no-fix h4, .has-fixed h4 { font-size: 0.95rem; line-height: 1.3; margin: 1.5em 0 0.5em; color: hsl(220, 15%, 35%); } .has-no-fix p, .has-fixed p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1em; color: hsl(220, 10%, 35%); } /* :has() fix */ .has-fixed :is(h2, h3, h4):has(+ :is(h2, h3, h4, h5, h6)) { margin-block-end: 0.25em; } `} height={380} /> ### flex/grid でのマージン相殺の罠 通常の block flow では、隣接する垂直方向のマージンは相殺されます。大きい方のマージンが採用されます。多くの prose スペーシング戦略は、この動作に暗黙的に依存しています。しかし flex と grid コンテナでは**マージンは相殺されません**。両方のマージンがそのまま適用され、ギャップが 2 倍になります。 markdown コンテンツのコンテナが flex や grid レイアウト内(サイドバー、目次パネル、マルチカラムレイアウトなど)にレンダリングされるケースが増えているため、この点は重要です。 ```css /* This relies on margin collapse — breaks in flex/grid */ .prose-block h2 { margin-block: 2em 1em; } .prose-block h3 { margin-block: 1.6em 0.6em; } /* h2 bottom (1em) + h3 top (1.6em) = 1.6em in block flow (collapsed) */ /* h2 bottom (1em) + h3 top (1.6em) = 2.6em in flex/grid (stacked) */ /* This works everywhere — only one margin per gap */ .prose-flow > * + * { margin-block-start: var(--flow-space, 1em); } ``` Block flow (margins collapse) Section A Subsection In block flow, h2 bottom margin and h3 top margin collapse to the larger value. Section B Spacing looks reasonable. Flex container (no collapse) Section A Subsection In flex, both margins apply. The gap between headings is much larger. Section B Same CSS rules, different result. `} css={` .collapse-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .collapse-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } /* Block flow — margins collapse */ .collapse-block { display: block; } .collapse-block h2 { font-size: 1.3rem; line-height: 1.3; margin: 2em 0 1em; color: hsl(220, 25%, 15%); } .collapse-block h2:first-child { margin-top: 0; } .collapse-block h3 { font-size: 1.1rem; line-height: 1.3; margin: 1.6em 0 0.6em; color: hsl(220, 20%, 25%); } .collapse-block p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1em; color: hsl(220, 10%, 35%); } /* Flex container — no margin collapse */ .collapse-flex { display: flex; flex-direction: column; } .collapse-flex h2 { font-size: 1.3rem; line-height: 1.3; margin: 2em 0 1em; color: hsl(220, 25%, 15%); } .collapse-flex h2:first-child { margin-top: 0; } .collapse-flex h3 { font-size: 1.1rem; line-height: 1.3; margin: 1.6em 0 0.6em; color: hsl(220, 20%, 25%); } .collapse-flex p { font-size: 0.9rem; line-height: 1.6; margin: 0 0 1em; color: hsl(220, 10%, 35%); } `} height={350} /> ### 本番向けの組み合わせアプローチ 本番の prose コンテナでは、フローユーティリティと `:has()` を組み合わせることで最大限の制御が可能です。 ```css .prose > * + * { margin-block-start: var(--flow-space, 1em); } /* Section separation before headings */ .prose :where(h2) { --flow-space: 2.5em; } .prose :where(h3) { --flow-space: 2em; } .prose :where(h4) { --flow-space: 1.5em; } /* Tighter gap between heading and its first content */ .prose :where(h2, h3, h4) + :where(p, ul, ol, table, pre) { --flow-space: 0.5em; } /* Consecutive headings: tight grouping */ .prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } /* Trim edges */ .prose > :first-child { margin-block-start: 0; } .prose > :last-child { margin-block-end: 0; } ``` ### 特定の見出しペアの微調整 組み合わせアプローチでは、すべての連続する見出しペアを同じスペーシングで扱います。デザインが特定のペア間で異なるスペーシングを求める場合 — たとえば、h2 と h3 の間(メインセクションからサブセクションへの遷移)は h3 と h4 の間(マイナーなネスト)より広くしたい場合 — 明示的なペアオーバーライドを追加します。 ```css /* Base: all consecutive headings get the same tight spacing */ .prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } /* Fine-tune: h2 → h3 gets slightly more room */ .prose :where(h2) + :where(h3) { --flow-space: 0.75em; } /* Fine-tune: h3 → h4 stays tighter */ .prose :where(h3) + :where(h4) { --flow-space: 0.4em; } ``` `:where()` ラッパーによって詳細度がフラットに保たれるため、宣言順で適用されるルールが決まります。ペア固有のオーバーライドは、連続する見出しの一括ルールの**後**に配置してください。 Uniform consecutive spacing Getting Started Prerequisites Node.js Version All heading pairs use the same tight gap. Configuration Basic Setup No distinction between pair types. Pair-specific tuning Getting Started Prerequisites Node.js Version h2→h3 has more room than h3→h4. Configuration Basic Setup Hierarchy is visually clearer. `} css={` .pair-demo { display: flex; gap: 32px; padding: 24px; font-family: system-ui, sans-serif; } .pair-col { flex: 1; min-width: 0; } .demo-label { font-size: 0.75rem; color: hsl(260, 60%, 55%); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 12px; font-weight: 600; } /* --- Uniform --- */ .pair-uniform > * + * { margin-block-start: var(--flow-space, 1em); } .pair-uniform :where(h2) { --flow-space: 2.5em; } .pair-uniform :where(h3) { --flow-space: 2em; } .pair-uniform :where(h4) { --flow-space: 1.5em; } .pair-uniform :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } .pair-uniform > :first-child { margin-block-start: 0; } .pair-uniform h2, .pair-tuned h2 { font-size: 1.3rem; line-height: 1.3; color: hsl(220, 25%, 15%); margin: 0; } .pair-uniform h3, .pair-tuned h3 { font-size: 1.1rem; line-height: 1.3; color: hsl(220, 20%, 25%); margin: 0; } .pair-uniform h4, .pair-tuned h4 { font-size: 0.95rem; line-height: 1.3; color: hsl(220, 15%, 35%); margin: 0; } .pair-uniform p, .pair-tuned p { font-size: 0.9rem; line-height: 1.6; color: hsl(220, 10%, 35%); margin: 0; } /* --- Tuned --- */ .pair-tuned > * + * { margin-block-start: var(--flow-space, 1em); } .pair-tuned :where(h2) { --flow-space: 2.5em; } .pair-tuned :where(h3) { --flow-space: 2em; } .pair-tuned :where(h4) { --flow-space: 1.5em; } .pair-tuned :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } .pair-tuned :where(h2) + :where(h3) { --flow-space: 0.75em; } .pair-tuned :where(h3) + :where(h4) { --flow-space: 0.4em; } .pair-tuned > :first-child { margin-block-start: 0; } `} height={340} /> これはオプションの調整レイヤーです。ほとんどの prose レイアウトは、連続する見出しに均一なスペーシングを適用すれば十分です。ペア固有のオーバーライドは、見出し遷移間の視覚的な階層の違いをデザインが明示的に求める場合にのみ追加しましょう。 ## クイックリファレンス | シナリオ | 戦略 | キーセレクタ | | --- | --- | --- | | 均一な兄弟スペーシング | フローユーティリティ | `.prose > * + * { margin-block-start: ... }` | | 見出しのセクション区切り | `--flow-space` のオーバーライド | `.prose :where(h2) { --flow-space: 2.5em }` | | 連続する見出しの詰め | 隣接見出しセレクタ | `:where(h2,h3,h4) + :where(h2,h3,h4,h5,h6)` | | 見出し後の次兄弟のゼロ化 | Tailwind スタイル | `:is(h2,h3,h4) + * { margin-block-start: 0 }` | | 先行する見出しのマージン縮小 | `:has()` セレクタ | `:is(h2,h3):has(+ :is(h3,h4,h5)) { margin-block-end: ... }` | | ペア固有の見出し調整 | 明示的なペアオーバーライド | `:where(h2) + :where(h3) { --flow-space: 0.75em }` | | flex/grid セーフなスペーシング | 単方向マージン | `margin-block-start` のみを使用し、両方向は避ける | ## AI がよくやるミス - **flex/grid コンテキストを考慮せずに見出しに `margin-top` と `margin-bottom` の両方を設定する。** block flow ではマージンが相殺されますが、flex/grid ではスタックされます。同じ CSS でも親コンテナによって異なるスペーシングになります。 - **連続する見出しのオーバーライドなしに、すべての見出しに大きな `margin-top` を追加する。** h2 + h3 + h4 のスタックで巨大なギャップが生まれます。 - **マージンの相殺をスペーシング戦略として依存する。** マージンの相殺はレイアウトコンテキストが変わると壊れる暗黙的な動作です。明示的な単方向マージンの方が予測しやすくなります。 - **同じ要素タイプに異なるマージン値を使う。** ある段落は `margin-bottom: 16px`、別の段落は `20px` になっているケースです。フローユーティリティによる均一なスペーシングでこれを防げます。 - **最初の子要素の `margin-block-start` をゼロにしない。** prose コンテナの最初の要素はコンテナの端にぴったり配置されるべきです。`:first-child { margin-block-start: 0 }` がないと、フローユーティリティが不要な上部スペースを追加します。 - **見出しからコンテンツへのスペーシングを詰めるのを忘れる。** 見出しの下のスペースは上のスペースより小さくする必要があります。見出しは後続のコンテンツに視覚的に「属する」べきであり、セクション間で等間隔に浮くべきではありません。 ## 使い分け ### フローユーティリティと見出しオーバーライド markdown/MDX の prose コンテナのデフォルトの選択肢です。どのレイアウトコンテキストでも動作します。任意の数の見出しレベルにスケールします。ドキュメントサイト、ブログ記事テンプレート、コンテンツ中心のレイアウトを構築する場合に使いましょう。 ### Tailwind Typography スタイル Tailwind の `@tailwindcss/typography` プラグインを採用する場合や、同様の opinionated な prose システムを構築する場合に使います。`heading + *` ワイルドカードはシンプルですが、見出しからコンテンツへのギャップの細かな制御ができなくなります。 ### `:has()` セレクタアプローチ block flow コンテナ内の既存の見出しマージンに対する、ターゲットを絞った修正として使います。マージン戦略を再構築せずに既存のスタイルシートにスペーシングを後付けする場合に特に有効です。フローユーティリティパターンの採用は不要です。マージンが相殺されない flex や grid コンテナでは避けてください。期待より大きなギャップが残ります。 ### 明示的なペアルール コンテンツ構造が厳密に管理されている場合(見出しのネストを制限する CMS など)かつ、見出しの組み合わせ数が少ない場合にのみ使います。任意の markdown コンテンツにはスケールしません。 ## Tailwind CSS 複雑な隣接兄弟セレクタ(`:where(h2,h3,h4,h5,h6) + :where(h2,h3,h4,h5,h6)`)は Tailwind のユーティリティクラスだけでは表現できません。3 つのアプローチがあります。 ### `@tailwindcss/typography` を使う `@tailwindcss/typography` プラグインは連続する見出しをすでに処理しています。`prose` クラスを適用すれば、見出しのスペーシングは自動的に管理されます。戦略 2 で説明した `h2 + *`、`h3 + *`、`h4 + *` のリセットパターンも含まれています。 ```html Getting Started Prerequisites Typography plugin handles the spacing. ``` ### Tailwind v4 でカスタム CSS を書く Tailwind v4 の CSS ファーストな設定では、スタイルシートにセレクタを直接書けます。見出しスペーシングのルールを `@layer` に追加します。 ```css @layer components { .prose > * + * { margin-block-start: var(--flow-space, 1em); } .prose :where(h2) { --flow-space: 2.5em; } .prose :where(h3) { --flow-space: 2em; } .prose :where(h4) { --flow-space: 1.5em; } .prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) { --flow-space: 0.5em; } .prose > :first-child { margin-block-start: 0; } } ``` `@tailwindcss/typography` を使わない場合の推奨アプローチです。Tailwind のレイヤーシステムでフルセレクタの制御が可能です。 ### シンプルなケース向けの Tailwind ユーティリティ 見出しオーバーライドが 1 レベルだけ必要なシンプルな prose レイアウトでは、Tailwind の arbitrary variant 構文が使えます。 ```html *+*]:mt-4 [&>h2]:mt-10 [&>h3]:mt-8"> Section Subsection Content ``` これは連続する見出しの問題を処理しません。対応するには arbitrary variant を以下のようにします。 ```html :is(h2,h3,h4)+:is(h2,h3,h4,h5,h6)]:mt-2"> ... ``` 動作はしますが、読みにくく保守が困難です。長い arbitrary variant よりも CSS `@layer` アプローチか `@tailwindcss/typography` を使いましょう。 Without prose class Getting Started Prerequisites Node.js Version Large gaps between consecutive headings — each heading adds its own top margin. Installation Normal spacing after a single heading. With prose class Getting Started Prerequisites Node.js Version Typography plugin tightens consecutive headings automatically. Installation Normal spacing after a single heading. `} height={400} /> ## 参考リンク - [Axiomatic CSS and Lobotomized Owls — Heydon Pickering, A List Apart](https://alistapart.com/article/axiomatic-css-and-lobotomized-owls) - [The Stack — Every Layout](https://every-layout.dev/layouts/stack/) - [CUBE CSS — Andy Bell](https://piccalil.li/blog/cube-css/) - [Tailwind Typography source (styles.js)](https://github.com/tailwindlabs/tailwindcss-typography/blob/master/src/styles.js) - [Mastering Margin Collapsing — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_box_model/Mastering_margin_collapsing) - [The :has() selector — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/:has) - [The :is() selector — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/:is) - [Everything You Need To Know About CSS Margins — Smashing Magazine](https://www.smashingmagazine.com/2019/07/margins-in-css/) --- # レスポンシブグリッドパターン > Source: https://takazudomodular.com/pj/zcss/ja/docs/responsive/responsive-grid-patterns ## 問題 レスポンシブなグリッドレイアウトの作成には、従来、各ブレークポイントで異なる `grid-template-columns` 値を持つ複数のメディアクエリが必要でした。AIエージェントはほぼ常にカラム数をハードコード(例:`grid-template-columns: repeat(3, 1fr)`)してから、2カラム、1カラムに切り替えるブレークポイントを追加します。これではコンテナ幅が予想外に変わると壊れる脆いレイアウトになります。CSS Grid にはメディアクエリなしでグリッドを本質的にレスポンシブにする組み込み機能があります。 ## 解決方法 `repeat()` と `auto-fill` または `auto-fit` を `minmax()` と組み合わせることで、利用可能なスペースに基づいてカラム数を自動調整するグリッドを作成できます。これは **RAM パターン**(Repeat, Auto, Minmax)と呼ばれることがあります。 ### auto-fill vs auto-fit - **`auto-fill`**: コンテナに収まるだけのトラックを作成します。空のトラックはそのまま残りスペースを占有します。 - **`auto-fit`**: コンテナに収まるだけのトラックを作成しますが、空のトラックを幅ゼロに折りたたみ、埋まっているトラックを引き伸ばします。 グリッドのアイテム数がカラム数より少ない場合、違いが顕著になります: - `auto-fill` は空のカラムスロットを保持します(一貫したカラム幅に便利です)。 - `auto-fit` は空のスロットを折りたたみ、既存のアイテムを行全体に引き伸ばします。 Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} css={` .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); gap: 1rem; padding: 1rem; } .grid-card { color: white; font-weight: 700; font-size: 1rem; padding: 2rem 1rem; border-radius: 0.5rem; display: flex; align-items: center; justify-content: center; min-height: 80px; } `} /> ## コード例 ### 基本的なレスポンシブグリッド(RAM パターン) ```css .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1.5rem; } ``` この1行で以下のグリッドが作成されます: - 各カラムの最小幅は `250px` です。 - カラムは残りのスペースを均等に満たすように伸びます(`1fr`)。 - コンテナが縮むと、カラムは自動的に1行あたりの数が減ります。 - メディアクエリは不要です。 ### auto-fill: 一貫したカラムスロット ```css .grid-fill { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; } ``` ```html Card 1 Card 2 ``` 利用可能なスロットよりアイテムが少ない場合でも一貫したカラムサイズにしたいときは `auto-fill` を使いましょう。 ### auto-fit: アイテムが引き伸ばされて埋まる ```css .grid-fit { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } ``` ```html Card 1 Card 2 ``` アイテム数に関係なくアイテムが利用可能なスペースを埋めるように伸びてほしいときは `auto-fit` を使いましょう。 ### min() によるオーバーフローの防止 `minmax(250px, 1fr)` でよくある問題は、ビューポートが `250px` より狭いとグリッドがオーバーフローすることです。`min()` を使って修正しましょう: ```css .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr)); gap: 1.5rem; } ``` `min(250px, 100%)` により、非常に狭い画面でもカラムがコンテナ幅を超えないようになります。 ### 一貫した高さのカードグリッド ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); gap: 1.5rem; } .card { display: flex; flex-direction: column; background: var(--color-surface, #f5f5f5); border-radius: 0.5rem; overflow: hidden; } .card__body { flex: 1; padding: 1.5rem; } .card__footer { padding: 1rem 1.5rem; margin-block-start: auto; } ``` ### 最小カラム数を保証するレスポンシブグリッド 狭い画面でも最低2カラムにしたい場合があります。RAM パターンに最小サイズ用のメディアクエリだけを組み合わせましょう: ```css .grid-min-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; } @media (min-width: 40rem) { .grid-min-2 { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } } ``` ### 非対称レスポンシブレイアウト 一方のカラムを広くしたいレイアウト(例:メインコンテンツ + サイドバー)の場合: ```css .layout { display: grid; grid-template-columns: 1fr; gap: 2rem; } @media (min-width: 50rem) { .layout { grid-template-columns: 1fr 20rem; } } ``` ### サイズが異なるアイテムのデンスパッキング グリッドアイテムのスパンが異なる場合、`grid-auto-flow: dense` を使ってギャップを埋めましょう: ```css .masonry-like { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-auto-flow: dense; gap: 1rem; } .masonry-like .wide { grid-column: span 2; } .masonry-like .tall { grid-row: span 2; } ``` ### Subgrid を使ったコンテンツ揃えのレスポンシブグリッド カードのコンテンツ(タイトル、テキスト、フッター)を行をまたいで揃える必要がある場合: ```css .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: 1.5rem; } @supports (grid-template-rows: subgrid) { .card-grid { grid-template-rows: auto; } .card { display: grid; grid-template-rows: subgrid; grid-row: span 3; /* title, body, footer */ } } ``` ## AIがよくやるミス - **カラム数をハードコードする**: `auto-fill`/`auto-fit` と `minmax()` を使う代わりに、`grid-template-columns: repeat(3, 1fr)` と書いてからメディアクエリで2カラム、1カラムに変更してしまいます。 - **`auto-fill` と `auto-fit` を混同する**: 互換的に使ってしまいます。グリッドのアイテム数がカラム数より少ない場合、挙動が異なります。 - **オーバーフローを防がない**: `min(250px, 100%)` なしで `minmax(250px, 1fr)` を使い、狭いビューポートで水平オーバーフローを引き起こします。 - **グリッドレイアウトに Flexbox を使う**: CSS Grid の `auto-fill` でより簡潔に解決できるのに、`display: flex; flex-wrap: wrap` とパーセント幅や gap ハックを使ってしまいます。 - **メディアクエリを多用する**: `auto-fill`/`auto-fit` に自動処理させる代わりに、カラム数が変わるたびにブレークポイントを追加してしまいます。 - **`grid-auto-flow: dense` を無視する**: アイテムのサイズが異なるときにグリッドにギャップを残してしまい、デンスパッキングを使いません。 ## 使い分け - **カードグリッド**: 商品リスト、ブログ記事グリッド、画像ギャラリーなど、均一なアイテムのグリッドに使いましょう。 - **ダッシュボードレイアウト**: 利用可能なスペースを埋めるべきウィジェットやパネルに使いましょう。 - **`auto-fill`**: アイテムが少なくても一貫したカラム幅にしたいとき(例:構造を維持すべき商品グリッド)に使いましょう。 - **`auto-fit`**: アイテムが行を引き伸ばして埋めてほしいとき(例:1〜3個のフィーチャーカードを持つヒーローセクション)に使いましょう。 - **複雑な非対称レイアウトには不向き**: サイドバー、ヘッダー、フッターを持つレイアウトには、明示的な `grid-template-columns` と `grid-template-areas` を使いましょう。 ## Tailwind CSS Tailwind はレスポンシブブレークポイントプレフィックス(`sm:`、`md:`、`lg:`、`xl:`)を使って、異なるビューポート幅でグリッドのカラム数を変更します。ビューポートボタンを使ってグリッドのリフローを確認しましょう。 ### レスポンシブカードグリッド Card 1 Card 2 Card 3 Card 4 Card 5 Card 6 `} /> ### 非対称レイアウト Main Content Takes full width on mobile, shares space with sidebar on wider screens. Sidebar Fixed 16rem width on desktop. `} height={260} /> ## 参考リンク - [Auto-Sizing Columns in CSS Grid: auto-fill vs auto-fit — CSS-Tricks](https://css-tricks.com/auto-sizing-columns-css-grid-auto-fill-vs-auto-fit/) - [minmax() — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/minmax) - [Auto-placement in Grid Layout — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Auto-placement) - [Responsive CSS Grid Layouts — Harshal V. Ladhe](https://harshal-ladhe.netlify.app/post/responsive-css-grid-layouts) --- # カラーコントラストとアクセシビリティ > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/color/color-contrast-accessibility ## 問題 カラーコントラストはウェブ上で最も多いアクセシビリティ違反です。WebAIMの年次分析では、ホームページの80%以上に低コントラストのテキストがあることが一貫して報告されています。AIエージェントは白い背景に薄いグレーのテキスト、コントラスト要件を満たさないプレースホルダーテキスト、読みやすさよりも美観を優先した装飾的な色選び、周囲のコンテンツと区別できないインタラクティブ要素を持つデザインを頻繁に生成します。その結果、ロービジョン、色覚異常、または困難な閲覧条件(明るい日差し、暗い画面)のユーザーにとって読みにくい、または読めないテキストになります。 ## 解決方法 WCAG(Web Content Accessibility Guidelines)は、前景色と背景色の間の最小コントラスト比を定義しています。これらの比率を満たすことで、視覚障害を持つ方を含む、最も幅広いユーザーにとってテキストが読みやすくなります。 ### WCAG コントラスト要件 | レベル | 通常テキスト(< 18pt / < 14pt 太字) | 大きなテキスト(≥ 18pt / ≥ 14pt 太字) | UIコンポーネント | | ------ | ------------------------------------ | --------------------------------------- | ---------------- | | AA | 4.5:1 | 3:1 | 3:1 | | AAA | 7:1 | 4.5:1 | — | 「大きなテキスト」は 18pt(24px)以上、または 14pt(18.67px)太字以上と定義されています。 ## コード例 ### 安全なカラーの組み合わせ ```css /* PASS AA — dark text on light background */ .text-on-light { color: oklch(25% 0.02 264); /* ~#1a1a2e */ background: oklch(98% 0.005 264); /* ~#f8f8fc */ /* Contrast ratio: ~15:1 ✓ */ } /* PASS AA — light text on dark background */ .text-on-dark { color: oklch(90% 0.01 264); /* ~#e0e0f0 */ background: oklch(18% 0.015 264); /* ~#1e1e30 */ /* Contrast ratio: ~11:1 ✓ */ } /* FAIL AA — light gray on white */ .text-low-contrast { color: oklch(70% 0 0); /* ~#a0a0a0 */ background: oklch(100% 0 0); /* white */ /* Contrast ratio: ~2.6:1 ✗ */ } ``` PASS AA Dark text on light background This text has a contrast ratio of approximately 15:1 — well above the WCAG AA minimum of 4.5:1. PASS AA Light text on dark background This text has a contrast ratio of approximately 11:1 — excellent readability on dark surfaces. FAIL AA Light gray on white This text has a contrast ratio of approximately 2.6:1 — fails WCAG AA. Hard to read, especially for users with low vision. FAIL AA Low contrast colored text Yellow-toned text on a light yellow background — fails contrast requirements despite looking "colorful." `} css={`.contrast-demo { padding: 1.5rem; display: flex; flex-direction: column; gap: 0.75rem; font-family: system-ui, sans-serif; } .example { display: flex; align-items: flex-start; gap: 0.75rem; } .badge { font-size: 0.7rem; font-weight: 700; padding: 0.2rem 0.5rem; border-radius: 4px; white-space: nowrap; flex-shrink: 0; margin-top: 0.5rem; } .badge.good { background: oklch(90% 0.08 145); color: oklch(30% 0.1 145); } .badge.bad { background: oklch(90% 0.08 25); color: oklch(35% 0.15 25); } .text-sample { padding: 0.75rem 1rem; border-radius: 8px; font-size: 0.9rem; line-height: 1.5; flex: 1; } .text-sample strong { display: block; margin-bottom: 0.25rem; }`} height={370} /> ### アクセシブルなブランドカラー ```css :root { /* Brand blue — test against both light and dark backgrounds */ --brand: oklch(45% 0.2 264); /* ✓ On white (contrast ~7:1) */ --brand-on-light: oklch(45% 0.2 264); /* ✓ On dark bg (contrast ~5:1) - lighter variant needed */ --brand-on-dark: oklch(72% 0.15 264); } /* Apply contextually */ .light-section a { color: var(--brand-on-light); } .dark-section a { color: var(--brand-on-dark); } ``` ### アクセシブルなプレースホルダーテキスト ```css /* WRONG: Default placeholder is typically too light */ input::placeholder { color: oklch(75% 0 0); /* ~#b0b0b0 — fails 4.5:1 on white */ } /* CORRECT: Darker placeholder that passes contrast */ input::placeholder { color: oklch(48% 0 0); /* ~#6b6b6b — passes 4.5:1 on white */ } /* Always provide visible labels — don't rely on placeholder as label */ ``` ### アクセシブルな無効状態 ```css /* Disabled elements are exempt from WCAG contrast requirements, but they should still be distinguishable from the background */ .button:disabled { color: oklch(60% 0 0); background: oklch(90% 0 0); cursor: not-allowed; /* Contrast ~2.5:1 — enough to see it exists, clearly different from active buttons */ } /* But NEVER use low contrast for text users need to read */ ``` ### 十分なコントラストのフォーカスインジケーター ```css /* Focus ring must have 3:1 contrast against adjacent colors */ :focus-visible { outline: 2px solid oklch(45% 0.2 264); outline-offset: 2px; /* The 2px offset creates a gap, so contrast is measured against the background */ } /* High-contrast focus ring for dark backgrounds */ .dark-section :focus-visible { outline: 2px solid oklch(80% 0.15 264); outline-offset: 2px; } ``` ### リンクのコントラスト ```css /* Links in body text need 3:1 contrast against surrounding text (WCAG 1.4.1) OR a non-color visual indicator (underline) */ /* Option 1: Underlined links (recommended — color alone is not enough) */ a { color: oklch(45% 0.2 264); text-decoration: underline; } /* Option 2: If removing underline, ensure 3:1 contrast with body text AND add non-color indicator on hover/focus */ a { color: oklch(45% 0.2 264); /* Must be 3:1 against body text color */ text-decoration: none; } a:hover, a:focus { text-decoration: underline; /* Non-color indicator */ } ``` ### 色だけに頼ってはいけない ```css /* WRONG: Only color differentiates error state */ .input-error { border-color: red; } /* CORRECT: Color plus additional visual indicator */ .input-error { border-color: oklch(55% 0.22 25); border-width: 2px; /* Thicker border */ box-shadow: 0 0 0 1px oklch(55% 0.22 25); /* Additional visual cue */ } ``` ```html Name is required ``` ### OKLCH でのコントラストテスト OKLCHの明度を大まかなコントラスト予測指標として使用: ```css :root { /* Rule of thumb: ~45-50 OKLCH lightness units between bg and text roughly corresponds to WCAG AA 4.5:1 contrast */ --bg-light: oklch(97% 0.005 264); /* L: 97% */ --text-on-light: oklch(25% 0.02 264); /* L: 25% — delta: 72% ✓ */ --bg-dark: oklch(15% 0.01 264); /* L: 15% */ --text-on-dark: oklch(90% 0.01 264); /* L: 90% — delta: 75% ✓ */ /* Muted text needs extra care */ --text-muted-light: oklch(45% 0.02 264); /* L: 45% — delta from bg: 52% ✓ */ --text-muted-dark: oklch(65% 0.01 264); /* L: 65% — delta from bg: 50% ✓ */ } ``` 注意:OKLCHの明度差は近似値であり、実際のコントラスト比テストの代替ではありません。必ずコントラストチェッカーツールで確認しましょう。 ### システムレベルのハイコントラストサポート ```css /* Respect Windows High Contrast / forced-colors mode */ @media (forced-colors: active) { .button { border: 2px solid ButtonText; /* Browser enforces system colors — don't fight it */ } .icon { fill: ButtonText; /* Use system color keywords */ } } ``` ## AIがよくやるミス - 白い背景に薄いグレーテキスト(`#999`、`#aaa`、`#bbb`)を使っている — これらはすべてWCAG AA(4.5:1)に不合格 - コントラストを満たさない薄いグレーのプレースホルダーテキストを設定し、そのプレースホルダーを唯一のラベルとして使用している - テキストとボタンのコントラストが不十分なカラーボタンを生成している(例:薄い黄色のボタンに白いテキスト) - 情報を伝える唯一の手段として色を使っている — エラー状態が赤色のみ、リンクが色のみで区別されている - インタラクティブ状態のコントラストをテストしていない:hover、focus、active の色もコントラスト比を満たす必要がある - 視覚的な階層のためにテキストに `opacity` を適用している(低コントラストの色を使う代わりに)— opacity は背景に応じて予測不能にコントラストを下げる - 「ダークモード = アクセシブル」と思い込んでいる — ダークモードにも独自のコントラスト検証が必要で、多くのダークテーマはコントラスト要件を満たしていない - 継承された値がすべてのコンテキストで十分なコントラストを提供することを確認せずに `color: inherit` や `currentColor` を使っている - ボーダー、アイコン、インタラクティブ要素の境界に対する非テキストコントラスト要件(3:1)を無視している - 透明性を含む実際のレンダリング色にWCAGコントラスト比が適用されることを無視している — 変化する背景上の半透明テキストレイヤーは一部のエリアでパスし、他のエリアで不合格になる可能性がある ## 使い分け コントラストチェックはデザインの**すべてのテキストとUI要素**で行うべきです: - **本文テキスト**: 背景に対して4.5:1(AA)または7:1(AAA)を満たす必要がある - **大きな見出し**(24px以上、または18.67px太字以上): 3:1(AA)または4.5:1(AAA)を満たす必要がある - **インタラクティブコントロール**: ボーダー、アイコン、フォーカスインジケーターは隣接する色に対して3:1を満たす必要がある - **テキスト内のリンク**: 周囲の本文テキストに対して3:1のコントラストが必要、またはアンダーラインのような非カラーインジケーターを使用 - **フォームラベルとヘルプテキスト**: 標準的なテキストコントラスト要件を満たす必要がある - **プレースホルダーテキスト**: 必要な情報を伝える場合は4.5:1を満たす必要がある(より良い方法:常に可視ラベルを使用) ### 推奨テストツール - [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) — 手動チェック用 - [OKLCH Color Picker](https://oklch.com/) — OKLCH値での視覚的なコントラストプレビュー - Chrome DevTools — 要素検査でテキストのコントラスト比を表示 - Lighthouse — 自動監査でコントラストの問題をフラグ - [Colour Contrast Analyser (CCA)](https://www.tpgi.com/color-contrast-checker/) — スポイト付きデスクトップアプリ ## 参考リンク - [MDN: Color contrast — Accessibility](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Understanding_WCAG/Perceivable/Color_contrast) - [WebAIM: Contrast and Color Accessibility](https://webaim.org/articles/contrast/) - [WCAG 2.2: Success Criterion 1.4.3 Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum) - [WCAG 2.2: Success Criterion 1.4.11 Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast) - [Color Contrast Accessibility: Complete WCAG 2025 Guide — AllAccessible](https://www.allaccessible.org/blog/color-contrast-accessibility-wcag-guide-2025) - [3 color contrast mistakes designers still make — UX Collective](https://uxdesign.cc/3-color-contrast-mistakes-designers-still-make-68cc224735b3) --- # CSS 3Dトランスフォーム > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/effects/css-3d-transforms ## 問題 開発者がカードフリップ、回転パネル、キューブなどの3Dエフェクトを試みると、原因不明のバグに遭遇します。カードの裏側が鏡像として透けて見える、回転が3Dではなく平面的に見える、エフェクト全体が2D平面に潰れてしまう、などです。これらのバグは紛らわしいものです。なぜなら、個々のプロパティ(`transform`、`backface-visibility`)は単体では正しく見えるからです。根本原因は、CSS 3Dトランスフォームが4つのプロパティ — `perspective`、`transform-style: preserve-3d`、`backface-visibility`、`perspective-origin` — の**連携したシステム**を必要とし、いずれか1つでも省略や誤配置があると錯覚が壊れるということです。 ## 解決方法 CSS 3Dトランスフォームは、各プロパティに特定の役割がある4つのプロパティのシステムとして機能します。 1. **`perspective`** を親コンテナに設定し、3Dの視距離を確立する 2. **`transform-style: preserve-3d`** を回転する要素に設定し、子要素が共有3D空間でレンダリングされるようにする 3. **`backface-visibility: hidden`** をカードの各面に設定し、回転して裏向きになったときに非表示にする 4. **`perspective-origin`** を親に設定し、消失点をずらす 4つすべてが存在し、正しい要素に配置されている必要があります。`perspective` と `perspective-origin` は**親**(ビューイングコンテナ)に設定します。`transform-style` は**回転される要素**に設定します。`backface-visibility` は裏向きになったときに隠すべき**個々の面**に設定します。 ### 基本原則 #### perspective が奥行きを作る `perspective` がないと、回転はフラットな2D投影で行われます。`rotateY(45deg)` は `scaleX()` のように要素を水平方向に圧縮するだけです。親コンテナに `perspective` を追加すると、設定された距離に仮想カメラが作成され、近い端が大きく、遠い端が小さく見えるようになります。これは現実世界の遠近法と同じです。 小さい値(200〜400px)は劇的で誇張された奥行きを作ります。大きい値(800〜1200px)は繊細で自然な3Dになります。カードエフェクトのデフォルトとしては `perspective: 1000px` が適切です。 #### preserve-3d が子要素を3D空間に保つ デフォルトでは、CSSはトランスフォームされた子要素を親の2D平面にフラット化します。これが `transform-style: flat` です。表と裏のあるカードを構築する場合、両面が同じ3D空間に存在する必要があります。共有の親(カードラッパー)に `transform-style: preserve-3d` を設定すると、このフラット化が防止され、`180deg` 回転した子要素が同じ平面で重なるのではなく、実際に兄弟要素の背後に位置するようになります。 #### backface-visibility が裏面を隠す すべてのHTML要素には表面と裏面があります。デフォルトでは裏面が表示されます。表面の鏡像としてレンダリングされます。カードフリップの場合、これは表と裏のコンテンツが同時に透けて見えることを意味します。各カード面に `backface-visibility: hidden` を設定すると、90度を超えて回転したときに非表示になり、視聴者に向いている面だけが表示されます。 #### perspective-origin が消失点をずらす `perspective-origin` は、親コンテナに対する視聴者の目の位置を制御します。デフォルトは `50% 50%`(中央)です。`top left` にずらすと、左上の要素が近くに見え、右下の要素がより後退して見えます。傾いたカードのグリッドでオフセンターの視点が必要な場合に便利です。 ## ライブプレビュー ### ホバーでカードフリップ クラシックなカードフリップは4つのプロパティすべてを使います。親が `perspective` を提供し、カードラッパーが `preserve-3d` を使ってホバー時に回転し、各面が `backface-visibility: hidden` を使って正面を向いているときだけ表示されます。 Front Side Hover to flip this card Back Side Here is the hidden content `} css={` .card-flip { perspective: 1000px; width: 260px; height: 200px; margin: 40px auto; font-family: system-ui, sans-serif; } .card-flip__inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.6s ease; } .card-flip:hover .card-flip__inner, .card-flip:focus-within .card-flip__inner { transform: rotateY(180deg); } .card-flip__front, .card-flip__back { position: absolute; inset: 0; backface-visibility: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; border-radius: 12px; padding: 24px; } .card-flip__front { background: hsl(220deg 90% 56%); color: hsl(0deg 0% 100%); } .card-flip__back { background: hsl(160deg 70% 40%); color: hsl(0deg 0% 100%); transform: rotateY(180deg); } .card-flip__front h3, .card-flip__back h3 { font-size: 20px; font-weight: 700; margin: 0 0 8px; } .card-flip__front p, .card-flip__back p { font-size: 14px; opacity: 0.9; margin: 0; } `} /> ### パースペクティブの比較 小さい perspective 値は極端な遠近感を作り、大きい値はより繊細な効果を生みます。以下の2つのパネルはどちらも同じ `rotateY(40deg)` トランスフォームです。親の `perspective` 値だけが異なります。 perspective: 200px Hello perspective: 1000px Hello `} css={` .perspective-demo { display: flex; gap: 40px; justify-content: center; align-items: center; padding: 32px 20px; font-family: system-ui, sans-serif; height: 100%; } .perspective-demo__group { text-align: center; } .perspective-demo__label { font-size: 13px; font-weight: 600; color: hsl(220deg 20% 40%); margin: 0 0 16px; font-family: monospace; } .perspective-demo__stage--low { perspective: 200px; } .perspective-demo__stage--high { perspective: 1000px; } .perspective-demo__box { width: 140px; height: 140px; background: hsl(260deg 70% 60%); color: hsl(0deg 0% 100%); display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; border-radius: 12px; transform: rotateY(40deg); } `} /> ### 3Dキューブ CSSキューブは `translateZ` と `rotateX`/`rotateY` で配置された6つの面を使います。親が `perspective` を提供し、キューブラッパーが `preserve-3d` を使います。ホバーでキューブを回転させます。 Front Back Right Left Top Bottom `} css={` .cube-scene { perspective: 600px; width: 150px; height: 150px; margin: 80px auto; font-family: system-ui, sans-serif; } .cube { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transform: rotateX(-20deg) rotateY(-30deg); transition: transform 1s ease; } .cube-scene:hover .cube { transform: rotateX(-20deg) rotateY(150deg); } .cube__face { position: absolute; width: 150px; height: 150px; display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: 700; color: hsl(0deg 0% 100%); border: 2px solid hsl(0deg 0% 100% / 0.3); border-radius: 4px; } .cube__face--front { background: hsl(220deg 80% 55% / 0.9); transform: translateZ(75px); } .cube__face--back { background: hsl(220deg 80% 55% / 0.9); transform: rotateY(180deg) translateZ(75px); } .cube__face--right { background: hsl(160deg 65% 45% / 0.9); transform: rotateY(90deg) translateZ(75px); } .cube__face--left { background: hsl(160deg 65% 45% / 0.9); transform: rotateY(-90deg) translateZ(75px); } .cube__face--top { background: hsl(40deg 80% 55% / 0.9); transform: rotateX(90deg) translateZ(75px); } .cube__face--bottom { background: hsl(40deg 80% 55% / 0.9); transform: rotateX(-90deg) translateZ(75px); } `} /> ### パースペクティブオリジン `perspective-origin` は視聴者の目の位置をずらします。以下では、同じ傾いたカードのグリッドを3つの異なる視点から見ています。各グループにホバーすると、傾いたカードの遠近感がどのように異なるかを確認できます。 top left 1 2 3 4 center (default) 1 2 3 4 bottom right 1 2 3 4 `} css={` .origin-demo { display: flex; gap: 24px; justify-content: center; padding: 24px 16px; font-family: system-ui, sans-serif; height: 100%; align-items: flex-start; } .origin-demo__group { text-align: center; flex: 1; max-width: 180px; } .origin-demo__label { font-size: 12px; font-weight: 600; color: hsl(220deg 20% 40%); margin: 0 0 12px; font-family: monospace; } .origin-demo__stage { perspective: 400px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .origin-demo__stage--top-left { perspective-origin: top left; } .origin-demo__stage--center { perspective-origin: center; } .origin-demo__stage--bottom-right { perspective-origin: bottom right; } .origin-demo__card { width: 100%; aspect-ratio: 1; background: hsl(340deg 75% 55%); color: hsl(0deg 0% 100%); display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; border-radius: 8px; transform: rotateY(30deg) rotateX(10deg); transition: transform 0.4s ease; } .origin-demo__stage:hover .origin-demo__card { transform: rotateY(0deg) rotateX(0deg); } `} /> ### モーション軽減対応のフリップカード 本番用のカードフリップは `prefers-reduced-motion` を尊重すべきです。ユーザーがモーション軽減を好む場合、カードは3D回転の代わりにオパシティによるクロスフェードで表と裏を切り替えます。 Accessible Flip Hover to reveal the back Back Content Uses cross-fade when motion is reduced `} css={` .a11y-flip { perspective: 1000px; width: 280px; height: 200px; margin: 40px auto; font-family: system-ui, sans-serif; } .a11y-flip__inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.6s ease; } .a11y-flip:hover .a11y-flip__inner, .a11y-flip:focus-within .a11y-flip__inner { transform: rotateY(180deg); } .a11y-flip__front, .a11y-flip__back { position: absolute; inset: 0; backface-visibility: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; border-radius: 12px; padding: 24px; } .a11y-flip__front { background: hsl(250deg 65% 55%); color: hsl(0deg 0% 100%); } .a11y-flip__back { background: hsl(20deg 85% 55%); color: hsl(0deg 0% 100%); transform: rotateY(180deg); } .a11y-flip__front h3, .a11y-flip__back h3 { font-size: 20px; font-weight: 700; margin: 0 0 8px; } .a11y-flip__front p, .a11y-flip__back p { font-size: 14px; opacity: 0.9; margin: 0; } /* Reduced motion: cross-fade instead of 3D rotation */ @media (prefers-reduced-motion: reduce) { .a11y-flip { perspective: none; } .a11y-flip__inner { transform-style: flat; transition: none; } .a11y-flip:hover .a11y-flip__inner, .a11y-flip:focus-within .a11y-flip__inner { transform: none; } .a11y-flip__front, .a11y-flip__back { backface-visibility: visible; transition: opacity 0.3s ease; } .a11y-flip__front { opacity: 1; } .a11y-flip__back { opacity: 0; transform: none; } .a11y-flip:hover .a11y-flip__front, .a11y-flip:focus-within .a11y-flip__front { opacity: 0; } .a11y-flip:hover .a11y-flip__back, .a11y-flip:focus-within .a11y-flip__back { opacity: 1; } } `} /> ## AIがよくやるミス - **親に `perspective` がない** — `perspective` を親のスタンドアロンプロパティではなく、要素自体の `transform` ショートハンドの一部として適用してしまう。`transform` 内の `perspective()` 関数はその単一の要素にのみ影響し、子要素には影響しません。 - **`transform-style: preserve-3d` を忘れる** — これがないと、子要素は親の2D平面にフラット化されます。カードフリップの構造は正しく見えますが、両面が同じ平面にレンダリングされ、回転するのではなく重なります。 - **`backface-visibility` を間違った要素に配置する** — 回転ラッパーではなく、個々の面に設定する必要があります。ラッパーに設定すると、各面を選択的に隠すのではなく、カード全体が裏返ったときに隠れてしまいます。 - **裏面を事前に回転していない** — 裏面は初期状態で `transform: rotateY(180deg)` が必要です。これにより、最初は視聴者から離れた方向を向き、ラッパーが回転したときに表示されるようになります。 - **`preserve-3d` コンテナに `overflow: hidden` を使う** — `overflow: hidden` は `transform-style: flat` を強制し、3Dエフェクトを暗黙的に壊します。クリッピングが必要な場合は、`preserve-3d` を使わない外側のラッパーに適用しましょう。 - **`prefers-reduced-motion` を無視する** — 3D回転は動揺を引き起こす可能性があります。本番のカードフリップは、ユーザーがモーション軽減を好む場合にオパシティのクロスフェードにフォールバックすべきです。 ## 使い分け - **カードフリップ** — ホバーやクリックで追加情報を表示する商品カード、フラッシュカード、プロフィールカード - **3Dショーケース** — 画像ギャラリーやフィーチャーハイライト用の回転キューブやプリズム - **パースペクティブティルト** — カーソルに向かって少し傾くホバーエフェクトで触覚的な感触を出す - **ヒーローセクション** — 大きな perspective 値を使ったスクロール時のドラマチックな3D登場アニメーション ## 注意点 - `perspective` と `perspective-origin` は回転要素ではなく**親**に設定する必要があります - `preserve-3d` 要素に `overflow: hidden` を設定すると、暗黙的に `transform-style: flat` に戻ります - `backface-visibility: hidden` は、祖先に `transform-style: preserve-3d` がないと効果がありません - ネストされた `preserve-3d` 要素は perspective を合成し、予期しない歪みを生む可能性があります - Mobile Safariには歴史的に `preserve-3d` のバグがあります。実際のiOSデバイスでテストしましょう ## 参考リンク - [perspective — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/perspective) - [transform-style — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-style) - [backface-visibility — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/backface-visibility) - [Intro to CSS 3D Transforms — David DeSandro](https://3dtransforms.desandro.com/) --- # カラーパレット戦略 > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/color/color-palette-strategy ## 問題 AIエージェントは色を個別に生成しがちです。プライマリカラーのブルーをここで選び、アクセントのグリーンをそこで選ぶ、というように、色同士の体系的な関係がありません。結果として、パレットが恣意的に感じられます。色が衝突し、調和せず、予測可能な構造もありません。パレット戦略がなければ、デザインは色数が多すぎて階層が不明確になり、セマンティックな役割(危険状態、ミュートされたテキスト色など)も欠けてしまいます。HSLの知覚的な不整合と組み合わさると、AIが生成したパレットは理論的には正しそうに見えても、見た目がおかしくなることがよくあります。 ## 解決方法 パレット戦略は3つの層で機能します。 1. **カラーハーモニー** — どの色相を使い、色相環上でどのように関係するか 2. **セマンティックアーキテクチャ** — カラーグループに役割(プライマリ、ニュートラル、フィードバック)を割り当てる 3. **知覚的一貫性** — シェードが意図通りに視覚的に見えるよう OKLCH を使う ### カラーハーモニー戦略 カラーハーモニー(color harmony)は、どの色相が組み合わさり、その理由を定義します。主要な5つの戦略は次の通りです。 - **モノクロマティック** — 1つの色相で明度とクロマを変えたもの。安全で一体感があり、衝突しない。 - **補色(Complementary)** — 色相環上で対面する2つの色相(約180°離れた)。高コントラストでエネルギッシュ。1つをドミナント、もう1つはアクセントのみに使う。 - **アナロガス(Analogous)** — 隣接する3つ以上の色相(それぞれ±30°)。自然で落ち着いた雰囲気で、背景に微妙な変化をつけるのに最適。 - **トライアディック(Triadic)** — 等間隔に並んだ3つの色相(120°ずつ)。鮮やかでバランスが良いが、制御が難しい。3つのうち2つはクロマを低くする。 - **スプリット補色(Split-complementary)** — 1つの色相と、その補色の両隣にある2つの色相(反対側から±30°)。補色の緊張感を持ちながら、不快な衝突を避けられる。 Monochromatic One hue (264°), varied lightness Complementary Blue (264°) + Yellow-green (84°) — 180° apart Analogous Adjacent hues 234°→306° (teal → blue → purple) Triadic Blue (264°) + Red-orange (24°) + Green (144°) — 120° each Split-complementary Blue (264°) + Yellow (54°) + Lime (114°) — softer than complementary `} css={`.harmonies { padding: 1.25rem; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 0.85rem; } .harmony { display: flex; align-items: center; gap: 0.75rem; } .harmony__name { font-size: 0.75rem; font-weight: 700; color: oklch(30% 0 0); width: 130px; flex-shrink: 0; } .harmony__swatches { display: flex; gap: 3px; flex: 0 0 auto; } .swatch { width: 36px; height: 36px; border-radius: 6px; } .swatch--gap { background: transparent; width: 12px; border-radius: 0; } .harmony__desc { font-size: 0.72rem; color: oklch(52% 0 0); }`} /> ### デザインカラーパレットの構築 完全なパレットは4つの層で構成されます。 #### プライマリ、セカンダリ、アクセント プライマリカラーはブランドを象徴し、インタラクティブな要素(リンク、ボタン、フォーカスリング)をすべて担います。セカンダリはサイドバーやセクションヘッダーなどの領域でプライマリを補助します。アクセントは最も重要な瞬間(CTA、バッジ、通知)を強調するもので、視覚的な領域の10%以下に抑える必要があります。 #### ニュートラル/グレースケール ニュートラルはUIの大部分を担います。背景、サーフェス、ボーダー、テキストです。近白色から近黒色まで8〜10ステップを使います。ブランドカラーの色相方向にわずかにクロマを持つニュートラル(低クロマ)は、純粋なグレーよりも洗練された印象を与えます。 #### フィードバックカラー すべてのUIはシステム状態のためのセマンティックカラーが必要です。 - **成功(Success)** — グリーン(色相約150) - **警告(Warning)** — アンバー(色相約65) - **危険/エラー(Danger/Error)** — レッド(色相約25) - **情報(Info)** — ブルー(色相約220) 同じシステムに属しているように感じられるよう、明度を統一してください。 #### 60-30-10ルール インテリアデザイン由来のクラシックな比率で、UIにも直接応用できます。 - **60%** — ドミナントニュートラル(背景、サーフェス、ページの大部分) - **30%** — セカンダリサポートカラー(サイドバー、ヘッダー、セカンダリアクション) - **10%** — アクセント(CTA、ハイライト、重要なインタラクション) Brand Dashboard Projects Team Reports Dashboard + New Project 2,847 Visitors 94% Satisfaction 12 Active 60% neutral — backgrounds & content 30% secondary — sidebar & header 10% accent — CTA only `} css={`:root { --neutral-bg: oklch(97% 0.005 264); --neutral-surface: oklch(100% 0 0); --neutral-border: oklch(88% 0.01 264); --neutral-text: oklch(22% 0.02 264); --neutral-text-muted: oklch(52% 0.015 264); --secondary: oklch(32% 0.08 264); --secondary-text: oklch(90% 0.04 264); --secondary-muted: oklch(50% 0.06 264); --accent: oklch(68% 0.22 55); --accent-text: oklch(20% 0.05 55); } .page-layout { display: flex; height: 100%; font-family: system-ui, sans-serif; background: var(--neutral-bg); } .page-layout__sidebar { width: 140px; background: var(--secondary); padding: 1rem 0.75rem; display: flex; flex-direction: column; gap: 1.25rem; flex-shrink: 0; } .sidebar-brand { font-size: 0.9rem; font-weight: 800; color: var(--secondary-text); letter-spacing: 0.05em; } .sidebar-nav { display: flex; flex-direction: column; gap: 0.2rem; } .sidebar-nav__item { font-size: 0.78rem; color: var(--secondary-muted); padding: 0.35rem 0.5rem; border-radius: 5px; cursor: pointer; text-decoration: none; } .sidebar-nav__item--active { background: oklch(50% 0.1 264 / 0.4); color: var(--secondary-text); } .page-layout__main { flex: 1; padding: 1rem 1.25rem; overflow: auto; display: flex; flex-direction: column; gap: 0.75rem; } .page-layout__header { display: flex; align-items: center; justify-content: space-between; } .page-layout__title { font-size: 1rem; font-weight: 700; color: var(--neutral-text); margin: 0; } .cta-btn { background: var(--accent); color: var(--accent-text); border: none; border-radius: 6px; padding: 0.4rem 0.75rem; font-size: 0.78rem; font-weight: 600; cursor: pointer; } .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; } .card { background: var(--neutral-surface); border: 1px solid var(--neutral-border); border-radius: 8px; padding: 0.75rem; } .card__value { font-size: 1.4rem; font-weight: 700; color: var(--neutral-text); } .card__label { font-size: 0.72rem; color: var(--neutral-text-muted); margin-top: 0.15rem; } .legend { display: flex; flex-direction: column; gap: 0.25rem; margin-top: auto; padding-top: 0.5rem; border-top: 1px solid var(--neutral-border); } .legend__item { font-size: 0.7rem; padding: 0.2rem 0.5rem; border-radius: 4px; } .legend__item--60 { background: oklch(88% 0.01 264); color: oklch(35% 0 0); } .legend__item--30 { background: oklch(32% 0.08 264); color: oklch(90% 0 0); } .legend__item--10 { background: oklch(68% 0.22 55); color: oklch(20% 0.05 55); }`} /> ## コード例 ### モノクロマティックパレットの実践 1つの色相を異なる明度で使うことで、完全なインターフェースに十分な視覚的多様性が生まれます。最も暗いシェードをテキストに、中間のシェードをインタラクティブ要素に、薄いシェードを背景や微妙なアクセントに使います。 TK Takazudo Design Engineer Active 48 Commits 7 PRs 12 Reviews View Profile Message `} css={`:root { --h: 264; --blue-50: oklch(96% 0.04 var(--h)); --blue-100: oklch(90% 0.07 var(--h)); --blue-200: oklch(80% 0.1 var(--h)); --blue-400: oklch(65% 0.16 var(--h)); --blue-600: oklch(50% 0.2 var(--h)); --blue-700: oklch(40% 0.18 var(--h)); --blue-900: oklch(25% 0.1 var(--h)); } .mono-ui { background: var(--blue-50); padding: 2rem; font-family: system-ui, sans-serif; height: 100%; box-sizing: border-box; display: flex; align-items: center; justify-content: center; } .mono-card { background: oklch(100% 0 0); border: 1px solid var(--blue-100); border-radius: 12px; padding: 1.25rem; width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 1rem; box-shadow: 0 2px 12px oklch(50% 0.1 264 / 0.1); } .mono-card__header { display: flex; align-items: center; gap: 0.75rem; } .mono-card__avatar { width: 44px; height: 44px; border-radius: 50%; background: var(--blue-600); color: oklch(97% 0.02 264); display: flex; align-items: center; justify-content: center; font-size: 0.85rem; font-weight: 700; flex-shrink: 0; } .mono-card__name { font-size: 0.95rem; font-weight: 700; color: var(--blue-900); } .mono-card__role { font-size: 0.78rem; color: var(--blue-400); } .mono-card__badge { margin-left: auto; background: var(--blue-50); color: var(--blue-700); border: 1px solid var(--blue-200); font-size: 0.7rem; font-weight: 600; padding: 0.2rem 0.5rem; border-radius: 20px; } .mono-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; background: var(--blue-50); border-radius: 8px; padding: 0.75rem; } .mono-stat__value { font-size: 1.3rem; font-weight: 700; color: var(--blue-700); } .mono-stat__label { font-size: 0.7rem; color: var(--blue-400); } .mono-card__actions { display: flex; gap: 0.5rem; } .mono-btn { flex: 1; padding: 0.5rem; border-radius: 7px; font-size: 0.8rem; font-weight: 600; cursor: pointer; border: none; } .mono-btn--primary { background: var(--blue-600); color: oklch(97% 0.02 264); } .mono-btn--ghost { background: transparent; color: var(--blue-600); border: 1.5px solid var(--blue-200); }`} /> ### 補色カラースキーム 補色を高コントラストのアクセントとして使います。自然と視線を引きつけます。ページ上で最も重要な1つのアクションのためだけに使いましょう。 New Feature Advanced Analytics Get deeper insights with real-time dashboards, custom reports, and automated anomaly detection. Available on Pro plans. Upgrade to Pro Learn more `} css={`:root { --primary: oklch(50% 0.2 264); --primary-dark: oklch(35% 0.18 264); --primary-light: oklch(93% 0.05 264); --primary-text: oklch(97% 0.02 264); --accent: oklch(68% 0.22 55); --accent-hover: oklch(62% 0.24 55); --accent-text: oklch(20% 0.08 55); } .comp-ui { background: var(--primary-light); padding: 2rem; font-family: system-ui, sans-serif; height: 100%; box-sizing: border-box; display: flex; align-items: center; justify-content: center; } .comp-card { background: oklch(100% 0 0); border-radius: 14px; overflow: hidden; max-width: 380px; width: 100%; box-shadow: 0 4px 20px oklch(50% 0.1 264 / 0.12); } .comp-card__header { background: var(--primary); padding: 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; } .comp-card__tag { background: oklch(60% 0.25 55); color: oklch(20% 0.08 55); font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; padding: 0.2rem 0.5rem; border-radius: 4px; width: fit-content; } .comp-card__title { font-size: 1.2rem; font-weight: 700; color: var(--primary-text); margin: 0; } .comp-card__body { font-size: 0.82rem; color: oklch(85% 0.05 264); margin: 0; line-height: 1.5; } .comp-card__footer { padding: 1rem 1.5rem; display: flex; gap: 0.75rem; align-items: center; } .comp-btn { border: none; border-radius: 8px; padding: 0.55rem 1rem; font-size: 0.82rem; font-weight: 600; cursor: pointer; } .comp-btn--accent { background: var(--accent); color: var(--accent-text); } .comp-btn--ghost { background: transparent; color: var(--primary); text-decoration: underline; padding-left: 0; }`} /> ### 完全なデザイントークンパレット 適切に構造化されたトークンセットは、コンポーネントを構築する前にすべてのカラーロールを定義します。この例では OKLCH を使い、すべての色相グループで知覚的な明度を統一しています。これらのトークンをパレット、テーマ、コンポーネントの各層に整理するアーキテクチャの全体像については、[Three-Tier Color Strategy](../three-tier-color-strategy) を参照してください。 ```css :root { /* プライマリ — ブルー、OKLCHの色相264 */ --color-primary-50: oklch(96% 0.04 264); --color-primary-100: oklch(90% 0.07 264); --color-primary-500: oklch(55% 0.2 264); --color-primary-700: oklch(38% 0.17 264); --color-primary-900: oklch(22% 0.1 264); /* ニュートラル — ほぼ無彩色、色相方向のティント */ --color-neutral-50: oklch(98% 0.005 264); --color-neutral-200: oklch(88% 0.01 264); --color-neutral-500: oklch(55% 0.01 264); --color-neutral-700: oklch(38% 0.015 264); --color-neutral-900: oklch(18% 0.015 264); /* フィードバック — すべての色相で同じ明度(58%) */ --color-success: oklch(58% 0.18 150); /* green */ --color-warning: oklch(58% 0.18 65); /* amber */ --color-danger: oklch(58% 0.2 25); /* red */ --color-info: oklch(58% 0.18 220); /* blue */ /* セマンティックエイリアス — コンポーネントが参照するトークン */ --color-bg: var(--color-neutral-50); --color-surface: oklch(100% 0 0); --color-border: var(--color-neutral-200); --color-text: var(--color-neutral-900); --color-text-muted: var(--color-neutral-500); --color-interactive: var(--color-primary-500); --color-interactive-fg: oklch(98% 0.01 264); } ``` Primary 50 100 200 300 500 700 900 Neutral 50 100 200 300 500 700 900 Feedback — same lightness across hues Success light Success Warning light Warning Danger light Danger Info light Info `} css={`.token-palette { padding: 1.25rem; font-family: system-ui, sans-serif; display: flex; flex-direction: column; gap: 1.1rem; background: oklch(97% 0.005 264); height: 100%; box-sizing: border-box; } .palette-group__label { font-size: 0.72rem; font-weight: 700; color: oklch(40% 0 0); text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 0.4rem; } .palette-group__swatches { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; height: 52px; } .palette-group__swatches--feedback { grid-template-columns: repeat(8, 1fr); } .token-swatch { border-radius: 5px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 4px; } .token-swatch--feedback { border-radius: 5px; } .token-swatch__label { font-size: 0.58rem; font-weight: 600; color: oklch(30% 0 0); } .token-swatch__label--light { color: oklch(95% 0 0); }`} /> ### OKLCHを使った体系的なパレット生成 OKLCHの主な利点は、明度とクロマを固定して色相を回転させると、得られるすべての色が同じ知覚的な明るさを持つことです。これが体系的な多色パレットを可能にします。 ```css /* すべての色が同じ視覚的な強さを持つカテゴリカルパレットを生成 */ :root { --palette-l: 62%; --palette-c: 0.18; --cat-red: oklch(var(--palette-l) var(--palette-c) 25); --cat-orange: oklch(var(--palette-l) var(--palette-c) 60); --cat-yellow: oklch(var(--palette-l) var(--palette-c) 90); --cat-green: oklch(var(--palette-l) var(--palette-c) 150); --cat-cyan: oklch(var(--palette-l) var(--palette-c) 200); --cat-blue: oklch(var(--palette-l) var(--palette-c) 264); --cat-purple: oklch(var(--palette-l) var(--palette-c) 310); } /* 単一のブランド色相から明度スケールを生成 */ :root { --brand-h: 264; --brand-c: 0.18; --brand-50: oklch(96% calc(var(--brand-c) * 0.3) var(--brand-h)); --brand-100: oklch(90% calc(var(--brand-c) * 0.5) var(--brand-h)); --brand-300: oklch(74% calc(var(--brand-c) * 0.8) var(--brand-h)); --brand-500: oklch(55% var(--brand-c) var(--brand-h)); --brand-700: oklch(40% calc(var(--brand-c) * 0.9) var(--brand-h)); --brand-900: oklch(24% calc(var(--brand-c) * 0.7) var(--brand-h)); } ``` ## カラーツールとリソース カラーパレットの構築、テスト、改善に役立つツールです。 - **[Adobe Color](https://color.adobe.com/)** — すべてのハーモニールールに対応したインタラクティブな色相環と、コントラスト比が不十分な色をチェックするアクセシビリティチェッカー。 - **[OKLCH Color Picker](https://oklch.com/)** — OKLCH空間で直接色を選択・調整できるツール。ガモットの可視化機能付き。OKLCHパレットの構築に欠かせません。 - **[Tailwind CSS Colors](https://tailwindcss.com/docs/colors)** — 22色相に対して丁寧に作られた10ステップのスケール。プロフェッショナルな明度スケールがどのように構成されているかを学ぶのに最適な参考資料です。 - **[Coolors](https://coolors.co/)** — ハーモニールールの提案、コントラストチェッカー、エクスポートオプションを備えた高速パレットジェネレーター。 - **[Realtime Colors](https://www.realtimecolors.com/)** — 抽象的なスウォッチではなく、実際のウェブサイトレイアウト上でパレットをプレビューできます。構築前に問題を発見するのに役立ちます。 - **[Vercel Geist Colors](https://vercel.com/geist/colors)** — Vercelのデザインシステムパレット。本番グレードのニュートラル+セマンティックトークンシステムのクリーンな事例です。 - **[Huetone](https://huetone.ardov.me/)** — APCAベースのパレットビルダー。知覚的にバランスの取れた明度スケールを、コントラストチェック機能付きで生成します。 - **[ColorSlurp](https://colorslurp.com/)** — OKLCHサポートとパレット管理機能を持つデスクトップカラーピッカー。 ## AIがよくやるミス - ハーモニーの関係を確立せずに色を1つずつ生成する — 結果はパレットではなく色の寄せ集めになる - HSLを使って「一貫した」明度スケールを作ろうとする — `hsl(60, 100%, 50%)` と `hsl(240, 100%, 50%)` の知覚的な明るさは全く異なる - 補色のアクセントを選び、UIの40%にフル彩度で使う — 補色はアクセント(10%以下)としてのみ機能する - パレットからフィードバックカラー(success、warning、danger、info)を省略する — コンポーネントが参照するトークンがなくなり、ハードコードされた一時的な色が生まれる - クロマがゼロの純粋なグレー(`oklch(n% 0 0)`)を使う — 生気がなく感じられる。ブランドカラーの色相方向にわずかにクロマを加えるとデザインに馴染む - セマンティックトークン15個の代わりに、コンポーネントに40個の一時的な色値を定義する — ブランドカラーが変わったときの保守が悪夢になる - パレットを固定されたものとして扱う — 優れたトークンシステムは `--brand-h` を1つ更新するだけでブランドカラーを全体に反映できる ## 使い分け - **デザインシステムのセットアップ** — コンポーネントを書く前に、完全なパレットをトークンとして定義する - **多色UI** — ハーモニールール(アナロガス、トライアディック)を使って競合しない色相を選ぶ - **データビジュアライゼーション** — 同じOKLCH明度のカテゴリカルパレットで、1色が他を圧倒しないようにする - **ダークモード** — 適切に構造化されたトークン層(生の値にセマンティックエイリアスを重ねた構造)で、ダークモードを完全な書き直しではなくパレットの切り替えにする - **アクセシブルな色選択** — テキストと背景の間のOKLCH明度差を45%以上に保つことで、安定したコントラストを確保する ### 完全なパレット戦略が過剰になる場合 - 1つのブランドカラーしかなく、複雑なUI状態もないシングルページのランディングサイト - トークンがメリットよりもオーバーヘッドになる簡単なプロトタイプ - OKLCHサポートが限られるメールテンプレート — hexを使い、広くテストする ## 参考リンク - [Adobe Color — color wheel and harmony tool](https://color.adobe.com/) - [OKLCH Color Picker](https://oklch.com/) - [Tailwind CSS default color palette](https://tailwindcss.com/docs/colors) - [Realtime Colors — preview palette on a live layout](https://www.realtimecolors.com/) - [Coolors — palette generator](https://coolors.co/) - [Huetone — APCA palette builder](https://huetone.ardov.me/) - [Vercel Geist Design System — Colors](https://vercel.com/geist/colors) - [MDN: oklch()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/oklch) - [OKLCH in CSS: why we moved from RGB and HSL — Evil Martians](https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl) - [Color theory for designers — Smashing Magazine](https://www.smashingmagazine.com/2010/01/color-theory-for-designers-part-1-the-meaning-of-color/) --- # @property(型付きカスタムプロパティ) > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/effects/at-property ## 問題 標準的なCSSカスタムプロパティは型がなく、ブラウザはそれらを任意の文字列として扱います。つまり、カスタムプロパティのアニメーションやトランジション(例:グラデーションのスムーズなトランジション)ができず、ブラウザが値を検証することもできず、継承の動作を制御することもできません。AIエージェントが`@property`を使うことは稀で、代わりに`@property`なら簡単に実現できるエフェクトに対してJavaScriptアニメーションや複雑な回避策に頼りがちです。 ## 解決方法 `@property`アットルールを使うと、CSSカスタムプロパティに型(`syntax`)、初期値、継承の制御を正式に登録できます。プロパティに型が付けられると、ブラウザがそれを補間できるようになり、グラデーション、グラデーション内の色、個別の数値など、これまでアニメーションが不可能だった値のスムーズなトランジションやアニメーションが可能になります。 ## コード例 ### 基本的な`@property`宣言 ```css @property --primary-color { syntax: ""; inherits: true; initial-value: #2563eb; } @property --card-radius { syntax: ""; inherits: false; initial-value: 8px; } @property --opacity-level { syntax: ""; inherits: false; initial-value: 1; } ``` ### グラデーションのアニメーション `@property`なしでは、グラデーションのトランジションは瞬時に切り替わります。型付きプロパティを使えば、スムーズな補間が可能になります。 ```css @property --gradient-start { syntax: ""; inherits: false; initial-value: #3b82f6; } @property --gradient-end { syntax: ""; inherits: false; initial-value: #8b5cf6; } .hero-banner { background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); transition: --gradient-start 0.6s, --gradient-end 0.6s; } .hero-banner:hover { --gradient-start: #ec4899; --gradient-end: #f59e0b; } ``` ### グラデーション角度のアニメーション ```css @property --angle { syntax: ""; inherits: false; initial-value: 0deg; } .rotating-gradient { background: linear-gradient(var(--angle), #3b82f6, #8b5cf6); animation: rotate-gradient 3s linear infinite; } @keyframes rotate-gradient { to { --angle: 360deg; } } ``` ### パーセンテージアニメーションによるプログレスインジケーター ```css @property --progress { syntax: ""; inherits: false; initial-value: 0%; } .progress-ring { background: conic-gradient( #2563eb var(--progress), #e5e7eb var(--progress) ); border-radius: 50%; transition: --progress 1s ease-out; } .progress-ring[data-value="75"] { --progress: 75%; } ``` ### 型安全なデザイントークン ```css @property --spacing-unit { syntax: ""; inherits: true; initial-value: 0.25rem; } @property --brand-hue { syntax: ""; inherits: true; initial-value: 220; } .design-system { --spacing-unit: 0.25rem; --brand-hue: 220; } .card { padding: calc(var(--spacing-unit) * 4); background: hsl(var(--brand-hue) 90% 95%); border: 1px solid hsl(var(--brand-hue) 60% 70%); } ``` ### 継承の制御 ```css @property --section-bg { syntax: ""; inherits: false; /* Does NOT cascade to children */ initial-value: transparent; } .section { --section-bg: #f0f9ff; background: var(--section-bg); } /* Nested sections get transparent (initial-value), not the parent's blue */ .section .section { /* --section-bg is transparent here because inherits: false */ background: var(--section-bg); } ``` ### サポートされるsyntax型 ```css /* All supported syntax descriptors */ @property --a { syntax: ""; inherits: false; initial-value: black; } @property --b { syntax: ""; inherits: false; initial-value: 0px; } @property --c { syntax: ""; inherits: false; initial-value: 0%; } @property --d { syntax: ""; inherits: false; initial-value: 0; } @property --e { syntax: ""; inherits: false; initial-value: 0; } @property --f { syntax: ""; inherits: false; initial-value: 0deg; } @property --g { syntax: ""; inherits: false; initial-value: 0s; } @property --h { syntax: ""; inherits: false; initial-value: 1dppx; } @property --i { syntax: ""; inherits: false; initial-value: 0px; } @property --j { syntax: ""; inherits: false; initial-value: url(); } @property --k { syntax: ""; inherits: false; initial-value: scale(1); } @property --l { syntax: ""; inherits: false; initial-value: none; } /* Union types */ @property --m { syntax: " | "; inherits: false; initial-value: black; } /* Universal (any value, like regular custom properties) */ @property --n { syntax: "*"; inherits: true; } ``` ## ブラウザサポート - Chrome 85+ - Edge 85+ - Firefox 128+ - Safari 15.4+ グローバルサポートは93%を超えています。Firefoxは2024年半ばにサポートを追加し、`@property`はすべての主要ブラウザで利用可能になりました。2024年7月時点でBaseline Newly availableです。 ## AIがよくやるミス - グラデーションやカスタムプロパティ値のアニメーション時に`@property`を使わず、スムーズなトランジションの代わりに瞬時の切り替えになる - 型付きカスタムプロパティがネイティブに処理できるエフェクトに対して、JavaScriptベースのアニメーション(requestAnimationFrame)を使用する - 3つのディスクリプタ(`syntax`、`inherits`、`initial-value`)がすべて必須であることを忘れる(`syntax`が`*`の場合の`initial-value`を除く) - プロパティがコンポーネントスコープであるべき場合に`inherits: true`を設定し、意図しないカスケードの漏れを引き起こす - `@property`がグラデーションのアニメーションを可能にすることを知らない — これが最もインパクトがあり、最も見落とされるユースケース - `@property`の登録をJavaScriptの`CSS.registerProperty()` APIと混同する — 静的な定義にはCSSアットルールが推奨される ## 使い分け - スムーズなグラデーションのトランジションとアニメーション(最も重要なユースケース) - conic/radialグラデーションを使ったアニメーション付きプログレスインジケーター、ローディングスピナー、ビジュアルエフェクト - ブラウザがプロパティ値を検証すべき型安全なデザイントークン - カスタムプロパティがネストされたコンポーネントにカスケードするのを防ぐための継承制御 - カスタムプロパティ値の補間が必要なあらゆるアニメーション ## ライブプレビュー Hover me Without @property, gradient transitions snap instantly. With typed properties, colors interpolate smoothly. `} css={` @property --gradient-start { syntax: ""; inherits: false; initial-value: #3b82f6; } @property --gradient-end { syntax: ""; inherits: false; initial-value: #8b5cf6; } .gradient-box { font-family: system-ui, sans-serif; background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); transition: --gradient-start 0.6s ease, --gradient-end 0.6s ease; border-radius: 16px; padding: 3rem 2rem; display: flex; align-items: center; justify-content: center; cursor: pointer; } .gradient-box:hover { --gradient-start: #ec4899; --gradient-end: #f59e0b; } .gradient-box span { color: white; font-size: 1.5rem; font-weight: 700; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 1rem; } `} /> The --angle property is typed as <angle>, enabling smooth continuous rotation via @keyframes `} css={` @property --angle { syntax: ""; inherits: false; initial-value: 0deg; } .rotating-gradient { width: 200px; height: 200px; margin: 0 auto; border-radius: 50%; background: conic-gradient(from var(--angle), #3b82f6, #8b5cf6, #ec4899, #f59e0b, #3b82f6); animation: rotate-gradient 3s linear infinite; box-shadow: 0 4px 24px rgba(59, 130, 246, 0.3); } @keyframes rotate-gradient { to { --angle: 360deg; } } .hint { font-family: system-ui, sans-serif; font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 1rem; } `} height={300} /> ## 参考リンク - [@property - MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@property) - [@property: Next-gen CSS variables now with universal browser support - web.dev](https://web.dev/blog/at-property-baseline) - [@property: giving superpowers to CSS variables - web.dev](https://web.dev/at-property) - [Providing Type Definitions for CSS with @property - Modern CSS Solutions](https://moderncss.dev/providing-type-definitions-for-css-with-at-property/) - [@property - CSS-Tricks](https://css-tricks.com/almanac/rules/p/property/) --- # 3層カラー戦略 > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/color/three-tier-color-strategy ## 問題 ウェブサイトやウェブアプリを構築する際、コンポーネントの CSS にカラー値を直接記述しがちです。ボタンに hex コードを指定し、サイドバーに別のシェードを使い、hover 状態にはさらに別の色を使います。最初はこれで問題ありませんが、2つの深刻な問題が生じます。 1. **ブランドカラーの変更にはすべてのコンポーネントを探す必要がある** — 一箇所で更新できる場所がない 2. **セマンティックな意味がない** — ボタンで `#89b4fa` を見ても、*なぜ*その色が選ばれたのか、どんな役割を果たしているのかわからない よくある改善策として、CSS カスタムプロパティのパレットを定義する方法があります(`--color-blue-500`、`--color-red-400`)。しかしパレットがあっても、コンポーネントは特定のパレットカラーに密結合してしまいます。デザインシステムが「主要なインタラクティブ要素」をブルーからインディゴに変更すると決めた場合、結局すべてのコンポーネントを検索し直すことになります。 ## 解決方法 カラーを**3つの層(ティア)**に整理し、それぞれに明確な目的を持たせます。 | ティア | 名前 | 目的 | 例 | |------|------|---------|---------| | 1 | **パレット** | 生のカラー値 — 利用可能なすべての色 | `--palette-blue-500` | | 2 | **テーマ** | セマンティックな役割 — デザインにおける各色の*意味* | `--theme-fg`, `--theme-accent` | | 3 | **コンポーネント** | スコープ付きオーバーライド — 1つのコンポーネント固有の色 | `--_button-shadow`, `--_card-highlight` | 重要なポイントは、**各層はその上の層のみを参照する**ということです。コンポーネントはテーマトークンを使います。テーマトークンはパレットカラーを指します。パレットは実際の値を保持します。 ## コード例 ### ティア 1: パレット パレットは素材そのものです。システムで利用可能なすべての色が含まれます。これらはコンポーネントで直接使いません。絵の具のチューブのようなものです。用意はしておきますが、計画なしにキャンバスに直接絞り出すことはしません。 Palette (Tier 1) These are the raw colors. Components should not use these directly. Blue Red Gray Green `} css={` :root { /* Tier 1: Palette — raw color values */ --palette-blue-100: oklch(92% 0.06 250); --palette-blue-300: oklch(74% 0.14 250); --palette-blue-500: oklch(58% 0.2 250); --palette-blue-700: oklch(42% 0.16 250); --palette-blue-900: oklch(28% 0.1 250); --palette-red-100: oklch(92% 0.05 25); --palette-red-300: oklch(72% 0.16 25); --palette-red-500: oklch(58% 0.22 25); --palette-red-700: oklch(42% 0.18 25); --palette-red-900: oklch(28% 0.12 25); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-700: oklch(40% 0.015 264); --palette-gray-900: oklch(22% 0.015 264); --palette-green-100: oklch(94% 0.06 150); --palette-green-300: oklch(76% 0.14 150); --palette-green-500: oklch(58% 0.18 150); --palette-green-700: oklch(42% 0.14 150); --palette-green-900: oklch(28% 0.1 150); } .palette-demo { padding: 1.25rem; font-family: system-ui, sans-serif; background: oklch(99% 0 0); height: 100%; box-sizing: border-box; } .palette-demo__title { margin: 0 0 0.25rem; font-size: 0.85rem; font-weight: 700; color: oklch(25% 0 0); } .palette-demo__desc { margin: 0 0 0.75rem; font-size: 0.72rem; color: oklch(50% 0 0); } .palette-demo__groups { display: flex; flex-direction: column; gap: 0.5rem; } .palette-demo__group { display: flex; align-items: center; gap: 0.6rem; } .palette-demo__label { font-size: 0.7rem; font-weight: 600; color: oklch(40% 0 0); width: 40px; flex-shrink: 0; } .palette-demo__swatches { display: flex; gap: 3px; } .swatch { width: 40px; height: 32px; border-radius: 5px; }`} /> ### ティア 2: テーマ テーマトークンはパレットカラーに**セマンティックな意味**を与えます。「blue-500」の代わりに、コンポーネントは「アクセントカラー」や「前景色」を参照します。この層がリデザインを容易にします。`--theme-accent` をブルーからインディゴに一箇所で変更するだけで、すべてのコンポーネントが更新されます。 Palette → Theme Mapping → --theme-fg → --theme-bg → --theme-accent → --theme-accent-subtle → --theme-muted → --theme-border → --theme-error → --theme-success Result: UI using Theme tokens App Dashboard Status Active Errors 3 View Details `} css={` :root { --palette-blue-100: oklch(92% 0.06 250); --palette-blue-500: oklch(58% 0.2 250); --palette-red-500: oklch(58% 0.22 25); --palette-green-500: oklch(58% 0.18 150); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-900: oklch(22% 0.015 264); /* Tier 2: Theme — semantic pointers to palette */ --theme-fg: var(--palette-gray-900); --theme-bg: var(--palette-gray-100); --theme-accent: var(--palette-blue-500); --theme-accent-subtle: var(--palette-blue-100); --theme-accent-fg: oklch(98% 0.01 250); --theme-muted: var(--palette-gray-500); --theme-border: var(--palette-gray-300); --theme-error: var(--palette-red-500); --theme-success: var(--palette-green-500); } .theme-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1rem; font-family: system-ui, sans-serif; background: oklch(99% 0 0); height: 100%; box-sizing: border-box; } .theme-demo__title { margin: 0 0 0.5rem; font-size: 0.72rem; font-weight: 700; color: oklch(30% 0 0); text-transform: uppercase; letter-spacing: 0.04em; } .mapping { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; } .mapping__from { width: 22px; height: 22px; border-radius: 4px; flex-shrink: 0; } .mapping__arrow { font-size: 0.75rem; color: oklch(60% 0 0); } .mapping__to-name { font-size: 0.75rem; font-family: monospace; color: oklch(35% 0 0); font-weight: 600; } .mini-ui { background: var(--theme-bg); border: 1px solid var(--theme-border); border-radius: 10px; overflow: hidden; } .mini-ui__header { background: var(--theme-fg); color: var(--theme-bg); padding: 0.4rem 0.6rem; display: flex; align-items: center; gap: 0.75rem; } .mini-ui__logo { font-size: 0.75rem; font-weight: 700; } .mini-ui__nav-item { font-size: 0.75rem; color: oklch(70% 0.01 264); } .mini-ui__body { padding: 0.6rem; display: flex; flex-direction: column; gap: 0.4rem; } .mini-ui__card { background: var(--theme-surface); border: 1px solid var(--theme-border); border-radius: 6px; padding: 0.4rem 0.6rem; display: flex; justify-content: space-between; align-items: center; } .mini-ui__card-title { font-size: 0.75rem; color: var(--theme-muted); } .mini-ui__card-value { font-size: 0.75rem; font-weight: 700; } .mini-ui__card-value--success { color: var(--theme-success); } .mini-ui__card-value--error { color: var(--theme-error); } .mini-ui__btn { background: var(--theme-accent); color: var(--theme-accent-fg); border: none; border-radius: 6px; padding: 0.4rem; font-size: 0.7rem; font-weight: 600; cursor: pointer; }`} /> ### ティア 3: コンポーネントスコープのカラー コンポーネントによっては、グローバルテーマに収まらない色が必要になることがあります。シャドウ、プロダクトのバリエーションカラー、微妙なグラデーションのストップなどです。これらが**ティア 3** の変数です。スコープが狭く、コンポーネント自体で定義され、テーマトークンまたはパレットトークンを参照します。 コンポーネントスコープのカスタムプロパティには、先頭にアンダースコア(`--_`)を付けてローカルスコープであることを示します。 ```css .product-card { --_card-variant: var(--palette-blue-500); --_card-shadow: oklch(58% 0.2 250 / 0.25); } ``` `--_` プレフィックスは「この変数はこのコンポーネントにローカルスコープされている」ことを読み手に伝えます。他の言語で `_privateMethod` がプライベートスコープを示すのと同様の規則です。これは CSS のルールではなく、プロジェクト内における命名規則の例です。 Tailwind + コンポーネントファーストのプロジェクト(React、Vue、Astro でユーティリティクラスを使用)では、ティア 3 のコンポーネントスコープ CSS カスタムプロパティが必要になることはほとんどありません。コンポーネントフレームワーク自体がスコープを提供するため、これらの変数を定義する別の CSS ファイルが存在しません。ティア 3 は主に一般的な CSS アプローチ(BEM、CSS Modules、バニラ CSS)で必要になります。 Ocean Wave Runner Waterproof wireless speaker $79 Sunset Wave Runner Waterproof wireless speaker $79 Forest Wave Runner Waterproof wireless speaker $79 `} css={` :root { --palette-blue-300: oklch(74% 0.14 250); --palette-blue-500: oklch(58% 0.2 250); --palette-blue-700: oklch(42% 0.16 250); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-900: oklch(22% 0.015 264); --theme-fg: var(--palette-gray-900); --theme-muted: var(--palette-gray-500); --theme-border: var(--palette-gray-300); --theme-accent: var(--palette-blue-500); } /* Tier 3: Component-scoped colors */ .product-card { /* Default variant colors — referencing palette */ --_card-variant: var(--palette-blue-500); --_card-variant-light: var(--palette-blue-300); --_card-variant-dark: var(--palette-blue-700); /* Shadow uses palette with transparency */ --_card-shadow: oklch(58% 0.2 250 / 0.25); background: oklch(100% 0 0); border: 1px solid var(--theme-border); border-radius: 12px; overflow: hidden; box-shadow: 0 4px 16px var(--_card-shadow); width: 160px; flex-shrink: 0; } /* Product color variants — only Tier 3 vars change */ .product-card--ocean { --_card-variant: oklch(58% 0.15 230); --_card-variant-light: oklch(80% 0.08 230); --_card-variant-dark: oklch(40% 0.12 230); --_card-shadow: oklch(58% 0.15 230 / 0.25); } .product-card--sunset { --_card-variant: oklch(62% 0.2 50); --_card-variant-light: oklch(82% 0.1 50); --_card-variant-dark: oklch(42% 0.15 50); --_card-shadow: oklch(62% 0.2 50 / 0.25); } .product-card--forest { --_card-variant: oklch(55% 0.15 150); --_card-variant-light: oklch(78% 0.08 150); --_card-variant-dark: oklch(38% 0.12 150); --_card-shadow: oklch(55% 0.15 150 / 0.25); } .product-card__badge { background: linear-gradient(135deg, var(--_card-variant), var(--_card-variant-dark)); color: oklch(97% 0.01 0); padding: 0.75rem; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; } .product-card__body { padding: 0.75rem; } .product-card__name { margin: 0 0 0.2rem; font-size: 0.82rem; font-weight: 700; color: var(--theme-fg); } .product-card__desc { margin: 0 0 0.5rem; font-size: 0.75rem; color: var(--theme-muted); line-height: 1.4; } .product-card__price { font-size: 0.9rem; font-weight: 700; color: var(--_card-variant-dark); } .tier3-demo { display: flex; gap: 0.75rem; padding: 1.5rem; font-family: system-ui, sans-serif; background: var(--palette-gray-100); height: 100%; box-sizing: border-box; align-items: flex-start; justify-content: center; }`} /> ### 3つのティアの連携 ここでは、3つのティアがどのように連携するかを示す完全な例を紹介します。任意の色をその出所まで簡単に追跡できることに注目してください。コンポーネントはテーマを使い、テーマはパレットを指します。 Tier 1: Palette --palette-blue-500: oklch(58% 0.2 250); --palette-gray-900: oklch(22% ...); ↓ Tier 2: Theme --theme-accent: var(--palette-blue-500); --theme-fg: var(--palette-gray-900); ↓ Tier 3: Component .btn { background: var(--theme-accent); } .btn { --_btn-shadow: ...; } MyApp Home Settings ✓ Changes saved successfully Welcome back Your project has 3 pending tasks. View Tasks Dismiss `} css={` :root { /* ===== Tier 1: Palette ===== */ --palette-blue-100: oklch(92% 0.06 250); --palette-blue-500: oklch(58% 0.2 250); --palette-blue-700: oklch(42% 0.16 250); --palette-green-100: oklch(94% 0.06 150); --palette-green-500: oklch(58% 0.18 150); --palette-gray-50: oklch(98% 0.003 264); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-800: oklch(30% 0.015 264); --palette-gray-900: oklch(22% 0.015 264); /* ===== Tier 2: Theme ===== */ --theme-fg: var(--palette-gray-900); --theme-bg: var(--palette-gray-50); --theme-surface: oklch(100% 0 0); --theme-accent: var(--palette-blue-500); --theme-accent-hover: var(--palette-blue-700); --theme-accent-subtle: var(--palette-blue-100); --theme-accent-fg: oklch(98% 0.01 250); --theme-muted: var(--palette-gray-500); --theme-border: var(--palette-gray-300); --theme-success: var(--palette-green-500); --theme-success-bg: var(--palette-green-100); } /* ===== Tier 3: Component ===== */ .demo-btn--primary { --_btn-shadow: oklch(58% 0.2 250 / 0.3); } .full-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1rem; font-family: system-ui, sans-serif; background: oklch(97% 0.005 264); height: 100%; box-sizing: border-box; } .full-demo__code { display: flex; flex-direction: column; justify-content: center; gap: 0.2rem; } .code-block { background: var(--palette-gray-800); border-radius: 6px; padding: 0.5rem 0.6rem; display: flex; flex-direction: column; gap: 0.15rem; } .code-block__label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: oklch(70% 0.15 250); margin-bottom: 0.15rem; } .code-block code { font-size: 0.75rem; color: oklch(85% 0.02 264); font-family: monospace; white-space: nowrap; } .code-block__arrow { text-align: center; font-size: 0.85rem; color: oklch(50% 0.1 250); line-height: 1; } .full-demo__result { display: flex; align-items: center; } .full-demo__ui { background: var(--theme-bg); border: 1px solid var(--theme-border); border-radius: 10px; overflow: hidden; width: 100%; } .demo-header { background: var(--theme-fg); padding: 0.45rem 0.75rem; display: flex; align-items: center; gap: 1rem; } .demo-header__brand { color: var(--theme-accent-fg); font-size: 0.8rem; font-weight: 700; } .demo-header__nav { display: flex; gap: 0.5rem; } .demo-header__link { color: oklch(70% 0.01 264); font-size: 0.75rem; cursor: pointer; } .demo-header__link--active { color: oklch(90% 0.04 250); } .demo-content { padding: 0.6rem; display: flex; flex-direction: column; gap: 0.5rem; } .demo-alert { display: flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.6rem; border-radius: 6px; font-size: 0.75rem; } .demo-alert--success { background: var(--theme-success-bg); color: var(--theme-success); } .demo-alert__icon { font-weight: 700; } .demo-card { background: var(--theme-surface); border: 1px solid var(--theme-border); border-radius: 8px; padding: 0.7rem; } .demo-card__title { margin: 0 0 0.2rem; font-size: 0.82rem; font-weight: 700; color: var(--theme-fg); } .demo-card__text { margin: 0 0 0.6rem; font-size: 0.7rem; color: var(--theme-muted); } .demo-card__actions { display: flex; gap: 0.4rem; } .demo-btn { padding: 0.35rem 0.7rem; border-radius: 6px; font-size: 0.7rem; font-weight: 600; cursor: pointer; border: none; } .demo-btn--primary { background: var(--theme-accent); color: var(--theme-accent-fg); box-shadow: 0 2px 8px var(--_btn-shadow); } .demo-btn--outline { background: transparent; color: var(--theme-accent); border: 1.5px solid var(--theme-border); }`} /> ### ティア 2 の威力: テーマの切り替え 3層システムの最大の利点は、テーマを変更するときに明らかになります。ティア 2 のポインタを再マッピングするだけで、コンポーネントの CSS に一切触れることなく、すべてのコンポーネントが更新されます。 Theme: Corporate Monthly Report Updated 2 hours ago Open Theme: Creative Monthly Report Updated 2 hours ago Open Theme: Night Monthly Report Updated 2 hours ago Open `} css={` :root { /* Tier 1: Palette (same for all themes) */ --palette-blue-500: oklch(58% 0.2 250); --palette-orange-500: oklch(65% 0.2 55); --palette-purple-500: oklch(55% 0.22 300); } .swap-demo { display: grid; grid-template-columns: repeat(3, 1fr); height: 100%; font-family: system-ui, sans-serif; } /* Tier 2: Theme variations — same component, different pointers */ .swap-col--blue { --theme-bg: oklch(96% 0.005 250); --theme-surface: oklch(100% 0 0); --theme-fg: oklch(22% 0.015 250); --theme-muted: oklch(55% 0.01 250); --theme-accent: var(--palette-blue-500); --theme-accent-fg: oklch(98% 0.01 250); --theme-border: oklch(85% 0.01 250); } .swap-col--warm { --theme-bg: oklch(96% 0.01 55); --theme-surface: oklch(100% 0 0); --theme-fg: oklch(25% 0.02 55); --theme-muted: oklch(55% 0.015 55); --theme-accent: var(--palette-orange-500); --theme-accent-fg: oklch(20% 0.05 55); --theme-border: oklch(85% 0.015 55); } .swap-col--dark { --theme-bg: oklch(18% 0.015 300); --theme-surface: oklch(24% 0.02 300); --theme-fg: oklch(90% 0.01 300); --theme-muted: oklch(60% 0.01 300); --theme-accent: var(--palette-purple-500); --theme-accent-fg: oklch(95% 0.01 300); --theme-border: oklch(32% 0.02 300); } .swap-col { background: var(--theme-bg); padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; } .swap-col__label { margin: 0; font-size: 0.7rem; font-weight: 700; color: var(--theme-muted); text-transform: uppercase; letter-spacing: 0.05em; } .swap-card { background: var(--theme-surface); border: 1px solid var(--theme-border); border-radius: 10px; padding: 0.85rem; display: flex; flex-direction: column; gap: 0.35rem; } .swap-card__title { font-size: 0.82rem; font-weight: 700; color: var(--theme-fg); } .swap-card__meta { font-size: 0.75rem; color: var(--theme-muted); } .swap-btn { margin-top: 0.4rem; background: var(--theme-accent); color: var(--theme-accent-fg); border: none; border-radius: 6px; padding: 0.4rem 0.75rem; font-size: 0.72rem; font-weight: 600; cursor: pointer; align-self: flex-start; }`} /> ### 完全な CSS コード構造 実際のプロジェクトでは、3つのティアをファイルごとに以下のように整理します。 ```css /* ===== tokens/palette.css — Tier 1 ===== */ :root { --palette-blue-100: oklch(92% 0.06 250); --palette-blue-300: oklch(74% 0.14 250); --palette-blue-500: oklch(58% 0.2 250); --palette-blue-700: oklch(42% 0.16 250); --palette-blue-900: oklch(28% 0.1 250); --palette-red-500: oklch(58% 0.22 25); --palette-green-500: oklch(58% 0.18 150); --palette-amber-500: oklch(62% 0.18 65); --palette-gray-50: oklch(98% 0.003 264); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-700: oklch(40% 0.015 264); --palette-gray-900: oklch(22% 0.015 264); } /* ===== tokens/theme.css — Tier 2 ===== */ :root { /* Layout */ --theme-fg: var(--palette-gray-900); --theme-bg: var(--palette-gray-50); --theme-surface: oklch(100% 0 0); --theme-border: var(--palette-gray-300); --theme-muted: var(--palette-gray-500); /* Interactive */ --theme-accent: var(--palette-blue-500); --theme-accent-hover: var(--palette-blue-700); --theme-accent-subtle: var(--palette-blue-100); --theme-accent-fg: oklch(98% 0.01 250); /* Feedback */ --theme-error: var(--palette-red-500); --theme-success: var(--palette-green-500); --theme-warning: var(--palette-amber-500); } /* ===== components/button.css — Tier 3 ===== */ .btn { /* Component-specific color derived from theme. Relative color syntax (oklch(from ...)) — Baseline 2024. For wider support, use a hardcoded fallback. */ --_btn-shadow: oklch(from var(--theme-accent) l c h / 0.3); background: var(--theme-accent); color: var(--theme-accent-fg); box-shadow: 0 2px 8px var(--_btn-shadow); } .btn:hover { background: var(--theme-accent-hover); } /* ===== components/product-card.css — Tier 3 ===== */ .product-card { /* Colors unique to this component, not in theme */ --_card-variant: var(--palette-blue-500); --_card-glow: oklch(from var(--_card-variant) l c h / 0.15); } .product-card--sunset { --_card-variant: oklch(62% 0.2 50); } ``` ### Tailwind CSS: カスタムテーマ設定による3層構成 3層戦略は Tailwind の設定に自然にマッピングできます。パレットカラーは `theme.colors` に配置し、テーマトークンは Tailwind クラスが参照する CSS カスタムプロパティにします。 tailwind.config = { theme: { extend: { colors: { // Tier 2: Theme tokens as Tailwind colors // These reference CSS vars set on :root 'theme-fg': 'var(--theme-fg)', 'theme-bg': 'var(--theme-bg)', 'theme-surface': 'var(--theme-surface)', 'theme-accent': 'var(--theme-accent)', 'theme-accent-hover': 'var(--theme-accent-hover)', 'theme-accent-fg': 'var(--theme-accent-fg)', 'theme-muted': 'var(--theme-muted)', 'theme-border': 'var(--theme-border)', 'theme-success': 'var(--theme-success)', 'theme-error': 'var(--theme-error)', } } } } :root { /* Tier 1: Palette */ --palette-blue-100: oklch(92% 0.06 250); --palette-blue-500: oklch(58% 0.2 250); --palette-blue-700: oklch(42% 0.16 250); --palette-red-500: oklch(58% 0.22 25); --palette-green-500: oklch(58% 0.18 150); --palette-gray-50: oklch(98% 0.003 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-900: oklch(22% 0.015 264); /* Tier 2: Theme — pointers to palette */ --theme-fg: var(--palette-gray-900); --theme-bg: var(--palette-gray-50); --theme-surface: oklch(100% 0 0); --theme-accent: var(--palette-blue-500); --theme-accent-hover: var(--palette-blue-700); --theme-accent-fg: oklch(98% 0.01 250); --theme-muted: var(--palette-gray-500); --theme-border: var(--palette-gray-300); --theme-success: var(--palette-green-500); --theme-error: var(--palette-red-500); } MyApp Dashboard Settings Welcome back Your project has 3 pending tasks and 1 alert. 12 Projects 98% Uptime 3 Alerts View Tasks Dismiss All colors use --theme-* tokens — swap the palette and everything updates. `} /> ### Tailwind: ティア 2 によるテーマ切り替え 同じ Tailwind マークアップが、まったく異なるカラースキームで動作します。CSS 変数を変更するだけです。 tailwind.config = { theme: { extend: { colors: { 'theme-fg': 'var(--theme-fg)', 'theme-bg': 'var(--theme-bg)', 'theme-surface': 'var(--theme-surface)', 'theme-accent': 'var(--theme-accent)', 'theme-accent-fg': 'var(--theme-accent-fg)', 'theme-muted': 'var(--theme-muted)', 'theme-border': 'var(--theme-border)', } } } } .theme-corporate { --theme-fg: oklch(22% 0.015 250); --theme-bg: oklch(96% 0.005 250); --theme-surface: oklch(100% 0 0); --theme-accent: oklch(58% 0.2 250); --theme-accent-fg: oklch(98% 0.01 250); --theme-muted: oklch(55% 0.01 250); --theme-border: oklch(85% 0.01 250); } .theme-warm { --theme-fg: oklch(25% 0.02 55); --theme-bg: oklch(96% 0.01 55); --theme-surface: oklch(100% 0 0); --theme-accent: oklch(65% 0.2 55); --theme-accent-fg: oklch(20% 0.05 55); --theme-muted: oklch(55% 0.015 55); --theme-border: oklch(85% 0.015 55); } .theme-night { --theme-fg: oklch(90% 0.01 300); --theme-bg: oklch(18% 0.015 300); --theme-surface: oklch(24% 0.02 300); --theme-accent: oklch(65% 0.22 300); --theme-accent-fg: oklch(95% 0.01 300); --theme-muted: oklch(55% 0.01 300); --theme-border: oklch(32% 0.02 300); } Corporate Dashboard 3 tasks pending 94% Score View Creative Dashboard 3 tasks pending 94% Score View Night Dashboard 3 tasks pending 94% Score View `} /> ### ダークモード: もう一つのティア 2 マッピング ダークモードは3層システムに自然に適合します。パレット(ティア 1)はそのままです。すべての色はすでに揃っています。ダークモードは、同じセマンティックトークンに対して異なるパレット値を選択する、もう一つのティア 2 マッピングにすぎません。CSS の `light-dark()` 関数を使うと、両方の値を1つの宣言にまとめられるため、特にすっきりと記述できます。 ダークモードのテクニックについて詳しくは、[ダークモード戦略](./dark-mode-strategies)を参照してください。 Light Mode Project Status All systems operational. Last deploy was 12 minutes ago. Healthy Details Dark Mode Project Status All systems operational. Last deploy was 12 minutes ago. Healthy Details `} css={` /* Tier 1: Palette — identical for both modes */ :root { --palette-blue-500: oklch(58% 0.2 250); --palette-blue-100: oklch(92% 0.06 250); --palette-gray-50: oklch(98% 0.003 264); --palette-gray-100: oklch(96% 0.005 264); --palette-gray-300: oklch(82% 0.01 264); --palette-gray-500: oklch(58% 0.01 264); --palette-gray-800: oklch(30% 0.015 264); --palette-gray-900: oklch(22% 0.015 264); --palette-green-500: oklch(58% 0.18 150); --palette-green-100: oklch(94% 0.06 150); --palette-green-900: oklch(28% 0.1 150); } /* Tier 2: Light theme — Tier 2 maps palette to semantic roles */ .dm-col--light { color-scheme: light; --theme-bg: var(--palette-gray-50); --theme-surface: oklch(100% 0 0); --theme-fg: var(--palette-gray-900); --theme-muted: var(--palette-gray-500); --theme-border: var(--palette-gray-300); --theme-accent: var(--palette-blue-500); --theme-accent-fg: oklch(98% 0.01 250); --theme-accent-subtle: var(--palette-blue-100); --theme-success: var(--palette-green-500); --theme-success-bg: var(--palette-green-100); --theme-success-fg: oklch(30% 0.08 150); } /* Tier 2: Dark theme — same tokens, different palette picks */ .dm-col--dark { color-scheme: dark; --theme-bg: oklch(15% 0.01 264); --theme-surface: oklch(20% 0.015 264); --theme-fg: oklch(92% 0.005 264); --theme-muted: oklch(60% 0.01 264); --theme-border: oklch(30% 0.015 264); --theme-accent: oklch(70% 0.17 250); --theme-accent-fg: oklch(15% 0.01 250); --theme-accent-subtle: oklch(25% 0.04 250); --theme-success: oklch(70% 0.15 150); --theme-success-bg: oklch(22% 0.04 150); --theme-success-fg: oklch(80% 0.1 150); } .dm-demo { display: grid; grid-template-columns: 1fr 1fr; height: 100%; font-family: system-ui, sans-serif; } .dm-col { background: var(--theme-bg); padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; } .dm-col__label { margin: 0; font-size: 0.75rem; font-weight: 700; color: var(--theme-muted); text-transform: uppercase; letter-spacing: 0.05em; } .dm-card { background: var(--theme-surface); border: 1px solid var(--theme-border); border-radius: 10px; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem; } .dm-card__title { font-size: 0.9rem; font-weight: 700; color: var(--theme-fg); } .dm-card__text { font-size: 0.8rem; color: var(--theme-muted); line-height: 1.5; margin: 0; } .dm-card__footer { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.25rem; } .dm-badge { font-size: 0.75rem; font-weight: 600; padding: 0.2rem 0.6rem; border-radius: 999px; } .dm-badge--success { background: var(--theme-success-bg); color: var(--theme-success-fg); } .dm-btn { margin-left: auto; background: var(--theme-accent); color: var(--theme-accent-fg); border: none; border-radius: 6px; padding: 0.35rem 0.75rem; font-size: 0.75rem; font-weight: 600; cursor: pointer; }`} /> `light-dark()` を使えば、同じティア 2 マッピングをセレクタを分けずに記述できます。 ```css /* Tier 2 with light-dark() — both modes in a single declaration */ :root { color-scheme: light dark; --theme-bg: light-dark(var(--palette-gray-50), oklch(15% 0.01 264)); --theme-surface: light-dark(oklch(100% 0 0), oklch(20% 0.015 264)); --theme-fg: light-dark(var(--palette-gray-900), oklch(92% 0.005 264)); --theme-muted: light-dark(var(--palette-gray-500), oklch(60% 0.01 264)); --theme-border: light-dark(var(--palette-gray-300), oklch(30% 0.015 264)); --theme-accent: light-dark(var(--palette-blue-500), oklch(70% 0.17 250)); --theme-accent-fg: light-dark(oklch(98% 0.01 250), oklch(15% 0.01 250)); } ``` ティア 1 とティア 3 はまったく同じままです。変わるのはティア 2 だけです。コンポーネントはライトモードなのかダークモードなのかを知る必要がありません。 ## AI がよくやるミス - **ティア 2 をスキップする** — パレットカラーをコンポーネントで直接使う(`color: var(--palette-blue-500)`)と目的が台無しになります。ブランドが変わったとき、すべてのコンポーネントを更新しなければなりません - **ティア 3 の変数が多すぎる** — コンポーネントが 10 個以上のローカルカラー変数を定義している場合、テーマ層を再発明している可能性があります。ティア 2 に昇格させましょう - **パレットとテーマを分離していない** — `--primary: oklch(58% 0.2 250)` のように定義すると、生の値とセマンティックな意味が混在し、パレットの交換が不可能になります - **命名の不統一** — 同じ層で `--color-primary`、`--brand-blue`、`--accent` を混在させると混乱を招きます。ティアごとに一貫したプレフィックスを使いましょう(`--palette-*`、`--theme-*`) - **ティア 1 が小さすぎる** — パレットに 5 色しかないと、コンポーネントが独自の生の値を発明せざるを得なくなり(ハードコードされた値のティア 3 カラー)、システムが崩壊します - **Tailwind ユーティリティにカラーをハードコードする** — `bg-blue-500` を `bg-theme-accent` の代わりに使うと、テーマ層を完全にバイパスしてしまいます ## 使い分け - **コンポーネントが数個以上あるプロジェクト** — 一貫性が必要になった時点で、3層のオーバーヘッドは元が取れます - **マルチテーマまたはホワイトラベル製品** — ティア 2 によりテーマの切り替えが容易になります - **デザインシステムやコンポーネントライブラリ** — コンポーネントは生の色ではなくテーマトークンを参照すべきです - **ダークモード** — ダークモードはもう一つのティア 2 マッピングにすぎません([ダークモード戦略](./dark-mode-strategies)を参照) - **段階的な導入** — ティア 1 + 2 から始めて、コンポーネントにスコープ付きカラーが必要になったらティア 3 を追加できます ### 3層構成が過剰なケース - ブランドカラーが1色でテーマのバリエーションがないシングルページサイト - 保守性よりもスピードが重要なクイックプロトタイプ - インタラクティブ要素が最小限の静的サイト ## 関連記事 - [カラーパレット戦略](./color-palette-strategy) — パレットカラー(ティア 1)の選び方と生成方法 - [ダークモード戦略](./dark-mode-strategies) — ティア 2 のトークン切り替えと連携するダークモードのテクニック - [カスタムプロパティ上級編](../../../methodology/design-systems/custom-properties-advanced/) — フォールバックチェーン、スペーストグル、3層パターンを実装するコンポーネント API - [テーマレシピ](../../../methodology/design-systems/custom-properties-advanced/theming-recipes) — このアーキテクチャを使った本番向けテーマレシピ - [カラートークンパターン](../../../methodology/design-systems/tight-token-strategy/color-tokens) — Tailwind の `@theme` システムにおける3層トークンの適用 - [3層フォントサイズ戦略](../../typography/font-sizing/three-tier-font-size-strategy/) — 同じ3層アーキテクチャをフォントサイズに適用する方法 ## 参考資料 - [MDN: Using CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) - [MDN: oklch()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/oklch) - [OKLCH Color Picker](https://oklch.com/) - [Design Tokens Format — W3C Community Group](https://design-tokens.github.io/community-group/format/) - [Tailwind CSS: Customizing Colors](https://tailwindcss.com/docs/colors) --- # グラデーションテクニック > Source: https://takazudomodular.com/pj/zcss/ja/docs/styling/effects/gradient-techniques ## 問題 AIエージェントは、背景、テキストフィル、装飾要素にグラデーションを使えばはるかに洗練された見た目になるのに、フラットな単色に頼りがちです。AIがグラデーションを使う場合でも、不調和な色の組み合わせを選んだり、滑らかなトランジションのための知覚的色空間の使用を見落としたり、レイヤードグラデーション、ハードストップパターン、グラデーションテキストなどの高度なテクニックを見逃したりします。 ## 解決方法 CSSには3つのグラデーション関数 — `linear-gradient()`、`radial-gradient()`、`conic-gradient()` — があり、それぞれにリピーティングバリアントがあります。これらはレイヤー化したり、ハードストップでパターンを作ったり、テキストに適用したり、`oklch` のような知覚的色空間で補間してスムーズで鮮やかな結果を得たりできます。 ## コード例 ### 線形グラデーションの基本 ```css /* Top-to-bottom (default) */ .gradient-basic { background: linear-gradient(#3b82f6, #8b5cf6); } /* Angled */ .gradient-angled { background: linear-gradient(135deg, #3b82f6, #8b5cf6); } /* Multi-stop with explicit positions */ .gradient-multi { background: linear-gradient( to right, #3b82f6 0%, #8b5cf6 50%, #ec4899 100% ); } ``` ### oklch によるスムーズなグラデーション 標準的なRGB補間では中間色が濁ることがあります。`oklch` を使うとよりスムーズで鮮やかなトランジションになります。 ```css /* Muddy middle in sRGB */ .gradient-srgb { background: linear-gradient(in srgb, #3b82f6, #ef4444); } /* Vibrant, smooth transition in oklch */ .gradient-oklch { background: linear-gradient(in oklch, #3b82f6, #ef4444); } /* oklch with explicit hue interpolation */ .gradient-oklch-longer { background: linear-gradient(in oklch longer hue, #3b82f6, #ef4444); } ``` ### 放射状グラデーション ```css /* Centered circle */ .radial-circle { background: radial-gradient(circle, #3b82f6, #1e3a5f); } /* Ellipse from top-left */ .radial-positioned { background: radial-gradient(ellipse at 20% 30%, #8b5cf6, #1e1b4b); } /* Spotlight effect */ .radial-spotlight { background: radial-gradient( circle at 50% 0%, hsl(220deg 80% 60%) 0%, hsl(220deg 80% 10%) 70% ); } ``` ### 円錐グラデーション ```css /* Color wheel */ .conic-wheel { background: conic-gradient(red, yellow, lime, aqua, blue, magenta, red); border-radius: 50%; } /* Pie chart segment */ .conic-pie { background: conic-gradient( #3b82f6 0deg 120deg, #8b5cf6 120deg 210deg, #e2e8f0 210deg 360deg ); border-radius: 50%; } ``` ### ハードストップグラデーションによるパターン ハードストップは、2つのカラーストップが同じ位置を共有する場合に発生し、スムーズなブレンドではなく瞬時のトランジションを作成します。 ```css /* Striped background */ .stripes { background: repeating-linear-gradient( 45deg, #3b82f6 0px, #3b82f6 10px, #2563eb 10px, #2563eb 20px ); } /* Checkerboard with conic-gradient */ .checkerboard { background: conic-gradient( #e2e8f0 25%, #fff 25% 50%, #e2e8f0 50% 75%, #fff 75% ); background-size: 40px 40px; } /* Progress bar with hard stop */ .progress-bar { background: linear-gradient( to right, #3b82f6 0%, #3b82f6 65%, #e2e8f0 65%, #e2e8f0 100% ); } ``` ### レイヤードグラデーションによる複雑な背景 複数のグラデーションは、カンマ区切りの `background` 値で重ねることができます。後の値は前の値の背後にレンダリングされるため、下のレイヤーが透けて見えるように透明度を使います。 ```css /* Mesh-like layered gradient */ .layered-gradient { background: radial-gradient( circle at 20% 80%, hsl(220deg 80% 60% / 0.6), transparent 50% ), radial-gradient( circle at 80% 20%, hsl(330deg 80% 60% / 0.6), transparent 50% ), radial-gradient( circle at 50% 50%, hsl(270deg 80% 60% / 0.4), transparent 60% ), hsl(220deg 40% 10%); } /* Noise-like texture using layered gradients */ .texture-gradient { background: repeating-linear-gradient( 0deg, transparent, transparent 2px, hsl(0deg 0% 100% / 0.03) 2px, hsl(0deg 0% 100% / 0.03) 4px ), repeating-linear-gradient( 90deg, transparent, transparent 2px, hsl(0deg 0% 100% / 0.03) 2px, hsl(0deg 0% 100% / 0.03) 4px ), linear-gradient(135deg, #1a1a2e, #16213e); } ``` ### グラデーションテキスト ```css .gradient-text { background: linear-gradient(135deg, #3b82f6, #8b5cf6); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; /* fallback for non-webkit */ } ``` ```html Gradient Heading ``` ### background-clip によるグラデーションボーダー ```css .gradient-border { border: 3px solid transparent; background: linear-gradient(white, white) padding-box, linear-gradient(135deg, #3b82f6, #8b5cf6) border-box; } ``` ## ライブプレビュー `} css={` .gradient-box { width: 100%; height: 100%; background: linear-gradient( 135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100% ); } `} height={200} /> `} css={` .radial-box { width: 100%; height: 100%; background: radial-gradient( circle at 30% 40%, hsl(280deg 80% 60%) 0%, transparent 50% ), radial-gradient( circle at 70% 60%, hsl(200deg 80% 60%) 0%, transparent 50% ), hsl(220deg 40% 15%); } `} height={200} /> `} css={` .conic-wrapper { display: flex; justify-content: center; align-items: center; height: 100%; background: #1a1a2e; } .conic-wheel { width: 180px; height: 180px; border-radius: 50%; background: conic-gradient( red, yellow, lime, aqua, blue, magenta, red ); } `} height={240} /> `} css={` .mesh { width: 100%; height: 100%; background: radial-gradient( circle at 20% 80%, hsl(220deg 80% 60% / 0.6), transparent 50% ), radial-gradient( circle at 80% 20%, hsl(330deg 80% 60% / 0.6), transparent 50% ), radial-gradient( circle at 50% 50%, hsl(270deg 80% 60% / 0.4), transparent 60% ), hsl(220deg 40% 10%); } `} height={250} /> Gradient Heading`} css={` .text-wrapper { display: flex; justify-content: center; align-items: center; height: 100%; background: #0f172a; } .gradient-text { font-family: system-ui, sans-serif; font-size: 48px; font-weight: 800; background: linear-gradient(135deg, #3b82f6, #8b5cf6, #ec4899); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; margin: 0; } `} height={200} /> ## 詳細解説 - [CSSのみのパターンライブラリ](./css-pattern-library) — グラデーションのみで構築されたCSS装飾パターンのコレクション:チェッカーボード、ストライプ、ドット、ウェーブなど ## AIがよくやるミス - **どこでもフラットな単色** — 微妙なグラデーション(`linear-gradient(#3b82f6, #2563eb)` など)で奥行きと洗練さを加えられるのに、`background: #3b82f6` を使う。 - **不調和な色の組み合わせ** — sRGB補間で濁った中間色を生む無関係な2色を選ぶ。`in oklch` を使えば解決します。 - **パターンに repeating-gradient を無視する** — 擬似要素でストライプやパターン効果を手動で作成するが、`repeating-linear-gradient()` でネイティブに処理できる。 - **グラデーションテキストで -webkit- プレフィックスを忘れる** — `background-clip: text` は多くのブラウザで `-webkit-background-clip: text` が依然として必要。 - **レイヤードグラデーションを使わず単一グラデーションだけ使う** — 単一の linear-gradient で十分な場合もありますが、放射状グラデーションをベースの上にレイヤーするとメッシュグラデーションのような複雑さが生まれます。 - **リピーティングパターンに background-size を設定しない** — `conic-gradient` パターンは正しくタイリングするために明示的な `background-size` が必要。 ## 使い分け - **微妙な奥行き** — カード、ボタン、ヘッダーにほぼ同じ2色のグラデーションで、派手さなく立体感を追加する - **ヒーローセクションと背景** — レイヤードグラデーションで画像のダウンロードなしに視覚的にリッチな背景を作る - **テキストハイライト** — 見出しやCTAに注目を集めるグラデーションテキスト - **装飾パターン** — ストライプ、ドット、幾何学的背景のためのハードストップのリピーティンググラデーション - **データビジュアライゼーション** — JavaScriptなしで簡単な円グラフ/ドーナツチャートのための円錐グラデーション - **ボーダー** — 角丸が必要な要素に `background-clip` によるグラデーションボーダー ## Tailwind CSS Tailwindはグラデーションの方向に `bg-gradient-to-*`、カラーストップに `from-*`、`via-*`、`to-*` のユーティリティを提供しています。これらは一般的な線形グラデーションパターンを簡潔にカバーします。 ### 線形グラデーション `} height={220} /> ### グラデーションカード Plan A Blue to purple Plan B Emerald to cyan Plan C Three-stop via `} height={220} /> ### グラデーションテキスト Gradient Heading `} height={220} /> ## 参考リンク - [Using CSS Gradients — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Images/Using_gradients) - [A Complete Guide to CSS Gradients — CSS-Tricks](https://css-tricks.com/a-complete-guide-to-css-gradients/) - [Make Beautiful Gradients in CSS — Josh W. Comeau](https://www.joshwcomeau.com/css/make-beautiful-gradients/) - [A Deep CSS Dive Into Radial and Conic Gradients — Smashing Magazine](https://www.smashingmagazine.com/2022/01/css-radial-conic-gradient/)