空のグリッドセルを使う余白設計を広めたい

広告
昨今では「margin
不要論」を唱える方々が目立つようになっています。margin
不要論者の意見としては、主に以下のような点が挙げられます。
margin
の相殺が厄介であること- 相殺を防ぐために
margin
を上下どちらに付けるかという宗教論争が起こりやすいこと gap
やpadding
で余白を設計すればmargin
を使う機会が減る
これに関しては、以下の記事がよくまとまっていますので、ぜひご覧ください。
私個人としては、margin
は未だに現役であり、有効活用できる場面は多いと考えています。
とはいえ、レイアウト目的でmargin
を利用するケースは、徐々に減ってきているのも事実です。margin
の代替案としてよく挙げられるのは、padding
、gap
、そしてスペーサーコンポーネントなどでしょう。
そんな中、ダイチさんが投稿された以下の記事が非常に興味深かったので、こちらもご覧いただければと思います。
こちらの記事では、margin
を一切使わずにpadding
とgap
だけで余白を組んでWebサイトを構築した事例が紹介されています。
margin
無しで作業しやすかった場面と、逆にmargin
が欲しくなった場面について分かりやすくまとめられています。
しかし、この記事を拝見して感じたことが一つあります。他のmargin
不要論者の意見を見てもそうなのですが、、、
誰も空のグリッドセルで余白を作ろうとしていないのである。
そこで先日、このような趣旨のポストをしたところ、「空のグリッドセルでどうやって余白を組むのか?」という質問をいただきましたので、この記事でその方法を広めたいと思います。
あまり一般的な方法ではなく、ややイレギュラーな手法であることはご承知おきください。
空のグリッドセルを余白とする
参考記事「CSSのmarginを使わずにWebサイト構築してみた」ではねこポンさんが運営する無料コーディング練習所で提供されている素材を使用していたので、私も同じサンプルを参考に解説を進めます。
参考記事では不規則な余白を持つレイアウトに関して、次のように述べられています。
規則的でない余白を持つレイアウトで無理にgapを使おうとすると、ムダにHTMLの階層が深くなったり、ムダに flex や grid が増えたりして、結果としてかえってコードが複雑になることがありました。
確かに、不規則な余白をgap
で設けようとすると無理が生じやすく、その余白からgap
の値を引いた分のpadding
を適用するためだけに追加の<div>
要素を設けたり、結局margin
に頼らざるを得なくなったりすることがあります。結果としてコードが複雑化するのは避けられないでしょう。
そこで私が提唱するのが、空のグリッドセルを用いた余白設計です。実装例の完全版は次のようになります。
まずはマークアップについて考えてみましょう。
<section class="ContactSection"> <div class="ContactSection__Layout"> <div class="ContactSection__Heading"> <BaseHeadingGroup /> </div> <div class="ContactSection__Paragraph"> <BaseParagraph /> </div> <div class="ContactSection__LinkButton"> <BaseLinkButton /> </div> </div></section>
デザインカンプをざっと確認したところ、お問い合わせセクションで用いられている見出しと副題のグループ (hgroup
)、段落 (p
)、リンクボタン (a
) は、状況によらず構造が共通化できそうです。
これらはプリミティブなコンポーネントとして定義しておくのが良いでしょう。私は、HTML要素レベルの細かい粒度を持つAtomsのようなコンポーネントには、Baseという接頭辞をつけて管理しています。
そして、別のコンポーネントを内包する場合は直接配置するのではなく、エレメントの子要素として定義するようにしています。これにより、CSSの記述が整理され、問題が発生した際の対処もしやすくなります。
また、margin
やwidth
、height
といった、コンポーネント自身に持たせるべきではないプロパティ(状況によって値が決定され、親要素で管理されるべきプロパティ)は、このエレメントで操作するようにします。
原則として、子コンポーネントにclass
属性を渡すべきではありません。これを許容すると、大抵ろくなことになりません。
そして肝心のCSS部分ですが、前提としてコンポーネントルートとレイアウト用のエレメントは分離します。
.ContactSection { container: contact-section / inline-size; background-color: var(--color-light-gray);}
.ContactSection__Layout { display: block grid;
/* ... */}
これはコンテナクエリを利用する事情が大きいのですが、ローカル変数や、コンポーネント全体に適用するタイポグラフィや配色のスタイルと、レイアウト用のスタイルは別のDOM要素に分けて責務を分離します。CSS初学者でありがちなのが、何でもかんでも一つの要素にスタイルを詰め込むことです。一つの要素にスタイルを詰め込むと可読性が悪化したり、リファクタリングがしにくくなります。
そして、子要素のエレメントにgrid-area
でエリア名を付けていきます。エリア名は分かりやすいように、エレメント名(子コンポーネント名)と合わせておくのが良いでしょう。少なくともa
とかb
みたいな命名は意図がわからないので避けたほうが無難です。
個々のclass
属性ごとに指定するのは手間がかかるため、grid-area
の値がレスポンシブで変動しないと断言できるのであれば、style
属性に直接記述することも検討できるかもしれません。
.ContactSection__Layout { display: block grid;
/* ... */
& > :where(.ContactSection__Heading) { grid-area: heading; }
& > :where(.ContactSection__Paragraph) { grid-area: paragraph; }
& > :where(.ContactSection__LinkButton) { grid-area: link-button; }}
grid-area
やflex-basis
のように、親要素のスタイルと依存関係があって成立するプロパティに関しては、ネストして構造を明確にしておくと良いでしょう。CSSのネストは可読性が悪化する、詳細度が上がりやすいといった理由で敬遠されがちですが、このような親子関係を明確にするスタイルや、BEMのModifierのような関連性を示す記法としては優れています。Sassの&__
みたいな記法は頭を抱えます。
とはいえ、エレメントの詳細度は一定に保った方がイレギュラーが発生しにくいため、一律で0.1.0
にするために:where()
疑似クラスで調整します。
そして、それぞれをgrid-template
プロパティで定義するのですが、その際に余白分の空のグリッドセルを作成します。
空のグリッドセルのエリア名にはピリオド (.
) を使用するのが慣習的なので、それで定義します。
そして、作成した空のグリッドセルに、それぞれgrid-template-rows
で余白分のサイズを定義します。
.ContactSection__Layout { display: block grid; grid-template-areas: "heading " ". " "paragraph " ". " "link-button"; grid-template-rows: auto 30px auto 20px auto;
& > :where(.ContactSection__Heading) { grid-area: heading; }
& > :where(.ContactSection__Paragraph) { grid-area: paragraph; }
& > :where(.ContactSection__LinkButton) { grid-area: link-button; }}
これだと少々可読性が悪いので、grid-template
プロパティのショートハンドで定義します。この場合、行の高さがauto
の部分は省略可能です。
.ContactSection__Layout { display: block grid; grid-template: "heading " ". " 30px "paragraph " ". " 20px "link-button";
& > :where(.ContactSection__Heading) { grid-area: heading; }
& > :where(.ContactSection__Paragraph) { grid-area: paragraph; }
& > :where(.ContactSection__LinkButton) { grid-area: link-button; }}
さて、上下左右のpadding
と諸々のサイズ調整ですが、各エレメントのコンテナには最大幅(インナー幅)が1140px
で設けられていて、上下のpadding
がレスポンシブで60px
から80px
に変動し、左右は15px
固定となっています。
普通に実装するなら、以下のような形が良いでしょう。max-inline-size: 1140px
とmargin-inline: auto
でセンタリングしつつ、インライン軸に15px
のpadding
を付与します。
.ContactSection__Layout { display: block grid; grid-template: "heading " ". " 30px "paragraph " ". " 20px "link-button"; box-sizing: revert; max-inline-size: 1140px; margin-inline: auto; padding-inline: 15px;
@container contact-section (inline-size >= 0) { padding-block: 60px; }
@container contact-section (inline-size >= 768px) { padding-block: 80px; }
& > :where(.ContactSection__Heading) { grid-area: heading; }
& > :where(.ContactSection__Paragraph) { grid-area: paragraph; }
& > :where(.ContactSection__LinkButton) { grid-area: link-button; }}
こういった実装をする際に、「最大幅が1140px
、左右のpadding
が15px
のコンテナを実装する時、そのままpadding
を指定するとコンテンツの最大幅が 1140px - (15px * 2)
で 1110px
になってしまう。だから横幅にpadding
分を加えてコンテンツの最大幅を1140px
にするようにしよう」と教えているオンラインサロンがありますが、自前でborder-box
しているbox-sizing
プロパティをrevert
やinitial
でロールバックすれば事足ります。
@container contact-section (inline-size >= 0)
のような指定は冗長に見えるかもしれませんが、ブレイクポイントを跨いで変化するプロパティを個別に分けて記述しておきたいという個人的な考えによるものです。原則的には不要な記述です。
さて、参考記事ではautoを使いたい場面として「今回はコンテンツを中央寄せにしたい場合でしたが、素直に margin: 0 auto のように書きたいというケースがありました。」とあります。確かにmargin-inline: auto
で実装するのがシンプルで良いですが、grid-template-columns
で実装することも可能です。次のように書き換えます。
.ContactSection__Layout { --_gutter: 15px; --_content-width: 1140px;
display: block grid; grid-template: ". heading . " ". . . " 30px ". paragraph . " ". . . " 20px ". link-button . " / minmax(var(--_gutter), 1fr) minmax(auto, var(--_content-width)) minmax(var(--_gutter), 1fr);
@container contact-section (inline-size >= 0) { padding-block: 60px; }
@container contact-section (inline-size >= 768px) { padding-block: 80px; }
& > :where(.ContactSection__Heading) { grid-area: heading; }
& > :where(.ContactSection__Paragraph) { grid-area: paragraph; }
& > :where(.ContactSection__LinkButton) { grid-area: link-button; }}
grid-template-areas
に左右の空グリッドセルを追加します。そして、grid-template-columns
にminmax(var(--_gutter), 1fr) minmax(auto, var(--_content-width)) minmax(var(--_gutter), 1fr)
と指定し、左右のグリッドセルのカラム幅を最小幅15px
、最大幅を1fr
とします。コンテンツ部分に関しては最大幅を1140px
とし、それ未満ではauto
となるように設定します。
ガターとなる15px
やコンテンツの最大幅は、ローカル変数に置き換えることでマジックナンバーを回避して共通化を図ります。
一見すると面倒に感じる方法かもしれません。今回のように1つのセクションのみに適用する場合はmargin-inline: auto
の方がスマートかもしれません。しかし、この手法は複数のセクションにまたがるフルブリードレイアウトを実現する際に非常に強力です。
仮にこういったセクションにインナー幅を超えていっぱいに表示したい何かが追加されても margin-inline: calc((100cqi - 100%) / -2)
みたいな手法を取らずとも対応がしやすいです。
レスポンシブで余白を変動させたい、というパターンでは次のように実装します。
.ContactSection__Layout { --_gutter: 15px; --_content-width: 1140px; --_template-columns: minmax(var(--_gutter), 1fr) minmax(auto, var(--_content-width)) minmax(var(--_gutter), 1fr);
display: block grid;
@container contact-section (inline-size >= 0) { grid-template: ". heading . " ". . . " 30px ". paragraph . " ". . . " 20px ". link-button . " / var(--_template-columns); padding-block: 60px; }
@container contact-section (inline-size >= 768px) { grid-template: ". heading . " ". . . " 60px ". paragraph . " ". . . " 40px ". link-button . " / var(--_template-columns); padding-block: 80px; }
& > :where(.ContactSection__Heading) { grid-area: heading; }
& > :where(.ContactSection__Paragraph) { grid-area: paragraph; }
& > :where(.ContactSection__LinkButton) { grid-area: link-button; justify-self: center; inline-size: min(220 * var(--to-rem), 100%); }}
.ContactSection__Paragraph { text-align: center; text-wrap: balance;}
ブレイクポイントごとにgrid-template
を定義し直し、余白のサイズだけを調整します。grid-template-columns
に関しては共通であるため、カスタムプロパティに定義しておくことで記述の重複を避けると良いでしょう。ついでにボタンのサイズ調整と段落のセンタリングも行ってます。
空グリッドセルを利用するメリット
一見すると冗長に思えるこの方法をアホかと一蹴する人もいるかもしれません。そう感じた方は、もうこの記事を閉じてしまっているかもしれません。しかし、このアプローチには明確なメリットが存在します。
レイアウトを一元管理できる
ブレイクポイントごとにgrid-template
を定義するのは冗長に感じるかもしれませんが、メディアクエリやコンテナクエリを各要素に分散させる必要がなくなり、子要素間の余白をgrid-template
プロパティの変更だけで一元的に管理できます。
grid-template-areas
は、ブレイクポイントを跨ぐとレイアウト構造が大きく変わるパターンで用いる方も多いと思いますが、そのメリットも享受できます。
細かいレイアウト制御用の余計な div を増やさなくていい
子要素にgrid-area
を付与できれば、それ以降はレイアウト調整のためだけの<div>
は基本的に不要です。細かいpadding
を付与するためだけに無駄な<div>
をネストさせる必要もありません。
今回は縦並びのみでしたが、左に画像、右に見出しとテキストが縦積み…のようなレイアウトも横並び用の<div>
が不要になります。過去記事でも触れましたが、flex
とdisplay:contents
を使ってやり繰りする実装は辛みを感じます。
grid-template を見れば構造が把握できる
余白の指定がCSSのあちこちに分散していると、「このコンポーネントのレイアウトは実際どうなっているんだっけ?」と、CSSを深く読み解いて推理する羽目になります。
一方、grid-template
で全てを管理すれば、「headingとparagraphの間には30pxの余白があるな」「ブレイクポイントを跨ぐと余白はこう変わるのか」「各エレメントの配置はこうなっているのか」といった情報を一目で把握できます。
空グリッドセルを利用するデメリット
もちろん、デメリットというか、この手法が不向きなケースもあります。
状況に応じてエレメントの配置や数が変動するパターン
つまり、「このページのこの部分では段落は必要なくなるよ」「このページのこの部分ではお問い合わせフォームを追加してリンクボタンは不要になるよ」みたいなパターンです。
このような変化が起こり得る場合は、この手法はあまり向いていないかもしれません。例えば、一部のケースで.ContactSection__Paragraph
が無くなる、といった程度であれば:has()
疑似クラスで対応できる可能性もありますが、分岐条件が複数あるような複雑なケースでは厳しいでしょう。
そういった場合は、隣接セレクタとmargin-top
などを利用して条件ごとにmargin
を出し分けるのが良さそうです。そもそも、コンテキスト毎にレイアウトが変動するのであれば、コンテキスト毎にコンポーネントを作成したほうがいいかもしれません。
また、参考記事の「文章コンテンツの扱い」のようなCMSでコンテンツが変わるケースでもこの手法は不向きです。
CMSのブロック間の余白は皆さんが目の敵にしているmargin
の相殺をあえて活かすことがベストプラクティスだと考えています。
Tailwind CSS
Tailwind CSSでは、任意の値(Arbitrary values)を使ってgrid-template-areas
を使用することも可能ですが、やはり扱いにくさが目立ちます。
そもそもTailwind CSSは、Gridレイアウトそのものがやや扱いにくいと感じることがあります。そのためか、観測する限りでは一方向のレイアウト構築に適したflex
が優先される傾向があり、「何でもflexboxで解決する(Anything is flexbox)」という実装になっている方を多く見かけます。
私もTailwind CSSに触れる機会は多いのですが、一度素のCSSでgrid-template
を使ってレイアウトを組んだ後、AIに頼んでTailwind CSSの任意値ユーティリティに変換してもらうという、本末転倒なことをしているのが現状です。解決策を探したい。
Tailwind CSSでレイアウトを組もうとすると、grid
以外にもwidth
やcqi
への変換などで任意値を多用しがちになります。Tailwind CSSはデザイントークンを管理するLintツールとしては非常に優れているので、UIコンポーネントのスタイリングはTailwind CSSで行い、レイアウトは素のCSSで記述するといった役割分担も一つの有効なアプローチではないかと考えているのですが、どうなんでしょうか。
終わりに
最近読んだGrid First, Flex Thirdという記事で語られているとおりですが、私はレイアウトを組むとき、最初にgrid-template
の使用を検討します。
過去数年間、私はFlexを多用するコードベースで作業してきましたが、ネストされたFlexbox、相対/絶対位置指定、多くの成長・縮小、ハードコーディングされたサイズを組み合わせたコンポーネントを再構築する必要がある場面が何度もありました。これらの問題のほとんどは、それらのスタイルを削除し、レイアウト部分をGridに変更することで解決されました。
私たちは、より優れた、より強力なレイアウトツールを持っています。慣れているからといってdisplay: flexを選ぶことは、最良の選択ではありません。もし私たちが慣れているものだけを選んでいたら、今でも誰も望んでいないテーブル要素でレイアウトを行っているでしょう。
「1次元の並びはflex、2次元の並びはgridが向いている」という風潮がありますが、1次元でもgrid
で良いケースが多いです。flex
との使い分けに関しては過去記事で解説しているのでこちらも参照してください。
みんなもflex
脳から脱却して、grid-template
を使おう。