実例で学ぶFlexboxとCSS Gridの使い分け

カテゴリ:
CSS
投稿日:

広告

タイムラインを見ていると「flexgridの使い分けがよく分からないよ」という人が多く散見されるので、今回は僕が普段意識していることを皆さんに紹介します。

これから紹介することはあくまで僕のやり方で、絶対的な正解とかではないので参考程度に留めておいてください。実装において頻出するレイアウトをサンプルに、どのように考えてレイアウトを組んでいけばよいかを自分なりに説明できたらなと思います。

はじめに

僕がレイアウトを組む上で大事にしていること、および意識していること。それは、レイアウトに変化が起こった際に崩れが生じないことはもちろん、将来的な変更に対して柔軟に対応できることです。

極論を言ってしまえばgridは使わなくても大抵のレイアウトは組めてしまいます。Internet Explorerに苦しめられていたあの頃を思い出してみてください。現在でもgridは難解だからflexだけ使用するって方も多いと思います。しかし、gridを活用すればこれまでflexや他の手法で実現困難だったレイアウトも、保守性と拡張性に優れた方法で実装できるかもしれません。

加えて、以前次のような投稿をしました。

ポストを別枠で表示する

この投稿でも触れられているようにflexgridどちらかを使用してレイアウトを組むといった際、僕はまずgridで実装できないか?を意識して考えるようにしています。gridのほうが後発的で機能が優れているから…という理由ではなく、グリッドテンプレート領域を使ってコンテナそのもので子要素のサイズを自在に調整できるのが大きな理由です。

チュートリアルとしてXのレイアウトを考えてみましょう。

Xの3カラムレイアウト

3列のカラムレイアウトになっており、左右に固定幅、中央は画面幅によって自在に伸縮するものとなっています。検証すると左のカラムは275px、右のカラムは350pxで固定されているようです。実際のマークアップとは違いますが、分かりやすくするために次のようなHTML構造で考えてみます。

HTMLの例
<div class="site-columns">
<header class="site-header">...</header>
<main class="site-contents">...</main>
<aside class="site-sidebar">...</aside>
</div>

カラムレイアウトを実現する.site-columns要素にそれぞれ独立したサイドヘッダー、メインコンテンツ、サイドバーがあるとします。この条件に従って左右の固定幅、中央は画面幅によって自在に伸縮する3カラムを実装する際にそれぞれのコンポーネントの大きさはどうやって制御したらよいでしょうか。考えてみてください。


まず、アンチパターンだと考えられるのはそれぞれのコンポーネントに固定の幅を持たせることです。

🙅‍♂ Not Recommended
.site-header {
inline-size: 275px;
}
.site-sidebar {
inline-size: 350px;
}

よく「汎用的に使用されるコンポーネントに固定の大きさを持たせるな」と言われていますが、このようなヘッダーコンポーネントやサイドバーコンポーネントはそのサイト上で単独でしか用いられないため、そのようなケースとは異なります。例えばメインビジュアルにmax-inline-size: 1280pxなどの最大幅を設けるケースはよくありますが、実質的に要素に固定の大きさを持たせているのと同じであるものの僕は気にしていません。

では、カラムレイアウトの場合に何が問題なのかというと、その固定の大きさはカラムレイアウトを実現する別のコンポーネントに依存しているのが理由です。つまり、カラムレイアウトを実現するために必要なスタイルが別の単独のコンポーネントに分散しているため、カラムレイアウトの全体像を理解する、もしくは改修する際に他のコンポーネントも参照する必要が出てきてしまうんですね。こういった理由からカラムの大きさはカラムレイアウトを実現する要素そのもので定義することを推奨しています。

それでは、flexgrid、それぞれを使用してカラムの大きさを制御する方法を考えていきましょう。

flexの場合

まずはflex。僕の推理だと多くの人がflexを使用しているのではないかなと思います。ですが実際にflexでカラムの大きさを制御しようとすると詰まりポイントが出てきます。それはflex-basisflex-growなどのプロパティはフレックスコンテナではなく子要素に指定するものだということです。

それではどうやってフレックスコンテナ側で大きさを制御するか?僕なら次のように実装します。

flexの場合
.site-columns {
display: block flex;
& > :nth-child(1) {
flex-shrink: 0;
flex-basis: 275px;
}
& > :nth-child(2) {
flex-grow: 1;
}
& > :nth-child(3) {
flex-shrink: 0;
flex-basis: 350px;
}
}

カラムの大きさを制御するためには直下セレクタを使用します。:nth-child()擬似クラスでそれぞれのカラムに大きさの指定を行います。コードに拒否感を感じる方もいるかと思いますが、僕はこの方法を推奨しています。BEMにおけるElementのようなclassで管理するのもオススメです。

gridの場合

続いてはgridでの実装を考えてみましょう。グリッドコンテナで子要素の大きさを制御する場合はグリッドテンプレート領域(grid-template)を使用します。

gridの場合
.site-columns {
display: block grid;
grid-template-columns: 275px 1fr 350px;
}

直下セレクタなどを使わずともたった1行のCSSでカラムの大きさを制御することができてしまいました。このようにgridであればカラムの大きさの制御を簡潔に指定することができるようになります。

grid-template-areasを明示するのも良いでしょう。エリア名を明示することで対応するカラムの役割が明確になってコードの見通しも良くなります。子要素にgria-areaを指定しない場合、grid-template-columnsで指定したルールに従って自動配置されるため順序の変動が起こらないケースではgria-areaの指定は不要になります。

エリア名を明示する
.site-columns {
display: block grid;
grid-template: 'header main sidebar' / 275px 1fr 350px;
}

gridを使用するメリットは他にもあります。先程のflexの例をもう一度見てください。

flexの場合
.site-columns {
display: block flex;
& > :nth-child(1) {
flex-shrink: 0;
flex-basis: 275px;
}
& > :nth-child(2) {
flex-grow: 1;
}
& > :nth-child(3) {
flex-shrink: 0;
flex-basis: 350px;
}
}

flex-basisが指定されているカラムにはflex-shrink: 0が、流動的なカラムにはflex-grow: 1が指定されています。これはどちらもflexでカラムレイアウトを実現するには必要な指定です。もしもflex-shrink: 0の指定を忘れた場合は流動的なカラムが広がった場合に幅の不足に反応して固定値を下回って縮んでしまいますし、流動的なカラムにflex-grow: 1が指定されていないとスペースに応じて広がってくれずに潰れて表示されてしまいます。タイムリーな話だと最近復活した某動画サイトの再生画面にてタイトルの文字数が多い場合に隣のカラムの投稿者情報が潰れて表示されるといった不具合が報告されていましたが、これはflex-shrink: 0の指定不足によるものです。

このようにflexで実装を行うとカラムの大きさの制御以外にもflex-shrinkflex-growの値に気を配る必要がありますが、gridであればその心配は不要です。よく「1次元の並びはflex、2次元の並びはgridが向いている」と言われますが、今回のケースを考慮しても1次元の並びでもgridが向いているケースは多く存在するように感じます。

1次元の場合でも flex-shrink, flex-grow が必要なら CSS Grid でもいいんじゃない? - Qiita

「1次元なら Flexbox, 2次元なら CSS Grid」のように Flexbox と CSS Grid を使い分けると考えている人は多いのではないでしょうか?じつは、1次元であっても、中身…

qiita.com


ただし、全てにおいてflexよりgridの方が優れているというわけではありません。と言うのも、先程の投稿の後から「flexを使うのはもう古い。時代はgridだ」や「flexを使うのは恥ずかしい」といったポストをされている方が散見されました。また、初学者の間でもflexを使用しない縛りをするといった動きが見受けられています。たしかにflexを使わずにgridのみでレイアウトを組むという試みは自己学習としてはgridの知識を深められる機会になるのでスキルアップには良い影響を与えるとは思います。しかし勘違いして欲しくないのは決してgridflexの上位互換ではないということです。gridよりもflexでやったほうが筋が通っているケースもありますし、flexgridはどちらにも強みと弱みがあります。故にflexを時代遅れだと言うのは誤りです。

重要なのはflexgrid、両方の特性を知ることで各レイアウトに適した手法を選択できるよう、自身なりのセオリーを確立することです。この記事では実例を交えながら僕なりのアプローチを紹介していきますので、迷っている方は参考にしてみてください。

flexgridinline-sizemargin-blockのように論理的な指定が前提となっています。そのため、「横並び」や「縦幅」、「左寄せ」と呼ばずに「インラインの方向に対して横並び」「ブロックの大きさ」「インライン方向の先頭側」と呼ぶのが適切なのですが、分かりやすさを優先して横or縦or左or右という表現をこの記事ではします。そこは注意してください。

要素を格子状に並べる場合

まずは比較的に簡単なところから。いわゆるタイルレイアウトと呼ばれるもので、記事一覧などでカードを並べるときに頻出するレイアウトです。このブログの記事一覧でも用いられているものですね。

このブログの記事一覧

おそらく皆さんも同じように実装すると思うんですが、僕ならこのようなレイアウトはgridを使って実装を行います。

flexgapを設けつつ要素を格子状に並べる場合は「コンテナの横幅からギャップの合計値を引いたものをn等分する」という計算式が必要となりますが、gridの場合は特別な計算は不要でかつgrid-template-columnsの指定だけで実現できます。

flexの場合
.container {
container-type: inline-size;
}
.flex {
--_column: 2;
--_gap: clamp(8px, 2cqi, 16px);
display: block flex;
flex-wrap: wrap;
gap: var(--_gap);
@container (480px <= inline-size) {
--_column: 3;
}
& > * {
flex-basis: calc((100% - var(--_gap) * (var(--_column) - 1)) / var(--_column));
}
}
gridの場合
.container {
container-type: inline-size;
}
.grid {
--_column: 2;
display: block grid;
grid-template-columns: repeat(var(--_column), 1fr);
gap: clamp(8px, 2cqi, 16px);
@container (480px <= inline-size) {
--_column: 3;
}
}

コンテナの横幅に合わせて柔軟にカラム数を変動させる

先程の例ではコンテナクエリを使用してブレイクポイント毎にカラム数を出し分けていましたが、flexgridどちらもアイテムの最小幅を決めながらコンテナの横幅に合わせて柔軟にカラムを切り替えることも可能です。

flexの場合
.container {
container-type: inline-size;
}
.flex {
display: block flex;
flex-wrap: wrap;
gap: clamp(8px, 2cqi, 16px);
& > * {
flex-grow: 1;
flex-basis: 360px;
}
}

flexのケースでは基準値は360pxとしつつ、flex-grow: 1でカラム落ちした際に生じた余ったスペースを等しく分配しています。

gridの場合
.container {
container-type: inline-size;
}
.grid {
display: block grid;
grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr));
gap: clamp(8px, 2cqi, 16px);
}

gridのケースではrepeat関数とauto-fill値を使用しつつアイテムの最小幅を360px最大幅を1frとすることで、これら2つの値の間で幅を自動調整してカラム数を変動しています。

前回の記事で触れましたが、minmax()関数を使用する際は最小値をminmax(min(<length>, 100%), 1fr)のようにmin()関数と組み合わせて指定するようにしてください。minmax(360px, 1fr)のような指定だとコンテナ幅が360pxを下回ったときにはみ出してしまいますが、min()関数と組み合わせればコンテナ幅が固定値を下回った場合でもアイテムの横幅が100%に調整されるため、レイアウトの崩れを防ぐことができます。

こちらの2つの挙動の違いに関しては次のとおりです。gridではカラム落ちした際も余ったアイテムの大きさが均等となっていますが、flexの場合はカラム落ちしたアイテムは等しくスペースを埋めるため広がっています。

gridの場合
flexの場合

多くのケースではgridの表示例が望まれるでしょうが、flexのような挙動が求められる場合も存在します。状況に応じて手段を変えるようにしましょう。

calc()を伴う際は意図を分かりやすくするためにカスタムプロパティを使用する

話は変わりますが、先程のカラム数が固定のflexの例を見てください。

カスタムプロパティを用いた例
.container {
container-type: inline-size;
}
.flex {
--_column: 2;
--_gap: clamp(8px, 2cqi, 16px);
display: block flex;
flex-wrap: wrap;
gap: var(--_gap);
@container (480px <= inline-size) {
--_column: 3;
}
& > * {
flex-basis: calc((100% - var(--_gap) * (var(--_column) - 1)) / var(--_column));
}
}

「コンテナの横幅からギャップの合計値を引いたものをn等分する」という計算式を用いてブレイクポイント毎にカラム数を出し分けていますが、このようなcalc()が伴う場合は僕はカスタムプロパティを用いて記述するようにしています。

多くの方はこのような指定をする際に次の例のように直接解答を指定しがちのように思えます。

🙅‍♂ Not Recommended
.flex {
display: block flex;
flex-wrap: wrap;
gap: clamp(8px, 2cqi, 16px);
& > * {
flex-basis: calc((100% - clamp(8px, 2cqi, 16px)) / 2);
@container (480px <= inline-size) {
flex-basis: calc((100% - clamp(8px, 2cqi, 16px) * 2) / 3);
}
}
}

たしかにこちらの方が記述量は少なくなり、カスタムプロパティを指定する手間は無くなりますが、計算の意図が分かりにくくなったり、後からgapやカラム数を変更するといった際にその都度calc()内の値を書き換える必要があります。人間なので当然変更のし忘れなども起こり得るでしょう。

一方、カスタムプロパティを用いた例ではブレイクポイント毎に各カスタムプロパティの値を変更すればいいだけなのでそのような手間やミスは防ぐことができます。加えて--_column: 3とすれば「あっ、このブレイクポイントを跨いだら3カラムになるんだな」と理解しやすくなります。

これはあくまで僕の感想でしかありませんが、「記述量が少ない=良いCSS」とは限りません。リリースしたら終わりの案件なら直接解答を指定してもいいかもしれませんが、大抵の場合改修や変更は起こるでしょう。後先を考えるのなら敢えて冗長に書くケースもあります。

コンテナクエリの利用

サンプルではブレイクポイントの切り替えにメディアクエリではなくコンテナクエリ(container size queries)を使用しています。

コンテナクエリは@containerルールおよびcqiのようなコンテナクエリ単位をコンテナそれ自体に指定することはできない(cqiはコンテナそれ自体に指定すると祖先要素のコンテナの幅を参照、祖先要素にコンテナが無い場合はsviと同じ扱いになります)ため、コンテナクエリを使うためだけに親要素を追加するコストが掛かるのが懸念点ですが、それを差し置いてもコンポーネント単位でレスポンシブが完結するメリットを得られるので導入する価値はあります。

また、実装例ではgapの中間値にcqiを使用していますが、これは基準コンテナのインラインサイズの1%を示す単位です。多くのケースではvwが用いられていますが、cqiを使うことでコンテナ幅を基準とできるので直感性は増すという印象です。cqiには物理的指定のcqwもありますが、コンテナクエリはcontainer-type: inline-sizeという指定からもわかるようにflexgridと同様論理的指定を前提としているため、原則的にはcqiを優先するようにしています。

もちろんコンテナクエリはメディアクエリの上位互換ではないので、使い分けは必要です。そのうち布教のためにコンテナクエリの記事を書こうかなと考えているので、気になる方はRSSフィードを購読しておいてください。

横幅が不定な要素を並べる場合

グローバルメニューのリンクの横並びやタグクラウドなど、横幅が不定な要素を並べる場合はどうでしょうか?こちらも頻出なレイアウトで、Clusterレイアウトとも呼ばれています。

このブログのタグクラウド
このブログのリンクの横並び

おそらく皆さんも同じように実装すると思うんですが、このようなレイアウトはflexを使うのがベストでしょう。横並びにしたい要素の大きさを気にしない、もしくは子要素に合わせたいのなら原則的にflexを使用します。

タグクラウドの実装例
.keywords-list {
display: block flex;
flex-wrap: wrap;
gap: 1em;
}
リンクの横並びの実装例
.menu-list {
display: block flex;
flex-wrap: wrap;
align-items: center;
gap: 1em;
}

gridでもgrid-auto-flow: columnを指定すればflexと同様に大きさが不定な要素をコンテンツに合わせて横並びできますが、タグクラウドの例のようにコンテンツ幅に合わせて改行する場合はgridでは厳しいです。

gridで大きさが不定な要素をコンテンツに合わせて横並びにする
.menu-list {
display: block grid;
grid-auto-flow: column;
}

ただし、記事カードなどで見かける「タグは1行で並べて親要素からはみ出る場合は隠すorスクロールさせる」といった実装のような、flexで行うとflex-shrink: 0が絡むような横並びの場合はgrid-auto-columns: max-contentで実装するほうがコンテナ自体で指定が完結するのでベターだと感じています。

子要素の大きさをそれらの要素が持ちうる最大の幅に設定する
.menu-list {
display: block grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
}

要素を格子状に並べつつ真ん中寄せにする場合

続いては記事一覧などのコンテンツを格子状に並べつつ真ん中寄せにする場合を考えてみましょう。次のようなレイアウトです。要素は格子状に並べつつ、1行および改行が発生した際は中央寄せを行っています。

レイアウトの例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

このようなレイアウトはflexを使用したほうがいいでしょう。2次元のレイアウトで要素を一定の大きさで並べているためgridを使ったほうがいいのではと考える方もいると思いますが、レスポンシブが絡むとなるとJSを使用したほうがいいレベルの力技が必要となります。

CSSの実装例
.container {
container-type: inline-size;
}
.flex {
--_column: 1;
--_gap: clamp(8px, 2cqi, 16px);
display: block flex;
flex-wrap: wrap;
gap: var(--_gap);
justify-content: center;
@container (320px <= inline-size) {
--_column: 2;
}
@container (480px <= inline-size) {
--_column: 3;
}
& > * {
flex-basis: calc((100% - var(--_gap) * (var(--_column) - 1)) / var(--_column));
}
}

「1次元の並びはflex、2次元の並びはgridが向いている」という風潮ですが、今回の例のように2次元の並びでもflexの方が向いているケースも存在します。

1行の場合は中央寄せにして、複数行のときは左寄せにする場合

先程は1行および複数行ともに常に中央寄せする実装を考えてみましたが、続いては1行の場合は中央寄せにして、複数行のときは左寄せにするレイアウトを考えてみましょう。

1行の場合は中央寄せ、複数行のときは左寄せの例

こちらのケースですが、gridflexどちらでも実装可能ですが記述量的にはgridのほうが適しているかなという印象です。どちらの実装も「コンテナの横幅からギャップの合計値を引いたものをn等分する」という計算式が必要になります。

flexの場合

flexで実装する場合は次のようなスタイルになります。

CSSの実装例
.container {
container-type: inline-size;
}
.flex {
--_column: 2;
--_gap: clamp(8px, 2cqi, 16px);
display: block flex;
flex-wrap: wrap;
gap: var(--_gap);
@container (480px <= inline-size) {
--_column: 3;
}
& > * {
flex-basis: calc((100% - var(--_gap) * (var(--_column) - 1)) / var(--_column));
}
& > :first-child {
margin-inline-start: auto;
}
& > :last-child {
margin-inline-end: auto;
}
}

flexでタイルレイアウトを組みつつ、justify-content: centerではなく最初と最後の要素にauto値のマージンを付与することで1行の場合にのみ中央寄せになります。

gridの場合

CSSの実装例
.container {
container-type: inline-size;
}
.grid {
--_column: 2;
--_gap: clamp(8px, 2cqi, 16px);
display: block grid;
grid-template-columns: repeat(auto-fit, calc((100% - var(--_gap) * (var(--_column) - 1)) / var(--_column)));
gap: var(--_gap);
justify-content: center;
@container (640px <= inline-size) {
--_column: 3;
}
}

justify-content: centerで要素を中央寄せにしつつ、grid-template-columnsの反復回数にauto-fitを指定します。

反復回数にauto-fitを使用することで要素がコンテナ幅より少ない場合に枠を埋めずに広げることができ、トラックに固定値を指定することで固定値のまま中央寄せすることができます。

1行の場合は中央寄せにして、複数行のときは左寄せにする実装例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

一部サイズが違うタイルレイアウトの場合

続いては一部のタイルのサイズが違うレイアウトを考えていきましょう。表示例は次のとおりです。

一部サイズが違うタイルレイアウトの例

グリッド幅は均等にしつつ、5n+1番目のタイルは縦横グリッド2つ分の大きさになっています。このケースではgridを使用しますが、僕は次のように実装します。

CSSの実装例
.container {
container-type: inline-size;
}
.grid {
display: block grid;
grid-template-columns: repeat(4, 1fr);
gap: clamp(8px, 2cqi, 16px);
& > :nth-child(5n + 1) {
grid-area: span 2 / span 2;
}
}

5n+1番目の子要素にグリッド領域の列側の先頭の端が末尾の端から2行になるように、グリッドアイテムの配置にグリッドスパンを設定しています。他の方の実装を見るとgrid-template-rowsauto値を反復して指定されている方もいらっしゃいますが、実装例のように行は暗黙的でも問題ありません。

grid-areaは配置される行と列を指定するプロパティです。grid-area: span 2 / span 2grid-rowgrid-columnspan 2を指定しているのと同等です。

もしくは次のような実装も良いでしょう。この場合はgrid-template-columnsの指定に従ってグリッドアイテムは自動配置されるのと、span <integer>の規定値は1なので、grid-columnの指定が不要になります。

CSSの実装例
.grid {
display: block grid;
grid-template-columns: 2fr repeat(2, 1fr);
gap: clamp(8px, 2cqi, 16px);
& > :nth-child(5n + 1) {
grid-row: span 2;
}
}

もしもサイズが違うタイルに固定幅を持たせたいなら以下のような指定が良いでしょう。次のコードではサイズが違うタイルに固定幅を持たせつつ、残りのタイルは横幅を等しく分け合います。

CSSの実装例
.grid {
display: block grid;
grid-template-columns: 480px repeat(2, 1fr);
gap: clamp(8px, 2cqi, 16px);
& > :nth-child(5n + 1) {
grid-row: span 2;
}
}

レンガ状に横並びする場合

続いてはデジタル庁のサイトなどで取り入れられている、レンガ状に横並びするレイアウトの実装方法についてです。今回の例では1行目は1カラム、2行目は2カラム、3行目以降は3カラムといった形になっています。

レンガ状に横並びするレイアウトの例

このレイアウトはflexgrid、どちらも同じくらいの記述量で実装できますが、個人的にはflexを使いたいなと考えています。

flexの場合

flexを使用した実装例
.flex {
--_column: 1;
--_gap: clamp(8px, 2cqi, 16px);
display: block flex;
flex-wrap: wrap;
gap: var(--_gap);
& > * {
flex-basis: calc((100% - var(--_gap) * (var(--_column) - 1)) / var(--_column));
}
@container (640px <= inline-size) {
& > :nth-child(n + 2) {
--_column: 2;
}
& > :nth-child(n + 4) {
--_column: 3;
}
}
}

gridの場合

gridを使用した実装例
.grid {
--_multiples: 6; /* カラムの公倍数: 1 * 2 * 3 */
--_column: 1;
display: block grid;
grid-template-columns: repeat(var(--_multiples), 1fr);
gap: clamp(8px, 2cqi, 16px);
& > * {
grid-column: span calc(var(--_multiples) / var(--_column));
}
@container (640px <= inline-size) {
& > :nth-child(n + 2) {
--_column: 2;
}
& > :nth-child(n + 4) {
--_column: 3;
}
}
}

見ていただいて分かる通り、flexgridも記述量的には大差ありません。しかし、flexは先述した「コンテナの横幅からギャップの合計値を引いたものをn等分する」という公式をそのまま流用すれば良いだけなのに対して、gridの場合はそれぞれの行のカラムの公倍数を求める必要があります。

もしも子要素で後述するsubgridを使いたいのならgridを選択しますが、どちらの方法も可読性を上げるのならcalc()とカスタムプロパティの利用は必要となるのでflexのほうが計算的には楽かもしれないという印象です。

カードやリストのコンテンツの大きさを揃える場合

続いてはカードやリストのコンテンツの大きさを揃える場合について考えていきましょう。ニュースリストの実装に関してはすみませんなんですけど、前回の記事でも紹介した方法の再放送になります。

カードの例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

行頭揃えのニュースリストの例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

かつてはJSやtableを使わないと実現できなかったレイアウトも現在ではsubgrid機能を活用できるgridを使用すれば簡単に各コンテンツの大きさを揃えることができます。カードレイアウトに関しては一つの行であればflexでもflex-direction: columnと揃えたい行にflex-grow: 1を指定すれば揃えることはできますが、複数行の場合はJSが必要になります。

カードの実装例
.cards-wrapper {
display: block grid;
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 24px;
}
.card {
display: block grid;
reading-flow: grid-order;
grid-template-rows: subgrid;
grid-row: span 4; /* コンテンツの量が4つの場合 */
row-gap: 16px;
}
行頭揃えのニュースリストの実装例
.news-list {
display: block grid;
grid-template-columns: max-content auto 1fr; /* 日付は改行が好ましくないので`max-content`を指定する */
}
.news-item {
display: block grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
column-gap: 24px;
}

カードの実装例では入れ子元が暗黙的なグリッドセルを生成している(grid-template-rowsが明示されていない)ため、コンテンツの量に対応してspan <integer>と指定する必要がありますが、ニュースリストのように入れ子元のグリッドセルが明示的な場合は1 / -1で問題ありません。

subgridは直接の子要素でないと適用できないので注意です。具体的には「gridで並べる要素>subgridを指定する要素>各コンテンツ」の構造でなければ適用できません。

以下の構造では「カードを並べる要素>section要素>a要素>各コンテンツ」となっており、そのままでは余分な要素が介入しているためsubgridが適用できなくなっています。

<div class="cards-wrapper">
<section class="card-container" aria-labelledby="card_title_0">
<a href="..." class="card">
<h2 id="card_title_0">...</h2>
<img />
<p>...</p>
<p>...</p>
</a>
</section>
...
</div>

この例であればsection要素にdisplay:contentsを指定し、a要素にsubgridを指定することで解決できます。

中間の要素にdisplay:contentsを指定する
.cards-wrapper {
display: block grid;
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 24px;
}
.card-container {
display: contents;
}
.card {
display: block grid;
reading-flow: grid-order;
grid-template-rows: subgrid;
grid-row: span 4; /* コンテンツの量が4つの場合 */
row-gap: 16px;
}

個人的にはカード全体をa要素でラップする実装は読み上げの懸念点などから行うことはありませんが、知識としてつけておくと良いでしょう。後述する理由からaria-labelledbyが付与されたsection要素にdisplay: contentsを付与する際はrole="region"を付与しておきます。

<div class="cards-wrapper">
<section class="card-container" role="region" aria-labelledby="card_title_0">
<a href="..." class="card">
<h2 id="card_title_0">...</h2>
<img />
<p>...</p>
<p>...</p>
</a>
</section>
...
</div>

レスポンシブな2カラム

続いてはよくある2カラムレイアウトの実装方法です。このブログで使われているリンクカードの実装をサンプルにしてみます。

リンクカードの例:コンポーネントの幅が広い時
リンクカードの例:コンポーネントの幅が狭い時

このリンクカードは以下のような条件で実装を行っています。

  • コンポーネントの大きさが狭い時は2カラム、狭い時は1カラムとする
  • 1カラムの時はサムネイルを先頭にし、2カラムの時は記事情報を先頭にする
  • 2カラムの時はサムネイルは固定幅にして、記事情報はリンクカードの幅に応じて伸び縮みする
  • ブレイクポイントはよしなに

このようなケースですが、grid+コンテナクエリでも問題ありませんがリンクカードの実装にはflexを選択しました。

HTMLの実装例
<div class="link-card" role="group" aria-label="参考リンク">
<a href="..." class="thumbnail" tabindex="-1" aria-hidden="true" target="_blank" rel="external">
<img src="..." alt="" loading="lazy" onerror="this.onerror=null; this.parentNode.style.display='none';" />
</a>
<div class="meta">...</div>
</div>
CSSの実装例
.link-card {
--_breakpoint: 640px;
--_static-column: 320px;
--_fluid-column: calc(var(--_breakpoint) - var(--_static-column));
display: block flex;
reading-flow: flex-visual;
flex-wrap: wrap-reverse;
& > :nth-child(1) {
flex-grow: 9999;
flex-basis: var(--_fluid-column);
}
& > :nth-child(2) {
flex-grow: 1;
flex-basis: var(--_static-column);
}
}

「冒頭でカラムレイアウトはflexよりgridの方が向いていると言っていたじゃないですか。嘘つくのやめてもらっていいですか」って思う人もいますよね。そう思った人、全員Xをフォローしてください。今回のケースのように2カラムで、かつよしなにのタイミングでカラム落ちするような場合はメディアクエリやコンテナクエリが絡まないflexのほうが簡単です。次の記事で紹介されている手法を用いて実装しています。

Flex-grow 9999 Hack

www.joren.co

この手法については参考記事を参照していただいたほうが分かりやすいと思いますが、固定幅のカラムには固定幅を指定したflex-basisflex-grow: 1を、また固定幅ではないカラムにflex-grow: 9999という非常に大きな値を指定することで2カラムの時はカラムの幅を流動的にしています。こうすることでカラム落ちが行われると自動的に固定幅のカラムが広がるようになります。

.link-card {
--_breakpoint: 640px;
--_static-column: 320px;
--_fluid-column: calc(var(--_breakpoint) - var(--_static-column));
display: block flex;
reading-flow: flex-visual;
flex-wrap: wrap-reverse;
& > :nth-child(1) {
flex-grow: 9999;
flex-basis: var(--_fluid-column);
}
& > :nth-child(2) {
flex-grow: 1;
flex-basis: var(--_static-column);
}
}

固定幅ではないカラムにflex-grow: 9999を指定しない場合、対象の要素は他の要素と同等もしくはそれに応じた割合でスペースを占有するようになります。つまり、特定の要素が他の要素よりも圧倒的に大きく広がることはなくなってしまうんですね。

ちなみにflex-growにはinfinityは使用することができないので気をつけてください。

一方、固定幅にflex-grow: 1を指定しないとカラム落ちした際に固定幅を超えて広がることはなくなります。

このように固定幅のカラム(今回のケースではサムネイル)にflex-grow: 1と固定値を指定したflex-basis、流動幅のカラム(今回のケースでは記事情報)にflex-grow: 9999と任意のブレイクポイントから固定値を差し引いたflex-basisを指定してあげることで、メディアクエリやコンテナクエリを使わずともレスポンシブ対応が行えるようになります。

また、1カラムの時はサムネイルを先頭にし、2カラムの時は記事情報を先頭にするような実装ですが、これは子要素にorderを指定するとメディアクエリやコンテナクエリが必要になりますよね。そこでflex-wrap: wrap-reverseを指定すればカラム落ちした際に順序が反転するのでorderを使わずとも順序を入れ替えすることができるようになります。

.link-card {
--_breakpoint: 640px;
--_static-column: 320px;
--_fluid-column: calc(var(--_breakpoint) - var(--_static-column));
display: block flex;
reading-flow: flex-visual;
flex-wrap: wrap-reverse;
& > :nth-child(1) {
flex-grow: 9999;
flex-basis: var(--_fluid-column);
}
& > :nth-child(2) {
flex-grow: 1;
flex-basis: var(--_static-column);
}
}

2カラムの際はカラムの大きさが均等になる場合

リンクカードの事例ではflexを使用しましたが、2カラムの際にカラムの大きさが均等になる場合はgridrepeat()関数とauto-fit値を使用したほうがより簡潔に指定することができます。

2カラムの際はカラムの大きさが均等になる場合

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

カラムの大きさが均等のレスポンシブする2カラムの場合
.link-card {
--_breakpoint: 640px;
display: block grid;
grid-template-columns: repeat(auto-fit, minmax(min(calc(var(--_breakpoint) / 2), 100%), 1fr));
}

gridでのrow-reverse

gridにはflex-direction: row-reverseflex-wrap: wrap-reverseにあたる機能は存在しないため、「1カラムの時はサムネイルを先頭にし、2カラムの時は記事情報を先頭にする」といった条件を実現するには原則的にメディアクエリやコンテナクエリが必要となります。…が、方法はあります。

.link-card {
--_breakpoint: 640px;
display: block grid;
grid-template-columns: repeat(auto-fit, minmax(min(calc(var(--_breakpoint) / 2), 100%), 1fr));
direction: rtl;
& > * {
direction: initial;
}
}

flexgridは論理的な指定を前提としているため、direction: rtlを適用することで列の方向が右から左に進むようになり、擬似的にrow-reverseのように動作させることができます。注意点としてはdirectionは書式の方向を制御するプロパティであり、内包されるテキストは右から左に記述されます。directionは子要素に継承されるため、コンテナにのみdirection: rtlを適用させるために子要素はdirectionを初期値に戻す必要があります。

3カラム以上で大きさが均等→ブレイクポイントを跨ぐと1カラムになる場合

3カラム以上のレイアウトで一定のブレイクポイント以上では大きさが均等、ブレイクポイント未満では1カラムの場合はflexの方が実装が簡単になります。

3カラム以上、カラムの大きさが均等になる場合

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

3カラム以上、カラムの大きさが均等になる場合
.column {
--_breakpoint: 480px;
display: block flex;
flex-wrap: wrap;
& > * {
flex-grow: 1;
flex-basis: calc((var(--_breakpoint) - 100%) * 9999);
}
}

リンクカードの例ではflex-grow9999という極端な値を指定しましたが、こちらのケースではflex-basisに極端な値を乗算しています。

flex-basisは負の数値を指定すると無効になるため、表示領域の大きさがブレイクポイントより大きい場合はcalc(var(--_breakpoint) - 100%)は無効の値となり無視されてflex-grow: 1の指定のみ生き残ります。

ブレイクポイントより小さい場合はflex-basisの値が有効になりますが、calc(var(--_breakpoint) - 100%)のみだとブレイクポイント付近で中途半端な改行が行われるため9999という極端な値を乗算することで中途半端な改行を防止しつつ画面いっぱいに広げることができる…というのがこのテクニックです。

reading-flowプロパティ

話は変わるんですが、orderflex-direction: row-reverseflex-wrap: wrap-reverseを指定した要素はフォーカスや読み上げの順序があべこべになるためアクセシビリティに悪いという指摘があります。正直言うとorderreverseにケチをつけるよりもブラウザや支援技術側で対応してくれればいいだけの気がするんですが、新しく登場したreading-flowプロパティの登場によりようやく見た目上の順序に従ってそれらの順序を制御することが可能になります。

.link-card {
--_breakpoint: 640px;
--_static-column: 320px;
--_fluid-column: calc(var(--_breakpoint) - var(--_static-column));
display: block flex;
reading-flow: flex-visual;
flex-wrap: wrap-reverse;
& > :nth-child(1) {
flex-grow: 9999;
flex-basis: var(--_fluid-column);
}
& > :nth-child(2) {
flex-grow: 1;
flex-basis: var(--_static-column);
}
}

読み取りフローと display: コンテンツを使用した要素に関するデベロッパー フィードバックのお願い  |  Blog  |  Chrome for Developers

読み上げフローがお客様のニーズに合ったものになるよう、ぜひご協力ください。

developer.chrome.com

reading-flowプロパティは本記事投稿時点ではモダンブラウザでサポートがされていませんが、Chrome Dev および Canary バージョン 128 以降で試すことができます。近い将来にChromeなどでサポートされることが予測されるので先行的に取り入れています。

ただし、注意点としては先述したdirection: rtlでの疑似リバースはreading-flowで制御できません。加えて支援技術では見出し→画像と読み上げさせたいがキーボードフォーカスは画像→見出しとしたいといった制御も現状では不可能です。

画像の高さを任意のアスペクト比に保ちつつカラムの高さに合わせるようにする場合

サンプルではサムネイル部分は1カラムの時は任意のアスペクト比、2カラムは任意のアスペクト比を下回らないようにしつつリンクカードの高さに応じて広がるようにしていますが、次のような実装を行っています。

サムネイルCSSの実装例
.thumbnail {
position: relative;
&::before {
content: '';
display: block flow;
aspect-ratio: 16 / 9;
}
& img {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
}

任意のアスペクト比の大きさの疑似要素を座り込みさせることで任意のアスペクト比を下回らないように領域を確保しつつ、画像はカラムの高さに多じて広がるようにします。

こうすることでメディアクエリやコンテナクエリを使わずとも高さを確保することができるようになりますので、覚えておくと良いでしょう。

レスポンシブで構造が変動するレイアウトの場合

続いてはレスポンシブで構造が変動するレイアウトの実装を考えていきましょう。サンプルではコンポーネントの幅が狭い時はタイトル→サブタイトル→画像→文章、コンポーネントの幅が広い時は左側に画像→隣のカラムにタイトル→サブタイトル→文章となっていて構造が異なっています。

HTMLの実装例
<div class="section-container">
<section class="section">
<hgroup class="section-headline" role="group">
<h2>タイトル</h2>
<p>サブタイトル</p>
</hgroup>
<div class="section-image">
<img />
</div>
<div class="section-paragraph">
<p>...</p>
<p>...</p>
</div>
</section>
</div>
構造が変動するレイアウトの例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

構造が変動するレイアウトの例:コンポーネントの幅が広い時
構造が変動するレイアウトの例:コンポーネントの幅が狭い時

タイムラインを見ているとflexdisplay:contentsorderなどでこねくり回して実装されている方が多い印象ですが、コンテナ側で配置を指定できるgridのほうがdisplay:contents用のdivの追加なども不要ということもあり好みです。

gridの場合

CSSの実装例
.section-container {
container-type: inline-size;
}
.section {
--_template:
'title'
'subtitle'
'image'
'paragraph';
display: block grid;
reading-flow: grid-rows;
grid-template: var(--_template);
gap: clamp(8px, 2cqi, 16px) clamp(16px, 4cqi, 32px);
@container (600px <= inline-size) {
--_template:
'image title'
'image subtitle'
'image paragraph'
/ 1fr 1fr;
}
}
.section-headline {
display: contents;
& h2 {
grid-area: title;
}
& p {
grid-area: subtitle;
}
}
.section-image {
grid-area: image;
}
.section-paragraph {
grid-area: paragraph;
}

子要素にgrid-column: 2のように<integer>を指定するのも良いですが、僕は対応するグリッドが視覚的に分かりやすく直感的になるという理由からgrid-template-areasエリア名を明示するようにしています。

懸念点としては各要素にgrid-areaを指定する必要がある点です。このようなケースでは子要素インデックス擬似クラスを使用するとコードの理解が難しくなるため、子要素に別の独立したコンポーネントが含まれる場合にはdivなどでラップしてBEMでいうElementにあたるclass属性を指定するのが望ましいでしょう。

BEM+ITCSSでのclass指定例
<div class="o-container" style="--container-size: 1200px;">
<section class="p-section">
<hgroup class="p-section__headline" role="group">
<h2 class="p-section__title">タイトル</h2>
<p class="p-section__subtitle">サブタイトル</p>
</hgroup>
<div class="p-section__image">
<img />
</div>
<div class="p-section__paragraph">
<p class="c-paragraph">...</p>
<p class="c-paragraph">...</p>
</div>
</section>
</div>

もしくは賛否両論あるかとは思いますが、grid-areaはHTMLに依存しているためclass属性同様にHTML側にstyle属性でエリア名を持たせるのもアリかと思われます(このブログではこの方法でエリア名を指定しています)。

HTMLにエリア名を持たせる
<div class="section-container">
<section class="section">
<hgroup class="section-headline" role="group">
<h2 style="--grid-area: title;">タイトル</h2>
<p style="--grid-area: subtitle;">サブタイトル</p>
</hgroup>
<div class="section-image" style="--grid-area: image;">
<img />
</div>
<div class="section-paragraph" style="--grid-area: paragraph;">
<p>...</p>
<p>...</p>
</div>
</section>
</div>
CSS側の指定
.section-container {
container-type: inline-size;
}
.section {
--_template:
'title'
'subtitle'
'image'
'paragraph';
display: block grid;
reading-flow: grid-rows;
grid-template: var(--_template);
gap: clamp(8px, 2cqi, 16px) clamp(16px, 4cqi, 32px);
@container (600px <= inline-size) {
--_template:
'image title'
'image subtitle'
'image paragraph'
/ 1fr 1fr;
}
& [style*="--grid-area:"] {
grid-area: var(--grid-area);
}
}
@property --grid-area {
syntax: "*";
inherits: false;
initial-value: auto;
}

また、テキストの合計の高さが画像の高さを下回った際に天地中央寄せをしたい場合は次の例のように1frを指定した空のグリッドを作成して上下に余白を作成します。空のグリッドにはエリア名にピリオド(.)を指定しておきます。

テキストの合計の高さが画像の高さを下回った際に天地中央寄せを行う表示例
テキストの合計の高さが画像の高さを下回った際に天地中央寄せをする
.section {
--_template:
'title'
'subtitle'
'image'
'paragraph';
display: block grid;
reading-flow: grid-rows;
grid-template: var(--_template);
gap: clamp(8px, 2cqi, 16px) clamp(16px, 4cqi, 32px);
@container (600px <= inline-size) {
--_template:
'image .' 1fr
'image title'
'image subtitle'
'image paragraph'
'image .' 1fr
/ 1fr 1fr;
}
}

ただし、この実装のままだと今度はテキストの合計の高さが画像の高さを上回った際に上下にgap分の余白が生じてしまいます。コードの煩雑化とトレードオフになりますが、それが気に入らない場合はさらにrow-gap分の空のグリッドを作成し、元のrow-gapunsetします。

row-gap分の空のグリッドを作成する
.section {
--_template:
'title'
'subtitle'
'image'
'paragraph';
--_row-gap: clamp(8px, 2cqi, 16px);
display: block grid;
reading-flow: grid-rows;
grid-template: var(--_template);
gap: var(--_row-gap) clamp(16px, 4cqi, 32px);
@container (480px <= inline-size) {
--_template:
'image .' 1fr
'image title'
'image .' var(--_row-gap)
'image subtitle'
'image .' var(--_row-gap)
'image description'
'image .' 1fr
/ 1fr 1fr;
row-gap: unset;
}
}

flexの場合

比較としてflexでの実装例も紹介します。

先述したようにgridにはflex-direction: row-reverseに相当する指定が存在しないため、奇数個目と偶数個目でカラムの順序を反転させたい場合には反転用のgrid-template-areasを指定する手間が掛かったり、先述したdirection: rtlのような裏技的な方法を使う必要がありますが、flexならflex-direction: row-reverseすればいいだけなのでその手間は省けます。

しかし、display: contentsを指定するラッパーが必要な都合上HTMLの構造に制約が起こったり、それに起因するコードの煩雑化は避けられないためrow-reverseのような実装が求められる場合でもgridを使う方が良いのではないかという印象です。

flex+orderの例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

HTMLの実装例
<div class="section-container">
<section class="section">
<div class="section-image">
<img />
</div>
<div class="section-multicolumn">
<hgroup class="section-headline" role="group">
<h2>タイトル</h2>
<p>サブタイトル</p>
</hgroup>
<div class="section-description">
<p>...</p>
<p>...</p>
</div>
</div>
</section>
...
</div>
CSSの実装例
.section-container {
container-type: inline-size;
}
.section {
--_direction: column;
--_column: 1;
--_multicolumn-display: contents;
--_headline-order: -1;
display: block flex;
reading-flow: flex-visual;
flex-direction: var(--_direction);
row-gap: clamp(8px, 2cqi, 16px);
@container (480px <= inline-size) {
--_direction: initial;
--_column: 2;
--_multicolumn-display: block grid;
--_headline-order: initial;
&:nth-child(odd) {
--_direction: row-reverse;
}
}
& > * {
flex-basis: calc(100% / var(--_column));
}
}
.section-multicolumn {
display: var(--_multicolumn-display);
row-gap: inherit;
align-content: center;
}
.section-headline {
display: contents;
& > * {
order: var(--_headline-order);
}
}

display:contentsのアクセシビリティの問題に関して

HTMLの実装例では見出しと副題のマークアップにhgroupを使用していますが、見出しと副題はアイテムとして扱いたいためhgroupdisplay:contentsを指定しています。ただし、display:contentsを指定した要素はその要素が本来持っているroleが消失するリスクも孕んでいるため、hgroup要素には暗黙的に持っているrole="group"を明示しています。

<hgroup class="section-headline" role="group">
<h1>タイトル</h1>
<p>サブタイトル</p>
</hgroup>

かつてはdisplay:contentsが指定された要素だけでなく子孫要素全てが読み上げから除外されるという深刻なバグが発生していましたが、現在は修正されています。ただし、display:contentsが指定された要素そのものの読み上げが除外される件に関しては現在でも一部ブラウザで残っているとのことなので、例えばul要素であればrole="list"といった暗黙的なroleを明示するか、もしくは暗黙的なroleが無い要素やgenericな要素(divspan)に限定して使用したほうが良いでしょう。

It’s Mid-2022 and Browsers (Mostly Safari) Still Break Accessibility via Display Properties

It was late 2020 when I last tested how browsers use CSS display properties to break the semantics of elements. I had been waiting for Safari to fix h...

adrianroselli.com

ただし、ui ol要素の暗黙的なrole="list"が消失する件に関してはlist-style: noneでリストマーカーを非表示にする場合にVoiceOver + Safariでリストとして認識されなくなるため、その部分にも気を配ったほうが良いという認識です。解決方法としてはrole="list"を明示する、もしくはlist-style: noneではなくlist-style-type: ''でリストマーカーを非表示にするというアプローチが存在します。

左端・中央・右端にコンテンツがあるグローバルヘッダーの場合

続いてはこのブログのように左端・中央・右端にコンテンツがあるグローバルヘッダーの実装例を考えていきます。

グローバルヘッダーの表示例

こういったケースの場合「1次元だからよしflexだな」と思いがちですが、justifiy-content: space-betweenなどで実装すると左右のコンテンツにある程度の最小幅を持たせておかないと真ん中のコンテンツが片方にズレてしまいます。

左右のコンテンツにはコンテンツそのものの大きさを持たせたい場合にはgridを使用すると良いでしょう。次のようなCSSで実装できます。

CSSの実装例
.global-header {
display: block grid;
grid-template-columns: minmax(max-content, 1fr) auto minmax(max-content, 1fr);
align-items: center;
& > :last-child {
justify-self: end;
}
}

中央のコンテンツはautoでコンテンツの幅に合わせつつ、左右のコンテンツには1frを指定することで余ったスペースに対して均等に伸縮するようになります。grid-template-columnsのみの指定だとすべての要素が先頭寄せとなるので、右側のコンテンツ=最後の要素(:last-child)にjustify-self: endを指定して右寄せとしています。

ポイントとしてgrid-template-columnsの指定は1fr auto 1frではなくminmax(max-content, 1fr) auto minmax(max-content, 1fr)としています。次の表示例を御覧ください。

`grid-template-columns: 1fr auto 1fr`の場合

これはgrid-template-columns: 1fr auto 1frを指定した際の表示例なのですが、リンクの数が著しく増えた際にロゴテキストは潰れて、テーマ切り替えボタンとメニューのリンクが被さっています。(画像ではテキスト自体は被さっていないものの、リンクの左右にはpaddingを設けているのでクリックエリアが重なってしまっています)。

一方、minmax(max-content, 1fr) auto minmax(max-content, 1fr)と指定しておくとどうでしょう。次の表示例を御覧ください。

`grid-template-columns: minmax(max-content, 1fr) auto minmax(max-content, 1fr)`の場合

左右のグリッドの最小幅はmax-contentとなるため、1fr auto 1frの指定で起こった表示崩れを防ぐことができますね。

WordPressなどのCMSではリンクの数を制御しきれない場合もあるため、このように先手を打っておくといいと思います。

スクロールする横並び

続いてはスライダーなどで横並びしつつスクロールさせるケースです。多くの方がスライダーはSplideなどのJSプラグインを使用されているかと思いますが、狭い画面幅では記事一覧を横スクロールさせるといったJSを使わないパターンもあるでしょう。

スライダーの例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

こういった場合flexを使いつつ子要素にflex-shrink: 0を指定するのがメジャーかなとは思いますが、個人的にはgridgrid-auto-flow: columnを指定したほうがflex-shrinkの値を気にする必要が無くなり、コンテナ側で大きさを指定できるという理由から僕はこちらで実装を行っています。

.container {
container-type: inline-size;
inline-size: min(1200px, 100%);
margin-inline: auto;
}
.carousel {
--_column: 1;
--_gap: clamp(8px, 2cqi, 16px);
@container (480px <= inline-size) {
--_column: 2;
}
@container (640px <= inline-size) {
--_column: 3;
}
}
.scroller {
--_gutter: calc((100dvi - 100cqi) / 2);
display: block grid;
grid-auto-columns: calc(100cqi - var(--_gap) * (var(--_column) - 1)) / var(--_column));
grid-auto-flow: column;
column-gap: var(--_gap);
overflow: auto;
overscroll-behavior-inline: contain;
margin-inline: calc(var(--_gutter) * -1);
padding-inline: var(--_gutter);
scroll-snap-type: inline mandatory;
scroll-padding-inline: var(--_gutter);
scroll-behavior: smooth;
/* JS有効時はスクロールバーを非表示 */
@media (scripting: enabled) {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
& > * {
scroll-snap-align: start;
scroll-snap-stop: always;
}
}

特定の位置にピタッと移動(スクロール)させるscroll-snap-typeプロパティ

scroll-snap-typeはユーザーがスクロールした際にアイテムを特定の位置にスナップさせるプロパティです。

今回の例ではグリッドコンテナにscroll-snap-type: inline mandatory、アイテムにscroll-snap-align: startを指定しています。これによりコンテナの左端にスライドをスナップさせています。

スマホなどでスワイプするとついアイテムがスライドしすぎるケースがありますが、アイテムにscroll-snap-stop: alwaysを同時に指定することで一つずつピッタリ止まるようになるため同時に指定しています。

.scroller {
--_gutter: calc((100dvi - 100cqi) / 2);
display: block grid;
grid-auto-columns: calc((100cqi - var(--_gap) * (var(--_column) - 1)) / var(--_column));
grid-auto-flow: column;
column-gap: var(--_gap);
overflow: auto;
overscroll-behavior-inline: contain;
margin-inline: calc(var(--_gutter) * -1);
padding-inline: var(--_gutter);
scroll-snap-type: inline mandatory;
scroll-padding-inline: var(--_gutter);
scroll-behavior: smooth;
/* JS有効時はスクロールバーを非表示 */
@media (scripting: enabled) {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
& > * {
scroll-snap-align: start;
scroll-snap-stop: always;
}
}

画面幅が狭い場合にカードなどをスクロールさせて表示させるといった実装をする際にこのようなスナップさせる動作のためだけにJSプラグインを導入しているケースも散見されますが、現在ではCSSのみで低コストで実現できます。

スクロール位置にまつわるテクニック

今回の例では次のような条件でスライダーを実装しています。

  • アイテムはコンテナ幅を超えてはみ出して表示させる
  • 最初のアイテムはコンテナの左端に表示する
  • 最後のアイテムはコンテナの右端で止まるようにする

多くのJSプラグインにはこういったレイアウトをサポートする機能が備わっている印象ですが、多くのケースではJSで実装されているのと、自前でこのようなレイアウトを実現する手段について触れている文献が少ないためかタイムラインでは実装手段について詰まっている方が多く見受けられます。

このようなレイアウトに関して、僕は次のように実装しています。

コンテナ幅を飛び出して画面いっぱいに表示させる
.scroller {
--_gutter: calc((100dvi - 100cqi) / 2);
...
margin-inline: calc(var(--_gutter) * -1);
padding-inline: var(--_gutter);
...
}

画面幅(100dvi)からコンテナ幅(100cqi)を差し引いたものを左右のガター(--_gutter: calc((100dvi - 100cqi) / 2))として、margin-inline: calc(var(--_gutter) * -1)で画面いっぱいに広げます。このテクニックはmargin-inline: calc(50% - 50vw)でコンテナ幅を飛び出して画面いっぱいに表示するテクニックとして有名ですね。

子要素を親要素(インナー幅)からはみ出して画面いっぱいにするCSS | HPcode(えいちぴーこーど)

子要素を親要素からはみ出して画面いっぱいにするは、今までであれば親要素から出してコーディングし直すというのが一般的でした。 ただし、今ではcalcとvwとい新たなCSSの概念があるので、子要素を親要素からはみ出して画面いっぱいにするというこ

haniwaman.com

これだけだとアイテムが画面端に移動してしまうため、padding-inline: var(--_gutter)で差し引いた分のガター分paddingを設けて基準の位置をコンテナ幅に合わせます。原則的にこれだけで上記の条件は満たすことができます。

しかし、今回はアイテムにscroll-snap-align: startを指定している関係でpaddingは無視されてアイテムが画面端に移動したままになってしまいます。これを防止するためにscroll-padding-inlinepaddingで指定した値と同じ値を指定することでサンプルのようにscroll-snap-align: startを指定しつつ基準位置をコンテナ端に合わせることが可能になります。

scroll-snap-align: startによる基準位置のズレを修正する
.scroller {
--_gutter: calc((100dvi - 100cqi) / 2);
...
margin-inline: calc(var(--_gutter) * -1);
padding-inline: var(--_gutter);
scroll-padding-inline: var(--_gutter);
...
}

ちなみにcalc((100dvi - 100%) / 2)でない理由はmargin-inlinepadding-inlineは機能するものの、scroll-padding-inlineの指定では機能しなくなるためです。

また、margin-inline: calc(50% - 50vw)同様に横スクロールバーが発生するため祖先要素側でoverflow-x: clipの指定は必要になります。overflow-x: hiddenではスクロールコンテナを生成する都合で子孫要素のposition: stickyが動作しなくなるリスクがあるためoverflow-x: clipとすると良いでしょう。

横スクロールを防止する
:where(:root, body) {
overflow-inline: clip;
@supports not (overflow-inline: clip) {
overflow-x: clip;
}
}

要素の上に要素を重ねるレイアウトの場合

続いては要素の上に要素を重ねるレイアウトの実装例を考えていきます。

要素の上に要素を重ねる場合、真っ先に思いつくのはflexでもgridでもなくposition:absoluteだと思いますが、gridで要素を重ねたほうがベターなケースも存在します。次の実装サンプルを見てください。

背景画像が追従するセクションの例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

このセクションのサンプルは以下のような条件で実装を行っています。

  • 最低限ブロック軸方向の大きいビューポートの長さの大きさ(100lvb)を確保する
  • ビューポートよりもテキスト量が少ない場合はテキストを天地中央寄せにして表示する
  • ビューポートよりもテキスト量が多い場合は背景画像をビューポートに追従して表示する

lvbはブロック軸方向の大きいビューポートの長さの大きさを基準にした単位です。横書きの場合は100lvhと同等になります。dvbでない理由はアドレスバーの有無で拡大縮小されるので見栄えが悪くなるためです。

こういったケースの場合、position:absoluteで行うとテキストか背景画像、どちらか片方の高さしか参照することができません。例えばテキストのラッパー要素にposition:absoluteを指定するとテキスト量が画像の大きさを上回った際にテキストの高さは浮いているので溢れてしまいます。

一方、gridで同じ行と列に重ねて実装すれば画像とテキストどちらか大きい方を基準とすることができるので、上記のような条件に基づいた実装も簡単にできます。実装例を見てみましょう。

マークアップの実装例
<section class="section">
<div class="inner">
<h2>...</h2>
<p>...</p>
</div>
<img />
</section>
CSSの実装例
.section {
--_foreground: #1c1c1c;
display: block grid;
grid-template-areas: 'stack';
align-items: center;
color: var(--_foreground);
& > * {
grid-area: stack;
}
&:has(> img) {
--_foreground: #fcfcfc;
}
& > img {
--_overlay: rgb(0 0 0 / 60%);
position: sticky;
inset: 0;
z-index: -1;
inline-size: 100%;
block-size: 100lvb;
object-fit: cover;
/* 画像に半透明のオーバーレイをかける */
/* 強制カラーモード適用下では`outline`がシステムカラーになるので`forced-colors`メディア特性内で指定する */
@media (forced-colors: none) {
outline: 100lvmax solid var(--_overlay);
outline-offset: -100lvmax;
}
}
}

今回の例ではgrid-template-areas"stack"という名前のエリア名を指定して、直下の要素全てにgrid-area: stackを指定しています。これはエリア名を指定しなくても直下の要素全てにgrid-area: 1 / 1を指定すればいいんですが、海外のなんかすごそうな人がこのような実装をしていて、意図が分かりやすくていいなぁと思ったので真似しています。

他にもgridで要素を重ねると良い例があります。これは前回の記事でも紹介した方法なんですけど、アコーディオンに「+」「‐」アイコンが表示されているのをよく見かけますが、これもgrid-areaで同じカラムに重ねるほうがスマートに実装できます。

アコーディオンの実装例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

アイコンの実装例
summary {
display: block grid;
grid-template: '. icon' / 1fr 1em;
column-gap: 1em;
align-items: center;
&::before,
&::after {
content: '';
grid-area: icon;
border-block-end: 1px solid;
}
&::after {
rotate: 90deg;
}
&:is([open] > *)::after {
opacity: 0;
}
}

position:absoluteで実装すると位置調整がややこしくなったりアイコン用のspan要素が必要になる場合もありますが、ただ「+」「‐」を右端に表示させるだけならgridで行ったほうが位置調整をgrid-templateで完結できるためオススメしています。

画像とテキストが重なったメインビジュアル

こちらも画像とテキストが重なったメインビジュアルの例ですが、ポイントはテキストは画像と重なっている部分とそうでない部分で配色が違うという点です。

メインビジュアルの例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

こちらに関してもflexorgridではなくposition: absoluteで実装されるケースがメジャーな気がしますが、今回はgridで実装を行います。

HTMLの実装例
<div class="main-visual-container">
<header class="main-visual" role="group">
<h1>Lorem ipsum dolor sit amet,<br />consectetur adipisicing elit</h1>
<img />
</header>
</div>
CSSの実装例
.main-visual-container {
--_layout-width: 1280; /* デザインカンプの幅が1280pxと仮定 */
--_fluid-ratio: calc(1 / var(--_layout-width) * 100cqi);
container-type: size;
inline-size: min(1600px, 100%);
block-size: max(480px, 100svb);
margin-inline: auto;
}
.main-visual {
--_text-safe-area: calc(120 * var(--_fluid-ratio));
--_padding: max(calc(32 * var(--_fluid-ratio)), 32px);
display: block grid;
grid-template:
var(--_padding) [contents-start] 1fr [contents-end] var(--_padding) /
var(--_padding) [text-start] var(--_text-safe-area) [image-start] 1fr [image-end text-end];
block-size: inherit;
&::after {
/* 画像のオーバーレイ */
content: '';
grid-area: contents / image;
background-image: linear-gradient(rgb(0 0 0 / 10%), rgb(0 0 0));
}
& > * {
grid-row: contents;
}
& h1 {
z-index: 1;
grid-column: text;
align-self: center;
background-image: linear-gradient(90deg, #000 0% var(--_text-safe-area), #fff var(--_text-safe-area) 100%);
background-clip: text;
color: transparent;
}
& img {
grid-column: image;
inline-size: 100%;
block-size: 0; /* 間延びを防止する */
min-block-size: 100%; /* 間延びを防止する */
object-fit: cover;
}
}

コードがごちゃごちゃしていて分かりにくい印象を持たれるかもしれませんが、それぞれの子要素(画像、テキスト、画像のオーバーレイ要素)にグリッド名を明示して重ねながら配置しています。

ただし、このケースではテキスト部分と画像部分が一部重なっており、grid-template-areasでエリア名を明示するのは厳しいのでgrid-template-columnsに線名を明示しています。

今回のケースでは左端に余白があり、その後にテキスト開始地点([text-start])、そこからしばらくして画像が開始する地点([image-start])、そして画像とテキストが終わる地点([image-start text-end])となっています。これをgrid-template-columnsで表すと次のようになります。分かりやすいようにテキスト開始地点から画像開始地点までの距離は120pxとしています。

.main-visual {
grid-template-columns: var(--_padding) [text-start] 120px [image-start] 1fr [image-end text-end];
}

そしてテキストが画像と重なっている部分とそうでない部分で配色を変更する方法ですが、こちらはbackground-clip: textを指定して2色に分割したlinear-gradient()をマスクすることで実装します。

h1 {
z-index: 1;
grid-column: text;
align-self: center;
background-image: linear-gradient(90deg, #000 0% 120px, #fff 120px 100%);
background-clip: text;
color: transparent;
}

テキスト開始地点から画像開始地点までの距離は120pxなので開始(0%)から120pxまでは黒色、120pxから終了(100%)までは白色にします。あとは意図を分かりやすくするのと変更に強くするために120pxをカスタムプロパティに定義することで配色の変更が実現できるという形です。

.main-visual {
--_text-safe-area: 120px;
grid-template-columns: var(--_padding) [text-start] var(--_text-safe-area) [image-start] 1fr [image-end text-end];
}
h1 {
z-index: 1;
grid-column: text;
align-self: center;
background-image: linear-gradient(90deg, #000 0% var(--_text-safe-area), #fff var(--_text-safe-area) 100%);
background-clip: text;
color: transparent;
}

HTMLはコンテナクエリ用のラッパー(コンテナクエリを使わないのなら不要)、ヘッダー要素、見出し要素、画像要素の4つだけで済むのでシンプルに纏まっていていい感じかなと思います。

画像の間延びを防止する

gridを指定した要素の子要素にblock-size:100%を指定したimg要素を配置すると高さが上手く取得できずに間延びするケースがあります。

この現象を修正する場合はblock-size:100%ではなくmin-block-size:100%として、block-sizeの値は0とすることで解決します。結構ハマりポイントだと思うので、覚えておいてください。

画像の間延びを防止する
img {
grid-column: image;
inline-size: 100%;
block-size: 0;
min-block-size: 100%;
object-fit: cover;
}

画像が不規則に背景に配置されたレイアウト

これは自由研究のようなものであくまで参考程度に聞いていただきたいですが、LPなどでよく見かける画像が不規則に背景に配置されたレイアウトについて考えてみます。

画像が不規則に背景に配置されたレイアウトの例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

こういった実装の場合position: absoluteが用いられると思いますが、今回はgridで実装しています。

HTMLの実装例
<div class="main-visual">
...
<div class="image-wrapper">
<img />
<img />
<img />
<img />
<img />
...
</div>
</div>
CSSの実装例
.main-visual {
--_layout-width: 1280; /* デザインカンプの幅が1280pxと仮定 */
--_fluid-ratio: calc(1 / var(--_layout-width) * 100cqi);
container-type: inline-size;
display: block grid;
grid-template-areas: 'stack';
inline-size: min(1600px, 100%);
margin-inline: auto;
& > * {
grid-area: stack;
}
}
.image-wrapper {
z-index: -1;
display: block grid;
grid-template-areas: 'stack';
& img {
grid-area: stack;
inline-size: calc(var(--_w) * var(--_fluid-ratio));
aspect-ratio: var(--_r, 16 / 9);
margin-block-start: calc(var(--_y, 0) * var(--_fluid-ratio));
margin-inline-start: calc(var(--_x, 0) * var(--_fluid-ratio));
border-radius: calc(8 * var(--_fluid-ratio));
object-fit: cover;
}
/* デザインカンプから取得した画像の幅、カンプからの左右の距離をそれぞれカスタムプロパティに格納 */
& > :nth-child(1) {
--_w: 298;
--_x: 640;
--_y: -52;
}
& > :nth-child(2) {
--_w: 360;
--_x: 936;
--_y: 114;
}
& > :nth-child(3) {
--_w: 360;
--_y: 204;
}
& > :nth-child(4) {
--_r: 1;
--_w: 200;
--_x: 358;
--_y: 402;
}
...
}

実装コストは画像の幅とカンプからの左右の距離を測るだけなので非常に少ないです。position:absoluteでは親要素の高さを明示する必要がありますが、gridの場合は各画像のmarginによって自動的に高さが調整されるため親要素の高さを気にしなくて良いというのが利点です。グリッドアイテムに付与されたmarginは相殺を起こさないのでその点を気にしなくて良いのもメリットです。

デザインカンプから取得した画像の幅、カンプからの左右の距離をそれぞれカスタムプロパティに格納することでコードが簡潔に書けるだけでなく、CMSの管理画面から変更するといった際もstyle属性を経由して指定できるのでやりやすくなります。

ただし、こういった実装をしているのは僕以外見たことがないので再度繰り返しますが参考程度に留めておいてください。

また、今回のようなリキッドレイアウトはなるべくコンテナクエリ+cqiを用いて実装するようにしています。vwベースだと同一のコンポーネントでも収まるレイアウトの大きさに応じてスタイル調整が必要になったり、ウルトラワイドモニターなどのビューポートがかなり広い環境では大きすぎて表示されるため最大値の調整が必要でしたが、cqiベースであればその点は簡単にクリアできます。

番外編:回り込みレイアウト

これは本題から逸れますが、次のような回り込みレイアウトについての実装を考えていきます。

回り込みレイアウトの例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

こういった回り込みレイアウトは原則としてflexでもgridでもなくfloatを使います。

.description {
display: block flow-root;
& img {
float: inline-end;
inline-size: 50%;
margin-inline-start: 1rem;
}
}

CSSに精通している方には「なんでそんなあたりまえのことを」と一蹴されそうですが、触れたのには理由があります。というのも「現在flexgridで行われているレイアウトをfloatで実装するのが古い」という風潮から派生して「float自体が古い」と勘違いされている方が散見されるからです。

floatは本来このような回り込みレイアウトを行うためのプロパティであり、回り込みレイアウトを行うなら現在でもfloat以外に方法はありません。タイムラインを見ていると「float自体が古い」と勘違いしたままこういった回り込みレイアウトをflexgridで行おうとしている方も見受けられますが、素直にfloatを使用してください。

ちなみに、現在では回り込みの解除は親要素にdisplay: block flow-rootを指定するだけでOKなので、かつてのclearfixのようなテクニックは使う機会は少ないです。

おわりに

この記事では、いくつかの事例を元にflexgridそれぞれを使用したレイアウトの実装方法について説明しました。

自己流なところもあるので今回紹介した内容が絶対的な正解というわけではありませんが、参考にしていただければ幸いです。

本文上部へ戻る

折りたたみメニュー