実例で学ぶFlexboxとCSS Gridの使い分け
広告
タイムラインを見ていると「flex
とgrid
の使い分けがよく分からないよ」という人が多く散見されるので、今回は僕が普段意識していることを皆さんに紹介します。
これから紹介することはあくまで僕のやり方で、絶対的な正解とかではないので参考程度に留めておいてください。実装において頻出するレイアウトをサンプルに、どのように考えてレイアウトを組んでいけばよいかを自分なりに説明できたらなと思います。
はじめに
僕がレイアウトを組む上で大事にしていること、および意識していること。それは、レイアウトに変化が起こった際に崩れが生じないことはもちろん、将来的な変更に対して柔軟に対応できることです。
極論を言ってしまえばgrid
は使わなくても大抵のレイアウトは組めてしまいます。Internet Explorerに苦しめられていたあの頃を思い出してみてください。現在でもgrid
は難解だからflex
だけ使用するって方も多いと思います。しかし、grid
を活用すればこれまでflex
や他の手法で実現困難だったレイアウトも、保守性と拡張性に優れた方法で実装できるかもしれません。
加えて、以前次のような投稿をしました。
この投稿でも触れられているようにflex
とgrid
どちらかを使用してレイアウトを組むといった際、僕はまずgrid
で実装できないか?を意識して考えるようにしています。grid
のほうが後発的で機能が優れているから…という理由ではなく、グリッドテンプレート領域を使ってコンテナそのもので子要素のサイズを自在に調整できるのが大きな理由です。
チュートリアルとしてXのレイアウトを考えてみましょう。
3列のカラムレイアウトになっており、左右に固定幅、中央は画面幅によって自在に伸縮するものとなっています。検証すると左のカラムは275px
、右のカラムは350px
で固定されているようです。実際のマークアップとは違いますが、分かりやすくするために次のようなHTML構造で考えてみます。
カラムレイアウトを実現する.site-columns
要素にそれぞれ独立したサイドヘッダー、メインコンテンツ、サイドバーがあるとします。この条件に従って左右の固定幅、中央は画面幅によって自在に伸縮する3カラムを実装する際にそれぞれのコンポーネントの大きさはどうやって制御したらよいでしょうか。考えてみてください。
まず、アンチパターンだと考えられるのはそれぞれのコンポーネントに固定の幅を持たせることです。
よく「汎用的に使用されるコンポーネントに固定の大きさを持たせるな」と言われていますが、このようなヘッダーコンポーネントやサイドバーコンポーネントはそのサイト上で単独でしか用いられないため、そのようなケースとは異なります。例えばメインビジュアルにmax-inline-size: 1280px
などの最大幅を設けるケースはよくありますが、実質的に要素に固定の大きさを持たせているのと同じであるものの僕は気にしていません。
では、カラムレイアウトの場合に何が問題なのかというと、その固定の大きさはカラムレイアウトを実現する別のコンポーネントに依存しているのが理由です。つまり、カラムレイアウトを実現するために必要なスタイルが別の単独のコンポーネントに分散しているため、カラムレイアウトの全体像を理解する、もしくは改修する際に他のコンポーネントも参照する必要が出てきてしまうんですね。こういった理由からカラムの大きさはカラムレイアウトを実現する要素そのもので定義することを推奨しています。
それでは、flex
とgrid
、それぞれを使用してカラムの大きさを制御する方法を考えていきましょう。
flexの場合
まずはflex
。僕の推理だと多くの人がflex
を使用しているのではないかなと思います。ですが実際にflex
でカラムの大きさを制御しようとすると詰まりポイントが出てきます。それはflex-basis
やflex-grow
などのプロパティはフレックスコンテナではなく子要素に指定するものだということです。
それではどうやってフレックスコンテナ側で大きさを制御するか?僕なら次のように実装します。
カラムの大きさを制御するためには直下セレクタを使用します。:nth-child()
擬似クラスでそれぞれのカラムに大きさの指定を行います。コードに拒否感を感じる方もいるかと思いますが、僕はこの方法を推奨しています。BEMにおけるElementのようなclassで管理するのもオススメです。
gridの場合
続いてはgrid
での実装を考えてみましょう。グリッドコンテナで子要素の大きさを制御する場合はグリッドテンプレート領域(grid-template
)を使用します。
直下セレクタなどを使わずともたった1行のCSSでカラムの大きさを制御することができてしまいました。このようにgrid
であればカラムの大きさの制御を簡潔に指定することができるようになります。
grid-template-areas
を明示するのも良いでしょう。エリア名を明示することで対応するカラムの役割が明確になってコードの見通しも良くなります。子要素にgria-area
を指定しない場合、grid-template-columns
で指定したルールに従って自動配置されるため順序の変動が起こらないケースではgria-area
の指定は不要になります。
grid
を使用するメリットは他にもあります。先程のflex
の例をもう一度見てください。
flex-basis
が指定されているカラムにはflex-shrink: 0
が、流動的なカラムにはflex-grow: 1
が指定されています。これはどちらもflex
でカラムレイアウトを実現するには必要な指定です。もしもflex-shrink: 0
の指定を忘れた場合は流動的なカラムが広がった場合に幅の不足に反応して固定値を下回って縮んでしまいますし、流動的なカラムにflex-grow: 1
が指定されていないとスペースに応じて広がってくれずに潰れて表示されてしまいます。タイムリーな話だと最近復活した某動画サイトの再生画面にてタイトルの文字数が多い場合に隣のカラムの投稿者情報が潰れて表示されるといった不具合が報告されていましたが、これはflex-shrink: 0
の指定不足によるものです。
このようにflex
で実装を行うとカラムの大きさの制御以外にもflex-shrink
やflex-grow
の値に気を配る必要がありますが、grid
であればその心配は不要です。よく「1次元の並びはflex
、2次元の並びはgrid
が向いている」と言われますが、今回のケースを考慮しても1次元の並びでもgrid
が向いているケースは多く存在するように感じます。
ただし、全てにおいてflex
よりgrid
の方が優れているというわけではありません。と言うのも、先程の投稿の後から「flex
を使うのはもう古い。時代はgrid
だ」や「flex
を使うのは恥ずかしい」といったポストをされている方が散見されました。また、初学者の間でもflex
を使用しない縛りをするといった動きが見受けられています。たしかにflex
を使わずにgrid
のみでレイアウトを組むという試みは自己学習としてはgrid
の知識を深められる機会になるのでスキルアップには良い影響を与えるとは思います。しかし勘違いして欲しくないのは決してgrid
はflex
の上位互換ではないということです。grid
よりもflex
でやったほうが筋が通っているケースもありますし、flex
とgrid
はどちらにも強みと弱みがあります。故にflex
を時代遅れだと言うのは誤りです。
重要なのはflex
とgrid
、両方の特性を知ることで各レイアウトに適した手法を選択できるよう、自身なりのセオリーを確立することです。この記事では実例を交えながら僕なりのアプローチを紹介していきますので、迷っている方は参考にしてみてください。
flex
やgrid
はinline-size
やmargin-block
のように論理的な指定が前提となっています。そのため、「横並び」や「縦幅」、「左寄せ」と呼ばずに「インラインの方向に対して横並び」「ブロックの大きさ」「インライン方向の先頭側」と呼ぶのが適切なのですが、分かりやすさを優先して横or縦or左or右という表現をこの記事ではします。そこは注意してください。
要素を格子状に並べる場合
まずは比較的に簡単なところから。いわゆるタイルレイアウトと呼ばれるもので、記事一覧などでカードを並べるときに頻出するレイアウトです。このブログの記事一覧でも用いられているものですね。
おそらく皆さんも同じように実装すると思うんですが、僕ならこのようなレイアウトはgrid
を使って実装を行います。
flex
でgap
を設けつつ要素を格子状に並べる場合は「コンテナの横幅からギャップの合計値を引いたものをn等分する」という計算式が必要となりますが、grid
の場合は特別な計算は不要でかつgrid-template-columns
の指定だけで実現できます。
コンテナの横幅に合わせて柔軟にカラム数を変動させる
先程の例ではコンテナクエリを使用してブレイクポイント毎にカラム数を出し分けていましたが、flex
とgrid
どちらもアイテムの最小幅を決めながらコンテナの横幅に合わせて柔軟にカラムを切り替えることも可能です。
flex
のケースでは基準値は360px
としつつ、flex-grow: 1
でカラム落ちした際に生じた余ったスペースを等しく分配しています。
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
のような挙動が求められる場合も存在します。状況に応じて手段を変えるようにしましょう。
calc()を伴う際は意図を分かりやすくするためにカスタムプロパティを使用する
話は変わりますが、先程のカラム数が固定のflex
の例を見てください。
「コンテナの横幅からギャップの合計値を引いたものをn等分する」という計算式を用いてブレイクポイント毎にカラム数を出し分けていますが、このようなcalc()
が伴う場合は僕はカスタムプロパティを用いて記述するようにしています。
多くの方はこのような指定をする際に次の例のように直接解答を指定しがちのように思えます。
たしかにこちらの方が記述量は少なくなり、カスタムプロパティを指定する手間は無くなりますが、計算の意図が分かりにくくなったり、後からgap
やカラム数を変更するといった際にその都度calc()
内の値を書き換える必要があります。人間なので当然変更のし忘れなども起こり得るでしょう。
一方、カスタムプロパティを用いた例ではブレイクポイント毎に各カスタムプロパティの値を変更すればいいだけなのでそのような手間やミスは防ぐことができます。加えて--_column: 3
とすれば「あっ、このブレイクポイントを跨いだら3カラムになるんだな」と理解しやすくなります。
これはあくまで僕の感想でしかありませんが、「記述量が少ない=良いCSS」とは限りません。リリースしたら終わりの案件なら直接解答を指定してもいいかもしれませんが、大抵の場合改修や変更は起こるでしょう。後先を考えるのなら敢えて冗長に書くケースもあります。
コンテナクエリの利用
サンプルではブレイクポイントの切り替えにメディアクエリではなくコンテナクエリ(container size queries)を使用しています。
コンテナクエリは@container
ルールおよびcqi
のようなコンテナクエリ単位をコンテナそれ自体に指定することはできない(cqi
はコンテナそれ自体に指定すると祖先要素のコンテナの幅を参照、祖先要素にコンテナが無い場合はsvi
と同じ扱いになります)ため、コンテナクエリを使うためだけに親要素を追加するコストが掛かるのが懸念点ですが、それを差し置いてもコンポーネント単位でレスポンシブが完結するメリットを得られるので導入する価値はあります。
また、実装例ではgap
の中間値にcqi
を使用していますが、これは基準コンテナのインラインサイズの1%
を示す単位です。多くのケースではvw
が用いられていますが、cqi
を使うことでコンテナ幅を基準とできるので直感性は増すという印象です。cqi
には物理的指定のcqw
もありますが、コンテナクエリはcontainer-type: inline-size
という指定からもわかるようにflex
やgrid
と同様論理的指定を前提としているため、原則的にはcqi
を優先するようにしています。
もちろんコンテナクエリはメディアクエリの上位互換ではないので、使い分けは必要です。そのうち布教のためにコンテナクエリの記事を書こうかなと考えているので、気になる方はRSSフィードを購読しておいてください。
横幅が不定な要素を並べる場合
グローバルメニューのリンクの横並びやタグクラウドなど、横幅が不定な要素を並べる場合はどうでしょうか?こちらも頻出なレイアウトで、Clusterレイアウトとも呼ばれています。
おそらく皆さんも同じように実装すると思うんですが、このようなレイアウトはflex
を使うのがベストでしょう。横並びにしたい要素の大きさを気にしない、もしくは子要素に合わせたいのなら原則的にflex
を使用します。
grid
でもgrid-auto-flow: column
を指定すればflex
と同様に大きさが不定な要素をコンテンツに合わせて横並びできますが、タグクラウドの例のようにコンテンツ幅に合わせて改行する場合はgrid
では厳しいです。
ただし、記事カードなどで見かける「タグは1行で並べて親要素からはみ出る場合は隠すorスクロールさせる」といった実装のような、flex
で行うとflex-shrink: 0
が絡むような横並びの場合はgrid-auto-columns: max-content
で実装するほうがコンテナ自体で指定が完結するのでベターだと感じています。
要素を格子状に並べつつ真ん中寄せにする場合
続いては記事一覧などのコンテンツを格子状に並べつつ真ん中寄せにする場合を考えてみましょう。次のようなレイアウトです。要素は格子状に並べつつ、1行および改行が発生した際は中央寄せを行っています。
このようなレイアウトはflex
を使用したほうがいいでしょう。2次元のレイアウトで要素を一定の大きさで並べているためgrid
を使ったほうがいいのではと考える方もいると思いますが、レスポンシブが絡むとなるとJSを使用したほうがいいレベルの力技が必要となります。
「1次元の並びはflex
、2次元の並びはgrid
が向いている」という風潮ですが、今回の例のように2次元の並びでもflex
の方が向いているケースも存在します。
1行の場合は中央寄せにして、複数行のときは左寄せにする場合
先程は1行および複数行ともに常に中央寄せする実装を考えてみましたが、続いては1行の場合は中央寄せにして、複数行のときは左寄せにするレイアウトを考えてみましょう。
こちらのケースですが、grid
、flex
どちらでも実装可能ですが記述量的にはgrid
のほうが適しているかなという印象です。どちらの実装も「コンテナの横幅からギャップの合計値を引いたものをn等分する」という計算式が必要になります。
flexの場合
flex
で実装する場合は次のようなスタイルになります。
flex
でタイルレイアウトを組みつつ、justify-content: center
ではなく最初と最後の要素にauto
値のマージンを付与することで1行の場合にのみ中央寄せになります。
gridの場合
justify-content: center
で要素を中央寄せにしつつ、grid-template-columns
の反復回数にauto-fit
を指定します。
反復回数にauto-fit
を使用することで要素がコンテナ幅より少ない場合に枠を埋めずに広げることができ、トラックに固定値を指定することで固定値のまま中央寄せすることができます。
一部サイズが違うタイルレイアウトの場合
続いては一部のタイルのサイズが違うレイアウトを考えていきましょう。表示例は次のとおりです。
グリッド幅は均等にしつつ、5n+1番目のタイルは縦横グリッド2つ分の大きさになっています。このケースではgrid
を使用しますが、僕は次のように実装します。
5n+1番目の子要素にグリッド領域の列側の先頭の端が末尾の端から2行になるように、グリッドアイテムの配置にグリッドスパンを設定しています。他の方の実装を見るとgrid-template-rows
にauto
値を反復して指定されている方もいらっしゃいますが、実装例のように行は暗黙的でも問題ありません。
grid-area
は配置される行と列を指定するプロパティです。grid-area: span 2 / span 2
はgrid-row
とgrid-column
にspan 2
を指定しているのと同等です。
もしくは次のような実装も良いでしょう。この場合はgrid-template-columns
の指定に従ってグリッドアイテムは自動配置されるのと、span <integer>
の規定値は1なので、grid-column
の指定が不要になります。
もしもサイズが違うタイルに固定幅を持たせたいなら以下のような指定が良いでしょう。次のコードではサイズが違うタイルに固定幅を持たせつつ、残りのタイルは横幅を等しく分け合います。
レンガ状に横並びする場合
続いてはデジタル庁のサイトなどで取り入れられている、レンガ状に横並びするレイアウトの実装方法についてです。今回の例では1行目は1カラム、2行目は2カラム、3行目以降は3カラムといった形になっています。
このレイアウトはflex
とgrid
、どちらも同じくらいの記述量で実装できますが、個人的にはflex
を使いたいなと考えています。
flexの場合
gridの場合
見ていただいて分かる通り、flex
もgrid
も記述量的には大差ありません。しかし、flex
は先述した「コンテナの横幅からギャップの合計値を引いたものをn等分する」という公式をそのまま流用すれば良いだけなのに対して、grid
の場合はそれぞれの行のカラムの公倍数を求める必要があります。
もしも子要素で後述するsubgrid
を使いたいのならgrid
を選択しますが、どちらの方法も可読性を上げるのならcalc()
とカスタムプロパティの利用は必要となるのでflex
のほうが計算的には楽かもしれないという印象です。
カードやリストのコンテンツの大きさを揃える場合
続いてはカードやリストのコンテンツの大きさを揃える場合について考えていきましょう。ニュースリストの実装に関してはすみませんなんですけど、前回の記事でも紹介した方法の再放送になります。
かつてはJSやtable
を使わないと実現できなかったレイアウトも現在ではsubgrid
機能を活用できるgrid
を使用すれば簡単に各コンテンツの大きさを揃えることができます。カードレイアウトに関しては一つの行であればflex
でもflex-direction: column
と揃えたい行にflex-grow: 1
を指定すれば揃えることはできますが、複数行の場合はJSが必要になります。
カードの実装例では入れ子元が暗黙的なグリッドセルを生成している(grid-template-rows
が明示されていない)ため、コンテンツの量に対応してspan <integer>
と指定する必要がありますが、ニュースリストのように入れ子元のグリッドセルが明示的な場合は1 / -1
で問題ありません。
subgrid
は直接の子要素でないと適用できないので注意です。具体的には「grid
で並べる要素>subgrid
を指定する要素>各コンテンツ」の構造でなければ適用できません。
以下の構造では「カードを並べる要素>section
要素>a
要素>各コンテンツ」となっており、そのままでは余分な要素が介入しているためsubgrid
が適用できなくなっています。
この例であればsection
要素にdisplay:contents
を指定し、a
要素にsubgrid
を指定することで解決できます。
個人的にはカード全体をa
要素でラップする実装は読み上げの懸念点などから行うことはありませんが、知識としてつけておくと良いでしょう。後述する理由からaria-labelledby
が付与されたsection
要素にdisplay: contents
を付与する際はrole="region"
を付与しておきます。
レスポンシブな2カラム
続いてはよくある2カラムレイアウトの実装方法です。このブログで使われているリンクカードの実装をサンプルにしてみます。
このリンクカードは以下のような条件で実装を行っています。
- コンポーネントの大きさが狭い時は2カラム、狭い時は1カラムとする
- 1カラムの時はサムネイルを先頭にし、2カラムの時は記事情報を先頭にする
- 2カラムの時はサムネイルは固定幅にして、記事情報はリンクカードの幅に応じて伸び縮みする
- ブレイクポイントはよしなに
このようなケースですが、grid
+コンテナクエリでも問題ありませんがリンクカードの実装にはflex
を選択しました。
「冒頭でカラムレイアウトはflex
よりgrid
の方が向いていると言っていたじゃないですか。嘘つくのやめてもらっていいですか」って思う人もいますよね。そう思った人、全員Xをフォローしてください。今回のケースのように2カラムで、かつよしなにのタイミングでカラム落ちするような場合はメディアクエリやコンテナクエリが絡まないflex
のほうが簡単です。次の記事で紹介されている手法を用いて実装しています。
この手法については参考記事を参照していただいたほうが分かりやすいと思いますが、固定幅のカラムには固定幅を指定したflex-basis
とflex-grow: 1
を、また固定幅ではないカラムにflex-grow: 9999
という非常に大きな値を指定することで2カラムの時はカラムの幅を流動的にしています。こうすることでカラム落ちが行われると自動的に固定幅のカラムが広がるようになります。
固定幅ではないカラムに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
を使わずとも順序を入れ替えすることができるようになります。
2カラムの際はカラムの大きさが均等になる場合
リンクカードの事例ではflex
を使用しましたが、2カラムの際にカラムの大きさが均等になる場合はgrid
のrepeat()
関数とauto-fit
値を使用したほうがより簡潔に指定することができます。
gridでのrow-reverse
grid
にはflex-direction: row-reverse
やflex-wrap: wrap-reverse
にあたる機能は存在しないため、「1カラムの時はサムネイルを先頭にし、2カラムの時は記事情報を先頭にする」といった条件を実現するには原則的にメディアクエリやコンテナクエリが必要となります。…が、方法はあります。
flex
やgrid
は論理的な指定を前提としているため、direction: rtl
を適用することで列の方向が右から左に進むようになり、擬似的にrow-reverse
のように動作させることができます。注意点としてはdirection
は書式の方向を制御するプロパティであり、内包されるテキストは右から左に記述されます。direction
は子要素に継承されるため、コンテナにのみdirection: rtl
を適用させるために子要素はdirection
を初期値に戻す必要があります。
3カラム以上で大きさが均等→ブレイクポイントを跨ぐと1カラムになる場合
3カラム以上のレイアウトで一定のブレイクポイント以上では大きさが均等、ブレイクポイント未満では1カラムの場合はflex
の方が実装が簡単になります。
リンクカードの例ではflex-grow
に9999
という極端な値を指定しましたが、こちらのケースではflex-basis
に極端な値を乗算しています。
flex-basis
は負の数値を指定すると無効になるため、表示領域の大きさがブレイクポイントより大きい場合はcalc(var(--_breakpoint) - 100%)
は無効の値となり無視されてflex-grow: 1
の指定のみ生き残ります。
ブレイクポイントより小さい場合はflex-basis
の値が有効になりますが、calc(var(--_breakpoint) - 100%)
のみだとブレイクポイント付近で中途半端な改行が行われるため9999
という極端な値を乗算することで中途半端な改行を防止しつつ画面いっぱいに広げることができる…というのがこのテクニックです。
reading-flowプロパティ
話は変わるんですが、order
やflex-direction: row-reverse
、flex-wrap: wrap-reverse
を指定した要素はフォーカスや読み上げの順序があべこべになるためアクセシビリティに悪いという指摘があります。正直言うとorder
やreverse
にケチをつけるよりもブラウザや支援技術側で対応してくれればいいだけの気がするんですが、新しく登場したreading-flow
プロパティの登場によりようやく見た目上の順序に従ってそれらの順序を制御することが可能になります。
reading-flow
プロパティは本記事投稿時点ではモダンブラウザでサポートがされていませんが、Chrome Dev および Canary バージョン 128 以降で試すことができます。近い将来にChromeなどでサポートされることが予測されるので先行的に取り入れています。
ただし、注意点としては先述したdirection: rtl
での疑似リバースはreading-flow
で制御できません。加えて支援技術では見出し→画像と読み上げさせたいがキーボードフォーカスは画像→見出しとしたいといった制御も現状では不可能です。
画像の高さを任意のアスペクト比に保ちつつカラムの高さに合わせるようにする場合
サンプルではサムネイル部分は1カラムの時は任意のアスペクト比、2カラムは任意のアスペクト比を下回らないようにしつつリンクカードの高さに応じて広がるようにしていますが、次のような実装を行っています。
任意のアスペクト比の大きさの疑似要素を座り込みさせることで任意のアスペクト比を下回らないように領域を確保しつつ、画像はカラムの高さに多じて広がるようにします。
こうすることでメディアクエリやコンテナクエリを使わずとも高さを確保することができるようになりますので、覚えておくと良いでしょう。
レスポンシブで構造が変動するレイアウトの場合
続いてはレスポンシブで構造が変動するレイアウトの実装を考えていきましょう。サンプルではコンポーネントの幅が狭い時はタイトル→サブタイトル→画像→文章、コンポーネントの幅が広い時は左側に画像→隣のカラムにタイトル→サブタイトル→文章となっていて構造が異なっています。
タイムラインを見ているとflex
でdisplay:contents
やorder
などでこねくり回して実装されている方が多い印象ですが、コンテナ側で配置を指定できるgrid
のほうがdisplay:contents
用のdiv
の追加なども不要ということもあり好みです。
gridの場合
子要素にgrid-column: 2
のように<integer>
を指定するのも良いですが、僕は対応するグリッドが視覚的に分かりやすく直感的になるという理由からgrid-template-areas
エリア名を明示するようにしています。
懸念点としては各要素にgrid-area
を指定する必要がある点です。このようなケースでは子要素インデックス擬似クラスを使用するとコードの理解が難しくなるため、子要素に別の独立したコンポーネントが含まれる場合にはdiv
などでラップしてBEMでいうElementにあたるclass
属性を指定するのが望ましいでしょう。
もしくは賛否両論あるかとは思いますが、grid-area
はHTMLに依存しているためclass
属性同様にHTML側にstyle
属性でエリア名を持たせるのもアリかと思われます(このブログではこの方法でエリア名を指定しています)。
また、テキストの合計の高さが画像の高さを下回った際に天地中央寄せをしたい場合は次の例のように1fr
を指定した空のグリッドを作成して上下に余白を作成します。空のグリッドにはエリア名にピリオド(.
)を指定しておきます。
ただし、この実装のままだと今度はテキストの合計の高さが画像の高さを上回った際に上下にgap
分の余白が生じてしまいます。コードの煩雑化とトレードオフになりますが、それが気に入らない場合はさらにrow-gap
分の空のグリッドを作成し、元の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
を使う方が良いのではないかという印象です。
display:contentsのアクセシビリティの問題に関して
HTMLの実装例では見出しと副題のマークアップにhgroup
を使用していますが、見出しと副題はアイテムとして扱いたいためhgroup
にdisplay:contents
を指定しています。ただし、display:contents
を指定した要素はその要素が本来持っているrole
が消失するリスクも孕んでいるため、hgroup
要素には暗黙的に持っているrole="group"
を明示しています。
かつてはdisplay:contents
が指定された要素だけでなく子孫要素全てが読み上げから除外されるという深刻なバグが発生していましたが、現在は修正されています。ただし、display:contents
が指定された要素そのものの読み上げが除外される件に関しては現在でも一部ブラウザで残っているとのことなので、例えばul
要素であればrole="list"
といった暗黙的なrole
を明示するか、もしくは暗黙的なrole
が無い要素やgeneric
な要素(div
やspan
)に限定して使用したほうが良いでしょう。
ただし、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で実装できます。
中央のコンテンツは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
を指定した際の表示例なのですが、リンクの数が著しく増えた際にロゴテキストは潰れて、テーマ切り替えボタンとメニューのリンクが被さっています。(画像ではテキスト自体は被さっていないものの、リンクの左右にはpadding
を設けているのでクリックエリアが重なってしまっています)。
一方、minmax(max-content, 1fr) auto minmax(max-content, 1fr)
と指定しておくとどうでしょう。次の表示例を御覧ください。
左右のグリッドの最小幅はmax-content
となるため、1fr auto 1fr
の指定で起こった表示崩れを防ぐことができますね。
WordPressなどのCMSではリンクの数を制御しきれない場合もあるため、このように先手を打っておくといいと思います。
スクロールする横並び
続いてはスライダーなどで横並びしつつスクロールさせるケースです。多くの方がスライダーはSplide
などのJSプラグインを使用されているかと思いますが、狭い画面幅では記事一覧を横スクロールさせるといったJSを使わないパターンもあるでしょう。
こういった場合flex
を使いつつ子要素にflex-shrink: 0
を指定するのがメジャーかなとは思いますが、個人的にはgrid
でgrid-auto-flow: column
を指定したほうがflex-shrink
の値を気にする必要が無くなり、コンテナ側で大きさを指定できるという理由から僕はこちらで実装を行っています。
特定の位置にピタッと移動(スクロール)させるscroll-snap-typeプロパティ
scroll-snap-type
はユーザーがスクロールした際にアイテムを特定の位置にスナップさせるプロパティです。
今回の例ではグリッドコンテナにscroll-snap-type: inline mandatory
、アイテムにscroll-snap-align: start
を指定しています。これによりコンテナの左端にスライドをスナップさせています。
スマホなどでスワイプするとついアイテムがスライドしすぎるケースがありますが、アイテムにscroll-snap-stop: always
を同時に指定することで一つずつピッタリ止まるようになるため同時に指定しています。
画面幅が狭い場合にカードなどをスクロールさせて表示させるといった実装をする際にこのようなスナップさせる動作のためだけにJSプラグインを導入しているケースも散見されますが、現在ではCSSのみで低コストで実現できます。
スクロール位置にまつわるテクニック
今回の例では次のような条件でスライダーを実装しています。
- アイテムはコンテナ幅を超えてはみ出して表示させる
- 最初のアイテムはコンテナの左端に表示する
- 最後のアイテムはコンテナの右端で止まるようにする
多くのJSプラグインにはこういったレイアウトをサポートする機能が備わっている印象ですが、多くのケースではJSで実装されているのと、自前でこのようなレイアウトを実現する手段について触れている文献が少ないためかタイムラインでは実装手段について詰まっている方が多く見受けられます。
このようなレイアウトに関して、僕は次のように実装しています。
画面幅(100dvi
)からコンテナ幅(100cqi
)を差し引いたものを左右のガター(--_gutter: calc((100dvi - 100cqi) / 2)
)として、margin-inline: calc(var(--_gutter) * -1)
で画面いっぱいに広げます。このテクニックはmargin-inline: calc(50% - 50vw)
でコンテナ幅を飛び出して画面いっぱいに表示するテクニックとして有名ですね。
これだけだとアイテムが画面端に移動してしまうため、padding-inline: var(--_gutter)
で差し引いた分のガター分padding
を設けて基準の位置をコンテナ幅に合わせます。原則的にこれだけで上記の条件は満たすことができます。
しかし、今回はアイテムにscroll-snap-align: start
を指定している関係でpadding
は無視されてアイテムが画面端に移動したままになってしまいます。これを防止するためにscroll-padding-inline
にpadding
で指定した値と同じ値を指定することでサンプルのようにscroll-snap-align: start
を指定しつつ基準位置をコンテナ端に合わせることが可能になります。
ちなみにcalc((100dvi - 100%) / 2)
でない理由はmargin-inline
とpadding-inline
は機能するものの、scroll-padding-inline
の指定では機能しなくなるためです。
また、margin-inline: calc(50% - 50vw)
同様に横スクロールバーが発生するため祖先要素側でoverflow-x: clip
の指定は必要になります。overflow-x: hidden
ではスクロールコンテナを生成する都合で子孫要素のposition: sticky
が動作しなくなるリスクがあるためoverflow-x: clip
とすると良いでしょう。
要素の上に要素を重ねるレイアウトの場合
続いては要素の上に要素を重ねるレイアウトの実装例を考えていきます。
要素の上に要素を重ねる場合、真っ先に思いつくのはflex
でもgrid
でもなくposition:absolute
だと思いますが、grid
で要素を重ねたほうがベターなケースも存在します。次の実装サンプルを見てください。
このセクションのサンプルは以下のような条件で実装を行っています。
- 最低限ブロック軸方向の大きいビューポートの長さの大きさ(
100lvb
)を確保する - ビューポートよりもテキスト量が少ない場合はテキストを天地中央寄せにして表示する
- ビューポートよりもテキスト量が多い場合は背景画像をビューポートに追従して表示する
lvb
はブロック軸方向の大きいビューポートの長さの大きさを基準にした単位です。横書きの場合は100lvh
と同等になります。dvb
でない理由はアドレスバーの有無で拡大縮小されるので見栄えが悪くなるためです。
こういったケースの場合、position:absolute
で行うとテキストか背景画像、どちらか片方の高さしか参照することができません。例えばテキストのラッパー要素にposition:absolute
を指定するとテキスト量が画像の大きさを上回った際にテキストの高さは浮いているので溢れてしまいます。
一方、grid
で同じ行と列に重ねて実装すれば画像とテキストどちらか大きい方を基準とすることができるので、上記のような条件に基づいた実装も簡単にできます。実装例を見てみましょう。
今回の例ではgrid-template-areas
で"stack"
という名前のエリア名を指定して、直下の要素全てにgrid-area: stack
を指定しています。これはエリア名を指定しなくても直下の要素全てにgrid-area: 1 / 1
を指定すればいいんですが、海外のなんかすごそうな人がこのような実装をしていて、意図が分かりやすくていいなぁと思ったので真似しています。
他にもgrid
で要素を重ねると良い例があります。これは前回の記事でも紹介した方法なんですけど、アコーディオンに「+」「‐」アイコンが表示されているのをよく見かけますが、これもgrid-area
で同じカラムに重ねるほうがスマートに実装できます。
position:absolute
で実装すると位置調整がややこしくなったりアイコン用のspan
要素が必要になる場合もありますが、ただ「+」「‐」を右端に表示させるだけならgrid
で行ったほうが位置調整をgrid-template
で完結できるためオススメしています。
画像とテキストが重なったメインビジュアル
こちらも画像とテキストが重なったメインビジュアルの例ですが、ポイントはテキストは画像と重なっている部分とそうでない部分で配色が違うという点です。
こちらに関してもflex
orgrid
ではなくposition: absolute
で実装されるケースがメジャーな気がしますが、今回はgrid
で実装を行います。
コードがごちゃごちゃしていて分かりにくい印象を持たれるかもしれませんが、それぞれの子要素(画像、テキスト、画像のオーバーレイ要素)にグリッド名を明示して重ねながら配置しています。
ただし、このケースではテキスト部分と画像部分が一部重なっており、grid-template-areas
でエリア名を明示するのは厳しいのでgrid-template-columns
に線名を明示しています。
今回のケースでは左端に余白があり、その後にテキスト開始地点([text-start]
)、そこからしばらくして画像が開始する地点([image-start]
)、そして画像とテキストが終わる地点([image-start text-end]
)となっています。これをgrid-template-columns
で表すと次のようになります。分かりやすいようにテキスト開始地点から画像開始地点までの距離は120px
としています。
そしてテキストが画像と重なっている部分とそうでない部分で配色を変更する方法ですが、こちらはbackground-clip: text
を指定して2色に分割したlinear-gradient()
をマスクすることで実装します。
テキスト開始地点から画像開始地点までの距離は120px
なので開始(0%
)から120px
までは黒色、120px
から終了(100%
)までは白色にします。あとは意図を分かりやすくするのと変更に強くするために120px
をカスタムプロパティに定義することで配色の変更が実現できるという形です。
HTMLはコンテナクエリ用のラッパー(コンテナクエリを使わないのなら不要)、ヘッダー要素、見出し要素、画像要素の4つだけで済むのでシンプルに纏まっていていい感じかなと思います。
画像の間延びを防止する
grid
を指定した要素の子要素にblock-size:100%
を指定したimg
要素を配置すると高さが上手く取得できずに間延びするケースがあります。
この現象を修正する場合はblock-size:100%
ではなくmin-block-size:100%
として、block-size
の値は0
とすることで解決します。結構ハマりポイントだと思うので、覚えておいてください。
画像が不規則に背景に配置されたレイアウト
これは自由研究のようなものであくまで参考程度に聞いていただきたいですが、LPなどでよく見かける画像が不規則に背景に配置されたレイアウトについて考えてみます。
こういった実装の場合position: absolute
が用いられると思いますが、今回はgrid
で実装しています。
実装コストは画像の幅とカンプからの左右の距離を測るだけなので非常に少ないです。position:absolute
では親要素の高さを明示する必要がありますが、grid
の場合は各画像のmargin
によって自動的に高さが調整されるため親要素の高さを気にしなくて良いというのが利点です。グリッドアイテムに付与されたmargin
は相殺を起こさないのでその点を気にしなくて良いのもメリットです。
デザインカンプから取得した画像の幅、カンプからの左右の距離をそれぞれカスタムプロパティに格納することでコードが簡潔に書けるだけでなく、CMSの管理画面から変更するといった際もstyle
属性を経由して指定できるのでやりやすくなります。
ただし、こういった実装をしているのは僕以外見たことがないので再度繰り返しますが参考程度に留めておいてください。
また、今回のようなリキッドレイアウトはなるべくコンテナクエリ+cqi
を用いて実装するようにしています。vw
ベースだと同一のコンポーネントでも収まるレイアウトの大きさに応じてスタイル調整が必要になったり、ウルトラワイドモニターなどのビューポートがかなり広い環境では大きすぎて表示されるため最大値の調整が必要でしたが、cqi
ベースであればその点は簡単にクリアできます。
番外編:回り込みレイアウト
これは本題から逸れますが、次のような回り込みレイアウトについての実装を考えていきます。
こういった回り込みレイアウトは原則としてflex
でもgrid
でもなくfloat
を使います。
CSSに精通している方には「なんでそんなあたりまえのことを」と一蹴されそうですが、触れたのには理由があります。というのも「現在flex
やgrid
で行われているレイアウトをfloat
で実装するのが古い」という風潮から派生して「float
自体が古い」と勘違いされている方が散見されるからです。
float
は本来このような回り込みレイアウトを行うためのプロパティであり、回り込みレイアウトを行うなら現在でもfloat
以外に方法はありません。タイムラインを見ていると「float
自体が古い」と勘違いしたままこういった回り込みレイアウトをflex
やgrid
で行おうとしている方も見受けられますが、素直にfloat
を使用してください。
ちなみに、現在では回り込みの解除は親要素にdisplay: block flow-root
を指定するだけでOKなので、かつてのclearfixのようなテクニックは使う機会は少ないです。
おわりに
この記事では、いくつかの事例を元にflex
とgrid
それぞれを使用したレイアウトの実装方法について説明しました。
自己流なところもあるので今回紹介した内容が絶対的な正解というわけではありませんが、参考にしていただければ幸いです。