Xây Dựng Lại Dashboard SaaS Trong Một Ngày với Claude Code — Tailwind v4 + CSS Grid Tabular Alignment

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

TL;DR

  • Vấn đề: 11 trong số 15 người dùng mới (73%) đã đăng ký nhưng rời đi mà không đăng ký URL nào
  • Giả thuyết: Mọi công cụ heatmap trong phân khúc của chúng tôi đều mặc định giao diện sáng — dashboard tối của chúng tôi có thể đang gây nhầm lẫn và khiến người dùng rời bỏ sớm
  • Những gì chúng tôi đã làm: Thiết kế lại toàn bộ dashboard trong một ngày — toggle ba trạng thái light / dark / system + danh sách dạng bảng kiểu GitHub
  • Bài học: @custom-variant của Tailwind v4, bẫy cột auto của CSS Grid, cơ chế phòng thủ 3 lớp chống văn bản dài, và tại sao 10–16 vòng lặp thiết kế là chuyện bình thường, không phải ngoại lệ
  • Kết quả: Tác động đến tỷ lệ rời bỏ sẽ được đo lường; bài viết tiếp theo sẽ được đăng sau hai tuần có dữ liệu

Bối cảnh: Chúng tôi có đang mất người dùng vì dashboard quá tối?

HeatMapX (heatmapx.com) là công cụ heatmap và CRO có thể gọi từ Claude Code qua CLI. Sau khi bắt đầu chiến dịch quảng bá đầu tiên, chúng tôi nhận thấy một xu hướng: 11 trong số 15 người dùng mới (73%) hoàn tất đăng ký nhưng rời đi mà không đăng ký một URL nào.

Một giả thuyết: mọi công cụ heatmap lớn trong phân khúc của chúng tôi đều ra mắt với giao diện sáng mặc định. HeatMapX là ngoại lệ duy nhất với giao diện quản trị tối. Người dùng lần đầu đến với dashboard tối trong một biển ứng dụng có giao diện sáng có thể đơn giản là bỏ đi vì nhầm lẫn.

Vì vậy chúng tôi đã kết hợp với Claude Code và thực hiện một thiết kế lại hoàn toàn trong một phiên làm việc. Bài viết này đề cập đến nửa đầu của bài kiểm tra giả thuyết đó — chẩn đoán nguyên nhân rời bỏ và thực thi việc thiết kế lại. Bài tiếp theo với số liệu thực tế sẽ đến sau khi chúng tôi có hai tuần dữ liệu sau khi triển khai.

Cấu hình Thiết kế Cuối cùng

Thành phần Light Dark
Nền trang bg-neutral-100 (#f5f5f5) dark:bg-neutral-950 (#0a0a0a)
Bề mặt card bg-white + border-slate-200 dark:bg-slate-900 + dark:border-slate-800
Văn bản chính text-slate-900 dark:text-slate-100
Màu chủ đạo (CTA) bg-orange-600 (#ea580c) Dùng chung cho cả hai chế độ
Border radius rounded-md (6px) · không bóng đổ · kiểu GitHub
Toggle theme ☀ / 💻 / 🌙 toggle ba trạng thái trong header

Tailwind v4 Dark Mode — Từ Zero-Config đến Toggle Thủ công

Bắt đầu với @media prefers-color-scheme

Hành vi mặc định của Tailwind v4 ánh xạ variant dark: trực tiếp đến @media (prefers-color-scheme: dark). Ngay từ đầu, không cần cấu hình gì, nó chỉ đơn giản theo dõi cài đặt dark mode của hệ điều hành.

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

Điều này bao gồm trường hợp theo hệ thống (tự động tối vào ban đêm, v.v.) mà không cần thiết lập thêm gì. Nhưng đôi khi người dùng muốn ghi đè cài đặt hệ điều hành — "system" có thể bắn nhầm, hoặc đơn giản là sở thích cá nhân — vì vậy chúng tôi đã mở rộng thành toggle ba trạng thái.

Chuyển sang class-based dark variant

Trong Tailwind v4, bạn có thể định nghĩa lại hành vi của variant dark: bằng @custom-variant. Chúng tôi chuyển sang chế độ mà dark: chỉ kích hoạt khi .dark có trên <html>:

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

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

Wrapper :where() giữ specificity ở mức 0. Không có nó, các style của dark variant có thể xung đột với các quy tắc specificity hiện có ở nơi khác trong stylesheet của bạn.

Script đồng bộ để ngăn SSR flash

Nếu bạn áp dụng theme bên trong useEffect sau khi React hydration, bạn sẽ gặp flash-of-unstyled-content (FOUC) — một cú nhấp nháy trắng ngắn khi render lần đầu. Cách khắc phục là một inline sync script được đưa vào <head> và chạy trước khi body render:

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

Script này chạy đồng bộ trước khi React mount, vì vậy .dark đã có trên phần tử <html> vào thời điểm body bắt đầu render. Thêm suppressHydrationWarning vào <html> để tắt cảnh báo hydration mismatch.

💡 Bài học 1: dark: của Tailwind v4 hoạt động zero-config. Nếu chỉ cần theo dõi hệ điều hành là đủ, bạn không cần chạm vào gì cả. Chỉ thêm @custom-variant + sync script khi bạn cần toggle thủ công cho người dùng — một cách tiếp cận hai giai đoạn gọn gàng.

CSS Grid Tabular Alignment — và Bẫy Cột auto

Danh sách một cột ban đầu không ổn

Sau khi nhận thấy các site card sau khi đăng ký cảm giác "không ổn", chúng tôi đã thử điều hiển nhiên trước: hoán đổi lưới card hai cột thành danh sách một cột. Mỗi SiteCard sử dụng flexbox bên trong để căn hàng name · url · status · events, với một chevron ở bên phải.

Vấn đề xuất hiện ngay khi có nhiều mục:

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

Các · Active, · 5,577, và › chevron đều rơi vào các tọa độ x khác nhau trên mỗi hàng. Mắt bạn không thể neo vào "cột trạng thái ở đây." Nó không quét được như một danh sách — nó đọc như các chuỗi chồng lên nhau.

Đây không phải là danh sách. Đây là văn bản, xếp chồng theo chiều dọc.

Chuyển đổi sang CSS Grid tabular layout

Chúng tôi thay thế phần bên trong flexbox bằng định nghĩa cột cố định CSS Grid. Vì mọi card đều dùng chung template grid, các cạnh trái của cột căn hàng hoàn hảo suốt trang:

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

Hàng header sử dụng đúng cùng template grid:

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

Bẫy: cột auto trong các grid riêng biệt được định kích thước độc lập

Lần đầu tiên chúng tôi dùng grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto_auto_auto]. Lý luận: "auto thích nghi với nội dung, và vì header và SiteCard dùng chung chuỗi template, chúng sẽ căn hàng."

Chúng đã không căn hàng. Đây là lý do:

  • Cột auto của header: được định kích thước theo độ rộng văn bản của "STATUS"
  • Cột auto của SiteCard: được định kích thước theo độ rộng văn bản của "● Active"
  • Cả hai đều là các grid container riêng biệt — định kích thước track được tính toán độc lập cho mỗi container

Hai giải pháp:

  1. Chiều rộng cố định: Thay auto bằng giá trị pixel tường minh như 110px, 72px (cái chúng tôi đã triển khai)
  2. CSS Subgrid: Đặt header và tất cả SiteCard vào một grid cha, sau đó để mỗi hàng kế thừa các track của cha qua subgrid (phức tạp hơn)

💡 Bài học 2: Đừng dùng cột auto khi bạn cần tabular alignment qua các grid container riêng biệt. Mỗi container tính chiều rộng track riêng dựa trên nội dung của chính nó. Hãy dùng chiều rộng pixel cố định hoặc chia sẻ track qua subgrid.

Phòng Thủ Chống Văn Bản Dài — 3 Lớp

Schema DB ban đầu có:

CREATE TABLE sites (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL,  -- không giới hạn độ dài
  url text NOT NULL,   -- không giới hạn độ dài
  ...
);

text của Postgres thực tế là không giới hạn. Tên site 10.000 ký tự là SQL hoàn toàn hợp lệ — và sẽ phá hủy tabular layout ngay lập tức. Chuyển sang grid cột cố định khiến các ràng buộc về độ dài văn bản trở thành bắt buộc.

Cơ chế phòng thủ 3 lớp mà chúng tôi đã xây dựng:

  1. Giới hạn HTML5 ở frontend: maxLength={60} / maxLength={512} trên các input
  2. Xác thực phía server: Server Action kiểm tra name.length > 60 và trả về lỗi
  3. Cắt ngắn hiển thị: Class truncate trên mỗi ô grid, kết hợp với minmax(0, ...) để kích hoạt overflow

Lớp 3 là thứ mọi người hay bỏ qua. truncate mở rộng thành overflow: hidden + text-overflow: ellipsis + white-space: nowrap, nhưng trong context flex hoặc grid, nó cũng cần min-width: 0 trên ô — nếu không ô sẽ mở rộng để vừa nội dung và overflow không bao giờ kích hoạt. minmax(0, ...) cung cấp chính xác điều đó.

16 Vòng Lặp Thiết kế

Chỉ riêng SiteCard đã trải qua 16 vòng lặp thiết kế trong phiên này. Một số vòng đáng chú ý:

# Thay đổi Lý do
1 Chuyển CopyBlock thành pill xám nhạt Nền đen + văn bản xanh trông sai trong GitHub light mode
3 So sánh bốn tùy chọn A/B/C/D Cần khả năng kích thích click mạnh hơn
4 Chuyển sang danh sách 1 cột Bị từ chối là "quá trang trí" — quay lại cơ bản
7 Xóa CTA "View Heatmap →" "Trông không gọn"
9 Chuyển sang CSS Grid tabular layout Layout dựa trên separator "không cảm giác như danh sách"
10 auto → chiều rộng pixel cố định Cột header và card không căn hàng
11 Khôi phục cột API KEY "Khoan, API key đâu rồi?"
12 Thêm cột TRACKER TAG "Đặt cả hai cạnh nhau"
14 Thêm maxLength + xác thực server Cách xử lý đúng đắn cho văn bản dài
16 Thêm nút "+" bên cạnh tiêu đề (chế độ compact) Thêm nhanh mà không cần cuộn xuống

Đừng kỳ vọng đúng ngay từ lần đầu. Vòng lặp thiết kế có nghĩa là "kiểm tra trên dev server → phản hồi ngay lập tức → thử lại", lặp lại hơn 10 lần. Hãy lên kế hoạch cho vòng lặp đó. Môi trường phát triển AI tương tác như Claude Code là nơi pattern này thực sự tỏa sáng.

Các Pattern Thực Sự Hiệu Quả với AI-Assisted Development

Đây không phải là những quan sát riêng về công cụ — mà là các pattern chung được chứng minh hiệu quả khi kết hợp với AI tương tác:

  • Chi phí tra cứu cú pháp API mới bằng không: @custom-variant của Tailwind v4 khá mới. Claude Code đưa ra nó ngay lập tức dưới dạng hoạt động, không cần lần mò tài liệu.
  • Vòng lặp implement → verify → fix chạy trong vài giây: Tái tạo sự không căn hàng của cột auto CSS Grid, xác định nguyên nhân, và triển khai bản sửa chỉ mất khoảng 5 phút.
  • Công việc cơ học thủ công biến mất: Thay thế văn bản placeholder qua 15 file locale (in-golexample.com) trong một vòng lặp duy nhất.
  • Thân thiện với HMR: Sửa → lưu → trình duyệt tự động refresh → phản hồi → sửa lại. Vòng lặp chạy đủ nhanh để bạn giữ được trạng thái tập trung.
  • End-to-end trong một phiên: Từ hoàn tất thiết kế lại thẳng đến push lên production và xem deploy trên Vercel — không có context switching.

Các pattern không hiệu quả bằng ở phần tiếp theo.

Những Gì Khó / Những Gì Còn Lại

  • Chuyển đổi phản hồi mơ hồ thành quyết định cấu trúc đã nhân số vòng lặp lên. "Cái này cảm giác không ổn" phải trở thành "tabular vs. list vs. cards" trước khi có thể làm gì có ích. Đạt được sự đồng thuận cấu trúc từ đầu sẽ tiết kiệm được 3–4 vòng lặp.
  • Khoảng vòng 10, các yêu cầu liên tục mở rộng — "tất cả inline", "mọi thứ đều truncated", "tất cả các cột" — và một số ràng buộc thực sự mâu thuẫn với nhau (mật độ thông tin vs. khả năng quét). Không có lối tắt nào để đến được hình dạng cuối cùng.
  • Mobile chưa được xử lý. Grid 7 cột tạo ra cuộn ngang trên màn hình nhỏ. Cách khắc phục là dùng media query sm: để xếp chồng các cột, hoặc một component mobile riêng. Đây là thứ tiếp theo cần giải quyết.

Kết luận

Trong một phiên làm việc, chúng tôi đã xây dựng lại dashboard HeatMapX từ card grid tối sang layout có toggle light/dark/system với danh sách dạng bảng kiểu GitHub. Những điểm kỹ thuật chính:

  1. Tailwind v4 dark mode: Bắt đầu với dark mode dựa trên @media — zero-config. Chỉ thêm @custom-variant + sync script khi người dùng cần override thủ công.
  2. Tabular alignment với CSS Grid: Cột auto không căn hàng qua các grid container riêng biệt. Dùng chiều rộng pixel cố định hoặc CSS Subgrid — đó là những tùy chọn đáng tin cậy duy nhất.
  3. Độ bền với văn bản dài: Ba lớp, tất cả đều cần thiết — maxLength ở frontend, kiểm tra độ dài phía server, và truncate hiển thị với minmax(0, ...).
  4. Ngân sách vòng lặp thiết kế: Giả định 10–16 vòng lặp mỗi component và xây dựng vòng phản hồi nhanh ngay từ đầu.

Liệu điều này có thực sự tác động đến tỷ lệ rời bỏ hay không vẫn là câu hỏi chưa có lời giải. Chúng tôi sẽ đăng bài tiếp theo với hai tuần dữ liệu thực tế.

Heatmap chạy từ Claude Code — bắt đầu miễn phí.

Dán một tag tracker, nhận phân tích và đề xuất CRO từ CLI.