🎼

marginではなく、レイアウトを指定しよう ~レイアウト手法 Stack、Clusterの紹介~

2023/12/19に公開
3

この記事は、Money Forward Engineering 1 Advent Calendar 2023 19日目の投稿です。
前回はTaskさんの『小さいプルリクエストを作る技術』でした!


いやはや、CSSは難しいですよね...
複雑な入れ子構造になった要素にスタイルを当てるときのカスケードバトル
どこから継承されたのか分からない謎のスタイル
インラインスタイル、クラスベースのスタイル、CSS in JSが混在しカオスになった指定方法
僕自身、今までたくさんの辛みCSSに頭を悩ませてきました...
今日はそんな管理の難しいCSSを少しでも分かりやすくするための、レイアウト手法を紹介していこうと思います。

本記事はEvery Layout-モジュラーなレスポンシブデザインを実現するCSS設計論で紹介されているレイアウト手法Stack、Clusterの紹介になります。

レイアウトの基本的な考え方

レイアウト手法を紹介する前に、基本的なレイアウトの考え方について整理したいと思います。

Webデザインの世界では、すべてが「ボックス」として扱われます。
border-radiusやtransformなどのプロパティーを使うと、四角形のボックスに限らず、さまざまな形状を表現することができます。しかし、これらの見た目上ボックスでないように見える要素も、実際には四角形のボックス状のスペースを占有しています。
したがって、レイアウトの基本は、これらのボックスをどのように配置していくかということになります。

ボックスを配置していく方向は基本的に、垂直方向(縦)か水平方向(横)かどちらかになると思います。
そして、垂直方向に積み重ねられた要素間のスペースを制御するレイアウト手法がStackで、
水平方向に要素を並べるために使用するレイアウト手法がClusterです。

Stack

垂直方向に要素を積み重ねていく場合、下の要素との間にスペースを設ける必要があるかと思います。
そのために使われるのがmarginプロパティです。
簡単な実装例を見ていきます。

はじめに、marginプロパティを使って愚直に実装した例です。

<div>
  <div class="box">box1</div>
  <div class="box">box2</div>
  <div class="box">box3</div>
</div>
.box {
  /* box固有のスタイル */
  margin-bottom: 12px;
}

このスタイルには問題点があります。
それは、本来親要素が責務を持つべき要素間のスペースが子要素に指定されている点です。[1]

この実装を文章に変換すると以下のようになります。
「.boxで、box固有のスタイルと要素の下の12pxのスペースを指定する」
しかし理想的には.boxで指定するスタイルはなるべく.box要素固有のもののみでありたいはずです。
marginの指定はあくまで他の要素との間にスペースを設けるためなので、本来.box要素の責務外にあるはずです。

なのでできれば、要素間のスペースの制御は親要素に任せ、子要素ではそれぞれの固有のスタイルのみを指定するようにしたいところです。
また、そのようにすることでコンポーネントの独立性が高まり、コンポーネント同士を組み合わせる際のデザイン崩れが起きにくくなります。
そのためのレイアウト手法がStackです。

Stackを用いた実装がこちらです。

<div className="stack-12">
  <div class="box">box1</div>
  <div class="box">box2</div>
  <div class="box">box3</div>
</div>
.stack-12 > * + * {
  margin-top: 12px;
}
.box { /* box固有のスタイル */ }

* + *はすべての子要素のうち最初の要素を除くすべての要素を対象にします。(これをフクロウセレクタといいいます[2])
なので.stack-12 > * + *では、.stack-12の全ての子要素のうち、最初の要素を除くすべての子要素が対象になります。なので余分なmarginができることがありません。(後述します。)

この実装を文章に変更すると以下のようになります。
「.stack-12で要素間に一貫した12pxのスペースを持つレイアウトを指定する」
「.boxでbox固有のスタイルを指定する」

要素間のスペース制御を親要素に移譲することができています。
これにより子要素は自分自身のスタイルにのみ関心を払うことができるようになります。

他にもStackを使用すると様々なメリットがあります。

不要なスペースができない

最初のmarginを用いての実装では、最後の.box要素の下に不要なスペースが生じてしまう問題があります。
このように入れ子構造になった要素があったとします。

<div>
  <div class="box-group">
    <div class="box">box1</div>
    <div class="box">box2</div>
    <div class="box">box3</div>
  </div>
  <div class="box-group">
    <div class="box">box1</div>
    <div class="box">box2</div>
    <div class="box">box3</div>
  </div>
</div>
.box {
  /* box固有のスタイル */
  margin-bottom: 12px;
}
.box-group { /* box-group固有のスタイル */ }

出力はこのようになります。

赤線部分のmarginは、.box要素間のスペースではなく、親要素の.box-group要素間とのスペースなので、.boxで指定すべきではなく、不要なmarginだと言えます。

そして仮に親要素である.box-groupmargin-bottom: 10pxを設定した場合も、marginの相殺[3]が生じます。その場合.boxで指定されているmargin-bottom: 20pxの方が値が大きいのでそちらが優先され、親要素のスタイリングを阻害することにも繋がってしまいます。

Stackを用いて実装すると以下のようになります。

<div>
  <div class="box-group stack-12">
    <div class="box">box1</div>
    <div class="box">box2</div>
    <div class="box">box3</div>
  </div>
  <div class="box-group stack-12">
    <div class="box">box1</div>
    <div class="box">box2</div>
    <div class="box">box3</div>
  </div>
</div>
.stack-12 > * + * {
  margin-top: 12px;
}
.box { /* box固有のスタイル */ }
.box-group { /* box-group固有のスタイル */ }

出力はこのようになり、不要なスペースがなくなっていることが分かります。

これは前述の通り、Stackのセレクタで使われている* + *が子要素のうち最初の要素を除くすべての要素を対象にし、かつmargin-topを使用しているため、子要素間にのみスペースが適用されるためです。

入れ子構造になっても使用できる

Stackは入れ子構造になっても使用することができます。
先ほどのStackの実装に手を加えてみます。

+ <div class="stack-24">
  <div class="box-group stack-12">
    <div class="box">box1</div>
    <div class="box">box2</div>
    <div class="box">box3</div>
  </div>
  <div class="box-group stack-12">
    <div class="box">box1</div>
    <div class="box">box2</div>
    <div class="box">box3</div>
  </div>
+ </div>
.stack-12 > * + * {
  margin-top: 12px;
}
+ .stack-24 > * + * {
+   margin-top: 24px;
+ }
.box { /* box固有のスタイル */ }
.box-group { /* box-group固有のスタイル */ }

出力はこのようになります。

この実装では、.box-groupの要素間に24pxのスペースを設定しており、さらにそれぞれの.box-group内の.box要素間には12pxのスペースが適用されていることが分かります。
このように、Stackは階層構造に関わらず使用することができます。

小さなメリット

ここからは細かいメリットです。(後述するClusterでも同様のことが言えます

  • 可読性の向上
    • CSSの実装を確認せずとも、HTMLファイルからレイアウトの構造を確認できるようになります。
  • デザインの意図の明確化
    • 「この要素は要素間にこの幅を取る」というビジュアルの意図を明確に伝えることができ、デザインの意図がより明確にコードに反映されます。
  • コンポーネント自体にmarginを書く必要がなくなるので、コンポーネントの独立性が高まります。(コンポーネントを組み合わせたときの崩れが起きにくくなります)
  • 人為的なミスが起こりにくくなる
    • 新しい要素を追加する際も、個別にmarginを指定する必要がないため、スペースの追加漏れが起こりにくくなります。
  • スタイルの肥大化を防げる
    • 要素ごとに個別のmarginを指定する必要がなくなることで、スタイルの肥大化を防ぐことができます。

flexとgapを用いたスタイリング

これまではmarginを用いたStackの実装方法を紹介しました。しかし以下のようにflexとgapを用いて実装することも可能です。
これにより、後述するClusterコンポーネントとの実装方法を統一し、より簡潔なスタイリングを行うことができます。

@for $i from 1 through 10 {
  $size: 4 * $i;
  .stack-#{$size} {
    display: flex;
    flex-direction: column;
    gap: #{$size}px;
  }
}

ユーティリティークラス,関数の実装

プロジェクト全体でStackを使えるよう、以下のようにユーティリティークラスを実装しておくと便利です。(デザインと実装の一貫性を確保するためグローバルなクラスとして実装することをオススメします。)

stack.module.scss
@for $i from 1 through 10 {
  $size: 4 * $i;
  .stack-#{$size} {
    display: flex;
    flex-direction: column;
    gap: #{$size}px;
  }
}

また、以下のようにユーティリティー関数を実装しておくのもオススメです。
この実装では、TypescriptとCSS Modulesを使用しています。

stack.ts
import styles from './stack.module.scss';

type Spacing = 4 | 8 | 12 | 16 | 20 | 24 | 28 | 32 | 36 | 40;

export const stack = (gap: Spacing) => 
  return styles[`stack-${gap}`];

こんな感じで使えます。

Hoge.tsx
import { stack } from './stack';

export const HogeComponent = () => {
  return (
    <div className={stack(12)}>
      <h2>My Heading</h2>
      <div className={`${stack(4)} additional-class`}>
        <label htmlFor="name">Name:</label>
        <input id="name" type="text" name="name" />
      </div>
    </div>
  );
};

Cluster

Clusterは水平方向に要素を並べるために使用するレイアウト手法です。
実装は以下のようになります。

<div class="cluster-12">
  <div class="box">box1</div>
  <div class="box">box2</div>
  <div class="box">box3</div>
</div>
.cluster-12 {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
.box { /* box固有のスタイル */ }

出力はこのようになります。

flex-wrap: wrap;を指定しているため、コンテナの幅が狭まり、要素が収まりきらなくなった場合は折り返すようになります。

入れ子構造になっても使用できる

ClusterもStack同様、入れ子構造になっても使用できます。

<div class="cluster-24">
  <div class="box-group cluster-12">
    <div class="box">box1</div>
    <div class="box">box2</div>
    <div class="box">box3</div>
  </div>
  <div class="box-group cluster-12">
    <div class="box">box1</div>
    <div class="box">box2</div>
    <div class="box">box3</div>
  </div>
</div>
.cluster-12 {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
.cluster-24 {
  display: flex;
  flex-wrap: wrap;
  gap: 24px;
}
.box { /* box固有のスタイル */ }
.box-group { /* box-group固有のスタイル */ }

出力はこのようになります。

この実装では、.box-groupの要素間に24pxのスペースを設定しており、さらにそれぞれの.box-group内の.box要素間には12pxのスペースが適用されていることが分かります。
このように、Clusterは階層構造に関わらず使用することができます。

justify-content, align-itemsの値を設定して、位置揃えを変更することもできる

justify-contentとalign-itemsの値を設定することで、要素の位置揃えを様々に変更することができます。

justify-content: flex-start

justify-content: center

justify-content: flex-end


align-items: start

align-items: center

align-items: end

align-items: stretch

ユーティリティークラス,関数の実装

Stack同様、Clusterもプロジェクト全体で使えるようユーティリティークラスを実装しておくと便利です。
こちらも、デザインと実装の一貫性を確保するためグローバルなクラスとして実装することをオススメします。
また、justify-contentalign-itemsの設定に関しては、必要になったタイミングで追加実装するのが良いと思います。最初はミニマムな実装から始め、運用しながら必要なスタイルを追加することをオススメします。(僕のプロジェクトではjustify-content: centerのみで今のところ不足していません。)

cluster.module.scss
@for $i from 1 through 10 {
  $size: 4 * $i;
  .cluster-#{$size} {
    display: flex;
    flex-wrap: wrap;
    gap: #{$size}px;
  }
}

また、以下のようにユーティリティー関数を実装しておくのもオススメです。
この実装では、TypescriptとCSS Modulesを使用しています。

cluster.ts
import styles from './cluster.module.scss';

type Spacing = 4 | 8 | 12 | 16 | 20 | 24 | 28 | 32 | 36 | 40;

export const = cluster = (gap: Spacing) => 
  return styles[`cluster-${size}`];

こんな感じで使えます。

Hoge.tsx
import { cluster } from './cluster';

const HogeComponent = () => {
  return (
    <div className={cluster(12)}>
      <div>Item 1</div>
      <div className={`${cluster(4)} additional-class`}>
        <div>Item 1</div>
        <div>Item 2</div>
        <div>Item 3</div>
      </div>
    </div>
  );
};

StackとClusterの応用

StackとClusterを使って簡単なUIを作ってみます。

Form

Stackの応用として簡単なFormを実装してみます。
完全な実装はこちらのCodePenから確認できます。
https://codepen.io/wozitto/pen/BaMXNrY

<form class="stack-16">
  <h2>Example Form</h2>
  <div class="stack-4">
    <label for="name">Name:</label>
    <input id="name" type="text" name="name" />
  </div>
  <div class="stack-4">
    <label for="email">Email:</label>
    <input id="email" type="email" name="email" />
  </div>
  <div class="stack-4">
    <label for="password">Password:</label>
    <input id="password" type="password" name="password" />
  </div>
  <input type="submit" value="submit" />
</form>
.stack-4 > * + * {
  margin-top: 4px;
}
.stack-16 > * + * {
  margin-top: 16px;
}

出力はこのようになります。

Stackを入れ子構造にすることで、各セクションに異なるスペーシングを適用することができます。
外側のStackでは全体のセクション間に対して16pxのスペースを設定し、内側のStackでは各フォーム要素間に4pxのスペースを設定しています。

Header Navigation

Clusterの応用として簡単なHeader Navigationを実装してみます。
完全な実装はこちらのCodePenから確認できます。
https://codepen.io/wozitto/pen/bGzXdMM

<header class="header cluster-between-20">
  <div>
    <a href="/">
      <svg />
    </a>
  </div>
  <div class="cluster-20">
    <a href="/notifications">Notifications</a>
    <a href="/settings">Settings</a>
    <a href="/account">User Name</a>
  </div>
</header>
.header {
  padding: 20px;
  border: solid 1px black;
}
svg {
  width: 200px;
  height: 40px;
  background: gray;
}
.cluster-20 {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
}
.cluster-between-20 {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
  justify-content: space-between;
  align-items: center;
}

出力はこのようになります。

外側のClusterで、justify-content: space-betweenを指定することで、要素がheaderいっぱいに広がるようになっています。
また、align-items: centerを指定することで上下中央に要素が揃うようになります。

コンテナの幅が狭まり、要素が収まりきらなくなった場合は、右のリンクのグループが折り返すようになります。

Dialog

最後に、StackとClusterの両方を利用したDialogの実装を紹介します。
完全な実装はこちらのCodePenから確認できます。
https://codepen.io/wozitto/pen/jOdgbgq

<dialog open aria-labelledby="dialog_label" class="dialog stack-12">
  <div class="stack-4">
    <h2 id="dialog_label">Example Modal</h2>
    <p>Please fill out your name and email address in the fields below.</p>
  </div>
  <div>
    <div class="stack-4">
      <label for="name">Name:</label>
      <input id="name" type="text" name="name" />
    </div>
    <div class="stack-4">
      <label for="email">Email:</label>
      <input id="email" type="email" name="email" />
    </div>
  </div>
  <div class="cluster-end-8">
    <button>Add</button>
    <button>Cancel</button>
  </div>
</dialog>
.dialog {
  width: 300px;
  padding: 16px;
}
.stack-4 > * + * {
  margin-top: 4px;
}
.stack-12 > * + * {
  margin-top: 12px;
}
.cluster-end-8 {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: flex-end;
}

出力はこのようになります。

Stackを入れ子構造にすることで、各セクションに異なるスペーシングを適用することできます。
また、justify-content: flex-endを指定することで、ボタンのグループをダイアログの右端に整列させています。

このようにStackとClusterを用いることで、様々なUIを実装することができます。

まとめ

今回は実装例を交えながら、StackとClusterという二つのレイアウト手法を紹介しました。
要素に個別にmarginを指定する前に、これらのレイアウトを用いて実装することができないかぜひ検討してみてください。
また、それでも個別にmarginを指定する必要がある場合は、レイアウトが複雑になりすぎていないか確認してみてください。決まったレイアウト手法に則って実装することはサービス全体のデザインの一貫性にも繋がります。

今回はStackとClusterの紹介でしたが、Every Layout-モジュラーなレスポンシブデザインを実現するCSS設計論には他にも様々なレイアウト手法が存在します。気になった方はぜひ確認してみてください!

では、より良いCSSライフを!👋🏻

脚注
  1. 要素間のスペースとはmarginのことです。padding,borderなどは要素内のスペースに当たります。 ↩︎

  2. https://coliss.com/articles/build-websites/operation/css/about-css-owl-selector.html ↩︎

  3. https://developer.mozilla.org/ja/docs/Web/CSS/CSS_box_model/Mastering_margin_collapsing ↩︎

Discussion

クロパンダクロパンダ

本文中で特に触れられていませんでしたが、Stack も flex と gap を使って実装可能だと思いますがそちらではなくあえて margin を使っているのはなぜでしょうか? 特に margin を使う方に目立った利点があるわけじゃなさそうだったので実装方法を統一するほうが簡潔かなと思いました

wozittowozitto

コメントありがとうございます!返信遅くなりすみません🙏
そうですね。Stack も flex と gap で実装する方が良いと思いますので追記しておきます!ここでは愚直に margin を指定した場合との比較を分かりやすくするために margin を利用しての Stack を紹介していました。

flexとgapを用いたスタイリングというセクションを追加しました。
https://zenn.dev/moneyforward/articles/075a74334ca512#flexとgapを用いたスタイリング

ゴリラゴリラゴリラゴリラ

いつも悩むのですが、.stack-4, .stack-8, .stack-12...とクラス名を付けた場合、レスポンシブサイトで例えばSPでは16px, PCでは32pxと幅が変わる場合はどのようにクラス名をつけていますか??