รีดีไซน์ Dashboard SaaS ใน 1 วันด้วย Claude Code — Tailwind v4 + CSS Grid Tabular Alignment
- engineering
- tailwind
- css-grid
- dark-mode
- claude-code
TL;DR
- ปัญหา: 11 ใน 15 ผู้ใช้ใหม่ (73%) สมัครเสร็จแล้วออกไปโดยไม่ได้ลงทะเบียน URL เลยสักเว็บ
- สมมติฐาน: ทุก heatmap tool ในหมวดเดียวกันใช้ Light UI เป็นค่าเริ่มต้น — dashboard มืดของเราอาจทำให้ผู้ใช้สับสนและออกไปก่อน
- สิ่งที่ทำ: รีดีไซน์ dashboard เต็มรูปแบบภายในวันเดียว — toggle สามทาง light / dark / system + รายการแบบตารางสไตล์ GitHub
- บทเรียน:
@custom-variantของ Tailwind v4, กับดักautocolumn ใน CSS Grid, การป้องกัน 3 ชั้นสำหรับข้อความยาว และเหตุผลที่ 10–16 รอบการออกแบบเป็นเรื่องปกติ ไม่ใช่ข้อยกเว้น- ผลลัพธ์: ผลกระทบต่ออัตราการออกจะวัดผลในภายหลัง มีแผนเขียนบทความติดตามหลังเก็บข้อมูล 2 สัปดาห์
บริบท: เราเสีย user ไปเพราะ dashboard มืดเกินไปหรือเปล่า?
HeatMapX (heatmapx.com) เป็น heatmap และ CRO tool ที่สามารถเรียกใช้จาก Claude Code ผ่าน CLI หลังจากเริ่มโปรโมชันครั้งแรก เราสังเกตเห็นรูปแบบที่น่าสนใจ: 11 จาก 15 ผู้ใช้ใหม่ (73%) สมัครเสร็จแต่ออกไปโดยไม่ได้ลงทะเบียน URL แม้แต่เว็บเดียว
สมมติฐานหนึ่ง: ทุก heatmap tool รายใหญ่ในหมวดนี้ใช้ Light UI เป็นค่าเริ่มต้น HeatMapX เป็นของแปลกหน้าตรงที่ใช้ dark admin interface ผู้ใช้ใหม่ที่เจอ dashboard มืดท่ามกลางแอปโทนขาวอื่น ๆ อาจแค่กด X ออกด้วยความสับสน
เราจึงจับมือกับ Claude Code และทำ full redesign ในเซสชันเดียว บทความนี้ครอบคลุม ครึ่งแรกของการทดสอบสมมติฐาน — การวินิจฉัยปัญหาและการ execute รีดีไซน์ ส่วนผลลัพธ์จริงจะมาในบทความติดตามหลังเก็บข้อมูล 2 สัปดาห์หลัง deploy
การกำหนด Design Configuration ขั้นสุดท้าย
| ส่วนประกอบ | Light | Dark |
|---|---|---|
| พื้นหลังหน้า | bg-neutral-100 (#f5f5f5) |
dark:bg-neutral-950 (#0a0a0a) |
| พื้นผิว Card | 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) |
ใช้ร่วมกันทั้งสองโหมด |
| Border radius | rounded-md (6px) · ไม่มีเงา · สไตล์ GitHub |
|
| Theme toggle | toggle สามทาง ☀ / 💻 / 🌙 ใน header |
Tailwind v4 Dark Mode — จาก Zero-Config สู่ Manual Toggle
เริ่มต้นด้วย @media prefers-color-scheme
พฤติกรรมเริ่มต้นของ Tailwind v4 คือ map variant dark: ตรงไปยัง @media (prefers-color-scheme: dark) แบบ zero-config ระบบจะ track การตั้งค่า dark mode ของ OS ได้ทันที
{/* layout.tsx */}
<div className="bg-neutral-100 text-slate-900 dark:bg-neutral-950 dark:text-slate-100">
...
</div>
วิธีนี้รองรับกรณี OS-follows-system (dark อัตโนมัติตอนกลางคืน ฯลฯ) โดยไม่ต้องตั้งค่าเพิ่ม แต่บางครั้ง user อยากเลือกเองแทน OS — "system" อาจทำงานผิดพลาด หรือแค่ชอบเลือกเอง — เราจึงขยายเป็น toggle สามทาง
เปลี่ยนเป็น class-based dark variant
ใน Tailwind v4 คุณสามารถนิยามพฤติกรรมของ variant dark: ใหม่ผ่าน @custom-variant เราเปลี่ยนเป็นโหมดที่ dark: จะทำงานก็ต่อเมื่อมี .dark อยู่บน <html> เท่านั้น:
/* globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
wrapper :where() ทำให้ specificity เป็น 0 ถ้าไม่มีมัน style ของ dark variant อาจชนกับ specificity rule อื่น ๆ ใน stylesheet
Script ซิงค์เพื่อป้องกัน SSR flash
ถ้าคุณ apply theme ใน useEffect หลัง React hydration จะเกิด flash-of-unstyled-content (FOUC) — กะพริบขาวสั้น ๆ ตอน render ครั้งแรก วิธีแก้คือใส่ inline sync script เข้าไปใน <head> ให้ทำงานก่อน 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 นี้ทำงาน synchronously ก่อน React mount ดังนั้น .dark จะอยู่บน <html> แล้วตอนที่ body เริ่ม render ให้เพิ่ม suppressHydrationWarning ใน <html> เพื่อปิด warning เรื่อง hydration mismatch
💡 บทเรียนที่ 1:
dark:ของ Tailwind v4 ทำงานแบบ zero-config ถ้าต้องการแค่ track OS ไม่จำเป็นต้องแตะอะไรเลย เพิ่ม@custom-variant+ sync script เมื่อต้องการ manual toggle โดยเฉพาะเท่านั้น — แนวทาง 2 ขั้นตอนที่สะอาดมาก
CSS Grid Tabular Alignment — และจุดที่ auto Columns ทำให้เจ็บปวด
รายการ single-column ตอนแรกไม่ work
หลังจากมีคอมเมนต์ว่า site card หลังลงทะเบียน "รู้สึกผิดปกติ" เราลองของง่ายก่อน: เปลี่ยน card grid สองคอลัมน์เป็น list หนึ่งคอลัมน์ แต่ละ SiteCard ใช้ flexbox ภายในเพื่อจัด name · url · status · events โดยมี chevron ด้านขวา
ปัญหาเกิดขึ้นทันทีที่มีหลายรายการ:
Short · example.com · ● Active · 5,577
Very Long Site Name That... · normal.com · ● Active · 5,577
ACME · acme.io · ● Active · 5,577
· Active, · 5,577 และ › chevron ต่างอยู่ใน พิกัด x ที่แตกต่างกันทุกแถว สายตาไม่สามารถยึด "คอลัมน์ status อยู่ตรงนี้" ได้เลย มันไม่ได้ scan แบบรายการ — มันอ่านเหมือนข้อความที่ซ้อนกันแนวตั้ง
นี่ไม่ใช่รายการ แต่คือข้อความที่ซ้อนกันแนวตั้ง
แปลงเป็น CSS Grid tabular layout
เราแทนที่ internals ของ flexbox ด้วย CSS Grid fixed-column definition เนื่องจากทุก card ใช้ 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>● Active</span>
<span>{count}</span>
<span>›</span>
</Link>
แถว header ใช้ 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>
กับดัก: auto columns ใน grid แยกกันจะ size อิสระจากกัน
รอบแรกเราใช้ grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto_auto_auto] ด้วยความคิดที่ว่า "auto ปรับตามเนื้อหา และเพราะ header กับ SiteCard ใช้ template string เดียวกัน มันจะ align กัน"
แต่มันไม่ align กัน เหตุผลคือ:
- คอลัมน์
autoของ header: ขนาดตามความกว้างข้อความ "STATUS" - คอลัมน์
autoของ SiteCard: ขนาดตามความกว้างข้อความ "● Active" - ทั้งสองเป็น grid container แยกกัน — การคำนวณขนาด track ทำอิสระต่างหาก
มีสองทางออก:
- Fixed width: เปลี่ยน
autoเป็น pixel value ชัดเจน เช่น110px,72px(แนวทางที่เราใช้) - CSS Subgrid: วาง header และ SiteCard ทั้งหมดไว้ใน parent grid เดียว แล้วให้แต่ละแถว inherit tracks ของ parent ผ่าน
subgrid(ซับซ้อนกว่า)
💡 บทเรียนที่ 2: อย่าใช้
autocolumns เมื่อต้องการ tabular alignment ข้ามหลาย grid container แต่ละ container คำนวณความกว้าง track ของตัวเองตาม content ของมันเอง ใช้ fixed pixel widths หรือใช้ subgrid แทน
การป้องกัน Long Text — 3 ชั้น
DB schema ตั้งต้นมีดังนี้:
CREATE TABLE sites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL, -- ไม่จำกัดความยาว
url text NOT NULL, -- ไม่จำกัดความยาว
...
);
text ของ Postgres ไม่จำกัดความยาวโดยพื้นฐาน ชื่อเว็บ 10,000 ตัวอักษรถูกต้องทาง SQL ทุกประการ — และจะทำลาย tabular layout ในทันที การย้ายมาใช้ fixed-column grid ทำให้การจำกัดความยาวข้อความกลายเป็นสิ่งที่ขาดไม่ได้
การป้องกัน 3 ชั้น ที่เราใช้:
- Frontend HTML5 limit:
maxLength={60}/maxLength={512}บน input - Server-side validation: Server Action ตรวจ
name.length > 60และ return error - Display truncation: class
truncateบนทุก grid cell ร่วมกับminmax(0, ...)เพื่อ enable overflow
ชั้น 3 คือสิ่งที่คนมักพลาด truncate ขยายเป็น overflow: hidden + text-overflow: ellipsis + white-space: nowrap แต่ใน context ของ flex หรือ grid ยังต้องการ min-width: 0 บน cell ด้วย — มิฉะนั้น cell จะขยายตาม content และ overflow จะไม่เกิดขึ้น minmax(0, ...) ให้ค่า min-width: 0 ได้พอดี
16 รอบการออกแบบ
SiteCard เพียงชิ้นเดียวผ่าน 16 รอบการออกแบบในเซสชันนี้ คัดเลือกมาบางรอบ:
| # | การเปลี่ยนแปลง | เหตุผล |
|---|---|---|
| 1 | แปลง CopyBlock เป็น light gray pill | พื้นหลังดำ + ข้อความเขียวดูแปลกในโหมด GitHub light |
| 3 | เปรียบเทียบ 4 ตัวเลือก A/B/C/D | ต้องการ click affordance ที่แข็งแกร่งขึ้น |
| 4 | เปลี่ยนเป็น list 1 คอลัมน์ | ถูกปฏิเสธว่า "ตกแต่งมากเกินไป" — กลับสู่พื้นฐาน |
| 7 | ลบ CTA "View Heatmap →" | "ดูไม่สะอาด" |
| 9 | แปลงเป็น CSS Grid tabular layout | layout แบบ separator-based "ไม่รู้สึกเหมือนรายการ" |
| 10 | auto → fixed pixel widths |
คอลัมน์ header และ card ไม่ align กัน |
| 11 | คืน column API KEY | "เดี๋ยวก่อน API key อยู่ไหน?" |
| 12 | เพิ่ม column TRACKER TAG | "วางไว้ข้าง ๆ กันเลย" |
| 14 | เพิ่ม maxLength + server validation | แก้ปัญหา long text อย่างถูกต้องถาวร |
| 16 | เพิ่มปุ่ม "+" ข้าง heading (compact mode) | เพิ่มรายการได้รวดเร็วโดยไม่ต้อง scroll ลง |
⚠ อย่าคาดหวังว่าจะได้ผลในรอบแรก การ iterate design หมายถึง "ตรวจสอบใน dev server → feedback ทันที → ลองใหม่" วนซ้ำ 10+ รอบ วางแผนสำหรับ loop นั้นไว้เลย สภาพแวดล้อม AI development แบบ interactive อย่าง Claude Code คือที่ที่ pattern นี้ส่องแสงได้ดีที่สุด
Pattern ที่ Work จริงกับ AI-Assisted Development
สิ่งเหล่านี้ไม่ใช่การสังเกตเฉพาะเครื่องมือ — แต่เป็น pattern ทั่วไปที่พิสูจน์แล้วว่าได้ผลเมื่อทำงานคู่กับ AI แบบ interactive:
- ไม่ต้องเสียเวลาค้น syntax API ใหม่:
@custom-variantของ Tailwind v4 ค่อนข้างใหม่ Claude Code ดึงมันมาในรูปแบบที่ใช้งานได้ทันที ไม่ต้องขุดเอกสาร - Loop implement → verify → fix ทำงานในไม่กี่วินาที: ทำซ้ำปัญหา misalignment ของ CSS Grid
auto, แยกสาเหตุ และ ship การแก้ไขใช้เวลารวมประมาณ 5 นาที - งาน bulk เชิงกลหายไปได้เลย: แทนที่ placeholder text ใน 15 locale file (
in-gol→example.com) ใน iteration เดียว - HMR affinity: Edit → save → browser refresh ทันที → feedback → re-edit loop นี้เร็วพอที่จะอยู่ใน flow state ได้
- End-to-end ในเซสชันเดียว: จากจบการรีดีไซน์ไปถึง production push และดู Vercel deploy — ไม่มีการสลับ context
ส่วน pattern ที่ไม่ค่อย work อยู่ในหัวข้อถัดไป
สิ่งที่ยากและสิ่งที่ยังค้างอยู่
- การแปล feedback คลุมเครือเป็นการตัดสินใจเชิงโครงสร้าง ทำให้รอบการ iterate เพิ่มขึ้น "รู้สึกผิดปกติ" ต้องกลายเป็น "tabular vs. list vs. cards" ก่อนถึงจะทำอะไรที่เป็นประโยชน์ได้ การตกลงเรื่องโครงสร้างตั้งแต่ต้นจะประหยัดได้ 3–4 รอบ
- รอบ iteration ที่ 10 ข้อกำหนดขยายเรื่อย ๆ — "inline ทั้งหมด" "truncate ทุกอย่าง" "ทุก column" — และ constraint บางอย่างขัดแย้งกันจริง ๆ (ความหนาแน่นข้อมูล vs. ความสะดวกในการสแกน) ไม่มีทางลัดที่สะอาดไปสู่รูปแบบสุดท้าย
- Mobile ยังไม่ถูกแตะ Grid 7 คอลัมน์ทำให้เกิด horizontal scroll บนจอเล็ก วิธีแก้คือ media query
sm:ที่ stack คอลัมน์ หรือ mobile component แยกต่างหาก นี่คือสิ่งที่ต้องทำต่อไป
สรุป
ในเซสชันเดียว เรา rebuild HeatMapX dashboard จาก dark card grid เป็น layout ที่มี toggle light/dark/system พร้อม tabular list สไตล์ GitHub บทเรียนหลักทางเทคนิค:
- Tailwind v4 dark mode: เริ่มด้วย
@media-based dark mode — zero-config ดีกว่า เพิ่ม@custom-variant+ sync script เฉพาะเมื่อ user ต้องการ manual override - Tabular alignment ด้วย CSS Grid:
autocolumns ไม่ align ข้ามหลาย grid container ใช้ fixed pixel widths หรือ CSS Subgrid — นั่นคือตัวเลือกที่เชื่อถือได้เพียงอย่างเดียว - ความทนทานต่อ long text: สามชั้นที่ขาดไม่ได้ทุกชั้น — frontend
maxLength, server-side length check และ displaytruncateพร้อมminmax(0, ...) - งบ design iteration: สมมติ 10–16 รอบต่อ component และสร้าง feedback loop ที่รวดเร็วตั้งแต่ต้น
ว่าสิ่งนี้จะส่งผลต่ออัตราการออกจริงหรือไม่ ยังเป็นคำถามที่ยังไม่มีคำตอบ เราจะ publish บทความติดตามพร้อมข้อมูลจริง 2 สัปดาห์