動的ビューポートベース・グリッドシステムの実装
動的ビューポートベース・グリッドシステムの実装 - フレームワークに依存しない独自グリッド設計 -
はじめに
Design H/Jack のマーケティングサイトでは、TailwindやBootstrapといった既存のグリッドフレームワークを使わず、ビューポートサイズに基づいて動的に計算する独自のグリッドシステムを実装しました。
この記事では、なぜ独自グリッドを作ったのか、どう実装したのか、そして得られたメリットとトレードオフを解説します。
なぜ独自グリッドを作ったのか
既存フレームワークの課題
Tailwind CSS / Bootstrap Grid:
- 固定されたブレークポイント
- ビューポートを完全に埋められない
- デザイナーのピクセルパーフェクトな要求に応えにくい
- グリッドセルのサイズが画面によって変わる
CSS Grid(標準):
- 柔軟だが、動的な計算が難しい
- ビューポート全体を埋めるための複雑な計算が必要
- デザイナーとのコミュニケーションが難しい(「何列?」が曖昧)
目指したもの
要件:
├── ビューポートを完全に埋める(オーバーフローなし)
├── グリッドセルは常に同じサイズ
├── モバイルとデスクトップで異なる戦略
├── 数学的に正確なレイアウト
├── ビジュアルグリッドオーバーレイでデザイン確認
└── フレームワークの肥大化なし
システム概要
基本戦略
モバイル(≤480px):
列数: 10列固定
行数: 動的(ビューポート高さに基づく)
グリッドサイズ = (幅 - 32px) / 10
デスクトップ(>480px):
行数: 14行固定
列数: 動的(ビューポート幅に基づく)
グリッドサイズ = (高さ - 32px) / 14
CSS変数による管理
すべてのグリッド寸法をCSS変数で管理し、JavaScriptから動的に更新。
:root {
--grid-size: [calculated]px;
--grid-cols: [calculated];
--grid-rows: [calculated];
--grid-cols-half: [calculated];
--grid-rows-half: [calculated];
--actual-grid-width: [calculated]px;
--actual-grid-height: [calculated]px;
--grid-margin-x: [calculated]px;
}
コア実装:useGridSystemフック
基本構造
// hooks/useGridSystem.ts
export function useGridSystem() {
const pathname = usePathname();
const [grid, setGrid] = useState<GridCalculation | null>(null);
useEffect(() => {
const calculateGrid = (): GridCalculation => {
const width = window.innerWidth;
const height = window.innerHeight;
// モバイル戦略
if (width <= 480) {
const cols = 10;
const gridSize = (width - 32) / cols;
let rows = Math.floor(height / gridSize);
rows = rows % 2 === 0 ? rows : rows - 1; // 偶数に強制
return { gridSize, cols, rows, /* ... */ };
}
// デスクトップ戦略
const rows = 14;
const gridSize = (height - 32) / rows;
let cols = Math.floor(width / gridSize);
cols = cols % 2 === 0 ? cols : cols - 1; // 偶数に強制
return { gridSize, cols, rows, /* ... */ };
};
const newGrid = calculateGrid();
setGrid(newGrid);
// CSS変数を更新
updateCSSVariables(newGrid);
}, [pathname]);
return grid;
}
重要なポイント
1. 偶数強制:
rows = rows % 2 === 0 ? rows : rows - 1;
シンメトリーを維持するため、行と列を必ず偶数に。
2. デバウンス付きリサイズ処理:
const debouncedHandleResize = debounce(() => {
const newGrid = calculateGrid();
setGrid(newGrid);
}, 250);
window.addEventListener('resize', debouncedHandleResize);
過度な再計算を防ぐため、250msのデバウンス。
3. pathname依存:
useEffect(() => {
// ...
}, [pathname]);
ルート変更時にグリッドを再計算。
ビジュアルグリッド:useGridBackgroundフック
SVGグリッドオーバーレイ
デザイン確認のため、SVGベースのグリッド背景を生成。
// hooks/useGridBackground.ts
export function useGridBackground(
sectionRef: RefObject<HTMLElement>,
options?: GridBackgroundOptions
) {
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
// data属性から設定を取得
const gridCols = section.dataset.gridCols;
const gridRows = section.dataset.gridRows;
// SVG生成
const svg = generateGridSVG(gridCols, gridRows, options);
section.style.backgroundImage = `url("data:image/svg+xml,${svg}")`;
}, [sectionRef, options]);
}
SVG生成ロジック
function generateGridSVG(
cols: number,
rows: number,
options: GridBackgroundOptions
): string {
const { stroke, fill, strokeWidth, dotRadius } = options;
let svg = `<svg xmlns="http://www.w3.org/2000/svg">`;
// 縦線
for (let col = 0; col <= cols; col++) {
const x = col * gridSize;
const strokeDasharray = col % 2 === 0 ? 'none' : '4 4';
svg += `<line x1="${x}" y1="0" x2="${x}" y2="${height}"
stroke="${stroke}" stroke-width="${strokeWidth}"
stroke-dasharray="${strokeDasharray}" />`;
}
// 横線
for (let row = 0; row <= rows; row++) {
const y = row * gridSize;
const strokeDasharray = row % 2 === 0 ? 'none' : '4 4';
svg += `<line x1="0" y1="${y}" x2="${width}" y2="${y}"
stroke="${stroke}" stroke-width="${strokeWidth}"
stroke-dasharray="${strokeDasharray}" />`;
}
// 交点ドット
for (let col = 0; col <= cols; col++) {
for (let row = 0; row <= rows; row++) {
svg += `<circle cx="${col * gridSize}" cy="${row * gridSize}"
r="${dotRadius}" fill="${fill}" />`;
}
}
svg += `</svg>`;
return encodeURIComponent(svg);
}
特徴
- 偶数行/列は実線、奇数は破線
- 交点にドットを配置
- data属性による設定可能な寸法
CSSアーキテクチャ
グローバル変数
/* styles/globals.css */
:root {
/* useGridSystemフックによって設定 */
--grid-size: [calculated]px;
--grid-cols: [calculated];
--grid-rows: [calculated];
--grid-cols-half: [calculated];
--grid-rows-half: [calculated];
--actual-grid-width: [calculated]px;
--actual-grid-height: [calculated]px;
--grid-margin-x: [calculated]px;
/* 派生サイズ */
--grid-size-large: calc(var(--grid-size) * 2);
}
セクショングリッドコンテナ
/* styles/main.css */
.section-grid {
position: relative;
display: grid;
grid-template-columns: repeat(
var(--section-grid-cols, var(--grid-cols)),
var(--grid-size)
);
grid-template-rows: repeat(
var(--section-grid-rows, var(--grid-rows)),
var(--grid-size)
);
gap: 0;
width: var(--actual-grid-width);
height: auto;
margin: var(--spacing-2) auto;
z-index: 1;
}
ユーティリティクラス
/* グリッド全体をカバー */
.grid-full {
grid-column: 1 / -1;
grid-row: 1 / -1;
}
/* 半幅配置 */
.grid-half-left {
grid-column: 1 / span calc(var(--grid-cols) / 2);
}
.grid-half-right {
grid-column: calc(var(--grid-cols) / 2 + 1) / -1;
}
/* カスタムスパンによる中央配置 */
.grid-center {
--grid-columns-span: 2;
--grid-rows-span: 2;
grid-column: var(--grid-columns-span) /
calc(-1 * var(--grid-columns-span));
grid-row: var(--grid-rows-span) /
calc(-1 * var(--grid-rows-span));
}
/* 列の中央配置 */
.grid-columns-center {
grid-column: calc(var(--grid-cols-half) - var(--grid-columns-span)) /
calc(var(--grid-cols-half) + var(--grid-columns-span));
}
使用例
例1: 基本セクション
<section
ref={sectionRef}
className="section section-grid"
data-background="true"
data-section="hero"
>
<div className="grid-full">
{/* コンテンツ */}
</div>
</section>
例2: カスタムグリッド寸法
<section
className="section section-grid"
data-grid-cols="50%" // 利用可能な列の50%
data-grid-rows="20" // 20行固定
data-background="true"
>
<div className="grid-full">
{/* コンテンツ */}
</div>
</section>
例3: 中央配置コンテンツ
<section className="section section-grid">
<div
className="grid-center grid-rows-center"
style={{
'--grid-columns-span': 3,
'--grid-rows-span': 4,
}}
>
{/* 中央配置、6列 × 8行にまたがる */}
</div>
</section>
例4: 動的グリッド行
const calculateGridRows = (itemCount: number): number => {
const itemHeightInGrids = 6;
const itemsPerRow = Math.floor(availableGridCols / 7);
const rowsOfItems = Math.ceil(itemCount / itemsPerRow);
const totalHeight = headerHeight + (rowsOfItems * itemHeightInGrids);
return totalHeight;
};
<section
className="section section-grid"
data-grid-rows={String(gridRows)}
>
{/* 動的コンテンツ */}
</section>
メリット
1. ピクセルパーフェクトなレイアウト
オーバーフローなしでビューポートを正確に埋める。数学的に計算された寸法により、推測や調整が不要。
2. 一貫したリズム
すべての要素が同じグリッドに整列し、視覚的なリズムが生まれる。
3. デザイナーフレンドリー
精密なビジュアルグリッドオーバーレイにより、デザイナーがレイアウトを確認しやすい。
4. 高度なカスタマイズ性
セクションごとにグリッド設定を変更可能。data属性による柔軟な制御。
5. フレームワークの肥大化なし
カスタム実装により、不要なCSSを排除。最小限のコードで最大限の制御。
6. メディアクエリなしのレスポンシブ
グリッドが自動的に適応するため、複雑なメディアクエリが不要。
トレードオフ
1. JavaScript依存
クライアントサイドの計算が必要。SSR非対応(初期レンダリングにwindowオブジェクトが必要)。
2. 複雑性
標準グリッドよりも学習曲線が急。チームメンバーが規約を学ぶ必要がある。
3. 潜在的なFOUC
初回ロード時のスタイル未適用コンテンツの一瞬の表示。
対策:
- 合理的なデフォルトCSS変数値を設定
- 初期は
opacity: 0
を使用し、グリッド計算後にフェードイン
4. カスタムシステム
既存のツールやライブラリとの統合が難しい場合がある。
パフォーマンス考慮事項
デバウンス付きリサイズ処理
250msのデバウンスにより、過度な再計算を防止。
const debouncedHandleResize = debounce(() => {
const newGrid = calculateGrid();
setGrid(newGrid);
}, 250);
CSSカスタムプロパティ
インラインスタイルの代わりにCSS変数を使用することで、ブラウザがリペイントとリフローを最適化。
SVGグリッド再生成
useGridBackgroundフックは以下の場合にのみSVGを再生成:
- ウィンドウのリサイズ時(デバウンス付き)
- data属性の変更時
- グリッド寸法の変更時
ベストプラクティス
1. 常に偶数を使用
カスタム--grid-columns-span
を設定する際は、対称性を維持するために偶数を使用。
// Good
style={{ '--grid-columns-span': 2 }}
style={{ '--grid-columns-span': 4 }}
// Avoid(非対称性を生む)
style={{ '--grid-columns-span': 3 }}
2. 複数のビューポートでテスト
モバイル(10列)とデスクトップ(可変列)で動作が異なるため、以下でテスト:
- 375px(モバイル)
- 768px(タブレット)
- 1440px(デスクトップ)
- 1920px(大型デスクトップ)
3. グリッドベースのタイポグラフィを使用
一貫した垂直リズムのために、グリッドベースのフォントサイズを使用。
.font-grid-size {
font-size: var(--grid-size);
line-height: 1;
}
.font-grid-size-large {
font-size: calc(var(--grid-size) * 2);
line-height: 1;
}
まとめ
Design H/Jack の独自グリッドシステムは、ビューポートサイズに基づいて正確な寸法を計算し、数学的精度を維持することで、ピクセルパーフェクトなレイアウトを実現します。
主な特徴:
- ビューポートを完全に埋める動的計算
- モバイル/デスクトップで異なる戦略
- SVGベースのビジュアルグリッドオーバーレイ
- CSS変数による柔軟な管理
- フレームワーク非依存
トレードオフ:
- JavaScript依存
- 学習曲線
- カスタムシステムの保守
学習曲線は必要ですが、このシステムはレイアウトと視覚的リズムに対する比類のないコントロールを提供します。
関連記事:
GitHub: [リンク]
Live Demo: [リンク]