Claude Code で SaaS ダッシュボードを 1 日で再設計した記録 — Tailwind v4 + CSS Grid タブラーアラインメント
- engineering
- tailwind
- css-grid
- dark-mode
- claude-code
TL;DR
- 課題: 新規ユーザー 15人中 11人 (73%) が URL登録せず離脱
- 仮説: 業界のヒートマップツールが全てライトUIなのに HeatMapX だけ暗いダッシュボードで戸惑い離脱
- 対応: 1日で全面再設計 — ライト/ダーク/システム 3択 + GitHub風タブラーリスト
- 学び: Tailwind v4
@custom-variant、CSS Gridauto列の落とし穴、長文耐性の3層防御、デザイン反復は 10–16 ターン前提- 効果検証: 2週間後のフォローアップ記事で離脱率の変化を測定予定(本記事時点では未測定)
背景:暗いダッシュボードが離脱されている疑い
HeatMapX (heatmapx.com) は Claude Code から CLI で呼べるヒートマップ&CRO ツールです。プロモーション展開を始めたところ、新規ユーザー 15 人中 11 人 (73%) が「サインアップしたが URL を登録しないまま離脱」する現象が観測されました。
仮説のひとつが「業界のヒートマップツールは全て ライト UI デフォルト で、HeatMapX だけ暗い管理画面のため、初見ユーザーが戸惑って離脱しているのでは」というものでした。
そこで、Claude Code を相棒に 1 日のセッション内でダッシュボードを全面再設計しました。本記事は仮説検証の前半(離脱要因の調査と再設計)の記録です。改善後の数値が出たら続編でフォローアップします。
採用した最終構成
| 要素 | Light | Dark |
|---|---|---|
| ページ背景 | bg-neutral-100 (#f5f5f5) |
dark:bg-neutral-950 (#0a0a0a) |
| カード面 | bg-white + border-slate-200 |
dark:bg-slate-900 + dark:border-slate-800 |
| 主テキスト | text-slate-900 |
dark:text-slate-100 |
| キーカラー (CTA) | bg-orange-600 (#ea580c) |
両モード共通 |
| 角丸 | rounded-md (6px) · 影なし · GitHub 風 |
|
| テーマ切替 | ヘッダー右側に ☀ / 💻 / 🌙 の3択トグル |
Tailwind v4 のダークモード — Zero-Config から手動切替へ
最初は @media prefers-color-scheme で済ませた
Tailwind v4 のデフォルト挙動は dark: variant が @media (prefers-color-scheme: dark) と等価。これは追加設定なしで OS のダークモード設定に追随します。
{/* layout.tsx */}
<div className="bg-neutral-100 text-slate-900 dark:bg-neutral-950 dark:text-slate-100">
...
</div>
これで OS の外観設定(夜間自動ダーク等)に追随する実装は完了。ただし、ユーザーが明示的にライト/ダークを選びたいケース ("system" が誤動作する、好みの問題) があるため、3 択トグル方式に拡張しました。
class-based dark variant への切替
Tailwind v4 では @custom-variant で dark の挙動を再定義できます。<html> に .dark クラスが付いた時のみ dark: を発火させる方式に変更:
/* globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:where() で specificity を 0 に抑えているのがポイント。これがないと既存のスタイル指定と競合する場合があります。
SSR-flash 防止の同期スクリプト
React の hydration 後に useEffect でテーマを適用すると、初回レンダで一瞬白くなる "FOUC" が発生します。これを防ぐため、<head> に同期スクリプトを inline で挿入:
{/* app/layout.tsx */}
<head>
<script dangerouslySetInnerHTML={{ __html: `
(function(){try{
var t = localStorage.getItem('theme') || 'system';
var d = t === 'dark' || (t === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (d) document.documentElement.classList.add('dark');
}catch(e){}})();
`}} />
</head>
React mount 前に同期実行されるので、body レンダリング時点で既に .dark クラスが付いた状態。suppressHydrationWarning を <html> に付けて hydration mismatch を抑制。
💡 学び1: Tailwind v4 の
dark:は zero-config で動く。シンプルに OS 連動のみで良ければ追加設定不要。手動切替を加える時のみ@custom-variant+ 同期スクリプトを追加する 2 段階アプローチが効率的。
CSS Grid タブラーアラインメントの落とし穴
初期:1-column リストで満足できなかった
「サイト追加後のカードが微妙」という指摘を受け、最初は単純に 2-col grid → 1-col リストに変えました。各 SiteCard は flex で内部に name · url · status · events を並べ、右端に chevron。
しかし複数件並べて気付いた:
Short · example.com · ● 計測中 · 5,577
Very Long Site Name That... · normal.com · ● 計測中 · 5,577
ACME · acme.io · ● 計測中 · 5,577
「· 計測中」「· 5,577」「› chevron」が 毎行違う x 座標 に出現。目が「ステータス列はここ」と固定できず、リストとして スキャンできない。
これは「リストではなく、文字列が縦に積まれているだけ」だった。
CSS Grid タブラーレイアウトへの転換
各 SiteCard 内部を CSS Grid で固定列定義 に変更。全カードが同一の grid template を持つので、列の左端が縦に完全一致する:
// SiteCard.tsx
<Link className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,3fr)_110px_72px_20px] items-center gap-x-4 ...">
<h3 className="truncate">{name}</h3>
<span className="truncate">{displayUrl}</span>
<span className="truncate font-mono">{apiKey}</span>
<span className="truncate font-mono">{trackerSnippet}</span>
<span>● 計測中</span>
<span>{count}</span>
<span>›</span>
</Link>
ヘッダー行も 完全同一の grid template で書く:
// page.tsx ヘッダー
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,3fr)_110px_72px_20px] gap-x-4 ...">
<span>SITE</span>
<span>URL</span>
<span>API KEY</span>
<span>TRACKER TAG</span>
<span>STATUS</span>
<span>TODAY</span>
<span></span>
</div>
落とし穴: auto カラムは grid 別なら別サイズ
最初は grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto_auto_auto] と書きました。「auto なら中身に合わせて伸縮するし、ヘッダーと SiteCard は同じ template なら揃うだろう」と。
結果:揃わない。なぜなら:
- ヘッダーの
auto列: "STATUS" / "ステータス" のテキスト幅で決定 - SiteCard の
auto列: "● 計測中" / "● Active" のテキスト幅で決定 - 両者は 別の grid container なので、track size は独立計算される
解決策は 2 つ:
- 固定幅指定:
autoを110px,72px等のピクセル値に変更(採用) - CSS Subgrid: ヘッダーと全 SiteCard を単一の親 grid 内に入れ、各 row が parent の track を
subgridで継承(複雑)
💡 学び2: タブラーアラインメントでは
auto列禁物。別の grid container 間で列幅を揃えたい場合、autoは内容次第で個別伸縮する。固定幅 (px) 指定するか、subgrid で track を共有する必要がある。
長文への防御 — 3 層の入力制限
初期の DB スキーマでは:
CREATE TABLE sites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL, -- 長さ無制限
url text NOT NULL, -- 長さ無制限
...
);
Postgres の text 型は実質無制限。10,000 文字の name でも保存できてしまい、UI レイアウトを破壊するリスクがあります。タブラーレイアウトに移行するなら、長文対策は必須。
採用した 3 層防御:
- フロントエンド HTML5 制限: input に
maxLength={60}/maxLength={512} - サーバーサイド検証: Server Action で
name.length > 60を check してエラー返却 - 表示側 truncate: grid 各セルに
truncateクラス。CSS Grid のminmax(0, ...)と組み合わせて長文を「…」省略
3 層目が特に重要。truncate は overflow: hidden + text-overflow: ellipsis + white-space: nowrap ですが、flex / grid 内では min-width: 0 が必要。minmax(0, ...) がこれを担保します。
デザイン反復は 16 ターン
今回のセッションで SiteCard のデザイン反復は 16 ターン。代表的なターン:
| # | 変更 | 理由 |
|---|---|---|
| 1 | CopyBlock を light gray pill 化 | 黒地+緑文字が GitHub light で浮く |
| 3 | A/B/C/D 4 案比較 | クリックアフォーダンス強化 |
| 4 | 1-col リストに転換 | 「装飾的すぎる」と却下されて構造から見直し |
| 7 | 「ヒートマップを見る →」CTA 撤廃 | 「スタイリッシュじゃない」 |
| 9 | CSS Grid タブラー化 | セパレータベース文字列が「リストに見えない」 |
| 10 | auto → 固定幅 |
ヘッダーとカードで列幅不一致 |
| 11 | API KEY 列復活 | 「あれ、APIは?」 |
| 12 | TRACKER TAG 列追加 | 「両方とも横並びで」 |
| 14 | maxLength + サーバー検証 | 長文耐性の根本対応 |
| 16 | 見出し横に「+」ボタン (compact mode) | 下スクロール不要のクイック追加 |
⚠ 1 発で当たることはほぼ無い。デザイン反復は「dev server で実機表示 → 即時フィードバック → 次案」を 10+ 回繰り返す前提で計画する。Claude Code のような対話型 AI 開発環境がここで効く。
AIアシスト開発で効いたパターン
ツール固有の感想というより、対話型 AI と組むときに「これは効いた」と思った汎用パターン:
- 新しい API 仕様の検索コストがゼロになる: Tailwind v4 の
@custom-variantのような少し新しい構文を、即座に提案+動く形で出してくれる - 「実装→検証→修正」の閉ループが秒単位で回る: CSS Grid
auto列の不一致を再現→特定→修正まで 5 分 - 同型の機械作業は一括で消化される: 15 言語ファイルの placeholder 置換 (
in-gol→example.com) を1ターンで - HMR との相性: 編集 → 保存 → ブラウザ即反映 → フィードバック → 再編集のフィードバックループを延々と回せる
- 完成後の本番 push と Vercel デプロイ監視まで一気通貫
逆効果だったパターンは次節の「難しかった点」で。
難しかった点 / 次の改善
- 「これ微妙」というレイヤーの抽象的なフィードバック を具体的な構造変更に翻訳するのが反復回数を増やした。最初に「タブラー or リスト or カード」レベルで合意を取るべきだった。
- 10 ターン目あたりで「全部横並び」「全部 inline」「全部 truncate」と要件が増え続けたが、相反する制約 (情報密度 vs スキャナビリティ) があり最終形に到達するまで試行錯誤が必要だった。
- モバイル対応はまだ手付かず。grid 7 列はスマホでは横スクロールが発生する。
sm:media query で stack 化するか、別 component 分岐が必要。
まとめ
1 日のセッション内で、HeatMapX ダッシュボードを暗いカード grid から、ライト/ダーク 3 択 + GitHub 風タブラーリストに全面再設計しました。技術的なポイントは:
- Tailwind v4 の dark mode は
@mediaベースで開始、ユーザー切替が必要になったら@custom-variant+ 同期スクリプトで拡張 - タブラーアラインメントは CSS Grid で 固定幅 (px) か subgrid でしか実現できない
- 長文耐性は フロント maxLength + サーバー length check + 表示 truncate の 3 層防御
- デザイン反復は 10–16 ターン前提で「速いフィードバックループ」を構築する
仮説の答え合わせ(離脱率が実際に下がったか)は、2週間データを取った続編記事で公開予定です。