Rebuilding a SaaS Dashboard in One Day with Claude Code — Tailwind v4 + CSS Grid Tabular Alignment

HeatMapX Engineering Team13 min read
  • engineering
  • tailwind
  • css-grid
  • dark-mode
  • claude-code

TL;DR

  • Problem: 11 out of 15 new users (73%) signed up but left without registering a URL
  • Hypothesis: Every heatmap tool in our category defaults to a light UI — our dark dashboard may be causing confusion and early drop-off
  • What we did: Full dashboard redesign in one day — light / dark / system toggle + GitHub-style tabular list
  • Lessons: Tailwind v4 @custom-variant, the CSS Grid auto column trap, a 3-layer defense against long text, and why 10–16 design iterations is the norm, not the exception
  • Outcome: Drop-off rate impact to be measured; follow-up post planned after two weeks of data

Background: Were we losing users because our dashboard was too dark?

HeatMapX (heatmapx.com) is a heatmap and CRO tool you can call from Claude Code via CLI. After kicking off our first promotional push, we noticed a pattern: 11 of 15 new users (73%) completed sign-up but left without registering a single URL.

One hypothesis: every major heatmap tool in our category ships with a light UI by default. HeatMapX was the odd one out with a dark admin interface. First-time users landing on a dark dashboard in a sea of white-themed apps may simply be bouncing out of confusion.

So we partnered with Claude Code and did a full redesign in a single session. This post covers the first half of that hypothesis test — diagnosing the drop-off and executing the redesign. The follow-up with actual numbers comes once we have two weeks of post-deploy data.

The Final Design Configuration

Element Light Dark
Page background bg-neutral-100 (#f5f5f5) dark:bg-neutral-950 (#0a0a0a)
Card surface bg-white + border-slate-200 dark:bg-slate-900 + dark:border-slate-800
Primary text text-slate-900 dark:text-slate-100
Key color (CTA) bg-orange-600 (#ea580c) Shared across both modes
Border radius rounded-md (6px) · no shadows · GitHub-style
Theme toggle ☀ / 💻 / 🌙 three-way toggle in the header

Tailwind v4 Dark Mode — From Zero-Config to Manual Toggle

Starting with @media prefers-color-scheme

Tailwind v4's default behavior maps the dark: variant directly to @media (prefers-color-scheme: dark). Out of the box, with zero configuration, it just tracks the OS dark mode setting.

{/* layout.tsx */}
<div className="bg-neutral-100 text-slate-900 dark:bg-neutral-950 dark:text-slate-100">
  ...
</div>

That covers the OS-follows-system case (auto dark at night, etc.) with no extra setup. But users sometimes want to override the OS setting — "system" can misfire, or it's just personal preference — so we extended it to a three-way toggle.

Switching to class-based dark variant

In Tailwind v4 you can redefine the dark: variant behavior using @custom-variant. We switched to a mode where dark: only fires when .dark is present on <html>:

/* globals.css */
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

The :where() wrapper keeps specificity at 0. Without it, the dark variant styles can conflict with existing specificity rules elsewhere in your stylesheet.

Sync script to prevent SSR flash

If you apply the theme inside useEffect after React hydration, you get a flash-of-unstyled-content (FOUC) — a brief white flicker on the first render. The fix is an inline sync script injected into <head> that runs before the body renders:

{/* 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>

This runs synchronously before React mounts, so .dark is already on the <html> element by the time the body starts rendering. Add suppressHydrationWarning to <html> to silence the hydration mismatch warning.

💡 Lesson 1: Tailwind v4's dark: works zero-config. If OS-tracking is all you need, you don't have to touch anything. Only add @custom-variant + the sync script when you specifically need a manual user toggle — a clean two-stage approach.

CSS Grid Tabular Alignment — and Where auto Columns Bite You

The initial single-column list wasn't cutting it

After a note that the post-registration site cards felt "off," we first tried the obvious thing: swapped a two-column card grid for a one-column list. Each SiteCard used flexbox internally to line up name · url · status · events, with a chevron on the right.

The problem showed up as soon as there were multiple items:

Short · example.com · ● Active · 5,577
Very Long Site Name That... · normal.com · ● Active · 5,577
ACME · acme.io · ● Active · 5,577

The · Active, · 5,577, and › chevron all land at different x-coordinates on every row. Your eye can't anchor to "the status column is here." It doesn't scan like a list — it reads like stacked strings.

This wasn't a list. It was text, stacked vertically.

Converting to a CSS Grid tabular layout

We replaced the flexbox internals with a CSS Grid fixed-column definition. Because every card shares the same grid template, column left-edges align perfectly down the page:

// 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>● Active</span>
  <span>{count}</span>
  <span>›</span>
</Link>

The header row uses the exact same grid template:

// page.tsx header row
<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>

The trap: auto columns in separate grids size independently

Our first pass used grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto_auto_auto]. The thinking: "auto adapts to content, and since the header and SiteCard share the same template string, they'll line up."

They didn't. Here's why:

  • The header's auto column: sized to the text width of "STATUS"
  • The SiteCard's auto column: sized to the text width of "● Active"
  • Both are separate grid containers — track sizing is calculated independently for each

Two solutions:

  1. Fixed width: Replace auto with explicit pixel values like 110px, 72px (what we shipped)
  2. CSS Subgrid: Put the header and all SiteCards inside a single parent grid, then have each row inherit the parent's tracks via subgrid (more complex)

💡 Lesson 2: Don't use auto columns when you need tabular alignment across separate grid containers. Each container calculates its own track widths based on its own content. Use fixed pixel widths or share tracks via subgrid.

Defending Against Long Text — 3 Layers

The initial DB schema had:

CREATE TABLE sites (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL,  -- no length limit
  url text NOT NULL,   -- no length limit
  ...
);

Postgres text is effectively unlimited. A 10,000-character site name is perfectly valid SQL — and would destroy a tabular layout instantly. Moving to a fixed-column grid made text length constraints non-negotiable.

The 3-layer defense we landed on:

  1. Frontend HTML5 limit: maxLength={60} / maxLength={512} on inputs
  2. Server-side validation: Server Action checks name.length > 60 and returns an error
  3. Display truncation: truncate class on every grid cell, combined with minmax(0, ...) to enable the overflow

Layer 3 is the one people miss. truncate expands to overflow: hidden + text-overflow: ellipsis + white-space: nowrap, but in a flex or grid context it also needs min-width: 0 on the cell — otherwise the cell expands to fit its content and overflow never triggers. minmax(0, ...) provides exactly that.

16 Design Iterations

The SiteCard alone went through 16 design iterations in this session. Selected turns:

# Change Reason
1 Converted CopyBlock to light gray pill Black background + green text looked wrong in GitHub light mode
3 A/B/C/D four-option comparison Needed stronger click affordance
4 Switched to 1-column list Rejected as "too decorative" — back to basics
7 Removed "View Heatmap →" CTA "Doesn't look clean"
9 Converted to CSS Grid tabular layout Separator-based string layout "doesn't feel like a list"
10 auto → fixed pixel widths Header and card columns not aligning
11 Restored API KEY column "Wait, where's the API key?"
12 Added TRACKER TAG column "Put both side by side"
14 Added maxLength + server validation Proper fix for long text robustness
16 Added "+" button next to heading (compact mode) Quick-add without scrolling down

Don't expect to land it on the first try. Design iteration means "check in dev server → immediate feedback → next attempt," repeated 10+ times. Plan for that loop. An interactive AI development environment like Claude Code is where this pattern really shines.

Patterns That Actually Worked with AI-Assisted Development

These aren't tool-specific observations — they're general patterns that proved effective when pairing with an interactive AI:

  • Zero lookup cost for new API syntax: Tailwind v4's @custom-variant is relatively new. Claude Code surfaced it immediately in working form, no docs spelunking needed.
  • The implement → verify → fix loop runs in seconds: Reproducing the CSS Grid auto misalignment, isolating the cause, and shipping the fix took about 5 minutes total.
  • Mechanical bulk work evaporates: Replacing placeholder text across 15 locale files (in-golexample.com) in a single iteration.
  • HMR affinity: Edit → save → instant browser refresh → feedback → re-edit. The loop runs fast enough that you stay in flow state.
  • End-to-end in one session: From finishing the redesign straight through to production push and watching the Vercel deploy — no context switching.

The patterns that didn't work as well are in the next section.

What Was Hard / What's Still Left

  • Translating vague feedback into structural decisions multiplied the iteration count. "This feels off" had to become "tabular vs. list vs. cards" before anything useful could happen. Getting that structural agreement upfront would have saved 3–4 iterations.
  • Around iteration 10, requirements kept expanding — "all inline," "everything truncated," "all columns" — and some constraints were genuinely in tension (information density vs. scannability). There was no clean shortcut to the final form.
  • Mobile is untouched. A 7-column grid produces horizontal scroll on small screens. The fix is either a sm: media query that stacks columns, or a separate mobile component. This is the next thing to tackle.

Conclusion

In a single session, we rebuilt the HeatMapX dashboard from a dark card grid to a light/dark/system-toggle layout with a GitHub-style tabular list. The key technical takeaways:

  1. Tailwind v4 dark mode: Start with @media-based dark mode — it's zero-config. Only layer in @custom-variant + the sync script when users need a manual override.
  2. Tabular alignment with CSS Grid: auto columns don't align across separate grid containers. Use fixed pixel widths or CSS Subgrid — those are the only reliable options.
  3. Long text robustness: Three layers, all required — frontend maxLength, server-side length check, and display truncate with minmax(0, ...).
  4. Design iteration budget: Assume 10–16 iterations per component and build a fast feedback loop from the start.

Whether this actually moves the drop-off rate is still an open question. We'll publish the follow-up with two weeks of real data.

Heatmaps you run from Claude Code — free to start.

Drop in one tracker tag. Analyze and ship CRO improvement PRs from the CLI. No credit card · 30-second setup.