【ITニュース解説】Debugging a Hexagonal CSS Masonry Layout
2025年10月01日に「Dev.to」が公開したITニュース「Debugging a Hexagonal CSS Masonry Layout」について初心者にもわかりやすく解説しています。
ITニュース概要
CSSでヘキサゴン型タイルを並べる複雑なレイアウトを作る際、JavaScriptで行の位置検出に挑戦。offsetTopと要素の高さで計算すると誤差が出たが、隣接要素のoffsetTopを比較するシンプルな方法で正確な行検出に成功した。デバッグの過程と解決策を解説する。
ITニュース解説
Webページ上でコンテンツをタイル状に並べるレイアウトは一般的である。特に、異なるサイズのコンテンツを効率的に配置し、隙間なく表示できる「Masonryレイアウト」がよく知られている。この記事で扱っているのは、すべてのタイルが同じサイズで「六角形」である特殊なMasonryレイアウトの実現方法についてである。一般的な四角形ではなく六角形を使うと、タイルの間に自然な隙間が生まれる。この隙間を埋め、六角形のレンガを積み上げたような美しいレイアウトを作成することが課題であった。
Webデザインでは、通常CSS(カスケーディングスタイルシート)を使ってレイアウトを定義する。最近のCSSには、CSS GridやFlexboxといった強力なレイアウト機能が備わっている。筆者は当初、CSS Gridで六角形Masonryレイアウトを実現しようと考えたが、CSS Gridには「アイテムが具体的に何行目に配置されたか」を検知し、その行に応じてスタイルを適用する機能がないことが判明した。Flexboxも同様に、アイテムがどの行に配置されたかを直接知る機能はない。このため、CSSだけでは実現が難しく、JavaScriptを使ってこの「行の検出」を行う必要が生じた。JavaScriptは、Webページ上の要素の情報を取得したり、動的にスタイルを変更したりできるプログラミング言語である。
六角形タイル間の隙間を埋めるには、たとえば偶数行のタイルを、一つ上の奇数行のタイルが作る谷間に合わせて少し上方向にずらすアプローチが考えられる。そのためには、各タイルが「何行目にいるか」を正確に知る必要がある。筆者は、JavaScriptを使って各タイル要素のページ上部からの距離(offsetTopプロパティ)を取得し、これを「1行分の高さ(gappedHeight)」で割ることで、何行目かを計算しようと試みた。1行分の高さとは、タイルの高さ(offsetHeight)と、FlexboxやCSS Gridのgapプロパティで指定される行間隔を合計した値である。Math.floor(tileElement.offsetTop / gappedHeight)という計算式を用い、小数点以下を切り捨てて行番号を得ることを試みた。これにより算出した行番号が偶数か奇数かに応じて、特定のCSSクラス(is-evenやis-odd)をタイルに付与し、そのクラスに対してCSSで上方向へのずれ(margin-topなどの負の値)を適用する計画であった。しかし、この初期の試みでは、最初の2行は正しく表示されたものの、3行目以降でレイアウトが崩れるという問題が発生した。
問題の原因を探るため、筆者はデバッグ手法を導入した。計算途中のoffsetTopやgappedHeight、算出された行番号などの値を、タイルのHTML要素のデータ属性に書き込むことで、開発者ツールで目視できるようにした。デバッグの結果、offsetTopの値が、期待される正確な倍数から微妙にずれていることが判明した。たとえば、2行目のoffsetTopは期待値よりも1ピクセル高く、3行目ではさらに大きくずれるなど、行が進むにつれてずれが蓄積されていった。このずれは、ブラウザのサブピクセルレンダリング(ピクセル未満のわずかな値を表示しようとする挙動)や、CSSで設定したmarginが正しく相殺されなかったことなどが原因で生じた。最初はわずかなピクセル単位であったため見落とされがちだが、最終的に行番号の誤検出につながった。このずれの主な原因として、Flexboxコンテナに設定されたgapプロパティによる行間隔と、タイル要素に適用された負のmargin(margin-top)が相互に影響し合っていることが明らかになった。特に、タイルを上部にずらすために負のmargin-topを設定しながら、その影響を打ち消すためのmargin-bottomが不十分だったり、計算式に正しく反映されていなかったりしたことが問題であった。また、gapプロパティによる行間隔は、ブラウザによっては計算が複雑になり、意図しないサブピクセルのずれを引き起こす可能性も考慮された。このため、筆者はgapプロパティの使用をやめ、代わりに各タイル要素の内部にpaddingを設定することで、間隔をより直接的に制御する方向に方針転換した。これにより、タイルの外部marginの計算を簡素化できると考えた。
さらにデバッグを進める中で、筆者は「最初の行以外のすべての行を上部にずらす」という意図でCSSを適用しようとした際に、「最初の要素以外のすべての要素」に適用されるような誤ったセレクタ指定をしてしまっていたことに気づいた。CSSのセレクタはHTMLの構造上の位置に基づいて要素を選択するため、意図した「行番号」に基づく選択とは異なる結果を生んでしまうのである。これは、JavaScriptで行番号を計算してクラスを付与する前に、CSSが先に適用されてしまうというタイミングの問題でもあった。Math.floor()を使うことによって生じる誤差もまた問題であった。わずかなサブピクセルのずれでも切り捨ててしまうため、累積的な誤差が生じると、異なる行にあるべき要素が同じ行に認識されてしまったり、行番号が誤って検出されたりした。Math.round()への変更も試みたが、これも根本的な解決にはならなかった。
このような試行錯誤とデバッグを経て、筆者はよりシンプルで堅牢な行検出方法にたどり着いた。それは、各タイルのoffsetTopを、直前のタイルのoffsetTopと比較するという方法である。もし現在のタイルのoffsetTopが直前のタイルのoffsetTopよりも大きければ、それは新しい行の始まりであると判断し、行カウンターを増やすというロジックである。この方法は、行の高さや行間隔といった絶対的な数値に基づく複雑な計算を避け、要素の垂直位置の相対的な変化に注目するため、ブラウザのサブピクセルレンダリングなどによる微妙なずれの影響を受けにくく、非常に堅牢であった。
レイアウト調整機能は、ウィンドウのサイズ変更やデバイスの向き変更といったユーザー操作にも対応する必要がある。ブラウザはこれらの操作時に要素の配置を再計算するため、JavaScriptによる行検出とスタイル適用も再実行されなければならない。そのため、筆者はこのレイアウト調整関数をDOMContentLoadedイベント(ページの読み込み完了時)とresizeイベント(ウィンドウサイズ変更時)で呼び出すように実装した。これにより、動的な環境下でもレイアウトの正確性が保たれる。また、将来的な再利用性とコードの整理を考慮し、このレイアウト調整ロジックを独立したTypeScriptファイルに記述し、最終的にはnpmパッケージとして公開するというソフトウェアアーキテクチャの選択をした。これにより、複数のプロジェクトで同じ機能を簡単に利用でき、一貫性を保つことができる。
最終的なデバッグの過程で、筆者は元のシンプルな例がタイルのペアをラップするだけでなく、内部のスペーシングにpaddingを使用していた点を見落としていたことに気づいた。CSSのmarginは要素の外側の余白を、paddingは要素の内側の余白を定義する。タイルの「外側」に任意にmarginを適用しようとすると、レイアウトアルゴリズムが破綻することが再確認された。要素の内側空間を調整するpaddingの利用が、外部のレイアウト計算に影響を与えにくく、より安定した結果を生むという教訓を得た。最終的にpaddingによる内部スペーシングを活用し、意図したレイアウトを実現した。Web開発、特に視覚的なフロントエンド開発では、一見簡単なアイデアでも、ブラウザの挙動、CSSとJavaScriptの相互作用、そしてピクセル単位の正確性といった多くの要因が絡み合い、複雑なデバッグが必要になることがよくある。複雑な計算や「オーバーエンジニアリング」だと感じた場合は、もっとシンプルで堅牢な解決策がないか再考することが重要である。この記事は、六角形Masonryレイアウトという具体的な課題を通して、このようなデバッグの旅路と、シンプルかつ効果的な解決策を見つけることの重要性を教えてくれるものであった。