2026年のモダンCSS:スタイルの書き方を変えた新機能たち
コンテナクエリ、CSSレイヤー、:has()、color-mix()、ネスティング、スクロール駆動アニメーション、アンカーポジショニング。JavaScriptに頼らなくてよくなったCSS機能を解説。
6ヶ月前に最後のSassファイルを削除しました。主張をしたかったからではありません。本当にもう必要なかったからです。
10年以上もの間、CSSは私たちが謝りながら使う言語でした。ネスティングと変数にはプリプロセッサが必要でした。コンテナベースのサイジング、スクロール連動アニメーション、親要素の選択、デザイナーが渡してくるレイアウトパターンの半分にはJavaScriptが必要でした。CSS-in-JSライブラリ、ユーティリティフレームワーク、PostCSSプラグインチェーンなど、言語がネイティブにできないことを補うためにランタイムシステムを丸ごと構築しました。
その時代は終わりました。「もうすぐ終わる」でも「そろそろ」でもありません。終わりました。
2024年から2026年の間にブラウザに搭載された機能は、単なる利便性の向上ではありませんでした。メンタルモデルを変えたのです。CSSはもはや格闘する相手のスタイリング言語ではありません。コンポーネント、詳細度管理、要素レベルのレスポンシブデザイン、ランタイム不要のアニメーションについて実際に考えてくれるスタイリング言語です。
何が変わったのか、なぜ重要なのか、そしてそのおかげで何をやめられるのかを紹介します。
コンテナクエリ:「ビューポートの幅はいくつ?」の終わり#
これが、レスポンシブデザインについての私の考え方を根本的に変えた機能です。20年間、メディアクエリがありました。メディアクエリは「ビューポートの幅はいくつ?」と問います。コンテナクエリは「このコンポーネントが置かれているコンテナの幅はいくつ?」と問います。
この違いは微妙に聞こえますが、そうではありません。1つのレイアウトコンテキストでしか動作しないコンポーネントと、どこでも動作するコンポーネントの違いです。
20年間存在し続けた問題#
カードコンポーネントを考えてみてください。サイドバーでは小さな画像で垂直に積み重なるべきです。メインコンテンツエリアでは大きな画像で水平になるべきです。フル幅のヒーローセクションではまた別のものになるべきです。
メディアクエリを使うと、次のようなコードを書くことになります:
/* The old way: coupling component styles to page layout */
.card {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.sidebar .card {
/* Still vertical in sidebar */
}
.main-content .card {
flex-direction: row;
/* Horizontal in main area */
}
}
@media (min-width: 1200px) {
.hero .card {
/* Yet another layout */
}
}カードは.sidebar、.main-content、.heroについて知っています。ページについて知っています。もはやコンポーネントではありません。ページを意識したフラグメントです。別のページに移動すればすべてが壊れます。
コンテナクエリがこれを完全に解決する#
/* The container query way: component knows only about itself */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
.card {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@container card (min-width: 400px) {
.card {
grid-template-columns: 200px 1fr;
}
}
@container card (min-width: 700px) {
.card {
grid-template-columns: 300px 1fr;
gap: 2rem;
font-size: 1.125rem;
}
}カードは自分がどこにいるか知りません。気にもしません。300pxのサイドバーに置けば垂直になります。700pxのメインエリアに置けば水平になります。フル幅のセクションに置けば適応します。ページレイアウトの知識はゼロです。
inline-size vs size#
ほぼ常にcontainer-type: inline-sizeを使いたいでしょう。これはインライン軸(横書きモードでの幅)でのクエリを有効にします。container-type: sizeを使うとインライン軸とブロック軸の両方のクエリが有効になりますが、コンテナが両方の次元で明示的なサイジングを持つ必要があり、ほとんどの場合で通常のドキュメントフローを壊します。
/* This is what you want 99% of the time */
.wrapper {
container-type: inline-size;
}
/* This requires explicit height — rarely what you want */
.wrapper-both {
container-type: size;
height: 500px; /* required, or it collapses */
}ネストされたコンテキストの名前付きコンテナ#
コンテナをネストすると、命名が不可欠になります:
.page-layout {
container-type: inline-size;
container-name: layout;
}
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
/* Target the sidebar specifically, not the nearest ancestor */
@container sidebar (max-width: 250px) {
.nav-item {
font-size: 0.875rem;
padding: 0.25rem;
}
}
/* Target the page layout */
@container layout (min-width: 1200px) {
.page-header {
font-size: 2.5rem;
}
}名前がなければ、@containerはコンテインメントを持つ最も近い祖先にクエリします。ネスティングがあると、それは望むコンテナではないことがよくあります。名前を付けてください。常に。
コンテナクエリユニット#
これは過小評価されています。コンテナクエリユニット(cqi、cqb、cqw、cqh)は、ビューポートではなくコンテナに対して相対的にサイジングできます:
@container (min-width: 400px) {
.card-title {
font-size: clamp(1rem, 4cqi, 2rem);
}
}4cqiはコンテナのインラインサイズの4%です。タイトルはウィンドウではなくコンテナに合わせてスケールします。これこそフルードタイポグラフィが最初からあるべき姿でした。
CSSレイヤー:詳細度戦争は終わった#
コンテナクエリがレスポンシブデザインの考え方を変えたなら、@layerはCSSアーキテクチャの考え方を変えました。初めて、プロジェクト全体の詳細度を管理する健全で宣言的な方法が手に入りました。
問題#
CSSの詳細度は、意図を気にしないポイントシステムです。.text-redのユーティリティクラスは.card .titleに負けます。後者のほうが詳細度が高いからです。修正は常に同じでした:セレクタをより具体的にするか、!importantを追加するか、すべてを再構築するか。
詳細度の衝突を避けるためだけに、方法論全体(BEM、SMACSS、ITCSS)とツールチェーンを構築しました。そのすべてが、欠けていた言語機能の回避策でした。
レイヤーの順序#
@layerを使えば、各グループ内の詳細度に関係なく、スタイルのグループが考慮される順序を宣言できます:
/* Declare layer order — this single line controls everything */
@layer reset, base, components, utilities, overrides;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
@layer base {
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
color: oklch(20% 0 0);
}
h1, h2, h3 {
line-height: 1.2;
text-wrap: balance;
}
}
@layer components {
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px oklch(0% 0 0 / 0.12);
}
.card .title {
font-size: 1.25rem;
font-weight: 600;
color: oklch(25% 0.05 260);
}
}
@layer utilities {
.text-red {
color: oklch(55% 0.25 25);
}
.hidden {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
}.card .titleは.text-redよりも詳細度が高いにもかかわらず、utilitiesレイヤーがcomponentsの後に宣言されているため、ユーティリティが勝ちます。!importantなし。詳細度ハックなし。レイヤーの順序が最終決定権を持ちます。
Tailwind CSS v4がレイヤーをどう使うか#
Tailwind v4は@layerを大いに活用しています。@import "tailwindcss"と書くと、次が得られます:
@layer theme, base, components, utilities;すべてのTailwindユーティリティはutilitiesレイヤーにあります。カスタムコンポーネントスタイルはcomponentsに入ります。text-red-500クラスが!importantなしでコンポーネントの色をオーバーライドできるのは、後のレイヤーにあるからです。
Tailwindなしで独自のデザインシステムを構築するなら、このアーキテクチャを盗んでください。正しいものです:
@layer reset, tokens, base, layouts, components, utilities, overrides;7つのレイヤーで十分です。それ以上必要だったことはありません。
レイヤー外のスタイルがすべてに勝つ#
1つ注意点があります。どのレイヤーにも属さないスタイルは最も高い優先度を持ちます。これは実は便利で、ページ固有の一回限りのオーバーライドが自動的に勝つことを意味します:
@layer components {
.modal {
background: white;
}
}
/* Not in any layer — wins over everything in layers */
.special-page .modal {
background: oklch(95% 0.02 260);
}しかし、レイヤーを意識していないサードパーティCSSがシステム全体をオーバーライドできることも意味します。サードパーティスタイルをレイヤーで囲んで制御しましょう:
@layer third-party {
@import url("some-library.css");
}:has()セレクタ:ずっと欲しかった親セレクタ#
文字通り何十年もの間、開発者は親セレクタを求めていました。「子要素に基づいて親要素をスタイリングしたい。」答えは常に「CSSにはそれができない」で、その後JavaScriptの回避策が続きました。:has()はこれを完全に変えました。そして、私たちが求めていた以上に強力であることが判明しました。
基本的な親要素の選択#
/* Style a form group when its input has focus */
.form-group:has(input:focus) {
border-color: oklch(55% 0.2 260);
box-shadow: 0 0 0 3px oklch(55% 0.2 260 / 0.15);
}
/* Style a card differently when it contains an image */
.card:has(img) {
padding-top: 0;
}
.card:has(img) .card-content {
padding: 1.5rem;
}
/* A card without an image gets different treatment */
.card:not(:has(img)) {
border-left: 4px solid oklch(55% 0.2 260);
}JavaScriptなしのフォームバリデーション状態#
:has()が本当にワクワクするのはここです。HTMLバリデーション擬似クラスと組み合わせることで、JavaScriptゼロでバリデーション状態に反応するフォームUIを構築できます:
/* The field wrapper reacts to its input's validity */
.field:has(input:invalid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.25 25);
}
.field:has(input:valid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.2 145);
}
.field {
--field-color: oklch(70% 0 0);
border: 2px solid var(--field-color);
border-radius: 0.5rem;
padding: 0.75rem;
transition: border-color 0.2s;
}
.field label {
color: var(--field-color);
font-size: 0.875rem;
font-weight: 500;
}
.field .error-message {
display: none;
color: oklch(55% 0.25 25);
font-size: 0.8rem;
margin-top: 0.25rem;
}
.field:has(input:invalid:not(:placeholder-shown)) .error-message {
display: block;
}:not(:placeholder-shown)の部分が重要です。まだ触れていない空のフィールドにバリデーションスタイルが表示されるのを防ぎます。
子要素の数に基づくレイアウト#
このパターンは信じられないほど便利で、:has()以前は本当に不可能でした:
/* Adjust grid columns based on how many items exist */
.grid-auto:has(> :nth-child(4)) {
grid-template-columns: repeat(2, 1fr);
}
.grid-auto:has(> :nth-child(7)) {
grid-template-columns: repeat(3, 1fr);
}
.grid-auto:has(> :nth-child(13)) {
grid-template-columns: repeat(4, 1fr);
}グリッドは子要素の数に基づいてカラム数を変更します。JavaScriptなし。ResizeObserverなし。クラスの切り替えなし。
サイドバーの検出#
お気に入りのパターンの1つ。サイドバーの有無に基づいてレイアウトを変更します:
.page-layout {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
/* If the layout contains a sidebar, switch to two columns */
.page-layout:has(.sidebar) {
grid-template-columns: 1fr 300px;
}
/* Adjust main content width when sidebar is present */
.page-layout:has(.sidebar) .main-content {
max-width: 65ch;
}DOMにサイドバーコンポーネントを追加すれば、レイアウトが調整されます。削除すれば、元に戻ります。CSSが真実の源であり、状態変数ではありません。
:has()と他のセレクタの組み合わせ#
:has()は他のすべてと美しく組み合わされます:
/* Style an article only when it has a specific class of figure */
article:has(figure.full-bleed) {
overflow: visible;
}
/* A navigation that changes when it contains a search input */
nav:has(input[type="search"]:focus) {
background: oklch(98% 0 0);
box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
/* Enable dark-mode at the component level */
.theme-switch:has(input:checked) ~ main {
color-scheme: dark;
background: oklch(15% 0 0);
color: oklch(90% 0 0);
}ブラウザサポートは現在素晴らしい状態です。2024年初頭以降、すべてのモダンブラウザが:has()をサポートしています。使わない理由はありません。
CSSネスティング:ネイティブで、ようやく安定#
これを大げさに売り込むつもりはありません。CSSネスティングは便利です。コンテナクエリや:has()のように革命的ではありません。しかし、Sassを使う最後の理由の1つを取り除きます。それは重要です。
構文#
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
.title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.description {
color: oklch(40% 0 0);
line-height: 1.7;
}
&:hover {
box-shadow: 0 4px 12px oklch(0% 0 0 / 0.1);
}
&.featured {
border: 2px solid oklch(60% 0.2 260);
}
}これは有効なCSSです。ビルドステップなし。プリプロセッサなし。PostCSSプラグインなし。ブラウザがネイティブに処理します。
Sassとの違い#
知っておく価値のある構文の違いがいくつかあります:
/* CSS Nesting — works now in all browsers */
.parent {
/* Direct class/element nesting works without & */
.child {
color: red;
}
/* & is required for pseudo-classes and compound selectors */
&:hover {
background: blue;
}
&.active {
font-weight: bold;
}
/* Nested media queries — this is great */
@media (width >= 768px) {
flex-direction: row;
}
}初期の実装では、要素セレクタの前に&が必要でした(pではなく& pのように)。その制約は緩和されました。2025年時点では、すべての主要ブラウザが素の要素ネスティングをサポートしています。.parent { p { ... } }は問題なく動作します。
ネストされたメディアクエリ#
私の意見では、これがCSSネスティングのキラーフィーチャーです。セレクタのネスティングではなく、ルールブロック内にメディアクエリを配置できる機能です:
.hero {
padding: 2rem;
font-size: 1rem;
@media (width >= 768px) {
padding: 4rem;
font-size: 1.25rem;
}
@media (width >= 1200px) {
padding: 6rem;
font-size: 1.5rem;
}
}以前のやり方では、.heroのスタイルが3つの異なる@mediaブロックに散らばり、数百行も離れている可能性がありました。ネスティングはレスポンシブな挙動をコンポーネントと同じ場所に保ちます。可読性が劇的に向上します。
ネストしすぎないこと#
1つ警告があります。6段階深くネストできるからといって、すべきとは限りません。Sassと同じアドバイスがここでも当てはまります。ネスティングが.page .section .card .content .text .highlightのようなセレクタを生成するなら、詳細度のモンスターとメンテナンスの悪夢を作り出しています。2〜3段階がスイートスポットです。
/* Good — two levels */
.nav {
.link {
color: inherit;
&:hover {
color: oklch(55% 0.2 260);
}
}
}
/* Bad — specificity nightmare */
.page {
.layout {
.sidebar {
.nav {
.list {
.item {
.link {
color: red; /* good luck overriding this */
}
}
}
}
}
}
}カラー関数:oklch()とcolor-mix()がすべてを変える#
hsl()はよく頑張りました。rgb()よりも直感的でした。しかし、根本的な欠陥があります。知覚的に均一ではないのです。hsl(60, 100%, 50%)(黄色)はhsl(240, 100%, 50%)(青)よりも人間の目には劇的に明るく見えますが、同じ明度値を持っています。
なぜoklch()が勝つのか#
oklch()は知覚的に均一です。等しい明度値は等しく明るく見えます。これはカラーパレットの生成、テーマの作成、アクセシブルなコントラストの確保において非常に重要です:
:root {
/* oklch(lightness chroma hue) */
--color-primary: oklch(55% 0.2 260); /* blue */
--color-secondary: oklch(55% 0.2 330); /* purple */
--color-success: oklch(55% 0.2 145); /* green */
--color-danger: oklch(55% 0.25 25); /* red */
--color-warning: oklch(75% 0.18 85); /* yellow — higher L for equal perception */
/* These all look equally "medium" to the human eye */
/* With HSL, you'd need different lightness values for each hue */
}3つの値は理解すれば直感的です:
- 明度(0%〜100%):どれだけ明るいか暗いか。0%は黒、100%は白。
- 彩度(0〜約0.37):どれだけ鮮やかか。0はグレー、高いほど飽和。
- 色相(0〜360):色の角度。0/360はピンクがかった赤、145は緑、260は青。
color-mix()による派生色#
color-mix()は、他の色からランタイムで色を作成できます。Sassのdarken()関数は不要。JavaScriptも不要。CSSだけです:
:root {
--brand: oklch(55% 0.2 260);
/* Lighten by mixing with white */
--brand-light: color-mix(in oklch, var(--brand) 30%, white);
/* Darken by mixing with black */
--brand-dark: color-mix(in oklch, var(--brand) 70%, black);
/* Create a subtle background */
--brand-bg: color-mix(in oklch, var(--brand) 8%, white);
/* Semi-transparent version */
--brand-overlay: color-mix(in oklch, var(--brand) 50%, transparent);
}
.button {
background: var(--brand);
color: white;
&:hover {
background: var(--brand-dark);
}
&:active {
background: color-mix(in oklch, var(--brand) 60%, black);
}
}
.button.secondary {
background: var(--brand-bg);
color: var(--brand-dark);
&:hover {
background: color-mix(in oklch, var(--brand) 15%, white);
}
}in oklchの部分が重要です。srgbでミックスすると濁った中間色になります。oklchでミックスすると知覚的に均一な結果が得られます。常にoklchでミックスしてください。
oklch()で完全なパレットを構築する#
単一の色相からシェードパレット全体を生成する方法です:
:root {
--hue: 260;
--chroma: 0.2;
--color-50: oklch(97% calc(var(--chroma) * 0.1) var(--hue));
--color-100: oklch(93% calc(var(--chroma) * 0.2) var(--hue));
--color-200: oklch(85% calc(var(--chroma) * 0.4) var(--hue));
--color-300: oklch(75% calc(var(--chroma) * 0.6) var(--hue));
--color-400: oklch(65% calc(var(--chroma) * 0.8) var(--hue));
--color-500: oklch(55% var(--chroma) var(--hue));
--color-600: oklch(48% var(--chroma) var(--hue));
--color-700: oklch(40% calc(var(--chroma) * 0.9) var(--hue));
--color-800: oklch(32% calc(var(--chroma) * 0.8) var(--hue));
--color-900: oklch(25% calc(var(--chroma) * 0.7) var(--hue));
--color-950: oklch(18% calc(var(--chroma) * 0.5) var(--hue));
}--hueを145に変えれば緑のパレットになります。25に変えれば赤になります。明度のステップは知覚的に均一です。彩度は極端な部分で先細りになり、最も明るいシェードと最も暗いシェードが過飽和にならないようになっています。これは以前ならデザインツールやSass関数が必要でした。今ではCSSの8行で済みます。
スクロール駆動アニメーション:JavaScriptは不要#
これは、最も多くのJavaScriptを削除させてくれた機能です。スクロール連動アニメーション(プログレスバー、パララックスエフェクト、リビールアニメーション、トランジション付きスティッキーヘッダー)は以前、IntersectionObserver、scrollイベントリスナー、GSAPのようなライブラリが必要でした。今ではCSSで実現できます。
スクロールプログレスインジケーター#
記事ページ上部の定番「読み進め度プログレスバー」です:
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: oklch(55% 0.2 260);
transform-origin: left;
z-index: 1000;
animation: grow-progress linear both;
animation-timeline: scroll();
}
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}これだけです。読み進め度インジケーターの全体です。JavaScriptなし。スクロールイベントリスナーなし。requestAnimationFrameなし。「スクロール率」の計算なし。animation-timeline: scroll()のバインディングがすべてを処理します。
スクロール時のリビール#
ビューポートに入るときにフェードインする要素です:
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}animation-timeline: view()はアニメーションをビューポート内の要素の可視性に紐づけます。animation-range: entry 0% entry 100%は、要素がビューポートに入り始めてから完全に表示されるまでアニメーションが実行されることを意味します。
ライブラリなしのパララックス#
.parallax-bg {
animation: parallax linear both;
animation-timeline: scroll();
}
@keyframes parallax {
from {
transform: translateY(-20%);
}
to {
transform: translateY(20%);
}
}
.parallax-section {
overflow: hidden;
position: relative;
}背景画像がスクロールと異なる速度で移動し、パララックスエフェクトを作り出します。スムーズで高パフォーマンス(ブラウザがGPUでコンポジットできます)、JavaScriptはゼロです。
名前付きスクロールタイムライン#
より細かい制御のために、スクロールタイムラインに名前を付けて他の要素から参照できます:
.scroller {
overflow-y: auto;
scroll-timeline-name: --my-scroller;
scroll-timeline-axis: block;
}
/* An element anywhere in the DOM can reference this timeline */
.indicator {
animation: progress linear both;
animation-timeline: --my-scroller;
}
@keyframes progress {
from { width: 0%; }
to { width: 100%; }
}これはインジケーターとスクローラーが親子関係でなくても動作します。どの要素でも名前付きスクロールタイムラインにリンクできます。スクロール可能なパネルが固定ヘッダーのインジケーターを駆動するダッシュボードUIに強力です。
アンカーポジショニング:ツールチップとポップオーバーを正しく#
アンカーポジショニング以前は、ツールチップをトリガー要素に接続するにはJavaScriptが必要でした。getBoundingClientRect()で位置を計算し、スクロールオフセットを処理し、ビューポートの衝突を管理し、リサイズ時に再計算する必要がありました。Popper.js(現Floating UI)のようなライブラリは、これが非常に難しかったために存在していました。
CSSアンカーポジショニングはこれを宣言的にします:
.trigger {
anchor-name: --my-trigger;
}
.tooltip {
position: fixed;
position-anchor: --my-trigger;
/* Position the tooltip's top-center at the trigger's bottom-center */
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
/* Fallback positioning if it overflows the viewport */
position-try-fallbacks: flip-block, flip-inline;
}ビューポート衝突の自動処理#
position-try-fallbacksプロパティは、JavaScriptでは200行かかった部分です。ブラウザに「ツールチップが下にはみ出したら上に反転し、右にはみ出したら左に反転しろ」と伝えます。
.dropdown-menu {
position: fixed;
position-anchor: --menu-button;
/* Default: below the button, aligned to the left edge */
top: anchor(bottom);
left: anchor(left);
/* If it doesn't fit below, try above. If it doesn't fit left-aligned, try right-aligned */
position-try-fallbacks: flip-block, flip-inline;
/* Add a gap between the anchor and the dropdown */
margin-top: 4px;
}名前付きポジションフォールバック#
フォールバック位置をより細かく制御するために、カスタムtryオプションを定義できます:
@position-try --above-right {
bottom: anchor(top);
right: anchor(right);
top: auto;
left: auto;
margin-top: 0;
margin-bottom: 4px;
}
@position-try --left-center {
right: anchor(left);
top: anchor(center);
left: auto;
bottom: auto;
translate: 0 -50%;
margin-top: 0;
margin-right: 4px;
}
.tooltip {
position: fixed;
position-anchor: --trigger;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 4px;
position-try-fallbacks: --above-right, --left-center;
}ブラウザは、要素がビューポート内に収まるフォールバックが見つかるまで順番に試します。これはJavaScriptでは本当に苦痛だった空間推論です。
Popover APIとのアンカリング#
アンカーポジショニングは新しいPopover APIと完璧に組み合わされます:
<button popovertarget="my-popover" style="anchor-name: --btn">Settings</button>
<div popover id="my-popover" style="
position-anchor: --btn;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 8px;
">
Popover content here
</div>表示/非表示にJavaScriptは不要(Popover APIが処理します)。ポジショニングにJavaScriptは不要(アンカーポジショニングが処理します)。ライトディスミス動作にJavaScriptは不要(Popover APIがそれも処理します)。ツールチップ/ポップオーバー/ドロップダウンパターン全体、つまりnpmパッケージを支えてきたパターンが、HTMLとCSSだけになりました。
CSS Grid Subgrid:本当に動くネストされた配置#
Gridは強力ですが、1つのフラストレーションのたまる制限がありました。子グリッドが親グリッドにアイテムを揃えることができなかったのです。カードの行があり、各カードのタイトル、コンテンツ、フッターをカード間で揃えたい場合、どうしようもありませんでした。各カードの内部グリッドは独立していました。
Subgridがこれを修正します。
カードの配置問題#
/* Parent grid */
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
/* Each card becomes a subgrid, aligning rows to the parent */
.card {
display: grid;
grid-row: span 3; /* Card spans 3 rows in the parent */
grid-template-rows: subgrid;
gap: 0; /* Card controls its own internal gap */
}
.card-title {
font-weight: 600;
padding: 1rem 1rem 0.5rem;
}
.card-body {
padding: 0 1rem;
color: oklch(40% 0 0);
}
.card-footer {
padding: 0.5rem 1rem 1rem;
margin-top: auto;
border-top: 1px solid oklch(90% 0 0);
}3つのカードすべてのタイトルが同じ行で揃います。ボディも揃います。フッターも揃います。あるカードのタイトルが2行で別のカードのタイトルが1行でも、親グリッドによって配置が維持されます。
Subgrid以前:ハック#
Subgridなしでこの配置を実現するには、以下のいずれかが必要でした:
- 固定の高さ(脆弱、動的コンテンツで壊れる)
- JavaScriptによる計測(遅い、ずれのフラッシュ)
- 諦めてずれを受け入れる(よくある、見苦しい)
/* The old hack — fragile and breaks with dynamic content */
.card-title {
min-height: 3rem; /* pray that no title exceeds this */
}
.card-body {
min-height: 8rem; /* even more prayer */
}Subgridがあればハックは不要です。親グリッドが、すべてのカードにわたって各行の最も高いコンテンツに基づいて行の高さを分配します。
フォームレイアウト用のカラムSubgrid#
Subgridはカラムでも動作し、フォームレイアウトに最適です:
.form {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem 1rem;
}
.form-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.form-row label {
grid-column: 1;
text-align: right;
font-weight: 500;
}
.form-row input {
grid-column: 2;
padding: 0.5rem;
border: 1px solid oklch(80% 0 0);
border-radius: 0.375rem;
}すべてのラベルが揃います。すべてのインプットが揃います。ラベルカラムは最も幅の広いラベルに自動サイズ調整されます。ハードコードされた幅はありません。
View Transitions API:フレームワークなしのSPAライクナビゲーション#
このリストで最も野心的な機能です。View Transitions APIは、ページナビゲーション間のアニメーションを可能にします。クロスドキュメントナビゲーション(マルチページアプリでの通常のリンククリック)を含みます。静的HTMLサイトでスムーズなアニメーション付きページ遷移が可能になります。
クロスドキュメントトランジション#
クロスドキュメントビュートランジションを有効にするには、旧ページと新ページの両方にCSSルールを1つ追加します:
@view-transition {
navigation: auto;
}これだけです。ブラウザはページ間をナビゲートするときにクロスフェードするようになります。デフォルトのトランジションはスムーズな不透明度フェードです。JavaScriptなし。フレームワークなし。各ページにCSSが2行だけ。
トランジションのカスタマイズ#
特定の要素に名前を付けることで、どのトランジションが発生するかをカスタマイズできます:
/* On both pages */
.page-header {
view-transition-name: header;
}
.main-content {
view-transition-name: content;
}
.hero-image {
view-transition-name: hero;
}
/* Transition animations */
::view-transition-old(content) {
animation: slide-out 0.3s ease-in both;
}
::view-transition-new(content) {
animation: slide-in 0.3s ease-out both;
}
::view-transition-old(hero) {
animation: fade-out 0.2s ease-in both;
}
::view-transition-new(hero) {
animation: none; /* The new hero just appears */
}
@keyframes slide-out {
to {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}view-transition-name: heroを持つ.hero-imageが両方のページにある場合、ブラウザは旧ページの位置から新ページの位置へ画像を自動的にアニメーションさせます。モバイル開発の「共有要素トランジション」パターンが、ブラウザで実現できるようになりました。
SPAビュートランジション#
シングルページアプリ(React、Vue、Svelteなど)では、JavaScript APIは簡潔です:
/* CSS side — define your transition animations */
::view-transition-old(root) {
animation: fade-out 0.2s ease-in;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-out;
}// JS side — wrap your DOM update in startViewTransition
document.startViewTransition(() => {
// Update the DOM here — React render, innerHTML swap, etc.
updateContent(newPageData);
});ブラウザは旧状態のスナップショットを取り、更新を実行し、新状態のスナップショットを取り、それらの間をアニメーションさせます。名前付き要素は個別のトランジションを取得し、それ以外はデフォルトのクロスフェードになります。
ユーザー設定の尊重#
常にprefers-reduced-motionを尊重してください:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}もう使わなくていいもの#
これらの機能は総合的に、何年も頼ってきた多くのツール、ライブラリ、パターンを不要にします。私が削除した、または手を伸ばさなくなったものを紹介します。
ネスティングと変数のためのSass/SCSS#
CSSにはネイティブのネスティングがあります。CSSにはカスタムプロパティがあります(何年も前から)。Sassに手を伸ばす2つの主な理由が言語に組み込まれました。Sassを$variablesとネスティングだけのために使っているなら、やめて大丈夫です。
/* Before: Sass */
$primary: #3b82f6;
.card {
background: white;
border: 1px solid lighten($primary, 40%);
&:hover {
border-color: $primary;
}
.title {
color: darken($primary, 15%);
}
}
/* After: Native CSS */
.card {
--primary: oklch(55% 0.2 260);
background: white;
border: 1px solid color-mix(in oklch, var(--primary) 20%, white);
&:hover {
border-color: var(--primary);
}
.title {
color: color-mix(in oklch, var(--primary) 70%, black);
}
}CSS版の方が強力です。oklchは知覚的に均一な色操作を提供し、color-mixはランタイムで動作します(Sassのlightenはコンパイル時のみ)。そしてカスタムプロパティはJavaScriptやメディアクエリから動的に変更できます。
スクロールアニメーション用のJavaScript#
IntersectionObserverによるリビールオンスクロールのコードを削除してください。スクロールプログレスバーのJavaScriptを削除してください。パララックススクロールハンドラを削除してください。animation-timeline: scroll()とanimation-timeline: view()がこれらすべてを、より良いパフォーマンス(メインスレッドではなくコンポジタースレッド)で処理します。
// Before: JavaScript you can delete
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));/* After: CSS that replaces all of the above */
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}ポジショニング用のPopper.js / Floating UI#
基本的なツールチップ/ポップオーバーのポジショニングだけにFloating UI(旧Popper.js)を使っているなら、CSSアンカーポジショニングがそれを置き換えます。高度な機能(バーチャル要素、カスタムミドルウェア)は失いますが、90%のユースケース、つまり「このポップオーバーをあのボタンの近くに置いてビューポート内に収めろ」にはCSSがネイティブに対応するようになりました。
margin: autoセンタリングハック#
これは新しくはありませんが、いまだにmargin: 0 autoをあちこちで見かけます。モダンなレイアウトツールがほとんどの場合で不要にします:
/* Old way */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Better: CSS Grid or Flexbox */
.page {
display: grid;
place-items: center;
}
/* Or: container queries make the container itself responsive */
.content {
container-type: inline-size;
width: min(100% - 2rem, 1200px);
margin-inline: auto; /* OK, still auto, but margin-inline is clearer */
}margin: 0 autoは今でも問題なく動作します。しかし、垂直方向のセンタリングや複雑な配置に使っているなら、代わりにFlexboxやGridを使ってください。
コンポーネントレベルのレスポンシブ性のためのメディアクエリ#
これが最大のものです。コンポーネントをレスポンシブにするために@mediaクエリを書いているなら、今ではおそらく間違ったやり方です。コンテナクエリがコンポーネントレベルのレスポンシブ性のデフォルトであるべきです。@mediaクエリはページレベルの決定に留めてください:レイアウト変更、ナビゲーションパターン、印刷スタイル。
/* Before: Media query for component styling (wrong scope) */
@media (min-width: 768px) {
.product-card {
flex-direction: row;
}
}
/* After: Container query (right scope) */
.product-card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.product-card {
flex-direction: row;
}
}全体像#
これらの機能は孤立して存在しません。組み合わさります。コンテナクエリ + :has() + ネスティング + oklch() + レイヤーを一緒に使うと、5年前には想像もつかなかったCSSのオーサリング体験が得られます:
@layer components {
.card-wrapper {
container-type: inline-size;
}
.card {
--accent: oklch(55% 0.2 260);
display: grid;
gap: 1rem;
padding: 1.5rem;
border-radius: 0.5rem;
background: white;
border: 1px solid oklch(90% 0 0);
&:has(img) {
padding-top: 0;
img {
border-radius: 0.5rem 0.5rem 0 0;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
}
&:has(.badge.new) {
border-color: var(--accent);
}
.title {
font-size: 1.125rem;
font-weight: 600;
color: oklch(20% 0 0);
text-wrap: balance;
}
.meta {
font-size: 0.875rem;
color: oklch(50% 0 0);
}
@container (min-width: 500px) {
grid-template-columns: 200px 1fr;
&:has(img) {
padding: 0;
img {
border-radius: 0.5rem 0 0 0.5rem;
height: 100%;
aspect-ratio: auto;
}
}
.content {
padding: 1.5rem;
}
}
}
}この1つのブロックが以下を処理します:
- コンポーネントレベルのレスポンシブレイアウト(コンテナクエリ)
- コンテンツに基づく条件付きスタイリング(
:has()) - クリーンなセレクタの整理(ネスティング)
- 知覚的に均一な色(
oklch) - 予測可能な詳細度管理(
@layer)
プリプロセッサなし。CSS-in-JSランタイムなし。ユーティリティクラスもこれには不要。CSSが本来すべきだったことを、ただCSSがやっているだけです。
プラットフォームが追いつきました。ようやく。そして、待つ価値がありました。