บทเรียน 81 · Frontend · Next.js App

Design Tokens — ตั้งชื่อสีและระยะไว้ที่เดียว ใช้ทั้งแอป

~20 นาที · ควรผ่าน บท 72 (Tailwind CSS) · บท 80 (shadcn/ui) มาก่อน

ถึงตอนนี้แอป Wongnok มี shadcn/ui ติดตั้งแล้ว แต่ปุ่มยังเป็นสีดำเทาตามค่าเริ่มต้น ไม่ใช่สีม่วงแบรนด์
สไลด์หน้า 17–27 ทำสองอย่าง: ให้ AI ช่วยออกแบบหน้าตาแอป แล้ว ถอดการตัดสินใจเรื่องสีกับตัวอักษรออกมาเป็น “design token” ใส่ไว้ใน app/globals.css
บทนี้เล่าส่วน AI แค่สั้น ๆ พอให้รู้ที่มา แล้วใช้เวลาเกือบทั้งหมดกับเรื่องที่พังเงียบได้: เพิ่มสีใหม่ใน globals.css แล้ว class ไม่ทำงาน โดยที่ build ไม่ฟ้องสักคำ

🧰 ของที่ใช้ตรวจในบทนี้

next         16.3.5
react        19.2.8
tailwindcss  4.3.3
typescript   5.9.3
node         v24.14.0
# โปรเจกต์ create-next-app + npx shadcn init -d (preset base-nova)
# + shadcn add button card badge skeleton

ทุกการทดลองในบทนี้ทำจริงบนโปรเจกต์นั้น: แก้ globals.css → npx next build → แล้ว grep ไฟล์ CSS ที่ build ออกมาใน .next/static/chunks/ ดูว่ามี rule ของ class นั้นจริงไหม
เรื่อง dark mode วัดจากหน้าเว็บจริงด้วย getComputedStyle() ใน browser (เปิดด้วย npx next start) · เฉลยภารกิจผ่าน next build · tsc --noEmit · eslint ครบ
ข้อสังเกตก่อนอ่าน: ในไฟล์ CSS ที่ build แล้ว ค่า oklch(0.205 0 0) ที่เขียนไว้ใน globals.css จะกลายเป็น #171717 — ตัวบีบไฟล์แปลงให้เอง สีเดียวกัน ไม่ต้องตกใจ

ก่อนเขียนโค้ด — สไลด์ให้ AI ช่วยออกแบบก่อน (หน้า 18–24)

สไลด์ขึ้นหัวข้อ “Build your design” (หน้า 18) แล้วโชว์เครื่องมือออกแบบที่ใช้ AI ช่วย (หน้า 19): Claude · Open Design · v0 · Figma · bolt · Lovable · Stitch
อาจารย์เลือกใช้ Claude Design (หน้า 20) ซึ่งสไลด์อธิบายว่าเป็นเครื่องมือของ Anthropic Labs ที่สร้างและแก้ prototype แบบกดเล่นได้

ขั้นตอนที่สไลด์โชว์มีสามจังหวะ:

  1. เขียน prompt วางแผนก่อน (หน้า 21) — ให้ AI รับบทเป็น senior UX/UI designer แล้วบอกบริบทของโปรเจกต์ “Wongnok”: เป็นแพลตฟอร์มแชร์สูตรอาหาร · ผู้ใช้ทั่วไปมีหน้า Landing, หน้ารวมสูตร (ค้นด้วยชื่อ กรองวันที่ กรองระดับความยาก), หน้ารายละเอียด, ให้ดาว, กดชอบ (ต้อง sign-in ก่อน), login ด้วย Keycloak, แก้โปรไฟล์ · ส่วนคนแชร์สูตรมีฟอร์มเพิ่มสูตร และแก้/ลบสูตรของตัวเองได้
  2. ให้ AI สร้างหน้าจอ (หน้า 22–23) — ภาพในสไลด์เป็นหน้า Landing กับหน้า All Recipes ที่ใช้สีม่วงเป็นสีหลัก · สังเกตว่าในแถบซ้ายของภาพ prompt ขอให้ออกแบบสถานะครบสี่แบบ (LOADING · EMPTY · ERROR · DEFAULT) และระบุชื่อ component ของ shadcn ที่จะใช้ด้วย (Card, Input, Select, Badge, Pagination, Skeleton, Sonner toast)
  3. สรุปธีมออกมาเป็น token (หน้า 24–27) — ซึ่งคือเนื้อหาหลักของบทนี้
⚠️ ส่วนนี้ผมไม่ได้รันซ้ำ

ผลลัพธ์ของ AI ในหน้า 22–23 เป็นภาพหน้าจอจากสไลด์ ผมเล่าตามที่เห็นในภาพเท่านั้น ไม่ได้สั่ง AI สร้างซ้ำ และ AI ก็ไม่ให้ผลเหมือนเดิมทุกครั้งอยู่แล้ว
ที่ควรเก็บไปใช้คือวิธีเขียน prompt ไม่ใช่หน้าตาที่ได้ — บอกบทบาท · บอกบริบท · แจกแจงฟีเจอร์ · ขอสถานะ empty/error ตั้งแต่แรก · และบอกชื่อ component ที่มีอยู่จริงในโปรเจกต์ (ถ้าไม่บอก AI จะออกแบบของที่คุณไม่มี)

Design token คืออะไร

ดูภาพหน้า 22 อีกที ปุ่ม “Browse Recipes” เป็นสีม่วง ป้าย “Home cooks welcome” ก็ม่วงอ่อน ลิงก์เมนูที่เลือกอยู่ก็ม่วง
ถ้าเขียนแบบตรง ๆ คุณจะพิมพ์ #A80689 ซ้ำไว้ในไฟล์เป็นสิบที่ · วันหนึ่งฝ่ายการตลาดขอเปลี่ยนเฉดม่วง คุณต้องไล่หาทั้งโปรเจกต์ แล้วก็จะพลาดสักที่

Design token คือการตั้งชื่อให้การตัดสินใจเรื่องหน้าตา แล้วเก็บไว้ที่เดียว
แทนที่จะพิมพ์ #A80689 เราตั้งชื่อว่า --primary แล้วทุกที่ใช้ชื่อนี้ · อยากเปลี่ยนสีก็แก้บรรทัดเดียว
สไลด์หน้า 25 นิยามไว้สั้น ๆ ว่าเป็นตัวแปรที่มีชื่อ ใช้เก็บการตัดสินใจเรื่อง สี · ฟอนต์ · ระยะห่าง และหน้า Wongnok UI ในภาพก็เขียนหลักไว้ว่า ใช้ --primary ไม่ใช่ใช้เลข hex เพื่อให้เปลี่ยนธีมได้โดยไม่ต้องแก้ component

ตัวอย่าง token ฝั่งสีแบรนด์จากสไลด์หน้า 25:

ชื่อ tokenค่าใช้ทำอะไร (ตามสไลด์)
--primary#A80689ปุ่มหลัก · ลิงก์ · สถานะที่เลือกอยู่
--primary-hover#8A0570ตอนเอาเมาส์ชี้ / กดปุ่มหลัก
--primary-subtle#F7E6F2พื้นหลังอวาตาร์ · badge · กล่องไอคอน
--primary-border#F0D5E8เส้นขอบบนพื้นม่วงอ่อน
--accent#FF9F1Cดาวให้คะแนน · ไฮไลต์
--accent-strong#B36A00ตัวอักษร/ไอคอนสีอำพันบนพื้นสว่าง
--accent-subtle#FFF1DCพื้นป้าย Easy · ชิปคะแนน

สังเกตว่าชื่อบอกหน้าที่ ไม่ได้บอกสี — ไม่มี token ชื่อ --purple เพราะถ้าวันหนึ่งแบรนด์เปลี่ยนเป็นสีเขียว ชื่อ --purple จะกลายเป็นคำโกหกทั้งโปรเจกต์ แต่ --primary ยังถูกอยู่

🍜 เทียบกับของจริง

token เหมือนสูตรน้ำจิ้มกลางของร้านอาหารที่มีหลายสาขา
ในครัวทุกสาขาไม่มีใครจำว่าน้ำจิ้มซีฟู้ดใส่พริกกี่เม็ด มะนาวกี่ช้อน — ทุกคนรู้แค่ว่า “จานนี้ใช้ น้ำจิ้มสูตร 1” แล้วไปหยิบจากถังที่ครัวกลางส่งมา
วันที่ครัวกลางปรับสูตรให้เผ็ดขึ้น ทุกสาขาเปลี่ยนตามทันทีโดยไม่ต้องสอนพ่อครัวใหม่สักคน

ที่ไม่เหมือนกัน: สาขาที่ยังมีน้ำจิ้มถังเก่าเหลืออยู่จะใช้สูตรเก่าต่อไปอีกหลายวัน แต่ CSS variable เปลี่ยนแล้วทุกที่เปลี่ยนพร้อมกันในการโหลดหน้าครั้งถัดไป ไม่มีของค้างสต็อก
และที่ต่างกว่านั้น: พ่อครัวที่แอบเติมพริกเองในจาน (คือคนที่เขียน bg-[#A80689] ตรง ๆ แทนการใช้ token) จะไม่ได้รับผลของการเปลี่ยนสูตรเลย — token ช่วยได้เฉพาะที่ที่ใช้ชื่อมันจริง ๆ

🗣 ศัพท์ที่เพิ่งเรียนไป
design token
ค่าหน้าตาหนึ่งค่า (สี · ขนาดตัวอักษร · ระยะ · ความโค้งมุม · เงา) ที่ถูกตั้งชื่อแล้วเก็บไว้ที่เดียว · คำนี้ใช้ร่วมกันทั้งฝั่ง designer และ developer เลยเป็นภาษากลางที่ดี · ถาม designer ได้ว่า “สีนี้มี token แล้วหรือยัง หรือเป็นค่าเฉพาะหน้านี้”
semantic token (token ตามหน้าที่)
token ที่ตั้งชื่อตามบทบาท (--primary, --destructive) ไม่ใช่ตามหน้าตา (--purple-600) · ชื่อแบบหลังเรียกว่า primitive/palette token · พิมพ์บอก AI ได้ว่า “use semantic tokens like --primary instead of hardcoded hex values”
CSS custom property (CSS variable)
ตัวแปรของ CSS เอง ขึ้นต้นด้วย -- อ่านค่าด้วย var(--ชื่อ) · ทำงานใน browser ตอนรันจริง ไม่ใช่ตอน build — เปลี่ยนค่าตรงไหน ลูกหลานใต้ตรงนั้นเห็นค่าใหม่ทันที · นี่คือกลไกที่ทำให้ dark mode ทำงานได้ (เดี๋ยวพิสูจน์ให้ดู)

เปิด globals.css ของจริง — token มีสองชั้น

สไลด์หน้า 26 บอกให้ “ใส่ธีมลงใน globals.css ตาม design token” และภาพ VS Code ในหน้านั้นเห็นบรรทัดหน้าตาแบบ --color-primary: var(--primary); เรียงกันยาว
ผมเปิดไฟล์ที่ shadcn init สร้างให้จริง ๆ มาดู (ตัดเหลือเฉพาะส่วนที่เกี่ยวกับ primary):

/* app/globals.css — ที่ shadcn init สร้างให้ (ตัดบางส่วน) */
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";

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

@theme inline {                                   /* ชั้นที่ 2: บอก Tailwind ให้สร้าง class */
  --color-primary-foreground: var(--primary-foreground);
  --color-primary: var(--primary);
  /* … อีกราว 40 บรรทัด … */
}

:root {                                           /* ชั้นที่ 1: ค่าจริงของธีมสว่าง */
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  /* … */
}

.dark {                                           /* ชั้นที่ 1 อีกชุด: ค่าจริงของธีมมืด */
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  /* … */
}

อ่านจากล่างขึ้นบนจะเข้าใจง่ายกว่า:

:root { --primary: #a80689 } ← ค่าจริง (ชั้น 1) │ ▼ @theme inline { --color-primary: var(--primary) } ← ตั้งชื่อให้ Tailwind (ชั้น 2) │ └──┬──┘ │ "--color-" + "primary" ▼ class ที่ใช้ได้: bg-primary · text-primary · border-primary · ring-primary │ ▼ (npx next build) CSS จริงที่ได้: .bg-primary{background-color:var(--primary)}

บรรทัดสุดท้ายในแผนภาพไม่ได้เดา — ก็อปมาจากไฟล์ CSS ที่ build ออกมาจริง (components/ui/button.tsx ใช้ bg-primary อยู่ Tailwind จึงสร้าง rule นี้ให้)

token (ชั้น 1 ใน :root)ชื่อใน @theme inline (ชั้น 2)class ที่ได้
--primary--color-primarybg-primary · text-primary · border-primary
--primary-foreground--color-primary-foregroundtext-primary-foreground
--border--color-borderborder-border
--radius--radius-lg: var(--radius)rounded-lg (ตระกูล --radius- ไม่ใช่ --color-)
--primary-hover (เพิ่มเอง)ยังไม่มี??? — ดูข้อทายข้างล่าง

กับดักหลักของบท — เพิ่ม token แค่ชั้นเดียว

สไลด์หน้า 27 มี --primary-hover: #8a0570; อยู่ใน :root (ภาพตัดส่วน @theme inline ไม่ได้โชว์) · สมมติคุณก็อปมาแค่บรรทัดนั้น แล้วเขียนปุ่มแบบนี้:

/* app/globals.css — เพิ่มบรรทัดเดียวใน :root */
:root {
  --primary-foreground: oklch(0.985 0 0);
  --primary-hover: #8a0570;          /* ← เพิ่ม */
}
/* @theme inline ไม่ได้แตะ */
// app/t81/page.tsx
export default function Page() {
  return (
    <button className="bg-primary-hover px-3 text-primary-foreground">
      Browse Recipes
    </button>
  );
}

สั่ง npx next build แล้วจะเกิดอะไร — build พังไหม และปุ่มเป็นสีม่วงไหม?

build ผ่านสบาย ๆ ไม่มี error ไม่มี warning · แต่ปุ่มไม่มีสีพื้นเลย

✓ Compiled successfully in 907ms
└ ○ /t81

$ grep -o '\.bg-primary-hover{[^}]*}' .next/static/chunks/*.css; echo "rc=$?"
rc=1

$ grep -o '\-\-primary-hover:[^;]*' .next/static/chunks/*.css
--primary-hover:#8a0570

อ่านผลสองก้อนนี้คู่กัน:
• ตัวแปร --primary-hover มีอยู่ใน CSS จริง ค่า #8a0570 ครบ
• แต่ ไม่มี rule .bg-primary-hover สักตัว (grep หาไม่เจอ คืน rc=1)

เพราะ Tailwind ไม่ได้ดู :root เลย มันดูแค่ @theme — เมื่อไม่มี --color-primary-hover คำว่า bg-primary-hover ในโค้ดของคุณ ก็เป็นแค่ข้อความที่ Tailwind ไม่รู้จัก แล้วมันข้ามไปเงียบ ๆ
นี่คือกับดักคลาสสิกของ Tailwind: พิมพ์ชื่อ class ผิดหรือไม่มีจริง = ไม่มีใครฟ้อง ต่างจาก Go ที่เรียกฟังก์ชันที่ไม่มีอยู่แล้ว compile ไม่ผ่านทันที

วิธีแก้คือเพิ่มบรรทัดเดียวในชั้นที่ 2:

@theme inline {
  --color-primary: var(--primary);
  --color-primary-hover: var(--primary-hover);   /* ← เพิ่ม */
}

build ใหม่แล้ว grep คำสั่งเดิม:

$ grep -o '\.bg-primary-hover{[^}]*}' .next/static/chunks/*.css; echo "rc=$?"
.bg-primary-hover{background-color:var(--primary-hover)}
rc=0

ตอนนี้ rule มาแล้ว และไม่ใช่แค่ bg- — ผมลองใส่ hover:bg-primary-hover ในอีกหน้าหนึ่ง ก็ได้ .hover\:bg-primary-hover:hover{background-color:var(--primary-hover)} ออกมาด้วย เพราะชื่อที่ลงทะเบียนใน @theme ใช้กับ variant ทุกตัวของ Tailwind ได้ทันที

🎨 เทียบกับของจริง

สองชั้นนี้เหมือนร้านสีที่มีโกดังกับเมนูหน้าร้าน
:root คือถังสีในโกดัง — สีมีอยู่จริง ผสมเสร็จแล้ว
@theme inline คือรายการสีบนเมนูหน้าร้าน ที่พนักงาน (Tailwind) ดูเวลามีคนสั่ง
ถ้าคุณขนถัง primary-hover เข้าโกดัง แต่ลืมเขียนลงเมนู — ลูกค้าสั่ง bg-primary-hover พนักงานเปิดเมนูไม่เจอ ก็ไม่ได้ของ

ที่ไม่เหมือนกัน: พนักงานร้านสีจริงจะบอกลูกค้าว่า “สีนี้ไม่มีครับ”
แต่ Tailwind ไม่พูดอะไรเลย — แค่ไม่สร้าง rule ให้ แล้ว build ขึ้นเครื่องหมายถูกสีเขียวตามปกติ คุณจะรู้ตัวก็ต่อเมื่อเปิดหน้าเว็บแล้วเห็นว่าปุ่มไม่มีสี
และในทางกลับกัน: เขียนลงเมนูแต่ลืมขนถังเข้าโกดัง (มี --color-x: var(--x) แต่ไม่มี --x) Tailwind จะสร้าง class ให้ แต่ browser หาค่าไม่เจอ สีก็หายเหมือนกัน

🗣 ศัพท์ของ Tailwind v4
@theme / theme variable
บล็อกใน CSS ที่ Tailwind v4 ใช้แทน tailwind.config.js ของ v3 · ตัวแปรในนี้ขึ้นต้นด้วยชื่อตระกูล (--color- · --font- · --radius- …) แล้ว Tailwind จะสร้าง class ของตระกูลนั้นให้ · ถาม senior ได้ว่า “token ตัวนี้ต้อง map ใน @theme ด้วยไหม หรือใช้เป็น CSS variable เฉย ๆ ก็พอ”
@theme inline
แบบที่ shadcn ใช้ · คำว่า inline แปลว่าให้ Tailwind เอาค่า var(--primary) ไปใส่ใน class ตรง ๆ จึงได้ background-color:var(--primary) · ถ้าไม่มี inline class จะอ้าง var(--color-…) อีกทอดหนึ่ง ซึ่งทำให้สีไม่เปลี่ยนตาม .dark ที่ครอบอยู่ข้างในหน้า (หัวข้อถัดไป)
unknown class ถูกข้ามเงียบ ๆ
Tailwind สแกนไฟล์หาคำที่หน้าตาเหมือน class แล้วสร้าง CSS เฉพาะคำที่มันรู้จัก · คำที่ไม่รู้จักไม่ถือเป็น error · เครื่องมือที่ช่วยจับได้คือ extension Tailwind CSS IntelliSense ในเอดิเตอร์ · พิมพ์บอก AI ได้ว่า “bg-primary-hover generates no CSS — check whether it is registered in @theme inline”

dark mode — ทำไมต้องเป็น @theme inline

shadcn ทำ dark mode ด้วยการประกาศ token ชื่อเดิมซ้ำในบล็อก .dark ด้วยค่าใหม่ · component ไม่ต้องรู้เรื่องธีมเลย มันใช้ bg-primary เหมือนเดิมทุกครั้ง ส่วน element ไหนอยู่ใต้ class dark ค่า --primary ตรงนั้นก็เปลี่ยนเอง

.dark {
  --primary-hover: #e879c9;   /* ค่าสมมติสำหรับทดลอง — สไลด์หน้า 25 มีแต่ธีมสว่าง */
}

ทีนี้ลองเทียบสองแบบ: สีที่ลงทะเบียนผ่าน @theme inline (แบบ shadcn) กับสีที่ลงทะเบียนผ่าน @theme ธรรมดา ชี้ไปที่ token ตัวเดียวกัน

@theme inline { --color-primary-hover: var(--primary-hover); }
@theme        { --color-brand-noinline: var(--primary-hover); }
// วางทั้งสองกล่องไว้ใต้ .dark
<div className="dark">
  <div id="in-inline"   className="bg-primary-hover">inline ใน .dark</div>
  <div id="in-noinline" className="bg-brand-noinline">no inline ใน .dark</div>
</div>
// ใน .dark ค่า --primary-hover คือ #e879c9 (ชมพูอ่อน)
// ส่วนนอก .dark คือ #8a0570 (ม่วงเข้ม)
getComputedStyle(#in-inline).backgroundColor    // ?
getComputedStyle(#in-noinline).backgroundColor  // ?

สองกล่องอยู่ใต้ .dark เหมือนกัน ชี้ไป --primary-hover เหมือนกัน — ได้สีเดียวกันไหม?

ไม่เหมือนกัน · วัดจากหน้าเว็บจริง (npx next start แล้วรันใน browser):

{
  "inline":   "rgb(232, 121, 201)",   // = #e879c9 เปลี่ยนตาม .dark ✅
  "noinline": "rgb(138, 5, 112)"      // = #8a0570 ยังเป็นค่าของธีมสว่าง ❌
}

ดูใน CSS ที่ build ออกมาจะเห็นสาเหตุ:

.bg-primary-hover{background-color:var(--primary-hover)}
.bg-brand-noinline{background-color:var(--color-brand-noinline)}
/* และแบบไม่ inline ถูกประกาศไว้ที่ :root */
:root,:host{ … --color-brand-noinline:var(--primary-hover)}

แบบไม่ inline: --color-brand-noinline ถูกคำนวณค่าเสร็จที่ :root (ตอนนั้น --primary-hover ยังเป็น #8a0570) แล้วส่งค่าที่คำนวณแล้วลงมาให้ลูกหลาน · พอลงไปถึงใต้ .dark ต่อให้ --primary-hover เปลี่ยน ตัวที่ class ใช้ก็ไม่ได้คำนวณใหม่
แบบ inline: class อ่าน var(--primary-hover) ตรงที่ element นั้นอยู่เลย จึงเห็นค่าของ .dark
แบบ inline ใช้ได้ทุกกรณี จึงเป็นแบบที่ shadcn เขียนทุกบรรทัด · token ที่เพิ่มเองก็ควรเพิ่มใน @theme inline บล็อกเดิม อย่าเปิดบล็อก @theme ใหม่

ความต่างนี้โผล่เฉพาะตอน .dark อยู่ข้างในหน้า — ในการทดลองข้างบนผมตั้ง .dark ไว้ที่ <div> ข้างใน
ผมลองอีกหน้าหนึ่ง ใส่ dark ที่ <html> ด้วย document.documentElement.classList.add('dark') (แบบที่แอปสลับธีมทั้งหน้ามักทำ) ได้ rgb(232, 121, 201) ทั้งสองแบบ — เพราะคราวนี้ :root เองกลายเป็นธีมมืด ตัวแปรกลางจึงคำนวณได้ค่ามืดตั้งแต่ต้น
แปลว่าแบบไม่ inline จะ “ดูเหมือนใช้ได้” ตอนสลับทั้งหน้า แล้วมาพังทีหลังตอนมีคนทำกล่องธีมมืดเฉพาะส่วน · ใช้ @theme inline ตั้งแต่แรกจะไม่ต้องเจอเรื่องนี้เลย

typography แบบ wongnok-text-h2 — ต้องใช้ @utility

prompt ในสไลด์หน้า 27 สั่ง AI ว่าให้ทำ typography เป็น class ที่ขึ้นต้นด้วย wongnok เช่น wongnok-text-h1 · และสั่งด้วยว่า อะไรที่ Tailwind มี class อยู่แล้ว ไม่ต้องเขียนเพิ่มใน globals.css
prompt เดียวกันยังสั่งอีกสองข้อ: ให้ใส่ token เรื่องเงา (shadow) ตามธีมด้วย · และไม่ต้องทำ token ส่วน Motion เพราะจะใช้ไลบรารี animation แยก — ที่น่าสังเกตคือในภาพ prompt มีไฟล์แนบชื่อ “Wongnok Design Tokens…” กับ support.js ไปด้วย และสั่งให้ทำตาม “Wongnok Design Token” — คือส่งของที่ออกแบบไว้ให้ AI อ่าน ไม่ได้ปล่อยให้เดาสีเอง
ทำไมต้องมี class แบบนี้? เพราะหัวข้อหนึ่งตัวต้องกำหนด 3–4 ค่าพร้อมกัน (ขนาด · ความสูงบรรทัด · ความหนา · ระยะตัวอักษร) ถ้าพิมพ์ text-3xl leading-9 font-bold tracking-tight ซ้ำทุกหัวข้อ วันที่ต้องปรับจะไล่แก้ไม่หมด

ใน Tailwind v4 วิธีที่ถูกคือ @utility:

/* app/globals.css */
@utility wongnok-text-h2 {
  font-size: 1.875rem;
  line-height: 2.25rem;
  font-weight: 700;
  letter-spacing: -0.01em;
}

ถ้าเขียนเป็น class CSS ธรรมดาก็ใช้ได้เหมือนกันในกรณีปกติ — แต่ลองดูว่าเกิดอะไรขึ้นเมื่อใส่ variant อย่าง md: (ผมใส่ทั้งสองแบบลงหน้าเดียวกันแล้ว build จริง):

class ธรรมดาใน @layer components

@layer components {
  .wongnok-plain-h2 {
    font-size: 1.875rem;
    font-weight: 700;
  }
}
  • wongnok-plain-h2 → ได้ rule ✅
  • md:wongnok-plain-h2 → ไม่มี rule เลย ❌ (grep นับได้ 0)

@utility

@utility wongnok-text-h2 {
  font-size: 1.875rem;
  /* … */
}
  • wongnok-text-h2 → ได้ rule ✅
  • md:wongnok-text-h2 → ได้ rule ใน @media (min-width:48rem) ✅

CSS ที่ build ออกมาจริงของฝั่ง @utility:

.wongnok-text-h2{letter-spacing:-.01em;font-size:1.875rem;font-weight:700;line-height:2.25rem}
/* … และในบล็อก @media (min-width:48rem){ … } ของ md: */
.md\:wongnok-text-h2{letter-spacing:-.01em;font-size:1.875rem;font-weight:700;line-height:2.25rem}

@utility ทำให้ class ของเราเป็นพลเมืองชั้นหนึ่งของ Tailwind — ใช้กับ md: · hover: · dark: ได้ และถ้าไม่มีใครใช้ class นี้เลย Tailwind ก็ไม่ใส่ลงไฟล์ CSS ให้เปลืองที่

⚠️ เรื่องที่ควรรู้ก่อนก็อป token จากสไลด์หน้า 27
🗣 ศัพท์เรื่อง custom class
@utility
คำสั่งของ Tailwind v4 สำหรับสร้าง utility class ของเราเอง ให้ทำงานกับ variant ได้เหมือน class ในตัว · ใน v3 เทียบได้กับการเขียนใน @layer utilities · พิมพ์บอก AI ได้ว่า “define wongnok-text-h1 with @utility so md: and dark: variants work”
variant
คำนำหน้า class ที่บอกว่า “ใช้เมื่อไหร่” — md: (จอกว้าง) · hover: (เมาส์ชี้) · dark: (ธีมมืด) · ในโปรเจกต์นี้ dark: ถูกนิยามใหม่ด้วย @custom-variant dark (&:is(.dark *)) ให้ผูกกับ class dark ไม่ใช่กับการตั้งค่าของระบบปฏิบัติการ
theming (การทำธีม)
การเปลี่ยนหน้าตาทั้งแอปโดยเปลี่ยนแค่ค่าของ token ไม่แตะ component · shadcn ออกแบบมาเพื่อสิ่งนี้ — component ทุกตัวอ้างแค่ชื่อ semantic · ถาม senior ได้ว่า “ถ้าจะเพิ่มธีมที่สาม ต้องแก้ไฟล์ไหนบ้าง” ถ้าคำตอบคือ “แค่ globals.css” แปลว่าโครงดีแล้ว

ภารกิจ — ใส่ token สีอำพันกับหัวข้อ h1 ของ Wongnok

🎯 ภารกิจ

ป้าย “Easy” บนการ์ดสูตรในสไลด์หน้า 23 เป็นพื้นครีมตัวหนังสือสีอำพัน · ทำให้ได้แบบนั้นด้วย token:

  1. เพิ่ม --accent-subtle: #fff1dc และ --accent-strong: #b36a00 (ค่าจากสไลด์หน้า 25/27) ลงใน :root
  2. ลงทะเบียนทั้งสองตัวใน @theme inline บล็อกเดิม ให้เกิด class bg-accent-subtle และ text-accent-strong
  3. สร้าง wongnok-text-h1 ด้วย @utility (ขนาด 2.25rem · ความสูงบรรทัด 2.5rem · หนา 700)
  4. สร้างหน้า app/recipes-demo/page.tsx ที่มีหัวข้อ “All Recipes” กับป้าย “Easy”
  5. พิสูจน์: รัน npx next build · npx tsc --noEmit · npx eslint ให้ผ่าน แล้ว grep ไฟล์ CSS ใน .next/static/chunks/ ให้เจอ rule ของทั้งสาม class — “เปิดหน้าเว็บดูแล้วสวย” ยังไม่นับว่าพิสูจน์

ส่วนที่เปลี่ยนใน globals.css (ผลจาก git diff จริง · ตัดบรรทัดบริบทที่ไม่เกี่ยวออกบางส่วน):

@@ -29,6 +29,8 @@
   --color-destructive: var(--destructive);
   --color-accent-foreground: var(--accent-foreground);
   --color-accent: var(--accent);
+  --color-accent-subtle: var(--accent-subtle);
+  --color-accent-strong: var(--accent-strong);
   --color-muted-foreground: var(--muted-foreground);
@@ -63,6 +65,8 @@
   --accent: oklch(0.97 0 0);
   --accent-foreground: oklch(0.205 0 0);
+  --accent-subtle: #fff1dc;
+  --accent-strong: #b36a00;
   --destructive: oklch(0.577 0.245 27.325);
@@ -117,6 +121,12 @@
   --sidebar-ring: oklch(0.556 0 0);
 }

+@utility wongnok-text-h1 {
+  font-size: 2.25rem;
+  line-height: 2.5rem;
+  font-weight: 700;
+}
+
 @layer base {
// app/recipes-demo/page.tsx
export default function RecipesDemo() {
  return (
    <main className="p-8">
      <h1 className="wongnok-text-h1">All Recipes</h1>
      <span className="rounded-md bg-accent-subtle px-2 py-0.5 text-accent-strong">Easy</span>
    </main>
  );
}

ผลตรวจจริง:

$ npx next build
✓ Compiled successfully in 450ms
└ ○ /recipes-demo

$ npx tsc --noEmit; echo "tsc rc=$?"
tsc rc=0
$ npx eslint app/recipes-demo; echo "eslint rc=$?"
eslint rc=0

$ grep -oE '\.(bg-accent-subtle|text-accent-strong|wongnok-text-h1)\{[^}]*\}' .next/static/chunks/*.css
.bg-accent-subtle{background-color:var(--accent-subtle)}
.wongnok-text-h1{font-size:2.25rem;font-weight:700;line-height:2.5rem}
.text-accent-strong{color:var(--accent-strong)}

$ grep -oE -- '--accent-(subtle|strong):[^;]*' .next/static/chunks/*.css
--accent-subtle:#fff1dc
--accent-strong:#b36a00

ครบสามอย่างที่ต้องพิสูจน์: ค่าจริงอยู่ในไฟล์ (grep ก้อนล่าง) · class ถูกสร้าง (grep ก้อนบน) · class ชี้ไปที่ token ไม่ใช่เลข hex (var(--accent-subtle))
ถ้า grep ก้อนบนหาไม่เจอ ให้ย้อนไปดูข้อ 2 ก่อนอย่างอื่น — เก้าในสิบคือลืมลงทะเบียนใน @theme inline

ลองตอบดู

เพิ่ม --primary-hover: #8a0570; ไว้ใน :root อย่างเดียว แล้วเขียน className="bg-primary-hover" — ผลคืออะไร?

ตัวเลือกที่ 2 ถูก · ผลจริงคือ build ขึ้น ✓ Compiled successfully · grep เจอ --primary-hover:#8a0570 แต่หา .bg-primary-hover{…} ไม่เจอ
ตัวเลือกที่ 1 และ 4 ผิดเพราะ Tailwind ไม่ฟ้องทั้ง error และ warning — คำที่ไม่รู้จักถูกข้ามเงียบ ๆ
ตัวเลือกที่ 3 ผิดเพราะ Tailwind ไม่ได้ดู :root เลย มันสร้าง class จากตัวแปรใน @theme เท่านั้น
จุดที่ต้องจำ: token ใหม่ต้องใส่สองที่ — ค่าจริงใน :root และชื่อ --color-… ใน @theme inline · แล้วพิสูจน์ด้วยการ grep CSS ที่ build แล้ว

ลงทะเบียน token ตัวเดียวกันผ่าน @theme (ไม่มี inline) แล้ววาง element ไว้ใต้ <div class="dark"> ที่ประกาศ --primary-hover ใหม่ — สีที่เห็นคืออะไร?

ตัวเลือกที่ 4 ถูก · วัดจริงได้ rgb(138, 5, 112) (= #8a0570 ค่าธีมสว่าง) ขณะที่แบบ inline ได้ rgb(232, 121, 201) ตามค่าใน .dark
ตัวเลือกที่ 1 ผิดเพราะแบบไม่ inline class อ้าง var(--color-brand-noinline) ซึ่งคำนวณไว้ที่ :root แล้วส่งค่าลงมา ไม่ได้อ่าน --primary-hover ใหม่ที่ element นั้น
ตัวเลือกที่ 2 ผิดเพราะ build แล้วมี .bg-brand-noinline{…} จริง — class ถูกสร้าง แค่ได้ค่าผิด · ตัวเลือกที่ 3 ผิดเพราะไฟล์ทดลองมีทั้ง @theme inline และ @theme แล้ว build ผ่าน
จุดที่ต้องจำ: token ที่ต้องเปลี่ยนตามธีม ให้ลงทะเบียนใน @theme inline บล็อกเดียวกับของ shadcn

ทำไม wongnok-text-h2 ควรเขียนด้วย @utility แทน class ธรรมดาใน @layer components?

ตัวเลือกที่ 1 ถูก · build จริงได้ .md\:wongnok-text-h2{…} ในบล็อก media แต่ md:wongnok-plain-h2 ไม่มี rule ออกมาเลย (grep นับได้ 0)
ตัวเลือกที่ 2 ผิดเพราะหน้าที่มี class ธรรมดา build ผ่านปกติ · ตัวเลือกที่ 3 ผิดเพราะผลลัพธ์เป็น CSS rule หน้าตาเดียวกัน browser ไม่ได้แยกว่ามาจากไหน · ตัวเลือกที่ 4 ผิดเพราะ .wongnok-plain-h2{font-size:1.875rem;font-weight:700} อยู่ในไฟล์จริง — ที่หายคือแค่แบบมี variant
จุดที่ต้องจำ: class ที่อยากใช้แบบ Tailwind (มี md: hover: dark:) ให้สร้างด้วย @utility

ฝ่ายการตลาดขอเปลี่ยนสีม่วงแบรนด์จาก #a80689 เป็นเฉดอื่น · ปุ่ม shadcn ใช้ bg-primary อยู่แล้ว — ต้องแก้อะไรน้อยที่สุด?

ตัวเลือกที่ 3 ถูก · rule ที่ build ออกมาคือ .bg-primary{background-color:var(--primary)} แปลว่า class อ่านค่าจาก --primary ตอนรันใน browser — เปลี่ยนค่าที่ต้นทาง ทุกที่เปลี่ยนตาม
ตัวเลือกที่ 1 และ 4 ผิดเพราะ component ไม่ได้รู้จักเลข hex เลย แก้ไปก็เสียเวลาและทำลายระบบ token · ตัวเลือกที่ 2 “ใช้ได้” ในธีมสว่าง แต่ตัดสายที่เชื่อมกับ --primary ทำให้ค่าใน .dark ไม่มีผลกับ bg-primary อีกต่อไป
จุดที่ต้องจำ: ค่าจริงอยู่ชั้น :root/.dark · ชั้น @theme inline มีไว้ตั้งชื่อ ห้ามใส่ค่าจริงตรงนั้น

ถามกลับได้เลย

ถ้าคุณเพิ่ม token แล้ว class ไม่ทำงาน ให้เอาของสามอย่างนี้มาคุย: บรรทัดที่เพิ่มใน :root · บรรทัดที่เพิ่มใน @theme inline · และผลของ grep ชื่อ class ในไฟล์ .next/static/chunks/*.css
สามอย่างนี้บอกได้เกือบทุกครั้งว่าพังที่ชั้นไหน
หรือถ้าอยากรู้ว่า token ฝั่ง spacing · radius · shadow ในสไลด์หน้า 25 (เมนูซ้าย) ควรลงทะเบียนในตระกูลไหนของ @theme ก็ถามได้

อ้างอิง