📏

Reactで余白をどうスタイリングするか

2021/02/18に公開
5

最近余白の実装について見直す機会があったので、考えをまとめてみました。

TL;DR

  • Grid なら grid-gap
  • flexbox なら flex-gap にしたい(が、safari が対応してないので記事執筆時点では使えない)
  • 適切な padding を指定する
  • 複数の同一のマージンには Stack、それ以外には Spacer コンポーネント

前提: 子コンポーネントは親コンポーネントの"レイアウトのスタイル"を知ってはならない

まず前提として「子コンポーネントは親コンポーネントの"レイアウトのスタイル"を知ってはならない」です。

(太古に書いた記事から具体例を引用)
https://qiita.com/seya/items/8814e905693f00cdade2#スタイルクローズドの原則

例えば、こんな感じのアイコンが複数並べたコンポーネントが存在するとします。

アイコンの間にはmarginが等間隔でありますね。
このmarginをアイコンコンポーネント内で定義していたとしましょう。

.icon {
  ...
  margin-right: 15px;  
}

さて、他のページでこのアイコンを使いたいとなったとします。そして今回はアイコンの右に対してmarginが100px必要なレイアウトだとします。この時どこにどうmarginのスタイルを記述して実現すると良いでしょう?

解決方法は色々ありますが、どんな選択肢であろうとこのアイコンコンポーネント内のmarginを気にしなくてはならないと思います。そしてそんな解決策が積み重なっていくとどんどん汚いCSSが増えていくことは容易に想像できると思います。

このため子のコンポーネントは親にどんなレイアウトで使われるのかについてのスタイルは記述するべきではありません。

適切な padding なら持たせていい

念の為補足ですが、同じ余白でも適切な padding であれば複数の文脈で使われうるコンポーネントでも持たせてもいいと考えています。当たり判定のためにボタンのコンポーネントを見た目以上に大きくするなどの工夫を凝らすこともあるでしょう。

余白は誰の持ち物なのか、というのを意識して正しい判断ができていれば大丈夫です。願わくばデザインの段階で padding とか意識して設定してくれていたら最高ですね。

margin は誰の持ち物?

「余白は誰の持ち物なのか、というのを意識して正しい判断ができていれば大丈夫」と述べましたが、では padding ではなく、要素間の間の大きさを指定する margin は誰の持ち物なのでしょうか?

私は「親」もしくは「誰の持ち物でもない」のどちらかだと考えています。

親の場合

分かりやすいのが Grid layout です。CSS の Grid では親である Container がレイアウトに関するスタイルを記述します。余白は grid-gap で指定したりします。

.grid {
	display: grid;
	grid-gap: 1rem;
}

誰の持ち物でもない場合

レイアウトに関して親が構造を規定しない場合、 margin は誰の持ち物でもなく、独立した存在であると考えることができます。(この場合も親コンポーネント内で並べるので厳密には親の責務なんですけど)

これはデザイナーが余白を作る時の考え方とも一致しているはずです。ある要素とある要素の間に余白を作る時は「余白はこっちに紐づいているな」などとは考えず「これらは同じ概念のものだから余白は小さめ、これらは全然違うからもっと離そう」など、それらの要素の "関係性" に基づいて決めているはずです。

なので、 margin はどちらにも属さず、 margin を持つという一点のみに責務を持った存在であると考えられます。これを実現するための Spacer については後述します。

ちなみに「margin-top と margin-bottom どちらを使うべきか」のような議論が度々起こりますが、この考え方で解消できると思っており、私の答えは「margin は独立しているのでそれ単体で扱うべできであり、どちらでもない」です。

実装方法を考える

では前提を確認したところで具体的な実装方法を考えていきます。

親 - Grid の場合

先ほども例として出しましたが、 Grid Layout の場合は grid-gap を用いて整えます。(grid- をつけないと Safari で動きません)
https://developer.mozilla.org/ja/docs/Web/CSS/gap

縦方向と横方向にそれぞれ指定することができます。

同じ行内や列内に違う幅を設定したくなった場合はうまいことネストしてなんとかさせましょう。

親 - flexbox の場合

gap を使いたいのですが、この記事執筆時点では Safari が対応していないので採用は難しいでしょう。早く実装してくれ!!
iOS14.5, Safari 14.1 を持って safari でも gap が使えるようになったみたいです!人類が 14.5 にアップデートしてくれないといけなさそうなので今はまだ勇足になるかもしれないですが、もう時間の問題ですね。

https://developer.mozilla.org/ja/docs/Web/CSS/gap
https://caniuse.com/flexbox-gap

親 - 子要素間のマージンが同一の場合

ul ol などのリスト要素を使う場合があてはまります。

これに関してはよくある Stack コンポーネントを使います。
例として Chakra UI の Stack を見てみます。
https://chakra-ui.com/docs/layout/stack

このように Stack と言うコンポーネントの spacing を設定するとその値が要素間の margin として適用されます。

自作する場合は色々方法あるとは思いますが、 Chakra UI では children を map してそれぞれ StackItem と言うコンポーネントでラップして余白のスタイルがあたるようにしているみたいです。

この Stack に関して、たまたま同じ margin の値が複数ある時とかもこの方法使ってもいいかもしれません。(ただ、概念的にはおかしいので、将来的に新しい要素が追加されて違う margin を設定したくなると分解せざるをえなくなったりはすると思います。なので、その場合は Spacer 使った方が丸いかなと)

誰の持ち物でもない場合

最後に誰の持ち物でもない場合ですが、ここでは Spacer コンポーネントを作る方向で考えてみます。

自作する場合は次のように 大きさ(size)方向(axis) が指定できる形で作るといいと思います。
(https://www.joshwcomeau.com/react/modern-spacer-gif/ より引用)

import React from 'react';

const Spacer = ({
  size,
  axis,
  style = {},
  ...delegated,
}) => {
  const width = axis === 'vertical' ? 1 : size;
  const height = axis === 'horizontal' ? 1 : size;
  return (
    <span
      style={{
        display: 'block',
        width,
        minWidth: width,
        height,
        minHeight: height,
        ...style,
      }}
      {...delegated}
    />
  );
};
export default Spacer;

必要な props は size だけになるのでシンプルに書けます。

// 16px × 16px になる
<Spacer size={16} />

もし、指定したい方向以外の Spacer の幅が邪魔になる場合は axis の方向を指定してあげて、必要最小限の形にします。

// 32px × 1px になる
<Spacer axis="horizontal" size={32} />

おわりに

Spacer は初見では気持ち悪く感じるとは思いますが、理にかなった存在であると個人的には思います。Stack と Spacer を駆使していい感じの余白スタイリングをしていきましょう💪

参考記事

https://www.joshwcomeau.com/react/modern-spacer-gif/
https://mxstbr.com/thoughts/margin/

Discussion

aodakiaodaki

「Spacer は初見では気持ち悪く感じるとは思いますが、理にかなった存在である」というのは私もそう思います。パット見の気持ち悪さではなく、やりたいことから筋道を立てた先の結論ですから説得力があります。


ところで、『 margin は誰の持ち物?』のところですが、親の持ち物である場合と誰の持ち物でもない場合を分けていますが、実はこの2つは分けなくてもいいのでは、と少し考えました。長くなってしまいますが、考えたことを書き残しておこうと思います。

まず、なぜこの2つを分けられるか考えてみます。「親の持ち物」としている場合の方は、おそらく親を実装するDOM要素に、CSSによって適切なmarginを表すスタイルが適用されているのでしょう。その一方で、Spacerは独立したDOM要素として存在しています。そういう意味では、前者はmarginは親要素の持ち物であり、後者は誰からも独立した要素のものになっています。

ただ、これは実装に踏み込んだ見方であり、意味論で考えれば必ずしも必要な見方ではないと思います。
親のDOM要素のスタイルとして書かれていようと、独立したdiv要素の大きさとして書かれていようと、画面上、あるいはアクセシビリティのための他のデバイスの表示において、その違いはないでしょう。

また、デザインのことを考えてみます。そこで重要なのは、WebにおけるDOM要素とスタイルの区分ではなく、文中で述べられているように「要素の "関係性" 」でしょう。そして、実装において、親のDOM要素のスタイルとして書かれている場合にも、デザインにおいてはやはり「関係性」を示しています。

こうしてみると、この2つは同じものとしてしまっていい気がしてきます。


では、結局marginとは何なのでしょう。

コンポーネントは、より小さなコンポーネントの組み合わせでてきています。この組み合わせ方は、個々のコンポーネント独自のものです。 (あるいは、デザインにおいてはコンポーネントではなく、「規約」と呼んだほうがいいかもしれません。デザインにおいては、小さな規約を組み合わせて、より大きな規約を組み立てていきます。) このとき、何を、どう組み合わせるか決めるのは親になるコンポーネントです。そうしてみると、「要素の "関係性" 」は常に親が規定していると言えるのではないでしょうか。コンポーネントという枠組みがある限り、 Spacer を使っても、それを揺るがしてしまう心配はないと思います。

seyaseya

コメントありがとうございます。

ただ、これは実装に踏み込んだ見方であり、意味論で考えれば必ずしも必要な見方ではないと思います。

これは確かにそうだなと思いました。親なのか独立したものなのかというのは実装する時に楽な書き方を考えた時の方法なので意味論では区別するものではないですね。

「要素の "関係性" 」は常に親が規定していると言えるのではないでしょうか。

自分でも「結局 Spacer 使っても、それをどこで使うかは親側が規定してるしなー」とぼんやり思ってたので、仰る通りだと思います。
思考がクリアになりました、ありがとうございます!

kkzkkz

自分もSpacerは理にかなっていると思います!
FlutterのWidgetはこの考え方に近いですね🧐
ずばりSpacerという名前のWidgetもありますし参考になるかもしれません。