Xây Dựng Lại Dashboard SaaS Trong Một Ngày với Claude Code — Tailwind v4 + CSS Grid Tabular Alignment
- 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-variantcủa Tailwind v4, bẫy cộtautocủ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
autocủa header: được định kích thước theo độ rộng văn bản của "STATUS" - Cột
autocủ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:
- Chiều rộng cố định: Thay
autobằng giá trị pixel tường minh như110px,72px(cái chúng tôi đã triển khai) - 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
autokhi 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:
- Giới hạn HTML5 ở frontend:
maxLength={60}/maxLength={512}trên các input - Xác thực phía server: Server Action kiểm tra
name.length > 60và trả về lỗi - Cắt ngắn hiển thị: Class
truncatetrên mỗi ô grid, kết hợp vớiminmax(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-variantcủ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
autoCSS 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-gol→example.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:
- 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. - Tabular alignment với CSS Grid: Cột
autokhô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. - Độ 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àtruncatehiển thị vớiminmax(0, ...). - 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ế.