รีดีไซน์ Dashboard SaaS ใน 1 วันด้วย Claude Code — Tailwind v4 + CSS Grid Tabular Alignment

HeatMapX Engineering Team12 min read
  • 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, กับดัก auto column ใน 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 ทำอิสระต่างหาก

มีสองทางออก:

  1. Fixed width: เปลี่ยน auto เป็น pixel value ชัดเจน เช่น 110px, 72px (แนวทางที่เราใช้)
  2. CSS Subgrid: วาง header และ SiteCard ทั้งหมดไว้ใน parent grid เดียว แล้วให้แต่ละแถว inherit tracks ของ parent ผ่าน subgrid (ซับซ้อนกว่า)

💡 บทเรียนที่ 2: อย่าใช้ auto columns เมื่อต้องการ 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 ชั้น ที่เราใช้:

  1. Frontend HTML5 limit: maxLength={60} / maxLength={512} บน input
  2. Server-side validation: Server Action ตรวจ name.length > 60 และ return error
  3. 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-golexample.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 บทเรียนหลักทางเทคนิค:

  1. Tailwind v4 dark mode: เริ่มด้วย @media-based dark mode — zero-config ดีกว่า เพิ่ม @custom-variant + sync script เฉพาะเมื่อ user ต้องการ manual override
  2. Tabular alignment ด้วย CSS Grid: auto columns ไม่ align ข้ามหลาย grid container ใช้ fixed pixel widths หรือ CSS Subgrid — นั่นคือตัวเลือกที่เชื่อถือได้เพียงอย่างเดียว
  3. ความทนทานต่อ long text: สามชั้นที่ขาดไม่ได้ทุกชั้น — frontend maxLength, server-side length check และ display truncate พร้อม minmax(0, ...)
  4. งบ design iteration: สมมติ 10–16 รอบต่อ component และสร้าง feedback loop ที่รวดเร็วตั้งแต่ต้น

ว่าสิ่งนี้จะส่งผลต่ออัตราการออกจริงหรือไม่ ยังเป็นคำถามที่ยังไม่มีคำตอบ เราจะ publish บทความติดตามพร้อมข้อมูลจริง 2 สัปดาห์

Heatmap จาก Claude Code — เริ่มฟรี

วางแท็ก tracker หนึ่งบรรทัด รับการวิเคราะห์และข้อเสนอ CRO จาก CLI