跳到主要内容
稻草人
プロフィール

EP.02

💻Technology

何年も使ってきたCSSを、あらためて整理してみる

基本・新機能・実装例・設計・JavaScript・Vue/Reactまで

BY 稻, 草人

Web開発を何年もやってきましたが、CSSについては「使ったことはあるけど、仕組みまで説明できるかと言われると怪しい」というものが結構あります。

たとえば、z-indexを大きくしても前に出なかったり、Flexboxの中で文字だけがはみ出したり。調べて直せても、しばらくすると同じところでまた悩んでしまいます。

そこで今回は、自分の復習も兼ねて、CSSの基本、最近の機能、実際の画面で使う書き方をまとめてみました。プロパティの名前だけを覚えるより、「どういう画面で使うか」「なぜその指定が必要か」を一緒に確認していきます。

前半はCSSそのもの、後半は設計やJavaScript、Vue・Reactとの連携です。最初から全部読む必要はありません。作りたいものがあるときは、第3部の実装例から探してもらえればと思います。

資料更新日:2026年10月1日。 年表は主要機能の抜粋です。すべての仕様改訂やブラウザ更新を列挙するものではありません。新機能の採用時は、リンク先の最新の互換性情報と、案件の対象ブラウザを確認してください。

目次

第1部|CSSはどう変わってきた?

1-1. 「追加された年」と「使えるようになった年」は違う

CSSは、毎年まとまった新バージョンが出る仕組みではありません。Selectors、Grid、Colorなどのモジュールが、それぞれ更新されています。「CSS3の次は全部CSS4」という理解では追いかけにくくなります。

年を見るときは、少なくとも次の3つを分けます。

基準意味開発での使い方
規格の公開・改訂W3Cなどで仕様が公開された仕組みや将来の方向を調べる
ブラウザへの実装特定のブラウザが対応したそのブラウザ向けに試す
相互運用の到達時期主要ブラウザで対応が揃った案件への採用を検討する

W3CのCSS Snapshotは、CSSを構成する仕様を整理した資料です。それだけで対象ユーザーのブラウザが対応しているとは判断できません。

参考:CSS Snapshot 2026

1-2. Baselineの読み方

Baselineは、主要ブラウザの対応をまとめた目安です。Newly availableは対応が揃った段階、Widely availableはその段階から30か月経過したものです。古い端末やアプリ内ブラウザを含むすべての環境を保証するものではありません。

自分は「Baselineだから無条件で使う」ではなく、対象ブラウザ、フォールバック、実機確認と合わせて判断したいと思っています。

参考:Baseline

1-3. 大きな流れ

時期主な変化当時の考え方から変わったこと
1996年CSS1が勧告文字、色、余白などをHTMLから分離
1998年CSS2が勧告配置、重なり、メディア別の表現などを拡張
2000年代CSS2の修正、CSSのモジュール化が進むブラウザ差への対処が大きな課題
2011年CSS2.1が勧告CSS2の実装経験を踏まえた仕様整理
2010年代前半Media Queries、変形、遷移などの実装が広がるスマホ対応や動きの表現が一般化
2010年代後半Flexbox、Grid、変数などの対応が進むレイアウト用のfloatや個別の数値指定を減らせる
2020年代コンテナ基準の分岐、親の状態選択、色計算など部品単位の設計やCSSだけでの条件表現が増える

参考:CSS1、CSS2.1

1-4. 2015年以降の主要機能を年ごとに確認する

以下は、原則として主要ブラウザで対応が揃った時期を基準にした抜粋です。2016・2019年は、表内に明記した個別ブラウザの実装時期です。「この年に初めて発明された」という意味ではありません。gapなどは、同じプロパティでもレイアウト方式によって対応時期が異なります。

年主な機能・変化使用例注意点
2015Flexboxのflexなど.item { flex: 1 1 0; }主軸と交差軸、縮小の仕組みを確認
2016Chrome 49でCSSカスタムプロパティを実装(個別実装)color: var(--brand);この時点で主要ブラウザすべての対応が揃ったわけではない
2017CSSカスタムプロパティ、Gridの対応が揃うgrid-template-columns: 16rem minmax(0, 1fr);変数はブラウザ上で継承・更新される
2018font-variation-settingsの対応が揃うfont-variation-settings: "wght" 650;可変フォントが必要。通常の太さ指定ならまずfont-weightを使う
2019Firefox 68で現在のScroll Snapの構文を実装(個別実装)scroll-snap-type: x proximity;主要ブラウザ間の対応時期の目安は2022年
2020object-fit、prefers-reduced-motion、min()・max()・clamp()font-size: clamp(1rem, 1rem + 1vw, 1.5rem);古いEdgeとの差も含めて対応が揃った時期。文字拡大にも配慮する
2021aspect-ratio、Flexboxでのgapの対応が進むaspect-ratio: 16 / 9;比率が効くには少なくとも片方の寸法が自動計算される必要がある
2022@layer、:focus-visible、Scroll Snap、overscroll-behavior@layer base, components;レイヤー外の通常宣言はレイヤー内より優先される
2023サイズContainer Queries、:has()、Nesting、Subgrid、color-mix()など.card:has(img) { ... }CSSで扱う条件とJSの状態を整理する
2024@property、text-wrap: balance、backdrop-filter、scrollbar-width・scrollbar-gutterなどscrollbar-gutter: stable;各機能の対応範囲・描画負荷を個別に確認
2025scrollbar-color、content-visibility、::details-content、同一文書のView Transitionsなどcontent-visibility: auto;View Transitionsの各機能は別々に対応を確認
2026field-sizing、Container Style Queries、@scope、:openなどfield-sizing: content;年次一覧は更新中。サイズクエリとスタイルクエリを混同しない

2026年の行は、確認できた一覧の抜粋です。年間の全更新を確定した一覧ではありません。

年次参考:2023、2024、2025、2026

個別参考:flex、CSS変数の互換性、Grid、可変フォント、object-fit、Scroll Snap、clamp()、aspect-ratio、@layer

個別実装の参考:Chrome 49のCSS変数、Firefox 68の変更

1-5. 新しい機能は、こう使う

CSS変数と計算

:root {
  --brand: #35624a;
  --space: 1rem;
}
.card {
  padding: calc(var(--space) * 2);
  color: var(--brand);
}
.page {
  width: min(100% - 2rem, 72rem);
  margin-inline: auto;
}
.title {
  font-size: clamp(1.5rem, 1rem + 2vw, 3rem);
}

min()は小さい方、max()は大きい方を選びます。clamp(最小, 希望, 最大)は希望値を上下限の間に収めます。calc()の+と-の前後には空白を入れます。

論理プロパティ

.section {
  padding-block: 2rem;
  padding-inline: 1rem;
}
.note {
  border-inline-start: 4px solid #35624a;
}

通常の横書きではblockが上下、inlineが左右に対応します。書字方向が変われば対応する方向も変わります。

セレクターの条件をまとめる

:where(h1, h2, h3) {
  line-height: 1.3;
}
.article :is(ul, ol) {
  padding-inline-start: 1.5rem;
}
.field:has(input:focus-visible) {
  background: #edf4ee;
}
.rows > :nth-child(2n of .row) {
  background: #f5f5f5;
}

:where()の詳細度は0です。:is()・:has()などは引数の詳細度の影響を受けます。親の条件を書くために:has()を使えますが、画面全体のあらゆる状態をCSSだけで管理する必要はありません。

Nestingとスコープ

.button {
  color: #fff;
  background: #35624a;
  &:hover {
    background: #254b36;
  }
  @media (width < 40rem) {
    inline-size: 100%;
  }
}
@scope (.article) {
  a {
    text-underline-offset: .2em;
  }
}

ネイティブNestingはSassの全機能を置き換えるものではありません。&__titleのようなSassの文字列連結は、そのまま同じ意味では使えません。@scopeもVueのscopedやShadow DOMとは別の仕組みです。

色の計算

.badge {
  color: #35624a;
  background: #edf4ee;
}
@supports (color: color-mix(in oklab, red, white)) {
  .badge {
    background: color-mix(in oklab, #35624a 12%, white);
  }
}

先に通常の色を指定するとフォールバックになります。新しい色空間を使っても、文字と背景のコントラスト確認は必要です。

型付きのCSS変数

@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.dial {
  transform: rotate(var(--angle));
  transition: --angle .3s;
}
.dial:hover {
  --angle: 30deg;
}

通常の未登録の変数とは違い、値の型や継承、初期値を指定できます。すべての変数に登録が必要なわけではありません。

テキストの行のバランスと、長いページの描画

.headline {
  text-wrap: balance;
}
.article-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 600px;
}

balanceは見出しの行の長さを整える用途で試せます。content-visibilityは画面外の描画処理を省略できる機能で、通信を省略する画像のlazy loadingとは別です。推定寸法、スクロール位置、検索や読み上げの挙動を確認し、何でも適用しません。

1-6. 対応していない環境を考える

.list {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}
.list > * {
  flex: 1 1 15rem;
}
@supports (display: grid) {
  .list {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

@supportsは構文の対応確認です。特定の不具合や、操作が期待通りかまで保証するものではありません。基本表示を先に作り、対応環境で表現を足すと扱いやすくなります。

第2部|よく使うCSSの仕組みと注意点

2-1. CSSが勝つ順番を「詳細度」だけで考えない

まず適用対象かどうか、宣言元・重要度・レイヤーなどの優先関係があり、その後に詳細度やスコープの近さ、記述順が関係します。同じ条件の通常宣言なら、次のように比較できます。

セレクター詳細度の例
#main1-0-0
.card:hover0-2-0
.card h20-1-1
h20-0-1
:where(.card) h20-0-1

「下に書けば勝つ」のは、他の条件が揃っている場合です。IDや!importantを追加する前に、DevToolsで勝っている宣言を確認します。

@layer reset, base, components, utilities;
@layer base {
  .text {
    color: #444;
  }
}
@layer components {
  .text {
    color: #35624a;
  }
}

通常宣言では後のレイヤーが優先されます。レイヤー外の通常宣言は、それらより優先されます。!importantではレイヤーの優先順が逆になるため、単純に「最後のレイヤーが常に勝つ」とは覚えません。

2-2. 継承と初期化

colorやフォント関係は通常継承します。marginやborderなどは通常継承しません。

値意味
inherit親の計算値を使う
initialCSS仕様上の初期値に戻す
unset継承するプロパティならinherit、それ以外ならinitial
revert現在の宣言元より前の段階へ戻す
revert-layer現在のカスケードレイヤーの指定を巻き戻す

initialは「ブラウザ標準の見た目に戻す」とは限りません。たとえばdisplayの初期値はinlineです。

2-3. ショートハンドで、指定していない値もリセットされる

.hero {
  background-color: #edf4ee;
}
.hero {
  background: url("photo.svg") center / cover no-repeat;
}

後のbackgroundは背景色なども初期化します。背景画像だけ変えたいならbackground-imageを使います。font、animation、borderなども同じように、まとめて指定する範囲を確認します。

2-4. ボックスモデル

*, *::before, *::after {
  box-sizing: border-box;
}
.box {
  width: 300px;
  padding: 20px;
  border: 2px solid;
}

content-boxなら外側の幅は344px。border-boxなら、指定した300pxにpaddingとborderを含みます。どちらでもmarginは含みません。

2-5. サイズ・余白・単位の落とし穴

項目注意点自分ならこう確認する
width: 100%親の幅を基準にする。余白を足すと超える場合があるbox-sizingと親の幅を見る
height: 100%通常のブロックでは親の高さが未確定だと期待通りにならない親の高さか、min-heightで目的を満たせるか確認
min-height下限を持ちながら内容に応じて伸びる文章のある領域で固定heightと比較
margin通常フローの縦マージンは相殺する場合がある親子・隣接要素を確認。必要ならflow-rootやgap
padding幅に対する割合指定は、上下も包含ブロックのinline寸法を基準にする高さの割合だと思い込まない
%何を基準にするかはプロパティで異なるwidth、transformなどを別々に確認
emfont-sizeでは親の文字サイズ、paddingなどでは自身の文字サイズが基準入れ子で倍率が増えないか確認
remルートの文字サイズが基準文字・余白の共通スケールに使う
vhモバイルのブラウザUIを考慮した高さと合わない場合があるsvh/dvhと実機で比較
dvhブラウザUIの変化に応じて値も変わるスクロール中のサイズ変化を確認
line-height固定pxだと文字拡大時に窮屈になりやすい本文は単位なしの値を検討

width: 100vwは、デスクトップのスクロールバーの幅との関係で横にはみ出すことがあります。通常のページ幅なら、まずwidth: 100%や自動幅で考えます。

2-6. Flexboxを理解する

.row {
  display: flex;
  gap: 1rem;
}
.fixed {
  flex: 0 0 12rem;
}
.fluid {
  flex: 1 1 0;
  min-width: 0;
}

flexはgrow shrink basisです。growは余りの配分、shrinkは不足時の縮小、basisは配分前の基準サイズです。flex: 1はブラウザでは一般に1 1 0%として扱われ、1 1 0と完全に同じではないケースがあります。

justify-contentは主軸、align-itemsは交差軸の整列です。flex-direction: columnなら軸が変わります。align-contentは複数行の配置に関係し、通常の1行Flexboxでの上下中央寄せとは違います。

文字が縮まらないときは、Flexアイテムの自動最小サイズを確認します。min-width: 0と折り返し指定を組み合わせると、解消できることがあります。

2-7. Gridを理解する

.layout {
  display: grid;
  grid-template-columns: 16rem minmax(0, 1fr);
  gap: 1.5rem;
}

1frは単純な「親の何分の1」ではなく、残った空間の配分単位です。1frだけでは内容の最小幅が効く場合があるので、縮めたい列にminmax(0, 1fr)を使います。

auto-fitは空の繰り返しトラックを畳み、auto-fillは空のトラックも残します。少数のカードを横いっぱいに伸ばすか、列の枠を残すかで選びます。

2-8. positionとz-index

値基本の動き
static通常フロー。insetによる移動は行わない
relative通常の場所を確保したまま、見た目をずらせる
absolute通常フローから外れ、包含ブロックを基準に配置
fixed通常はビューポート基準。祖先のtransformなどで基準が変わる場合がある
sticky通常配置から、指定したスクロール位置で追従する

絶対配置の基準を作るときに親へposition: relativeを指定することが多いですが、包含ブロックを作る条件はそれだけではありません。

opacityが1未満、transform、isolation: isolateなどはスタッキングコンテキストを作ります。その中で子のz-indexを999999にしても、親ごとの重なり順を飛び越せません。dialogのモーダル表示やPopoverのトップレイヤーも、普通のz-indexとは別に考えます。

2-9. overflowとsticky

overflow: hiddenとclipは同じではありません。hiddenはスクロールコンテナになり得ますが、clipはスクロールコンテナを作りません。overflow-x: hiddenだけを指定したつもりでも、もう一方の軸が計算上autoになる場合があります。

stickyが効かないときは、次を確認します。

  1. topやinset-block-startなどを指定しているか。
  2. どの祖先がスクロールコンテナになっているか。
  3. 親の高さが十分にあり、追従できる移動範囲があるか。
  4. Flex/Gridのstretchで要素が親いっぱいの高さになっていないか。

2-10. 文字・画像・非表示

目的指定注意点
改行せず表示white-space: nowrap横にはみ出す可能性
入力された改行を残すwhite-space: pre-wrap長い文字列の折り返しも確認
長いURLを折り返すoverflow-wrap: anywhere日本語すべてにbreak-allをかけない
画像を枠いっぱいにするobject-fit: cover一部が切り取られる
画像全体を見せるobject-fit: contain余白ができる場合がある
配置ごと非表示display: none通常、アクセシビリティツリーからも外れる
配置を残して非表示visibility: hidden子でvisibleを指定するケースもある
透明にするopacity: 0操作・フォーカス・読み上げは自動で無効にならない

疑似要素は装飾に便利ですが、重要な文章をcontentだけに入れるのは避けます。文字はHTMLに置く方が、コピー・翻訳・読み上げを扱いやすくなります。

2-11. 動きと操作

.button {
  transition: background-color .2s, transform .2s;
}
.button:focus-visible {
  outline: 3px solid #245d9c;
  outline-offset: 3px;
}
@media (hover: hover) {
  .button:hover {
    transform: translateY(-2px);
  }
}
@media (prefers-reduced-motion: reduce) {
  .button {
    transition: none;
  }
}

outlineを消すなら、代わりのフォーカス表示が必要です。hoverだけで重要な情報や操作を出さないようにします。

transformとopacityは、条件によってレイアウト再計算を避けやすいプロパティです。ただし常に無料ではありません。大きなぼかし、影、画像、大量のレイヤーにも負荷があります。will-changeは調査した問題に絞って使い、全要素へ常時指定しません。

参考:MDN CSSリファレンス、position、prefers-reduced-motion

第3部|実際の画面で使う、24のCSS組み合わせ実装

この部の読み方

ここでは「中央寄せだけ」「角丸だけ」のような例は扱いません。自分が仕事で欲しいのは、プロパティの短いサンプルより、画面の要求をどう分担して解くかという例です。レイアウト、スクロール、文字量、レスポンシブ、操作状態をまとめて扱います。

各例には、作るもの、実行したときの表示・操作、組み合わせの理由、実務で気を付ける点を付けました。CSSが配置と見た目を担当し、JSが開閉・検索・入力などの状態を担当する例もあります。通信処理は含めず、UIの動きを確認するためのデモです。

3-1. 画面の骨格とスクロール

実装01|画面全体を使う管理画面と、独立してスクロールするサイドバー

作るもの:ヘッダーを残し、メニューと本文を別々にスクロールしたい。狭い画面ではメニューを上へ移します。

実行すると:PCでは左右の領域だけがスクロールします。700px以下では通常の縦長ページになり、横方向にはみ出しません。

組み合わせの考え方:Gridで行と列を作り、minmax(0, 1fr)とmin-height: 0で領域を縮められるようにします。スクロールは子へ担当させます。

HTMLパネル

<div class="app">
  <header class="app-head">
    <strong>
      管理画面
    </strong>
    <button>
      ヘルプ
    </button>
  </header>
  <aside class="menu" aria-label="管理メニュー">
    <h2>
      メニュー
    </h2>
    <nav id="menu-items">
    </nav>
  </aside>
  <main class="workspace">
    <h1>
      売上レポート
    </h1>
    <div id="reports">
    </div>
  </main>
</div>

CSSパネル

<em>/* 共通ベース:このCSSパネルだけで実行できます */</em>
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.app {
  height: 100vh;
  height: 100dvh;
  display: grid;
  grid-template: auto minmax(0, 1fr) / 15rem minmax(0, 1fr);
}
.app-head {
  grid-column: 1 / -1;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  border-bottom: 1px solid var(--line);
}
.menu, .workspace {
  min-height: 0;
  min-width: 0;
  overflow: auto;
  overscroll-behavior: contain;
  scrollbar-gutter: stable;
  scrollbar-width: thin;
  scrollbar-color: var(--brand) var(--soft);
  padding: 1.25rem;
}
.menu {
  background: var(--soft);
}
.menu a {
  display: block;
  padding: .7rem;
}
.report {
  min-height: 12rem;
  border-bottom: 1px solid var(--line);
}
@media (max-width: 700px) {
  .app {
    height: auto;
    min-height: 100vh;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto auto 1fr;
  }
  .menu, .workspace {
    overflow: visible;
  }
  .menu nav {
    display: flex;
    overflow-x: auto;
  }
  .menu a {
    flex: 0 0 auto;
  }
}

JavaScriptパネル

document.querySelector('#menu-items').innerHTML = Array.from({length: 18}, (_,i) => `<a href="#report-${i+1}">レポート ${i+1}</a>`).join('');
document.querySelector('#reports').innerHTML = Array.from({length: 18}, (_,i) => `<section class="report" id="report-${i+1}"><h2>集計 ${i+1}</h2><p>本文とグラフを置く領域です。</p></section>`).join('');

実務で気を付けるところ:100dvhはブラウザUIに応じて変わります。対応しない環境向けに100vhを先に書きます。固定高さのUIは文字拡大とスマホのキーボード表示でも確認します。スクロールバーの色・太さはOSやブラウザによって表示が異なります。

実装02|高さが変わる追従ヘッダーと、隠れないページ内リンク

作るもの:ナビが折り返しても、目次リンクの移動先がヘッダーに隠れないようにします。

実行すると:リンクを押すと見出しがヘッダーの下へ移動します。幅を狭めてヘッダーが高くなっても補正されます。

組み合わせの考え方:stickyで通常の流れを保ち、ResizeObserverの測定値をCSS変数へ渡します。scroll-paddingとscroll-marginを組み合わせます。

HTMLパネル

<header class="site-head">
  <strong>
    CSSノート
  </strong>
  <nav>
    <a href="#intro">
      概要
    </a>
    <a href="#design">
      設計と実装
    </a>
    <a href="#check">
      確認事項
    </a>
  </nav>
</header>
<main>
  <section id="intro">
    <h1>
      概要
    </h1>
    <p>
      ヘッダーを見ながら記事を読みます。
    </p>
  </section>
  <section id="design">
    <h2>
      設計と実装
    </h2>
    <p>
      画面幅を変えて確認してください。
    </p>
  </section>
  <section id="check">
    <h2>
      確認事項
    </h2>
    <p>
      見出しが隠れていないか確認します。
    </p>
  </section>
</main>

CSSパネル

<em>/* 共通ベース:このCSSパネルだけで実行できます */</em>
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
html {
  scroll-padding-top: var(--head-h, 5rem);
  scroll-behavior: smooth;
}
.site-head {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: .7rem 2rem;
  padding: 1rem;
  background: var(--surface);
  border-bottom: 1px solid var(--line);
}
.site-head nav {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}
main {
  max-width: 64rem;
  margin-inline: auto;
  padding: 1.5rem;
}
section {
  min-height: 65vh;
  scroll-margin-top: 1rem;
}
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
}

JavaScriptパネル

const head = document.querySelector('.site-head');
const update = () => document.documentElement.style.setProperty('--head-h', `${head.getBoundingClientRect().height}px`);
update();
new ResizeObserver(update).observe(head);

実務で気を付けるところ:高さを固定値で決め打ちすると翻訳・文字拡大・折り返しでずれます。scroll-paddingはスクロールする要素に設定します。

実装03|長い目次だけがスクロールする記事レイアウト

作るもの:本文の横に目次を追従させつつ、目次が画面より長い場合も最後まで読めるようにします。

実行すると:広い画面では目次が上端に追従し、目次内をスクロールできます。狭い画面では本文の前に並びます。

組み合わせの考え方:Gridのminmax、align-self: start、sticky、max-height、overflowをセットで使います。

HTMLパネル

<div class="article-layout">
  <aside class="toc">
    <h2>
      この記事の内容
    </h2>
    <nav id="toc">
    </nav>
  </aside>
  <main id="article">
  </main>
</div>

CSSパネル

<em>/* 共通ベース:このCSSパネルだけで実行できます */</em>
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.article-layout {
  max-width: 72rem;
  margin: auto;
  padding: 1.5rem;
  display: grid;
  grid-template-columns: 16rem minmax(0, 1fr);
  gap: 2rem;
  align-items: start;
}
.toc {
  position: sticky;
  top: 1rem;
  max-height: calc(100vh - 2rem);
  max-height: calc(100dvh - 2rem);
  overflow: auto;
  overscroll-behavior: contain;
  scrollbar-gutter: stable;
  border: 1px solid var(--line);
  border-radius: 1rem;
  padding: 1rem;
}
.toc a {
  display: block;
  padding: .6rem;
}
#article {
  min-width: 0;
  overflow-wrap: anywhere;
}
#article section {
  min-height: 20rem;
  scroll-margin-top: 1rem;
}
@media (max-width: 800px) {
  .article-layout {
    grid-template-columns: 1fr;
  }
  .toc {
    position: static;
    max-height: 15rem;
  }
}

JavaScriptパネル

document.querySelector('#toc').innerHTML = Array.from({length: 20}, (_,i) => `<a href="#s${i}">第${i+1}節:設計メモ</a>`).join('');
document.querySelector('#article').innerHTML = Array.from({length: 20}, (_,i) => `<section id="s${i}"><h2>第${i+1}節</h2><p>本文の長さに関係なく目次を使えるようにします。</p></section>`).join('');

実務で気を付けるところ:stickyの祖先へ不用意にoverflow: hiddenを付けると、追従の基準が変わります。目次自体を画面内に収めることも必要です。

実装04|固定表頭・固定先頭列・横スクロールを組み合わせた一覧表

作るもの:列も行も多いデータを、どの項目か見失わずに読みたい。

実行すると:表内で縦横にスクロールすると、表頭と商品名列が残ります。左上の交点は両方に追従します。

組み合わせの考え方:overflowを一つのラッパーへ集約し、表頭・先頭列・交点のz-indexを分けます。背景色で背後の文字を隠します。

HTMLパネル

<h1>
  月別売上
</h1>
<p id="table-help">
  表は縦横にスクロールできます。
</p>
<div class="table-scroll" role="region" aria-label="月別売上一覧" aria-describedby="table-help" tabindex="0">
  <table>
    <caption>
      金額の単位:円
    </caption>
    <thead>
      <tr>
        <th scope="col">
          商品
        </th>
        <th scope="col">
          1月
        </th>
        <th scope="col">
          2月
        </th>
        <th scope="col">
          3月
        </th>
        <th scope="col">
          4月
        </th>
        <th scope="col">
          5月
        </th>
        <th scope="col">
          6月
        </th>
      </tr>
    </thead>
    <tbody id="rows">
    </tbody>
  </table>
</div>

CSSパネル

<em>/* 共通ベース:このCSSパネルだけで実行できます */</em>
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
body {
  padding: 1.5rem;
}
.table-scroll {
  max-height: 24rem;
  overflow: auto;
  overscroll-behavior: contain;
  scrollbar-gutter: stable;
  border: 1px solid var(--line);
  border-radius: .7rem;
}
table {
  width: 100%;
  min-width: 65rem;
  border-spacing: 0;
  border-collapse: separate;
}
caption {
  text-align: left;
  padding: .7rem;
}
th, td {
  padding: .9rem 1.2rem;
  border-right: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  white-space: nowrap;
  background: var(--surface);
}
td {
  text-align: right;
  font-variant-numeric: tabular-nums;
}
thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--soft);
}
tbody th {
  position: sticky;
  left: 0;
  z-index: 1;
  text-align: left;
}
thead th:first-child {
  left: 0;
  z-index: 3;
}
tbody tr:hover td, tbody tr:hover th {
  background: #edf6f1;
}

JavaScriptパネル

document.querySelector('#rows').innerHTML = Array.from({length: 24}, (_,i) => `<tr><th scope="row">商品 ${i+1}</th>${Array.from({length: 6}, (_,m) => `<td>${((i+1)*1200+m*350).toLocaleString('ja-JP')}</td>`).join('')}</tr>`).join('');

実務で気を付けるところ:border-collapse: separateでセルの境界を扱います。表をdisplay: blockにせず、表の意味と見出しのscopeを維持します。

3-2. 一覧・カード・情報の密度

実装05|列数が変わっても、説明と購入ボタンが揃う商品カード

作るもの:タイトルや説明の長さが違うカードを、スマホからPCまで自然に並べます。

実行すると:カードの列数が変わり、画像比率とボタン位置が揃います。長い英数字を入れてもカード幅が広がりません。

組み合わせの考え方:auto-fitとminmaxで列数を決め、カード内部はGridで伸びる説明行を用意します。画像はaspect-ratioとobject-fit、省略は複数行clampを使います。

HTMLパネル

<main>
  <h1>
    商品一覧
  </h1>
  <div class="products" id="products">
  </div>
</main>

CSSパネル

<em>/* 共通ベース:このCSSパネルだけで実行できます */</em>
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
main {
  max-width: 75rem;
  margin: auto;
  padding: clamp(1rem, 3vw, 2.5rem);
}
.products {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
  gap: 1.25rem;
}
.product {
  min-width: 0;
  display: grid;
  grid-template-rows: auto auto 1fr auto auto;
  gap: .8rem;
  padding: 1rem;
  border: 1px solid var(--line);
  border-radius: 1rem;
  background: var(--surface);
}
.product img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: .6rem;
}
.product h2 {
  margin: 0;
  font-size: 1.2rem;
  overflow-wrap: anywhere;
}
.product p {
  margin: 0;
}
.description {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  overflow: hidden;
}
.price {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.product a {
  display: block;
  text-align: center;
  padding: .6rem;
  border-radius: .5rem;
  background: var(--brand);
  color: white;
}

JavaScriptパネル

const descriptions = ['短い説明です。', '素材、使い心地、収納のしやすさまで詳しく紹介する商品です。'.repeat(5), '型番 ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'];
document.querySelector('#products').innerHTML = descriptions.map((text,i) => `<article class="product"><img src="photo.svg" alt="サンプル商品のイメージ" width="960" height="640"><h2>商品 ${i+1}${i===1?':少し長い名前の収納セット':''}</h2><p class="description">${text}</p><p class="price">¥${(2400+i*1300).toLocaleString('ja-JP')}</p><a href="#detail-${i}">商品詳細</a></article>`).join('');

実務で気を付けるところ:省略は全文を消さない設計にします。リンク先で全文を読めるようにし、重要な価格や条件を省略しません。

実装06|配置場所の幅で、画像付きカードの構成を変える

作るもの:同じカードを本文と狭いサイドバーに置き、カード自身の幅でレイアウトを変えます。

実行すると:本文側では画像と説明が横並び、サイドバー側では縦並びになります。ブラウザ幅だけでは分岐しません。

組み合わせの考え方:外側にcontainer-type: inline-size、内側に@containerを設定します。min-width: 0とobject-fitも使います。

HTMLパネル

<div class="slots">
  <main class="slot">
    <article class="feature">
      <img src="photo.svg" alt="記事イメージ">
      <div>
        <span class="tag">
          CSS設計
        </span>
        <h2>
          部品の幅から考えるレスポンシブ
        </h2>
        <p>
          同じカードを違う領域で使います。
        </p>
        <a href="#more">
          続きを読む
        </a>
      </div>
    </article>
  </main>
  <aside class="slot">
    <article class="feature">
      <img src="photo.svg" alt="記事イメージ">
      <div>
        <span class="tag">
          CSS設計
        </span>
        <h2>
          部品の幅から考えるレスポンシブ
        </h2>
        <p>
          同じカードを違う領域で使います。
        </p>
        <a href="#more">
          続きを読む
        </a>
      </div>
    </article>
  </aside>
</div>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.slots {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 18rem;
  gap: 1.5rem;
  padding: 1.5rem;
}
.slot {
  container-type: inline-size;
  min-width: 0;
}
.feature {
  display: grid;
  gap: 1rem;
  padding: 1rem;
  border: 1px solid var(--line);
  border-radius: 1rem;
}
.feature img {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: .6rem;
}
.feature h2 {
  font-size: 1.2rem;
}
.tag {
  display: inline-block;
  padding: .1em .6em;
  border-radius: 999px;
  background: var(--soft);
}
@container (min-width: 30rem) {
  .feature {
    grid-template-columns: 40% minmax(0, 1fr);
    align-items: center;
  }
  .feature img {
    height: 100%;
    aspect-ratio: auto;
  }
}
@media (max-width: 700px) {
  .slots {
    grid-template-columns: 1fr;
  }
}

実務で気を付けるところ:サイズクエリの対象はコンテナの子です。未対応環境では縦並びの基礎スタイルが残るようにします。

実装07|本文中のラベル、長いURL、コードを崩さず表示する記事本文

作るもの:文章の中にNEWラベルやコードを入れ、長いURL・コードブロックもスマホで破綻させません。

実行すると:文章は適度な行長で、ラベルはまとまりを保ちます。URLは折り返し、長いコードだけが横スクロールします。

組み合わせの考え方:max-widthをchで制限し、overflow-wrap、inline-block、box-decoration-break、preのoverflowを役割ごとに分けます。

HTMLパネル

<article class="prose">
  <h1>
    CSS変数の更新メモ
  </h1>
  <p>
    <span class="inline-tag">
      NEW
    </span>
    今回は
    <code>
      --header-height
    </code>
    を使って、レイアウトを調整します。ラベルは文の途中に入れても行の高さを極端に変えません。
  </p>
  <p>
    参照先:
    <a href="https://example.com">
      https://example.com/docs/very-long-path/ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789
    </a>
  </p>
  <blockquote>
    <p>
      変数名だけでなく、どの要素がその値を使うかも確認したいところです。
    </p>
  </blockquote>
  <pre tabindex="0" aria-label="コード例"><code>document.documentElement.style.setProperty('--header-height', header.getBoundingClientRect().height + 'px');</code></pre>
  <p>
    <kbd>
      Ctrl
    </kbd>
    +
    <kbd>
      S
    </kbd>
    で保存します。
  </p>
</article>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.prose {
  max-width: 68ch;
  margin: auto;
  padding: clamp(1rem, 4vw, 3rem);
  line-height: 1.9;
}
.prose h1 {
  line-height: 1.35;
  font-size: clamp(1.5rem, 4vw, 2.4rem);
}
.prose a {
  overflow-wrap: anywhere;
  text-underline-offset: .2em;
}
.inline-tag {
  display: inline-block;
  vertical-align: baseline;
  font-size: .75em;
  line-height: 1.5;
  padding: .1em .55em;
  border-radius: .3em;
  background: var(--brand);
  color: white;
  white-space: nowrap;
}
.prose :not(pre) > code {
  padding: .1em .35em;
  background: var(--soft);
  border-radius: .3em;
  overflow-wrap: anywhere;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
.prose pre {
  max-width: 100%;
  overflow: auto;
  padding: 1rem;
  border-radius: .7rem;
  background: #172d24;
  color: #f2f8f4;
  tab-size: 2;
}
.prose blockquote {
  margin-inline: 0;
  padding-inline: 1rem;
  border-inline-start: .25rem solid var(--brand);
  background: var(--soft);
}
kbd {
  padding: .1em .4em;
  border: 1px solid var(--line);
  border-bottom-width: 3px;
  border-radius: .3em;
}

実務で気を付けるところ:本文全体にword-break: break-allを付けると普通の単語も分割されます。読みやすさと局所的なはみ出し対策を分けます。

実装08|表形式の比較プランを、スマホでは縦のカードにする

作るもの:料金、機能、CTAを比較しやすく揃え、狭い画面ではカードごとに読めるようにします。

実行すると:PCでは各プランの価格・特徴・ボタンが横方向に揃い、スマホでは一列になります。

組み合わせの考え方:通常のGridをフォールバックにし、対応環境ではsubgridで内部の行を共有します。おすすめラベルのための余白も確保します。

HTMLパネル

<div class="plans">
  <article class="plan">
    <h2>
      個人
    </h2>
    <p class="cost">
      ¥980
      <small>
        /月
      </small>
    </p>
    <ul>
      <li>
        1プロジェクト
      </li>
      <li>
        基本サポート
      </li>
    </ul>
    <a href="#start">
      このプランを選ぶ
    </a>
  </article>
  <article class="plan recommended">
    <span class="ribbon">
      おすすめ
    </span>
    <h2>
      チーム
    </h2>
    <p class="cost">
      ¥2,980
      <small>
        /月
      </small>
    </p>
    <ul>
      <li>
        10プロジェクト
      </li>
      <li>
        メンバー管理
      </li>
      <li>
        優先サポート
      </li>
    </ul>
    <a href="#start">
      このプランを選ぶ
    </a>
  </article>
  <article class="plan">
    <h2>
      法人
    </h2>
    <p class="cost">
      ご相談
    </p>
    <ul>
      <li>
        個別の権限設計
      </li>
      <li>
        導入支援と運用サポート
      </li>
    </ul>
    <a href="#contact">
      お問い合わせ
    </a>
  </article>
</div>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.plans {
  max-width: 70rem;
  margin: 3rem auto;
  padding: 1rem;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1.25rem;
}
.plan {
  position: relative;
  display: grid;
  grid-template-rows: auto auto 1fr auto;
  gap: 1rem;
  padding: 1.5rem;
  border: 1px solid var(--line);
  border-radius: 1rem;
}
.plan h2, .plan p {
  margin: 0;
}
.plan ul {
  margin: 0;
  padding-inline-start: 1.2rem;
}
.cost {
  font-size: 1.8rem;
  font-weight: bold;
}
.cost small {
  font-size: 1rem;
}
.plan a {
  background: var(--brand);
  color: white;
  text-align: center;
  padding: .8rem;
  border-radius: .5rem;
}
.recommended {
  border: 2px solid var(--brand);
}
.ribbon {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translate(-50%, -50%);
  background: var(--brand);
  color: white;
  border-radius: 999px;
  padding: .2rem 1rem;
  white-space: nowrap;
}
@supports (grid-template-rows: subgrid) {
  .plan {
    grid-row: span 4;
    grid-template-rows: subgrid;
  }
}
@media (max-width: 700px) {
  .plans {
    grid-template-columns: 1fr;
    gap: 2rem;
  }
}

実務で気を付けるところ:この例は意味上のデータ表ではなくプランカードです。セル同士の比較が主目的の表ならtableを選びます。

3-3. 操作とナビゲーション

実装09|PCでは横並び、スマホでは開閉できるナビゲーション

作るもの:同じナビを画面幅によって切り替え、キーボード操作でも開閉できるようにします。

実行すると:狭い画面ではメニューボタンが現れ、クリック・Escapeで開閉します。PCへ戻すと常にリンクが見えます。

組み合わせの考え方:Flexの折り返し、メディアクエリ、hidden、aria-expandedを連携させます。レイアウト変更はCSS、開閉状態はJSへ任せます。

HTMLパネル

<header class="nav-head">
  <a class="logo" href="#home">
    CSS LAB
  </a>
  <button id="menu-toggle" aria-controls="main-nav" aria-expanded="false" hidden>
    メニュー
  </button>
  <nav id="main-nav" aria-label="メイン">
    <a href="#home">
      ホーム
    </a>
    <a href="#works">
      事例
    </a>
    <a href="#about">
      私たちについて
    </a>
    <a href="#contact">
      お問い合わせ
    </a>
  </nav>
</header>
<main>
  <h1>
    画面を狭めて試します
  </h1>
  <p>
    メニューは本文を押し下げて表示されます。
  </p>
  <a href="#next">
    本文のリンク
  </a>
</main>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.nav-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 1rem;
  padding: 1rem 1.5rem;
  border-bottom: 1px solid var(--line);
}
.logo {
  font-weight: 800;
  text-decoration: none;
}
#main-nav {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem 1.25rem;
}
#main-nav a {
  padding: .5rem;
}
main {
  padding: 2rem;
}
@media (max-width: 700px) {
  #main-nav {
    flex-basis: 100%;
    flex-direction: column;
    border-top: 1px solid var(--line);
    padding-top: .5rem;
  }
}

JavaScriptパネル

const mq = matchMedia('(max-width: 700px)');
const toggle = document.querySelector('#menu-toggle');
const nav = document.querySelector('#main-nav');
function closeMenu() { toggle.setAttribute('aria-expanded', 'false'); nav.hidden = mq.matches; }
function sync() { toggle.hidden = !mq.matches; if (nav.contains(document.activeElement) && mq.matches) toggle.focus(); closeMenu(); }
toggle.addEventListener('click', () => { const open = toggle.getAttribute('aria-expanded') !== 'true'; toggle.setAttribute('aria-expanded', String(open)); nav.hidden = !open; });
nav.addEventListener('keydown', e => { if (e.key === 'Escape' && mq.matches) { closeMenu(); toggle.focus(); } });
nav.addEventListener('click', e => { if (e.target.closest('a') && mq.matches) { closeMenu(); toggle.focus(); } });
mq.addEventListener('change', sync); sync();

実務で気を付けるところ:今回は押し下げ型のナビです。画面に重なるモーダル型メニューならフォーカスの閉じ込めなど別の設計が必要です。

実装10|横スクロール、Scroll Snap、前後ボタンを備えたカード棚

作るもの:スマホでは指で、PCではボタンとキーボードでもカード一覧を送れるようにします。

実行すると:ドラッグや横スクロールでカードが止まり、前後ボタンで次のカードへ移動します。端では対応するボタンが無効になります。

組み合わせの考え方:overflow-x、scroll-snap、scroll-padding、flex-basisを合わせ、JSはスクロール位置とボタン状態を管理します。

HTMLパネル

<section class="shelf">
  <div class="shelf-head">
    <h1>
      おすすめ記事
    </h1>
    <div>
      <button id="prev" aria-label="前のカード">
        ←
      </button>
      <button id="next" aria-label="次のカード">
        →
      </button>
    </div>
  </div>
  <div class="rail" id="rail" tabindex="0" role="region" aria-label="おすすめ記事一覧">
  </div>
</section>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.shelf {
  max-width: 70rem;
  margin: auto;
  padding: 1.5rem;
}
.shelf-head {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: center;
  gap: 1rem;
}
.rail {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x proximity;
  scroll-padding: .25rem;
  padding: .25rem .25rem 1rem;
  overscroll-behavior-x: contain;
  scrollbar-color: var(--brand) var(--soft);
}
.rail article {
  flex: 0 0 min(80%, 20rem);
  min-width: 0;
  scroll-snap-align: start;
  border: 1px solid var(--line);
  border-radius: 1rem;
  padding: 1rem;
}
.rail img {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: .5rem;
}
.rail h2 {
  font-size: 1.2rem;
}
button:disabled {
  opacity: .4;
  cursor: default;
}

JavaScriptパネル

const rail = document.querySelector('#rail');
rail.innerHTML = Array.from({length: 7}, (_,i) => `<article><img src="photo.svg" alt="記事イメージ"><h2>記事 ${i+1}</h2><p>CSSの実装メモです。</p><a href="#post-${i}">続きを読む</a></article>`).join('');
const prev = document.querySelector('#prev'), next = document.querySelector('#next');
function update() { prev.disabled = rail.scrollLeft <= 1; next.disabled = rail.scrollLeft >= rail.scrollWidth - rail.clientWidth - 1; }
function move(direction) { const card = rail.querySelector('article'); const step = card.getBoundingClientRect().width + parseFloat(getComputedStyle(rail).gap); rail.scrollBy({left: direction * step, behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'}); }
prev.addEventListener('click', () => move(-1)); next.addEventListener('click', () => move(1));
rail.addEventListener('scroll', update, {passive: true}); new ResizeObserver(update).observe(rail); update();

実務で気を付けるところ:自動再生は入れません。カード内リンクのフォーカスも確認します。幅変更時にスクロール量と端の判定を更新します。

実装11|タブが増えても崩れず、矢印キーで切り替えられる設定画面

作るもの:見た目だけのタブではなく、キーボードとパネルの対応まで実装します。

実行すると:選択タブに下線が付き、左右キー・Home・Endで切り替えます。狭い画面ではタブ列が横スクロールします。

組み合わせの考え方:横スクロールするFlex、aria-selectedの属性セレクター、hidden、ロービングtabindexを組み合わせます。

HTMLパネル

<div class="settings">
  <div class="tabs" role="tablist" aria-label="設定項目">
    <button role="tab" id="t1" aria-controls="p1" aria-selected="true" tabindex="0">
      プロフィール
    </button>
    <button role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">
      通知と配信
    </button>
    <button role="tab" id="t3" aria-controls="p3" aria-selected="false" tabindex="-1">
      アカウント管理
    </button>
  </div>
  <section id="p1" role="tabpanel" aria-labelledby="t1" tabindex="0">
    <h2>
      プロフィール
    </h2>
    <p>
      表示名と紹介文を設定します。
    </p>
  </section>
  <section id="p2" role="tabpanel" aria-labelledby="t2" tabindex="0" hidden>
    <h2>
      通知と配信
    </h2>
    <label>
      <input type="checkbox">
      メール通知を受け取る
    </label>
  </section>
  <section id="p3" role="tabpanel" aria-labelledby="t3" tabindex="0" hidden>
    <h2>
      アカウント管理
    </h2>
    <button>
      パスワードを変更
    </button>
  </section>
</div>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.settings {
  max-width: 55rem;
  margin: 2rem auto;
  padding: 1rem;
}
.tabs {
  display: flex;
  overflow-x: auto;
  gap: .5rem;
  border-bottom: 1px solid var(--line);
}
.tabs button {
  flex: 0 0 auto;
  border: 0;
  border-radius: .5rem .5rem 0 0;
  padding: .9rem 1rem;
  background: transparent;
  color: var(--text);
  border-bottom: 3px solid transparent;
}
.tabs [aria-selected="true"] {
  color: var(--brand);
  border-bottom-color: var(--brand);
  background: var(--soft);
}
[role="tabpanel"] {
  padding: 1.5rem;
  min-height: 12rem;
  border: 1px solid var(--line);
  border-top: 0;
}

JavaScriptパネル

const tabs = [...document.querySelectorAll('[role="tab"]')];
function activate(index, focus = false) { tabs.forEach((tab,i) => { const selected = i === index; tab.setAttribute('aria-selected', String(selected)); tab.tabIndex = selected ? 0 : -1; document.getElementById(tab.getAttribute('aria-controls')).hidden = !selected; }); if (focus) { tabs[index].focus(); tabs[index].scrollIntoView({block:'nearest',inline:'nearest'}); } }
tabs.forEach((tab,i) => { tab.addEventListener('click', () => activate(i)); tab.addEventListener('keydown', e => { let next; if(e.key==='ArrowRight') next=(i+1)%tabs.length; if(e.key==='ArrowLeft') next=(i-1+tabs.length)%tabs.length; if(e.key==='Home') next=0; if(e.key==='End') next=tabs.length-1; if(next!==undefined) { e.preventDefault(); activate(next,true); } }); });

実務で気を付けるところ:ここでは内容がローカルにあるので自動選択します。切り替え時に通信する画面なら矢印移動と確定操作を分ける選択肢もあります。

実装12|長い内容でも閉じる操作が残るモーダル

作るもの:確認画面の本文が長くても、タイトルと操作ボタンを見失わないようにします。

実行すると:ダイアログ内の本文だけがスクロールします。閉じるボタン、キャンセル、Escapeで閉じられます。

組み合わせの考え方:dialogのトップレイヤーと::backdropを使い、内部はGridのauto / minmax(0, 1fr) / autoで分割します。

HTMLパネル

<main>
  <h1>
    利用条件の確認
  </h1>
  <button id="open">
    条件を読む
  </button>
  <p>
    確認ダイアログを開いてください。
  </p>
</main>
<dialog id="terms" aria-labelledby="dialog-title">
  <div class="modal-inner">
    <header>
      <h2 id="dialog-title">
        利用条件
      </h2>
      <button id="close" aria-label="閉じる">
        ×
      </button>
    </header>
    <div class="modal-body" tabindex="0" id="terms-text">
    </div>
    <form method="dialog">
      <button value="cancel">
        キャンセル
      </button>
      <button value="agree">
        確認しました
      </button>
    </form>
  </div>
</dialog>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
main {
  padding: 2rem;
}
dialog {
  width: min(40rem, calc(100% - 2rem));
  max-height: calc(100vh - 2rem);
  max-height: calc(100dvh - 2rem);
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 1rem;
  color: var(--text);
  background: var(--surface);
}
dialog::backdrop {
  background: rgb(0 0 0 / .5);
}
.modal-inner {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
  max-height: calc(100vh - 2rem);
  max-height: calc(100dvh - 2rem);
}
.modal-inner header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 1rem;
  border-bottom: 1px solid var(--line);
}
.modal-inner h2 {
  margin: 0;
}
.modal-body {
  overflow: auto;
  overscroll-behavior: contain;
  scrollbar-gutter: stable;
  padding: 1.5rem;
}
.modal-inner form {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: .7rem;
  padding: 1rem;
  border-top: 1px solid var(--line);
}

JavaScriptパネル

const dialog = document.querySelector('#terms');
document.querySelector('#terms-text').innerHTML = Array.from({length: 16}, (_,i) => `<h3>第${i+1}条</h3><p>長い利用条件を読むためのサンプル文章です。</p>`).join('');
let oldOverflow;
document.querySelector('#open').addEventListener('click', () => { oldOverflow = document.documentElement.style.overflow; document.documentElement.style.overflow = 'hidden'; dialog.showModal(); });
document.querySelector('#close').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => { document.documentElement.style.overflow = oldOverflow; });

実務で気を付けるところ:CSSだけでモーダルの意味やフォーカス制御は作りません。showModal()を使います。ページ全体のスクロール抑制も開閉状態と連動させます。

3-4. 検索・フォーム・入力状態

実装13|検索、カテゴリ、並び替えをまとめたフィルター画面

作るもの:検索条件が増えても操作領域が崩れず、結果数と空の状態が分かる一覧を作ります。

実行すると:文字、カテゴリ、価格順を変えると一覧と結果数が更新されます。条件に合う商品がない場合は空のメッセージが出ます。

組み合わせの考え方:可変幅のGridとFlex、フォームのmin-width: 0、結果カードのauto-fit、hiddenを組み合わせます。

HTMLパネル

<main class="catalog">
  <h1>
    商品を探す
  </h1>
  <form class="filters" id="filters">
    <label>
      キーワード
      <input name="q" type="search" placeholder="商品名">
    </label>
    <label>
      カテゴリ
      <select name="category">
        <option value="">
          すべて
        </option>
        <option value="収納">
          収納
        </option>
        <option value="照明">
          照明
        </option>
      </select>
    </label>
    <label>
      並び順
      <select name="sort">
        <option value="name">
          名前
        </option>
        <option value="price">
          価格が安い順
        </option>
      </select>
    </label>
    <button type="reset">
      条件をリセット
    </button>
  </form>
  <p id="count" role="status" aria-live="polite">
  </p>
  <div class="results" id="results">
  </div>
  <p id="empty" class="empty" hidden>
    条件に合う商品がありません。検索条件を変えてみてください。
  </p>
</main>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.catalog {
  max-width: 70rem;
  margin: auto;
  padding: 1.5rem;
}
.filters {
  display: grid;
  grid-template-columns: minmax(10rem, 2fr) repeat(2, minmax(8rem, 1fr)) auto;
  align-items: end;
  gap: 1rem;
  background: var(--soft);
  padding: 1rem;
  border-radius: .8rem;
}
.filters label {
  display: grid;
  gap: .4rem;
  min-width: 0;
}
.filters input, .filters select {
  width: 100%;
  min-width: 0;
  padding: .7rem;
}
.results {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
  gap: 1rem;
}
.result {
  border: 1px solid var(--line);
  border-radius: .8rem;
  padding: 1rem;
}
.empty {
  padding: 2rem;
  background: var(--soft);
  text-align: center;
}
@media (max-width: 750px) {
  .filters {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .filters label:first-child {
    grid-column: 1 / -1;
  }
}
@media (max-width: 420px) {
  .filters {
    grid-template-columns: 1fr;
  }
}

JavaScriptパネル

const products = [{name:'収納ボックス',category:'収納',price:2400},{name:'デスクライト',category:'照明',price:4800},{name:'小物ケース',category:'収納',price:1200},{name:'フロアライト',category:'照明',price:9800}];
const form = document.querySelector('#filters');
function render() { const values = new FormData(form); const q = String(values.get('q')).trim(); const category = values.get('category'); const filtered = products.filter(p => p.name.includes(q) && (!category || p.category === category)).sort((a,b) => values.get('sort') === 'price' ? a.price-b.price : a.name.localeCompare(b.name,'ja'));
 document.querySelector('#results').innerHTML = filtered.map(p => `<article class="result"><h2>${p.name}</h2><p>${p.category}</p><strong>¥${p.price.toLocaleString('ja-JP')}</strong></article>`).join('');
 document.querySelector('#count').textContent = `${filtered.length}件の商品`; document.querySelector('#empty').hidden = filtered.length !== 0; }
form.addEventListener('input', render); form.addEventListener('submit', e => e.preventDefault()); form.addEventListener('reset', () => requestAnimationFrame(render)); render();

実務で気を付けるところ:CSSはレイアウトと表示状態を担当し、絞り込みはJSへ任せます。通信が必要な検索ではdebounceと読み込み状態も追加します。

実装14|項目、説明、エラーをまとめたレスポンシブ入力フォーム

作るもの:PCではラベルを左、スマホでは上へ並べ、送信後のエラーを色以外でも伝えます。

実行すると:未入力や不正なメールで確認するとエラーが現れ、最初の不正な入力へフォーカスします。正しく入力するとデモの成功メッセージが出ます。

組み合わせの考え方:Gridで項目を揃え、aria-invalidを属性セレクターに使います。説明とエラーはaria-describedbyで関連付けます。

HTMLパネル

<form id="profile" class="profile" novalidate>
  <h1>
    プロフィール設定
  </h1>
  <div class="field">
    <label for="name">
      表示名
      <span>
        必須
      </span>
    </label>
    <div>
      <input id="name" name="name" required maxlength="40" aria-describedby="name-help name-error">
      <p id="name-help">
        40文字以内で入力します。
      </p>
      <p class="error" id="name-error" hidden>
      </p>
    </div>
  </div>
  <div class="field">
    <label for="email">
      メール
      <span>
        必須
      </span>
    </label>
    <div>
      <input id="email" name="email" type="email" required aria-describedby="email-help email-error">
      <p id="email-help">
        例:name@example.com
      </p>
      <p class="error" id="email-error" hidden>
      </p>
    </div>
  </div>
  <div class="actions">
    <button>
      入力内容を確認
    </button>
    <p role="status" id="message">
    </p>
  </div>
</form>

CSSパネル

<em>/* 共通ベース:このCSSパネルだけで実行できます */</em>
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.profile {
  max-width: 50rem;
  margin: auto;
  padding: 2rem 1rem;
}
.field {
  display: grid;
  grid-template-columns: 10rem minmax(0, 1fr);
  gap: 1rem;
  padding-block: 1.25rem;
  border-bottom: 1px solid var(--line);
}
.field label {
  font-weight: 700;
}
.field label span {
  font-size: .75em;
  padding: .15em .4em;
  background: var(--soft);
  border-radius: .25rem;
}
.field input {
  width: 100%;
  min-width: 0;
  padding: .8rem;
  border: 1px solid var(--line);
  border-radius: .5rem;
  background: var(--surface);
  color: var(--text);
}
.field p {
  margin: .4rem 0 0;
  font-size: .9rem;
}
.field [aria-invalid="true"] {
  border: 2px solid #a52a2a;
}
.error {
  color: #a52a2a;
  font-weight: 700;
}
.actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 1rem;
  padding-top: 1.5rem;
}
@media (max-width: 600px) {
  .field {
    grid-template-columns: 1fr;
    gap: .5rem;
  }
}

JavaScriptパネル

const form = document.querySelector('#profile');
let attempted = false;
function check(input) { const invalid = !input.validity.valid; input.setAttribute('aria-invalid', String(invalid)); const error = document.getElementById(`${input.id}-error`); error.hidden = !invalid; error.textContent = invalid ? (input.validity.valueMissing ? '入力が必要です。' : '入力形式を確認してください。') : ''; return !invalid; }
form.addEventListener('submit', e => { e.preventDefault(); attempted = true; const inputs = [...form.querySelectorAll('input')]; inputs.forEach(check); const first = inputs.find(input => !input.validity.valid); document.querySelector('#message').textContent = first ? '入力内容を確認してください。' : '入力を確認しました。これは送信しないデモです。'; if(first) first.focus(); });
form.addEventListener('input', e => { if(attempted && e.target.matches('input')) { check(e.target); document.querySelector('#message').textContent = ''; } });

実務で気を付けるところ:この例は送信しません。実際の保存にはサーバー側の検証も必要です。入力前から全項目を赤くしないようにします。

実装15|選択カードとして見せる料金オプション

作るもの:ラジオボタンの意味とキーボード操作を保ったまま、カード全体を選択できるUIを作ります。

実行すると:カードを押すか矢印キーで選択すると、枠・背景・選択ラベルが変わります。

組み合わせの考え方:native radioを残し、:checked + spanでカードを装飾します。focus-visibleの輪郭を親カードへ伝えます。

HTMLパネル

<fieldset class="options">
  <legend>
    配送方法を選ぶ
  </legend>
  <div class="option-grid">
    <label class="option">
      <input type="radio" name="delivery" value="standard" checked>
      <span>
        <strong>
          通常配送
        </strong>
        <small>
          3〜5日・無料
        </small>
        <em>
          選択中
        </em>
      </span>
    </label>
    <label class="option">
      <input type="radio" name="delivery" value="express">
      <span>
        <strong>
          お急ぎ便
        </strong>
        <small>
          翌日・¥500
        </small>
        <em>
          選択中
        </em>
      </span>
    </label>
    <label class="option">
      <input type="radio" name="delivery" value="pickup">
      <span>
        <strong>
          店舗受け取り
        </strong>
        <small>
          準備後・無料
        </small>
        <em>
          選択中
        </em>
      </span>
    </label>
  </div>
</fieldset>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.options {
  max-width: 60rem;
  margin: 2rem auto;
  border: 0;
  padding: 1rem;
}
.options legend {
  font-size: 1.4rem;
  font-weight: 700;
}
.option-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
  gap: 1rem;
}
.option {
  position: relative;
  cursor: pointer;
}
.option input {
  position: absolute;
  top: 1rem;
  left: 1rem;
  width: 1.2rem;
  height: 1.2rem;
  accent-color: var(--brand);
}
.option > span {
  height: 100%;
  display: grid;
  gap: .5rem;
  padding: 1rem 1rem 1rem 3rem;
  border: 2px solid var(--line);
  border-radius: .8rem;
}
.option em {
  font-style: normal;
  font-size: .85rem;
  visibility: hidden;
}
.option input:checked + span {
  border-color: var(--brand);
  background: var(--soft);
}
.option input:checked + span em {
  visibility: visible;
}
.option input:focus-visible + span {
  outline: 3px solid var(--brand);
  outline-offset: 4px;
}

実務で気を付けるところ:inputをdisplay: noneで消しません。通常のラジオ操作を使い、選択状態を色だけに依存させません。

実装16|編集内容が長くても保存操作に戻れる設定ページ

作るもの:長い設定項目をスクロールしても、保存・取消と変更状態が見えるようにします。

実行すると:画面下に保存バーが追従します。未変更では保存できず、編集すると保存できるようになります。

組み合わせの考え方:Gridでフッターを通常の流れに置き、sticky bottomで追従させます。flex-wrapとsafe-areaを合わせます。

HTMLパネル

<form id="preferences" class="preferences">
  <main>
    <h1>
      通知の設定
    </h1>
    <div id="checks">
    </div>
  </main>
  <footer class="save-bar">
    <p id="save-status" role="status">
      変更はありません
    </p>
    <div>
      <button type="reset">
        取り消す
      </button>
      <button id="save" disabled>
        保存する
      </button>
    </div>
  </footer>
</form>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.preferences {
  min-height: 100vh;
  display: grid;
  grid-template-rows: 1fr auto;
}
.preferences main {
  width: min(100%, 50rem);
  margin: auto;
  padding: 2rem 1rem;
}
.preference {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding: 1.5rem 0;
  border-bottom: 1px solid var(--line);
}
.preference input {
  flex-shrink: 0;
  width: 1.4rem;
  height: 1.4rem;
  accent-color: var(--brand);
}
.save-bar {
  position: sticky;
  bottom: 0;
  z-index: 5;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: .5rem 1rem;
  padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
  background: var(--surface);
  border-top: 1px solid var(--line);
  box-shadow: 0 -4px 16px rgb(0 0 0 / .08);
}
.save-bar p {
  margin: 0;
}
.save-bar div {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem;
}
button:disabled {
  opacity: .45;
  cursor: default;
}

JavaScriptパネル

const form = document.querySelector('#preferences');
document.querySelector('#checks').innerHTML = Array.from({length: 12}, (_,i) => `<label class="preference"><span><strong>通知カテゴリ ${i+1}</strong><br>関連する更新を受け取ります。</span><input type="checkbox" name="n${i}"></label>`).join('');
const save = document.querySelector('#save'), status = document.querySelector('#save-status');
let snapshot = new URLSearchParams(new FormData(form)).toString();
function update() { const dirty = new URLSearchParams(new FormData(form)).toString() !== snapshot; save.disabled = !dirty; status.textContent = dirty ? '未保存の変更があります' : '変更はありません'; }
form.addEventListener('change', update);
form.addEventListener('reset', () => requestAnimationFrame(update));
form.addEventListener('submit', e => { e.preventDefault(); snapshot = new URLSearchParams(new FormData(form)).toString(); form.querySelectorAll('input').forEach(input => input.defaultChecked = input.checked); save.disabled = true; status.textContent = 'このデモ内で保存しました(通信は行いません)'; });

実務で気を付けるところ:今回は追従する要素の高さぶんが通常の流れにも残ります。固定配置へ変えるなら本文側にも高さぶんの余白が必要です。

実装17|ステップ表示と入力状態が連動する複数画面フォーム

作るもの:入力、確認、完了の位置を伝え、画面が狭くてもステップラベルが潰れないようにします。

実行すると:必須項目を入れて次へ進むと確認画面になり、戻って編集できます。現在のステップが表示されます。

組み合わせの考え方:CSS counter、疑似要素、Grid、aria-current、hiddenを組み合わせます。JSは画面の状態とフォーカス移動だけを管理します。

HTMLパネル

<main class="wizard">
  <h1>
    お問い合わせ
  </h1>
  <ol class="steps">
    <li aria-current="step">
      入力
    </li>
    <li>
      確認
    </li>
    <li>
      完了
    </li>
  </ol>
  <form id="wizard-form">
    <section id="input-step">
      <h2 tabindex="-1">
        内容を入力
      </h2>
      <label>
        お名前
        <input name="name" required>
      </label>
      <label>
        お問い合わせ
        <textarea name="text" required rows="5">
        </textarea>
      </label>
      <button>
        確認へ進む
      </button>
    </section>
    <section id="confirm-step" hidden>
      <h2 tabindex="-1">
        入力内容の確認
      </h2>
      <dl>
        <dt>
          お名前
        </dt>
        <dd id="confirm-name">
        </dd>
        <dt>
          内容
        </dt>
        <dd id="confirm-text">
        </dd>
      </dl>
      <div class="wizard-actions">
        <button type="button" id="back">
          戻って編集
        </button>
        <button type="button" id="finish">
          デモを完了
        </button>
      </div>
    </section>
    <section id="done-step" hidden>
      <h2 tabindex="-1">
        完了
      </h2>
      <p>
        操作を確認しました。実際の送信はしていません。
      </p>
      <button type="button" id="restart">
        最初から
      </button>
    </section>
  </form>
</main>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.wizard {
  max-width: 48rem;
  margin: auto;
  padding: 2rem 1rem;
}
.steps {
  counter-reset: step;
  list-style: none;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: .5rem;
  padding: 0;
}
.steps li {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: .5rem;
  padding: .8rem .3rem;
  background: var(--soft);
  border-bottom: 3px solid transparent;
}
.steps li::before {
  counter-increment: step;
  content: counter(step);
  display: grid;
  place-items: center;
  width: 1.7rem;
  height: 1.7rem;
  border-radius: 50%;
  border: 1px solid var(--line);
}
.steps [aria-current="step"] {
  border-bottom-color: var(--brand);
  font-weight: 700;
}
.steps [aria-current="step"]::before {
  background: var(--brand);
  color: white;
}
.wizard label {
  display: grid;
  gap: .5rem;
  margin-block: 1rem;
}
.wizard input, .wizard textarea {
  width: 100%;
  min-width: 0;
  padding: .8rem;
  border: 1px solid var(--line);
  border-radius: .5rem;
}
.wizard dd {
  margin: .3rem 0 1rem;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.wizard-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}

JavaScriptパネル

const form=document.querySelector('#wizard-form'); const sections=['input-step','confirm-step','done-step'];
function show(index) { sections.forEach((id,i)=>document.getElementById(id).hidden=i!==index); document.querySelectorAll('.steps li').forEach((li,i)=>{if(i===index)li.setAttribute('aria-current','step');else li.removeAttribute('aria-current');});document.querySelector(`#${sections[index]} h2`).focus(); }
form.addEventListener('submit',e=>{e.preventDefault();const data=new FormData(form);document.querySelector('#confirm-name').textContent=data.get('name');document.querySelector('#confirm-text').textContent=data.get('text');show(1);});
document.querySelector('#back').addEventListener('click',()=>show(0));document.querySelector('#finish').addEventListener('click',()=>show(2));document.querySelector('#restart').addEventListener('click',()=>{form.reset();show(0);});

実務で気を付けるところ:見た目のステップ表示だけでは入力検証や送信はできません。デモは通信しません。実フォームではサーバー側の検証と失敗状態を追加します。

3-5. 読み込み・通知・動き

実装18|読み込み中、成功、失敗、空の状態が揃う一覧

作るもの:スピナーだけではなく、実際の一覧画面で必要になる状態をまとめて扱います。

実行すると:ボタンで読み込み結果を切り替えられ、スケルトンの後に成功・空・失敗が表示されます。連打しても古い結果が上書きしません。

組み合わせの考え方:aria-busy、hidden、スケルトンのGrid、疑似要素の動き、prefers-reduced-motionを組み合わせます。

HTMLパネル

<main class="feed">
  <h1>
    更新一覧
  </h1>
  <div class="controls">
    <button data-mode="success">
      成功を試す
    </button>
    <button data-mode="empty">
      空を試す
    </button>
    <button data-mode="error">
      失敗を試す
    </button>
  </div>
  <p id="load-status" role="status">
  </p>
  <section id="feed-area" aria-busy="false">
    <div id="skeleton" class="feed-grid" aria-hidden="true" hidden>
      <div class="skeleton">
      </div>
      <div class="skeleton">
      </div>
      <div class="skeleton">
      </div>
    </div>
    <div id="loaded" class="feed-grid">
    </div>
    <p id="load-empty" hidden>
      更新はまだありません。
    </p>
    <p id="load-error" role="alert" hidden>
      読み込みに失敗しました。もう一度お試しください。
    </p>
  </section>
</main>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.feed {
  max-width: 65rem;
  margin: auto;
  padding: 1.5rem;
}
.controls {
  display: flex;
  flex-wrap: wrap;
  gap: .6rem;
}
.feed-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
  gap: 1rem;
}
.feed-card, .skeleton {
  min-height: 12rem;
  padding: 1rem;
  border-radius: .8rem;
  border: 1px solid var(--line);
}
.skeleton {
  position: relative;
  overflow: hidden;
  background: var(--soft);
}
.skeleton::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, rgb(255 255 255 / .7), transparent);
  transform: translateX(-100%);
  animation: shimmer 1.2s infinite;
}
@keyframes shimmer {
  to {
    transform: translateX(100%);
  }
}
#load-error {
  border-inline-start: 4px solid #a52a2a;
  padding: 1rem;
  background: #fff0f0;
}
@media (prefers-reduced-motion: reduce) {
  .skeleton::after {
    animation: none;
    display: none;
  }
}

JavaScriptパネル

let request = 0;
async function load(mode) { const id = ++request; const area = document.querySelector('#feed-area'); area.setAttribute('aria-busy','true'); document.querySelector('#load-status').textContent = '読み込み中です'; ['loaded','load-empty','load-error'].forEach(name => document.getElementById(name).hidden = true); document.querySelector('#skeleton').hidden = false;
 await new Promise(resolve => setTimeout(resolve, 1000)); if(id !== request) return;
 document.querySelector('#skeleton').hidden = true; area.setAttribute('aria-busy','false'); document.querySelector('#load-status').textContent = mode==='success' ? '3件を読み込みました' : mode==='empty' ? '0件でした' : '読み込みに失敗しました';
 if(mode==='success') { const loaded=document.querySelector('#loaded'); loaded.innerHTML = [1,2,3].map(n => `<article class="feed-card"><h2>更新 ${n}</h2><p>新しい機能を追加しました。</p></article>`).join(''); loaded.hidden=false; } else { document.getElementById(mode==='empty'?'load-empty':'load-error').hidden=false; } }
document.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => load(button.dataset.mode))); load('success');

実務で気を付けるところ:通信は模擬です。実際の通信ではAbortControllerなども検討します。スケルトンは支援技術へ読み上げさせず、状態文を別に用意します。

実装19|閉じるまで残る、画面端の通知スタック

作るもの:通知が複数重なっても、本文と操作を邪魔しすぎず読みやすく表示します。

実行すると:通知を追加すると画面右下へ積まれます。長い通知でも折り返し、閉じるボタンで消せます。

組み合わせの考え方:固定配置、safe-area、Grid、minmax(0, 1fr)、max-heightとoverflowを使い、通知領域自体も画面内に収めます。

HTMLパネル

<main>
  <h1>
    保存通知
  </h1>
  <button id="notify">
    通知を追加
  </button>
  <p>
    何度か押して積み重なりを確認します。
  </p>
</main>
<p class="sr-only" id="notice-status" role="status">
</p>
<aside class="notices" aria-label="通知一覧" id="notices">
</aside>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
main {
  padding: 2rem;
}
.notices {
  position: fixed;
  right: max(1rem, env(safe-area-inset-right));
  bottom: calc(1rem + env(safe-area-inset-bottom));
  width: min(24rem, calc(100% - 2rem));
  max-height: calc(100vh - 2rem);
  max-height: calc(100dvh - 2rem);
  overflow: auto;
  overscroll-behavior: contain;
  display: grid;
  gap: .7rem;
  z-index: 20;
  padding: .3rem;
}
.notice {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 1rem;
  padding: 1rem;
  border: 1px solid var(--line);
  border-inline-start: 4px solid var(--brand);
  border-radius: .7rem;
  background: var(--surface);
  box-shadow: 0 4px 20px rgb(0 0 0 / .12);
  animation: arrive .18s ease-out;
}
.notice p {
  margin: 0;
  overflow-wrap: anywhere;
}
@keyframes arrive {
  from {
    opacity: 0;
    transform: translateY(.5rem);
  }
}
@media (prefers-reduced-motion: reduce) {
  .notice {
    animation: none;
  }
}

JavaScriptパネル

let count = 0;
const trigger = document.querySelector('#notify');
trigger.addEventListener('click', () => { count++; const item=document.createElement('div'); item.className='notice'; const text=document.createElement('p'); text.textContent=`設定 ${count} を保存しました。変更内容を確認できます。`; const close=document.createElement('button'); close.textContent='閉じる'; close.setAttribute('aria-label',`通知 ${count} を閉じる`); item.append(text,close); document.querySelector('#notices').append(item); document.querySelector('#notice-status').textContent=text.textContent; close.addEventListener('click', () => { const next=item.nextElementSibling || item.previousElementSibling; item.remove(); if(next) next.querySelector('button').focus(); else trigger.focus(); }); });

実務で気を付けるところ:重要なメッセージを数秒で勝手に消しません。読み上げ専用のstatusを分け、通知を閉じた後のフォーカス位置も扱います。

実装20|スクロール量と動きの設定に対応する読書進捗

作るもの:記事をどこまで読んだか示し、本文の長さが変わっても計算を合わせます。

実行すると:上端の細いバーがスクロール量に合わせて伸びます。末尾では100%になります。

組み合わせの考え方:fixedとtransform-originでバーを置き、JSで進捗をCSS変数へ渡します。aria-valuenowも同期します。

HTMLパネル

<div class="reading" role="progressbar" aria-label="記事の読書進捗" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
  <span>
  </span>
</div>
<article class="long-article" id="reading-article">
  <h1>
    長い記事のサンプル
  </h1>
</article>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.reading {
  position: fixed;
  inset: 0 0 auto;
  height: .3rem;
  background: var(--soft);
  z-index: 30;
  pointer-events: none;
}
.reading span {
  display: block;
  height: 100%;
  background: var(--brand);
  transform-origin: left;
  transform: scaleX(var(--read, 0));
}
.long-article {
  max-width: 65ch;
  margin: auto;
  padding: 2rem 1rem;
}
.long-article section {
  padding-block: 2rem;
  line-height: 2;
  border-bottom: 1px solid var(--line);
}

JavaScriptパネル

const article=document.querySelector('#reading-article');
article.insertAdjacentHTML('beforeend',Array.from({length:18},(_,i)=>`<section><h2>第${i+1}節</h2><p>${'読みながら進捗を確認する文章です。'.repeat(24)}</p></section>`).join(''));
const bar=document.querySelector('.reading'); let scheduled=false;
function update() { scheduled=false; const scroller=document.scrollingElement; const max=scroller.scrollHeight-scroller.clientHeight; const ratio=max>0?Math.min(1,Math.max(0,scroller.scrollTop/max)):1; bar.style.setProperty('--read',ratio); bar.setAttribute('aria-valuenow',String(Math.round(ratio*100))); }
addEventListener('scroll',()=>{if(!scheduled){scheduled=true;requestAnimationFrame(update);}}, {passive:true}); addEventListener('resize',update); new ResizeObserver(update).observe(article); update();

実務で気を付けるところ:ここでは互換性を取りやすいJS方式です。スクロール連動アニメーションを使うCSS方式とは分けて考えます。CodePenではプレビュー内をスクロールします。

3-6. テーマ・メディア・実務

実装21|OS設定と手動指定を両立させるテーマ切り替え

作るもの:ライト/ダーク/OSに合わせる、の3つを選べる画面を作ります。

実行すると:OS設定か手動設定で背景、文字、カード、フォームの配色が切り替わり、選択は保存できる環境では次回にも残ります。

組み合わせの考え方:意味で名前を付けたCSS変数、data-theme属性、prefers-color-scheme、color-schemeを一つの優先ルールで扱います。

HTMLパネル

<main class="theme-demo">
  <h1>
    表示テーマ
  </h1>
  <label>
    テーマ
    <select id="theme">
      <option value="auto">
        OSに合わせる
      </option>
      <option value="light">
        ライト
      </option>
      <option value="dark">
        ダーク
      </option>
    </select>
  </label>
  <article>
    <h2>
      設定カード
    </h2>
    <p>
      背景だけではなく、枠や入力欄の色も変えます。
    </p>
    <label>
      表示名
      <input value="CSSユーザー">
    </label>
    <button>
      確認する
    </button>
  </article>
</main>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
:root {
  color-scheme: light;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
  --brand: #245f43;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]):not([data-theme="dark"]) {
    color-scheme: dark;
    --surface: #18231d;
    --soft: #26392e;
    --text: #edf5ef;
    --line: #52695a;
    --brand: #a0dfb9;
  }
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --surface: #18231d;
  --soft: #26392e;
  --text: #edf5ef;
  --line: #52695a;
  --brand: #a0dfb9;
}
body {
  background: var(--surface);
  color: var(--text);
}
.theme-demo {
  max-width: 42rem;
  margin: auto;
  padding: 2rem 1rem;
}
.theme-demo article {
  display: grid;
  gap: 1rem;
  margin-top: 2rem;
  padding: 1.5rem;
  border: 1px solid var(--line);
  border-radius: 1rem;
  background: var(--soft);
}
.theme-demo label {
  display: grid;
  gap: .4rem;
}
input, select, button {
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: .4rem;
  padding: .7rem;
}
.theme-demo h2, .theme-demo p {
  margin: 0;
}

JavaScriptパネル

const select=document.querySelector('#theme');
function apply(value) { if(value==='auto') delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme=value; select.value=value; }
let saved='auto'; try { saved=localStorage.getItem('css-demo-theme')||'auto'; } catch {}
apply(['auto','light','dark'].includes(saved)?saved:'auto');
select.addEventListener('change',()=>{apply(select.value);try{localStorage.setItem('css-demo-theme',select.value);}catch{}});

実務で気を付けるところ:CodePenのiframeなどで保存が制限される場合もあります。実サイトで初期表示のちらつきを抑えるなら、テーマ適用処理をhead側で早く実行します。

実装22|印刷でも読める請求書レイアウト

作るもの:画面ではカード風、印刷では余計なUIを消して紙に収まる請求書を作ります。

実行すると:画面では明細を横スクロールできます。印刷プレビューでは操作ボタンが消え、白地の明細と合計が表示されます。

組み合わせの考え方:画面用のGridとoverflowを、@media printで解除します。@page、break-inside、table-header-groupで印刷を調整します。

HTMLパネル

<div class="invoice-tools">
  <button id="print">
    印刷プレビュー
  </button>
</div>
<article class="invoice">
  <header>
    <h1>
      請求書
    </h1>
    <div>
      <p>
        No. INV-2026-001
      </p>
      <p>
        発行日:2026年10月1日
      </p>
    </div>
  </header>
  <section>
    <h2>
      株式会社サンプル 御中
    </h2>
    <p>
      CSS Lab
      <br>
      東京都サンプル区 1-2-3
    </p>
  </section>
  <div class="invoice-scroll">
    <table>
      <thead>
        <tr>
          <th scope="col">
            内容
          </th>
          <th scope="col">
            数量
          </th>
          <th scope="col">
            単価
          </th>
          <th scope="col">
            金額
          </th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <th scope="row">
            Web画面設計
          </th>
          <td>
            1
          </td>
          <td>
            ¥80,000
          </td>
          <td>
            ¥80,000
          </td>
        </tr>
        <tr>
          <th scope="row">
            CSS実装
          </th>
          <td>
            1
          </td>
          <td>
            ¥120,000
          </td>
          <td>
            ¥120,000
          </td>
        </tr>
      </tbody>
    </table>
  </div>
  <section class="total">
    <p>
      税抜合計 ¥200,000
    </p>
    <p>
      消費税 ¥20,000
    </p>
    <strong>
      ご請求額 ¥220,000
    </strong>
  </section>
  <footer>
    お支払い期限:2026年10月31日
  </footer>
</article>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
body {
  background: var(--soft);
  padding: 1rem;
}
.invoice-tools {
  max-width: 52rem;
  margin: 0 auto 1rem;
}
.invoice {
  max-width: 52rem;
  margin: auto;
  padding: clamp(1rem, 4vw, 3rem);
  background: white;
  color: #20382b;
  border-radius: 1rem;
  box-shadow: 0 6px 24px rgb(0 0 0 / .08);
}
.invoice header {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 1rem;
  border-bottom: 2px solid #245f43;
}
.invoice-scroll {
  overflow: auto;
  margin-block: 2rem;
}
.invoice table {
  width: 100%;
  min-width: 30rem;
  border-collapse: collapse;
}
.invoice th, .invoice td {
  text-align: right;
  padding: .8rem;
  border-bottom: 1px solid #c7d6cd;
  font-variant-numeric: tabular-nums;
}
.invoice th:first-child {
  text-align: left;
}
.total {
  text-align: right;
}
.invoice footer {
  margin-top: 2rem;
  border-top: 1px solid #c7d6cd;
  padding-top: 1rem;
}
@page {
  size: A4;
  margin: 15mm;
}
@media print {
  body {
    padding: 0;
    background: white;
  }
  .invoice-tools {
    display: none;
  }
  .invoice {
    max-width: none;
    padding: 0;
    border-radius: 0;
    box-shadow: none;
  }
  .invoice-scroll {
    overflow: visible;
  }
  .invoice table {
    min-width: 0;
  }
  .invoice thead {
    display: table-header-group;
  }
  .invoice tr, .total, .invoice footer {
    break-inside: avoid;
  }
}

JavaScriptパネル

document.querySelector('#print').addEventListener('click',()=>window.print());

実務で気を付けるところ:改ページやヘッダーの繰り返しはブラウザや印刷設定で差があります。CodePenの埋め込み外のページ全体を印刷することとは別です。

実装23|画像に文字を重ねても読めるヒーローと、切れないCTA

作るもの:幅と文字量が変わっても、背景画像と見出し・CTAが重なりすぎないヒーローを作ります。

実行すると:画像は領域を覆い、暗いグラデーション上に文字が載ります。狭い画面や長いタイトルでも高さが伸び、CTAが折り返します。

組み合わせの考え方:absoluteの画像と疑似要素、isolation、min-height、clamp、flex-wrapを組み合わせます。

HTMLパネル

<section class="hero">
  <img class="hero-image" src="photo.svg" alt="" width="960" height="640">
  <div class="hero-copy">
    <p class="eyebrow">
      CSS PRACTICE
    </p>
    <h1>
      画面の構成から、CSSの組み合わせを考える
    </h1>
    <p>
      文字が増えても、スマホでも、読めるレイアウトを作っていきます。
    </p>
    <div class="hero-actions">
      <a href="#examples">
        実装例を見る
      </a>
      <a href="#guide">
        設計ガイドを読む
      </a>
    </div>
  </div>
</section>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.hero {
  position: relative;
  isolation: isolate;
  display: grid;
  align-items: center;
  min-height: 28rem;
  padding: clamp(2rem, 7vw, 6rem) clamp(1rem, 5vw, 4rem);
  color: white;
  background: #193f2d;
}
.hero-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 65% center;
  z-index: -2;
}
.hero::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(90deg, rgb(0 0 0 / .85), rgb(0 0 0 / .6));
}
.hero-copy {
  max-width: 42rem;
}
.hero h1 {
  margin: .7rem 0;
  font-size: clamp(2rem, 5vw, 3.6rem);
  line-height: 1.25;
  overflow-wrap: anywhere;
}
.eyebrow {
  letter-spacing: .15em;
  font-weight: 700;
}
.hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  margin-top: 1.5rem;
}
.hero-actions a {
  color: white;
  border: 1px solid white;
  border-radius: .5rem;
  padding: .8rem 1.2rem;
  text-decoration: none;
}
.hero-actions a:first-child {
  color: #193f2d;
  background: white;
}

実務で気を付けるところ:heightで領域を固定しません。文字のコントラストは写真を変えた後にも確認します。意味のある画像には内容に合うaltが必要です。

実装24|メイン画像とサムネイルを切り替える商品ギャラリー

作るもの:商品画像の比率を保ちながら、狭い画面ではサムネイル列を横へ流します。

実行すると:サムネイルを押すと大きな画像が切り替わり、選択されたサムネイルに枠が付きます。

組み合わせの考え方:Grid、aspect-ratio、object-fit、overflow-xとaria-pressedの属性セレクターを組み合わせます。

HTMLパネル

<section class="gallery" aria-label="商品画像">
  <div class="thumbs" id="thumbs">
  </div>
  <figure>
    <img id="main-image" src="photo.svg" alt="商品の正面">
    <figcaption id="caption">
      商品の正面
    </figcaption>
  </figure>
</section>

CSSパネル

/* 共通ベース:このCSSパネルだけで実行できます */
*, *::before, *::after {
  box-sizing: border-box;
}
:root {
  --brand: #245f43;
  --surface: #fff;
  --soft: #edf4f0;
  --text: #20382b;
  --line: #c7d6cd;
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
  background: var(--surface);
  color: var(--text);
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
  padding: .5rem .8rem;
}
img {
  max-width: 100%;
  height: auto;
}
a {
  color: var(--brand);
  text-underline-offset: .2em;
}
:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.gallery {
  max-width: 65rem;
  margin: 2rem auto;
  padding: 1rem;
  display: grid;
  grid-template-columns: 6rem minmax(0, 1fr);
  gap: 1rem;
}
.thumbs {
  display: flex;
  flex-direction: column;
  gap: .6rem;
}
.thumbs button {
  padding: .25rem;
  border: 2px solid var(--line);
  background: var(--surface);
  border-radius: .5rem;
}
.thumbs [aria-pressed="true"] {
  border-color: var(--brand);
  box-shadow: 0 0 0 1px var(--brand);
}
.thumbs img {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
}
.gallery figure {
  margin: 0;
  min-width: 0;
}
#main-image {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: contain;
  background: var(--soft);
  border-radius: .8rem;
}
.gallery figcaption {
  padding: .7rem;
  text-align: center;
}
@media (max-width: 600px) {
  .gallery {
    grid-template-columns: 1fr;
  }
  .thumbs {
    order: 2;
    flex-direction: row;
    overflow-x: auto;
    padding: .3rem;
  }
  .thumbs button {
    flex: 0 0 5rem;
  }
}

JavaScriptパネル

const labels=['商品の正面','商品の側面','商品の背面'];
const images=labels.map((label,i)=>'data:image/svg+xml;charset=utf-8,'+encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600"><rect width="800" height="600" fill="${['#dbece1','#dae4ed','#eadfd4'][i]}"/><rect x="200" y="120" width="400" height="340" rx="30" fill="${['#38664c','#365674','#79563b'][i]}"/><text x="400" y="520" text-anchor="middle" font-size="32" font-family="sans-serif">${label}</text></svg>`));
const thumbs=document.querySelector('#thumbs');
thumbs.innerHTML=labels.map((label,i)=>`<button aria-label="${label}を表示" aria-pressed="${i===0}" data-index="${i}"><img src="${images[i]}" alt=""></button>`).join('');
function select(index) { const img=document.querySelector('#main-image');img.src=images[index];img.alt=labels[index];document.querySelector('#caption').textContent=labels[index];thumbs.querySelectorAll('button').forEach((b,i)=>b.setAttribute('aria-pressed',String(i===index))); }
thumbs.addEventListener('click',e=>{const button=e.target.closest('button');if(button)select(Number(button.dataset.index));});select(0);

実務で気を付けるところ:デモ画像はSVGなので外部画像配信への依存を避けています。実際の商品では表示サイズに合う画像、alt、width/heightも設計します。

この部で使った仕組みの資料

第4部|実際のWeb開発で、CSSをどう設計する?

4-1. 最初に、共通の値を決める

色や余白を毎回別々に書くと、「少しだけ色を変えたい」ときに修正箇所が増えます。まず全体で使う値を定義します。

:root {
  --color-brand: #35624a;
  --color-text: #25332c;
  --color-muted: #536158;
  --color-bg: #fff;
  --color-surface: #edf4ee;
  --color-border: #a8b8ac;
  --space-1: .25rem;
  --space-2: .5rem;
  --space-3: 1rem;
  --space-4: 1.5rem;
  --space-5: 2rem;
  --space-6: 3rem;
  --font-body: system-ui, sans-serif;
  --font-size-body: 1rem;
  --line-height-body: 1.7;
  --container-width: 72rem;
  --radius-card: .75rem;
  --shadow-card: 0 .25rem 1rem rgb(0 0 0 / .08);
  --z-header: 100;
  --z-floating: 200;
}

--green-500のような色そのものの名前と、--color-textのような役割の名前を分ける設計もあります。テーマを変える場合は、役割の名前を使うと部品側を変更しなくて済みます。

すべての1pxを変数にする必要はありません。再利用する値、変更理由が共通する値から始める方が、自分には管理しやすいと思います。

4-2. 全体の変数と、部品の変数

.button {
  --button-bg: var(--color-brand);
  --button-text: #fff;
  background: var(--button-bg);
  color: var(--button-text);
}
.button--secondary {
  --button-bg: var(--color-surface);
  --button-text: var(--color-text);
}

ページ全体で使うものは:root、部品に限定するものは部品側へ置きます。変数は通常子孫へ継承されるので、名前が短すぎると別の部品に影響する場合があります。

4-3. Resetとベーススタイル

Resetは標準スタイルを積極的に揃えるもの、Normalizeはブラウザ差を補正しながら有用な標準スタイルを残す考え方です。どちらでも、何が消えるかを理解して導入します。

自分なら、小さいサイトでは次のような最小構成から始めます。

*, *::before, *::after {
  box-sizing: border-box;
}
body {
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--font-size-body);
  line-height: var(--line-height-body);
  color: var(--color-text);
  background: var(--color-bg);
}
img, video {
  display: block;
  max-width: 100%;
  height: auto;
}
button, input, select, textarea {
  font: inherit;
}
button {
  cursor: pointer;
}
button:disabled {
  cursor: default;
}
:focus-visible {
  outline: 3px solid #245d9c;
  outline-offset: 3px;
}
[hidden] {
  display: none !important;
}

リストや見出しの余白まで全部消すなら、本文用のスタイルで戻す必要があります。* { margin: 0; padding: 0; }だけで済ませると、読みやすい文書を作るための設定まで消えます。

font-size: 62.5%でremを計算しやすくする方法もありますが、必須ではありません。初期のルート文字サイズを尊重し、1remを基準に設計する方法も十分実用的です。

4-4. ページを作る順番

  1. HTMLの意味と読み順を決める。
  2. コンテナ、ヘッダー、本文、フッターなどの大枠を配置する。
  3. ボタン、カード、入力欄など共通部品を作る。
  4. 文字、色、余白、装飾を揃える。
  5. 長文・少ない内容・多い内容を入れる。
  6. 狭い幅、中間幅、広い幅、文字拡大で確認する。
  7. 最後に動きや追加表現を入れる。

最初から全部absoluteで位置を合わせると、文章が増えたときに崩れやすくなります。通常フロー、Flexbox、Gridで組んで、重ねたい部分だけ配置指定を使います。

4-5. ファイル構成

ファイル役割
tokens.css共通の変数
reset.css初期スタイルの整理
base.css本文、見出し、リンク、標準要素
layout.cssコンテナ、ページの大枠
components.cssボタン、カード、フォームなど
utilities.css小さな補助クラス
pages/*.css特定のページで必要なもの

これは分類の例です。小規模なら1ファイルの中で同じ順に並べるだけでも構いません。分割したファイルを毎回何十個も配信するのか、ビルドでまとめるのかも別に考えます。

@layer reset, tokens, base, layout, components, utilities;
@import url("reset.css") layer(reset);
@import url("tokens.css") layer(tokens);
@import url("base.css") layer(base);
@import url("layout.css") layer(layout);
@import url("components.css") layer(components);
@import url("utilities.css") layer(utilities);

@importはレイヤー順宣言などを除き、通常のルールより前へ置きます。これは構成説明用で、ネットワークの連鎖を避けるためにビルド時の統合やlinkによる読み込みも検討します。

4-6. 命名と適用範囲

<article class="card card--featured" data-state="loading">
  <h2 class="card__title">記事のタイトル</h2>
  <p class="card__description">説明文</p>
</article>
.card {
  padding: 1rem;
}
.card__title {
  margin-top: 0;
}
.card--featured {
  border: 2px solid var(--color-brand);
}
.card[data-state="loading"] {
  opacity: .7;
}

BEMは「部品・部品内の要素・バリエーション」を名前で表す方法です。状態はis-activeのようなクラスでもdata属性でも表せます。チームやプロジェクトで方式を揃える方が重要です。

.page .main .section .card div h2のように深い階層へ依存すると、HTMLを少し変えるだけで影響が出ます。部品のクラスへ直接指定し、詳細度を上げすぎないようにします。

4-7. ベース・部品・補助クラスの実用的な組み合わせ

<main class="container stack">
  <h1>記事一覧</h1>
  <div class="card-grid">
    <article class="card stack"><h2 class="card__title">CSSの整理</h2><p>基本から見直してみました。</p><a class="button" href="#article">記事を読む</a></article>
    <article class="card stack"><h2 class="card__title">Gridの使い方</h2><p>実際の画面で試してみます。</p><a class="button" href="#grid">記事を読む</a></article>
  </div>
</main>
.container {
  width: min(100% - 2rem, var(--container-width));
  margin-inline: auto;
  padding-block: var(--space-5);
}
.stack > * {
  margin-block: 0;
}
.stack > * + * {
  margin-block-start: var(--space-3);
}
.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
  gap: var(--space-4);
}
.card {
  padding: var(--space-4);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-card);
}
.card__title {
  font-size: 1.25rem;
  line-height: 1.4;
}
.button {
  display: inline-block;
  padding: .6rem 1rem;
  background: var(--color-brand);
  color: #fff;
  border-radius: .4rem;
  text-decoration: none;
}

この例は4-1の変数と4-3のベーススタイルを合わせて使います。stackは縦の間隔を揃える補助クラスです。別の部品でも同じ間隔が必要なら再利用できます。

4-8. Sass・PostCSS・Autoprefixerの役割

道具役割CSSとの違い
Sass変数、ミックスイン、関数などを使ってCSSを生成$変数は基本的にビルド時に展開される
PostCSSプラグインでCSSを変換・解析する本体だけで特定の変換がすべて行われるわけではない
Autoprefixer対象ブラウザに応じて接頭辞などを処理未対応の新機能を全部再現するものではない
CSS Modulesクラス名を局所化するビルド方式ブラウザのネイティブCSS機能ではない

ネイティブCSSで十分書ける部分が増えても、既存プロジェクトのSassを全部消す必要はありません。採用理由と保守コストで判断します。

4-9. 「効かない」ときの確認順

  1. CSSファイルが読み込まれているか。Networkで状態を見る。
  2. セレクターが対象要素へ一致しているか。
  3. 構文や値が無効になっていないか。
  4. Stylesで打ち消された宣言を確認する。
  5. Computedで最終値と継承元を確認する。
  6. 親のサイズ、overflow、Flex/Grid、包含ブロックを見る。
  7. Media/Container Queryの条件を確認する。
  8. 対象ブラウザの対応、キャッシュ、配信されたファイルを確認する。

横にはみ出す問題を隠す前に、DevToolsで幅の大きい要素を探します。読み込み高速化も、まず測定して、レンダーブロック、ファイル量、不要なCSS、フォント、描画処理などを区別します。

第5部|JavaScriptとCSSを連携させる

5-1. JS変数とCSS変数は、別の場所にある

const size = 24;
/* 普通のCSSはJSのsizeを直接参照できない */
/* font-size: size; */

JSで値を持つだけではCSSへ届きません。CSSカスタムプロパティへ値を渡すと、CSSからvar()で参照できます。

種類値を扱う場所更新の仕方
JSのconst・letJavaScriptJSの処理で更新。constの再代入は不可
CSSの--変数ブラウザのスタイル計算CSSの条件・カスケード・JSなどで更新
Sassの$変数CSS生成時Sassを再ビルドする

5-2. CSS変数をJSから読む

HTMLパネル

<div class="card" id="card">CSS変数を読みます</div>

CSSパネル

/* 各Penで使う共通ベース */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.7; color: #25332c; }
button, input, select, textarea { font: inherit; }
img, video { max-width: 100%; height: auto; }

:root { --brand: #35624a; }
  .card { --card-gap: 1.5rem; color: var(--brand); }

JavaScriptパネル

const rootStyle = getComputedStyle(document.documentElement);
  console.log(rootStyle.getPropertyValue("--brand").trim());

  const card = document.querySelector("#card");
  console.log(getComputedStyle(card).getPropertyValue("--card-gap").trim());

値は文字列として取得します。カスタムプロパティの1.5remが常にpxへ変換されて返るわけではありません。実際のpaddingなどの計算値が必要なら、そのプロパティを読みます。

element.style.getPropertyValue()は主にインラインの指定を読みます。外部CSSや継承も含む適用値を確認するならgetComputedStyleを使います。

5-3. JSの値をCSS変数へ渡す

HTMLパネル

<p id="message" class="message">JSから文字サイズを変更します。</p>

CSSパネル

/* 各Penで使う共通ベース */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.7; color: #25332c; }
button, input, select, textarea { font: inherit; }
img, video { max-width: 100%; height: auto; }

.message { font-size: var(--message-size, 1rem); }

JavaScriptパネル

const message = document.querySelector("#message");
  let fontSize = 20;
  message.style.setProperty("--message-size", `${fontSize}px`);

  fontSize = 24;
  message.style.setProperty("--message-size", `${fontSize}px`);

JS変数の再代入だけではCSSも更新されません。もう一度渡す必要があります。ページ全体へ渡すならdocument.documentElement、部品だけならその要素に設定します。

document.documentElement.style.setProperty("--brand", "#245d9c");
// インラインで設定した値を外し、CSS側の指定へ戻す
document.documentElement.style.removeProperty("--brand");

varのフォールバックは変数が未定義などの場合に使われます。--message-size: redのように変数の値は存在するがfont-sizeとして無効な場合、期待したフォールバックになるとは限りません。渡す値の単位と形式を確認します。

5-4. スライダーで文字サイズを変える

HTMLパネル

<label for="size">文字サイズ <output id="size-value" for="size">20px</output></label>
<input id="size" type="range" min="14" max="36" value="20">
<p id="preview">CSS変数を動かしてみます。</p>

CSSパネル

/* 各Penで使う共通ベース */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.7; color: #25332c; }
button, input, select, textarea { font: inherit; }
img, video { max-width: 100%; height: auto; }

#preview { --size: 20px; font-size: var(--size); }

JavaScriptパネル

const input = document.querySelector("#size");
  const output = document.querySelector("#size-value");
  const preview = document.querySelector("#preview");
  function update() {
    const value = `${input.valueAsNumber}px`;
    preview.style.setProperty("--size", value);
    output.value = value;
  }
  input.addEventListener("input", update);
  update();

入力のたびに更新するのがinput、操作の確定時に処理する用途ではchangeも使います。数値を受け取る場合は有効な範囲も決めます。

5-5. 進捗率をプログレスバーへ反映する

HTMLパネル

<label for="progress-input">進捗率</label>
<input id="progress-input" type="range" min="0" max="100" value="35">
<div id="progress" class="progress" role="progressbar" aria-label="処理の進捗" aria-valuemin="0" aria-valuemax="100" aria-valuenow="35"><span></span></div>
<p id="progress-text">35%</p>

CSSパネル

/* 各Penで使う共通ベース */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.7; color: #25332c; }
button, input, select, textarea { font: inherit; }
img, video { max-width: 100%; height: auto; }

.progress { --progress: 35%; height: 1rem; background: #e1e8e3; border-radius: 999px; overflow: hidden; }
  .progress span { display: block; height: 100%; width: var(--progress); background: #35624a; }

JavaScriptパネル

const input = document.querySelector("#progress-input");
  const progress = document.querySelector("#progress");
  const text = document.querySelector("#progress-text");
  input.addEventListener("input", () => {
    const value = Math.max(0, Math.min(100, input.valueAsNumber));
    progress.style.setProperty("--progress", `${value}%`);
    progress.setAttribute("aria-valuenow", String(value));
    text.textContent = `${value}%`;
  });

単純な進捗ならHTMLのprogress要素も選択肢です。進捗不明の処理に、適当な数値を出して完了率だと思わせないようにします。

5-6. ポインター位置をCSSへ渡す

HTMLパネル

<div id="spot" class="spot">ポインターを動かしてください</div>

CSSパネル

/* 各Penで使う共通ベース */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.7; color: #25332c; }
button, input, select, textarea { font: inherit; }
img, video { max-width: 100%; height: auto; }

.spot { --x: 50%; --y: 50%; min-height: 15rem; padding: 2rem; background: radial-gradient(circle at var(--x) var(--y), #e7d7ad, #edf4ee 60%); }

JavaScriptパネル

const spot = document.querySelector("#spot");
  let frame = 0;
  let x = 0;
  let y = 0;
  spot.addEventListener("pointermove", (event) => {
    const rect = spot.getBoundingClientRect();
    x = event.clientX - rect.left;
    y = event.clientY - rect.top;
    if (frame) return;
    frame = requestAnimationFrame(() => {
      spot.style.setProperty("--x", `${x}px`);
      spot.style.setProperty("--y", `${y}px`);
      frame = 0;
    });
  });

高頻度のイベントでは、描画の更新をrequestAnimationFrameへまとめる方法があります。この例は装飾で、ポインター操作がなくても文章を読めます。背景の再描画コストは確認します。

5-7. 測ったヘッダーの高さをCSSに渡す

HTMLパネル

<header id="header">文字が折り返すと高さが変わるヘッダーです。</header>
<main><a href="#section">見出しへ</a><div class="spacer"></div><h2 id="section">移動先</h2><div class="spacer"></div></main>

CSSパネル

/* 各Penで使う共通ベース */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.7; color: #25332c; }
button, input, select, textarea { font: inherit; }
img, video { max-width: 100%; height: auto; }

:root { --header-height: 0px; }
  #header { position: fixed; inset: 0 0 auto; padding: 1rem; background: #edf4ee; z-index: 10; }
  main { padding-top: var(--header-height); }
  #section { scroll-margin-top: calc(var(--header-height) + 1rem); }
  .spacer { min-height: 100svh; }

JavaScriptパネル

const header = document.querySelector("#header");
  const observer = new ResizeObserver(() => {
    const height = header.getBoundingClientRect().height;
    document.documentElement.style.setProperty("--header-height", `${height}px`);
  });
  observer.observe(header);

ヘッダーのサイズ変化を監視します。ここでは渡した変数をヘッダー自身の寸法へ使わず、監視と更新のループを避けています。SPAの部品として使う場合は、破棄時にobserver.disconnect()で監視を解除します。

5-8. クラスと変数を使い分ける

変更内容向いている方法
開いている/閉じているclassListやdata属性
選択中/エラー/読み込み中状態クラス、属性、必要なARIA
色、進捗率、寸法、座標CSS変数、style
画面幅に応じたレイアウトMedia/Container Queries
DOMの実際の寸法が必要JSで測定し必要な値だけ渡す

CSSで決められる幅の分岐を、全部resizeイベントに置き換える必要はありません。DOMの寸法読み取りとスタイル書き込みを交互に何十回も行うと、再計算が増える場合があります。読み取りをまとめ、必要な値だけ更新します。

また、通常のvar()はMedia Query条件の@media (width >= var(--breakpoint))へそのまま使えません。CSS変数は主に宣言値で使い、ビルド時の変数や別の設計と区別します。

参考:CSSカスタムプロパティ、setProperty、ResizeObserver

第6部|Vue・ReactでCSSを使う

この部のVue SFC(.vue)、CSS Modules、Reactのimportを含むコードは、プロジェクト構成を説明するものです。前の例のように、標準のHTML/CSS/JSパネルへそのまま貼るだけでは動きません。VueやReactの実行環境、JSXの変換、モジュールの解決などを用意します。今回のCodePenキットは、第3部の24実装と、第5部の6例を対象にしています。

6-1. フレームワークを使っても、CSSの基本は変わらない

VueやReactがあっても、最後にブラウザへ渡るのはHTML要素とスタイルです。FlexboxやGrid、継承、詳細度はそのまま働きます。

変わるのは、部品ごとのスタイル管理や、JSの状態を見た目へ渡す方法です。ここでは共通のベースCSSと、部品のCSSを分けて考えます。

6-2. Vueのclassとstyle

以下はVue 3の単一ファイルコンポーネント(SFC)です。Vueのビルド環境で.vueファイルとして使います。

<script setup>
import { ref } from "vue";

const active = ref(false);
const textColor = ref("#35624a");
</script>

<template>
  <button
    class="button"
    :class="{ 'is-active': active }"
    :style="{ '--button-color': textColor }"
    :aria-pressed="active"
    type="button"
    @click="active = !active"
  >
    {{ active ? '選択中' : '選択する' }}
  </button>
</template>

<style scoped>
.button {
  padding: .6rem 1rem;
  border: 1px solid var(--button-color);
  color: var(--button-color);
  background: #fff;
}
.button.is-active {
  color: #fff;
  background: var(--button-color);
}
</style>

状態の切り替えはclass、動的な色はCSS変数へ渡しています。Vue 2にもclass・styleのオブジェクトによるバインディングはありますが、script setupは同じように使えるとは限りません。

6-3. scopedはどう動く?

<style scoped>
.card { padding: 1rem; }
</style>

Vueのビルド処理が.card[data-v-xxxx]のようなセレクターへ変換し、対応する要素へ属性を追加します。Shadow DOMで完全に閉じ込める仕組みではありません。

親のscoped CSSは、子コンポーネントのルート要素へもレイアウト目的で影響する場合があります。また、通常のcolorやCSS変数の継承は残ります。「scopedなら何も子へ届かない」とは考えません。

<style scoped>
.wrapper :deep(.child-label) { color: #35624a; }
:slotted(.slot-label) { font-weight: 700; }
:global(.app-notice) { border-color: #35624a; }
</style>
記法使う場面注意点
:deep()子部品の内部へ指定する内部のクラス変更に依存するので使いすぎない
:slotted()渡されたslotの内容へ指定する通常のscoped指定との範囲の違いを確認
:global()一部のルールを全体へ適用する名前の衝突に注意

v-htmlで生成した内容は通常のscopedセレクターでは対象にならないため、必要ならラッパーからdeepで指定します。HTML挿入の安全性はCSSとは別の問題です。

Vue 2の古い構成では、deepの記法や対応がvue-loaderなどのバージョンで異なります。>>>や/deep/を使う古い記事を、そのままVue 3へコピーしません。使用中のコンパイラに対応する記法を確認します。

6-4. Vue 3ではstyle内のv-bind()も使える

<script setup>
import { ref } from "vue";
const textColor = ref("#35624a");
const fontSize = ref(20);
</script>

<template>
  <label>文字色 <input v-model="textColor" type="color"></label>
  <label>文字サイズ <input v-model.number="fontSize" type="range" min="14" max="36"></label>
  <p class="preview">Vueの状態をCSSで使ってみます。</p>
</template>

<style scoped>
.preview {
  color: v-bind(textColor);
  font-size: v-bind('fontSize + "px"');
}
</style>

式は引用符で囲みます。これはVue SFCの機能で、普通の外部CSSへ書いてもJS変数を読むことはできません。ビルド時にCSSカスタムプロパティへ変換され、状態に合わせて更新されます。

6-5. Vue 2でCSS変数を使う基本形

Vue 2のSFCでは、styleバインディングでCSS変数へ渡せます。次の例はOptions APIです。Vue 3のv-bind() in CSSを前提にしません。

<template>
  <section>
    <label>文字サイズ <input v-model.number="size" type="range" min="14" max="36"></label>
    <p class="preview" :style="{ '--preview-size': size + 'px' }">Vue 2でCSS変数を更新します。</p>
  </section>
</template>

<script>
export default {
  data() {
    return { size: 20 };
  }
};
</script>

<style scoped>
.preview { font-size: var(--preview-size, 1rem); }
</style>

参考:Vue 2 class/style、Vue 3 class/style

6-6. VueのCSS Modules

<template>
  <p :class="$style.message">部品ごとのクラス名に変換します。</p>
</template>

<style module>
.message { padding: 1rem; color: #35624a; }
</style>

クラス名の衝突を避ける方式です。タグセレクターを全部自動で局所化する、という意味ではありません。scopedとCSS Modulesは、目的や既存構成に合わせて選びます。

6-7. VueのTransitionとCSS

<script setup>
import { ref } from "vue";
const visible = ref(true);
</script>

<template>
  <button type="button" @click="visible = !visible">表示を切り替える</button>
  <Transition name="fade">
    <p v-if="visible">切り替える内容です。</p>
  </Transition>
</template>

<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .fade-enter-active, .fade-leave-active { transition: none; }
}
</style>

Vue 3のクラス名です。Vue 2の入場開始は通常fade-enterで、同じクラス名を前提にしないようにします。

Vue SFCの参考:SFC CSS Features、Transition

6-8. Reactの通常のCSS

ReactのJSXではclassではなくclassNameを使います。次のCSS importは、CSS読み込みに対応するビルド環境を前提にしています。

<em>// SelectButton.jsx</em>
import { useState } from "react";
import "./SelectButton.css";

export default function SelectButton() {
  const [selected, setSelected] = useState(false);
  return (
    <button
      className={`button${selected ? " is-selected" : ""}`}
      type="button"
      aria-pressed={selected}
      onClick={() => setSelected(value => !value)}
    >
      {selected ? "選択中" : "選択する"}
    </button>
  );
}
<em>/* SelectButton.css */</em>
.button {
  padding: .6rem 1rem;
  border: 1px solid #35624a;
  background: #fff;
  color: #35624a;
}
.button.is-selected {
  background: #35624a;
  color: #fff;
}

通常のCSS importは、コンポーネントの中で読み込んでも自動的にscopedにはなりません。.buttonが他の部品と衝突しない設計が必要です。

6-9. Reactのstyleオブジェクト

export default function Message() {
  const size = 20;
  return <p style={{ fontSize: size, backgroundColor: "#edf4ee", padding: "1rem" }}>文字サイズを指定します。</p>;
}

font-sizeはfontSizeのようにcamelCaseで書きます。通常、寸法系の数値にはpxが付きますが、lineHeightなど単位なしのプロパティもあります。CSS変数の数値に同じ自動px付与を期待せず、必要な単位を明示します。

インラインstyleへ:hoverや@mediaをそのままネストすることはできません。通常のCSS、CSS Modules、対応するライブラリなどへ書きます。

6-10. Reactの状態をCSS変数へ渡す

<em>// FontPreview.jsx</em>
import { useState } from "react";
import "./FontPreview.css";

export default function FontPreview() {
  const [size, setSize] = useState(20);
  const color = "#35624a";
  return (
    <section>
      <label>
        文字サイズ
        <input
          type="range" min="14" max="36" value={size}
          onChange={event => setSize(event.currentTarget.valueAsNumber)}
        />
      </label>
      <p className="font-preview" style={{ "--preview-size": `${size}px`, "--preview-color": color }}>
        Reactの状態をCSSで使います。
      </p>
    </section>
  );
}
<em>/* FontPreview.css */</em>
.font-preview {
  font-size: var(--preview-size, 1rem);
  color: var(--preview-color, #25332c);
}
@media (width < 30rem) {
  .font-preview {
    padding: 1rem;
  }
}

JSは値と状態、CSSは見た目と条件を担当します。単純な値の計算を同期するために、追加のEffectを作る必要はありません。

TypeScriptでカスタムプロパティが型エラーになる場合は、許可するキーを型へ追加します。

import type { CSSProperties } from "react";

type VariableStyle = CSSProperties & {
  [key: `--${string}`]: string | number;
};

const previewStyle: VariableStyle = {
  "--preview-size": "20px",
  "--preview-color": "#35624a"
};

export default function Message() {
  return <p className="font-preview" style={previewStyle}>CSS変数を渡します。</p>;
}

型は書き方の検査であり、渡した色や単位の妥当性を実行時に検証するものではありません。

6-11. ReactのCSS Modules

<em>// Card.jsx</em>
import styles from "./Card.module.css";

export default function Card({ featured = false }) {
  return (
    <article className={`${styles.card}${featured ? ` ${styles.featured}` : ""}`}>
      <h2 className={styles.title}>カードのタイトル</h2>
      <p>部品のクラス名を局所化します。</p>
    </article>
  );
}
<em>/* Card.module.css */</em>
.card {
  padding: 1rem;
  border: 1px solid #a8b8ac;
}
.featured {
  border-width: 3px;
}
.title {
  margin-top: 0;
  color: #35624a;
}

CSS Modules対応のビルド環境が必要です。クラス名を変換する仕組みなので、継承やCSS変数の伝わり方まで遮断するわけではありません。

6-12. CSS-in-JSをどう考える?

styled-componentsやEmotionなどは、JS側でスタイルを組み立てる選択肢です。コンポーネントとスタイルを近くに置きやすい一方、ランタイム処理、SSR、スタイル挿入順、フレームワークとの互換性を確認する必要があります。

ライブラリによって、実行時にCSSを生成するものと、ビルド時に抽出する方式があります。ここでは「Reactなら必ずCSS-in-JS」とは考えません。通常のCSSやCSS Modulesで十分なら、その構成も選べます。

6-13. Vue・React共通の注意点

場面注意すること
グローバルReset他の部品へも効く。読み込み場所と適用範囲を決める
親から子への見た目設定propsやCSS変数など、公開した受け口を使う
deepや内部クラスへの上書き子のHTML変更で壊れやすい
リストごとに異なる色・幅各部品の要素へCSS変数を渡す
Portal/TeleportDOM上の親が変わるので、継承する変数や祖先セレクターも変わる
直接DOM操作フレームワークが管理する同じstyleと競合しないようにする
SSRrender中にwindow・document・getComputedStyleを無条件で使わない
テーマの初期表示サーバーのHTMLとクライアントの初期状態を整合させる
高頻度の座標更新すべてをstateへ入れると更新が増える。必要ならrefと描画更新で扱う

テーマ変数を画面の小さいラッパーへ定義した場合、bodyへ移動したモーダルには継承されません。ルートへ定義するか、移動先にもテーマを伝えます。

参考:React DOMの共通props、ReactとTypeScript

第7部|CSSフレームワーク・ライブラリをどう選ぶ?

7-1. 「CSSライブラリ」でも役割は違う

ボタンやナビなどの部品が欲しいのか、クラスで細かく組み立てたいのか、HTMLを少し装飾したいのかで、合う道具は変わります。

次の表は代表的な候補の比較です。人気順のランキングではありません。導入コマンドや必要なブラウザは更新されるので、バージョンに対応した公式資料を確認します。

候補主な役割向いている用途導入前に確認すること
Bootstrapグリッド・ユーティリティ・UI部品管理画面、既定の部品で素早く作る画面標準デザインの調整、Reset、JS部品
Tailwind CSSユーティリティで見た目を構成独自デザイン、部品ベースの開発ビルド、クラス検出、テーマ、バージョン差
BulmaCSS中心のフレームワーククラスを使って画面を組み立てるメニューなどの操作は別に実装する
Pico CSSセマンティックHTML中心の軽量スタイル小さなツール、フォーム、文書全体の要素スタイルと独自調整
daisyUITailwind向けのコンポーネントクラス・テーマTailwindでUI部品も使いたいTailwindとの互換性、テーマ管理
Animate.css既製のCSSアニメーション部分的な演出の追加動きの量、クラス名、低減設定
Open PropsCSSカスタムプロパティのトークン集独自CSSの値を揃える読み込む範囲と、部品は自分で作る点

公式資料:Bootstrap、Tailwind、Bulma、Pico、daisyUI、Animate.css、Open Props

7-2. 書き方を比べる

以下のHTMLは、対応するライブラリを読み込んだ状態で使う例です。単独で貼っただけでは、そのライブラリの見た目にはなりません。

<em><!-- Bootstrap --></em>
<button type="button" class="btn btn-primary">保存</button>

<em><!-- Tailwind --></em>
<button type="button" class="rounded-md bg-emerald-800 px-4 py-2 text-white hover:bg-emerald-900 focus-visible:outline-2 focus-visible:outline-offset-2">保存</button>

<em><!-- Bulma --></em>
<button type="button" class="button is-primary">保存</button>

<em><!-- daisyUI --></em>
<button type="button" class="btn btn-primary">保存</button>

<em><!-- Pico CSSの基本スタイル --></em>
<button type="button">保存</button>

<em><!-- Animate.css --></em>
<p class="animate__animated animate__fadeIn">表示する文章</p>

同じbtnというクラスがあっても、ライブラリ間で意味は同じではありません。複数の大きなフレームワークを同時に読み込む前に、Resetやクラスの衝突を確認します。

7-3. Tailwindと普通のCSS変数

Tailwind v4系はCSSを使ったテーマ設定を備えています。v3の設定例と混ぜないようにします。

<em>/* Tailwind v4対応のビルド環境で使う */</em>
@import "tailwindcss";
@theme {
  --color-brand: #35624a;
}
<button type="button" class="bg-brand text-white rounded-md px-4 py-2">保存</button>

@themeはTailwindの処理に使う構文で、普通のブラウザCSSだけではユーティリティクラスを生成しません。:rootへ変数を定義するだけの場合との違いを確認します。

また、'bg-' + color + '-500'のようにJSでクラス名を組み立てると、ビルド側が必要なクラスを検出できない場合があります。候補の完全なクラス名を列挙するか、動的な値をCSS変数へ渡す方法を選びます。

参考:TailwindのTheme variables、クラスの検出

7-4. Vue・ReactのUIライブラリとの違い

CSSフレームワークはスタイルやクラスを提供するのが中心です。Vue・ReactのUIライブラリには、状態管理、イベント、フォーカス制御などまで含むものがあります。

Bootstrapなども一部の部品へJSを提供するため、境界は完全ではありません。「CSSを読み込めばタブやモーダルの操作も全部完成する」とは考えず、採用する部品の担当範囲を確認します。

Reactの描画管理と、別のJSライブラリによるDOM変更が同じ要素へ重なる場合も注意が必要です。公式の統合方法や、そのフレームワークに対応した実装を確認します。

7-5. 自分なら、こう選ぶ

作るもの最初に考える選択肢
小規模な紹介サイト通常のCSS+必要な共通部品
独自デザインのサイト通常のCSS、CSS Modules、Tailwindなど
部品の多い管理画面Bootstrapや、使用フレームワークに合うUIライブラリ
小さなフォームツールPicoなど、少ない設定で整うもの
部分的なアニメーション自前の短いCSS、必要ならAnimate.css
共通の色・余白を揃えたい自前のトークン、Open Propsなど

ライブラリを使ってもCSSの仕組みは必要です。むしろ既定のスタイルを変更するときに、カスケード・レイヤー・継承を理解していると調査が楽になります。

付録|開発中に見返す早見表

A. 目的から探す

目的まず確認するもの本文の例
中央寄せmargin-inline、Grid、Flexbox01・07
複数カラムGrid、Flexbox、minmax04・05・11
部品の幅で切り替えるcontainer-type、@container17
長文のはみ出しmin-width:0、overflow-wrap19・22
文字を省略text-overflow、行数制限20・21
画像の比率aspect-ratio、object-fit28
スクロールの装飾scrollbar-width、scrollbar-color34
固定ヘッダーとリンクscroll-margin、scroll-padding38
表の固定sticky、背景、z-index43
入力部品の色accent-color50
開閉details、状態クラス、JS53・54
動きを減らすprefers-reduced-motion64〜68
ダークモードCSS変数、color-scheme69・70
動的な数値CSS変数、setProperty第5部

B. FlexboxとGrid

項目FlexboxGrid
基本主に1方向の並びを扱う行と列の2方向を扱う
親の指定display:flexdisplay:grid
間隔gapgap
サイズflex-grow/shrink/basisgrid-template-columns/rows
位置justify-content、align-itemsなどjustify-items、align-items、grid-areaなど
典型例ボタン群、ヘッダー、1列の並びページ、カード一覧、複雑な配置

「Flexboxは1次元だから折り返せない」という意味ではありません。行や列の関係を明示して揃えたいなら、Gridの方が設計しやすい場合があります。

C. JS・Vue・Reactの連携

やりたいこと通常JSVueReact
状態クラスclassList.toggle:classclassNameをstateから組み立てる
CSS変数を渡すstyle.setProperty:style、Vue 3 SFCのv-bind()styleの--変数キー
CSSを局所化命名、@scopeなどscoped、CSS ModulesCSS Modulesなど
実寸を測るResizeObservermounted以降、破棄時解除Effect・ref、cleanup

D. 追加で試したい新機能

内容に合わせて伸びる入力欄

<label for="memo">メモ</label><textarea id="memo" class="memo" rows="3">文章を追加してみてください。</textarea>
.memo {
  display: block;
  width: min(100%, 30rem);
  min-height: 6rem;
  max-height: 20rem;
  padding: .75rem;
}
@supports (field-sizing: content) {
  .memo {
    field-sizing: content;
  }
}

field-sizingの対応を確認して使います。最大高さを指定した後も入力内容を読めるか確認します。

カスタムプロパティを条件にするスタイルクエリ

<div class="theme-container"><p class="message">親のテーマを条件にします。</p></div>
.theme-container {
  container-name: theme;
  --theme: dark;
}
.message {
  padding: 1rem;
  background: #edf4ee;
}
@container theme style(--theme: dark) {
  .message {
    color: #eef4ef;
    background: #18221c;
  }
}

ここではカスタムプロパティの値を条件にしています。CSSのあらゆるプロパティを同じように判定できるとは限りません。サイズクエリとは対応範囲も別に確認します。

アンカーに合わせるPopover

<button class="anchor" type="button" popovertarget="anchor-help">補足を見る</button>
<div id="anchor-help" popover>ボタンを基準に配置します。</div>
.anchor {
  anchor-name: --help-button;
}
#anchor-help {
  padding: 1rem;
  border: 1px solid #a8b8ac;
}
@supports (position-anchor: --help-button) {
  #anchor-help {
    position: fixed;
    position-anchor: --help-button;
    inset: auto;
    top: anchor(bottom);
    left: anchor(left);
    margin: .5rem 0 0;
  }
}

これは基本の配置例です。画面端でのはみ出し、position-tryによる代替位置、複数部品のアンカー名の衝突も実際の採用時に検討します。

参考:field-sizing、Container Style Queries、anchor-name

E. 確認に使う資料

資料調べる内容
MDN CSS値、仕様、互換性、例
W3C CSS SnapshotCSSを構成する仕様
web.dev Baseline主要ブラウザの対応状況の目安
Can I use対応バージョンや利用状況を調べる補助
WAI-ARIA APGタブなどの役割とキーボード操作
Vue公式scoped、CSS Modules、v-bind()
React公式className、styleなどのDOM props

おわりに

CSSはプロパティを知っているだけだと、画面が崩れたときに原因を追いにくいことがあります。親のサイズ、通常フロー、継承、カスケードを順に見ていくと、「なぜこの指定が必要なのか」が少しずつ繋がってきます。

自分も、何となく動かしていた部分を一つずつ整理していきたいと思っています。作りたい画面を小さなHTMLで試して、うまくいかなかった理由まで残しておく。そんな使い方ができる記事になればと思います。