🧰 ของที่ใช้ตรวจในบทนี้
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 แบบกดเล่นได้
ขั้นตอนที่สไลด์โชว์มีสามจังหวะ:
- เขียน prompt วางแผนก่อน (หน้า 21) — ให้ AI รับบทเป็น senior UX/UI designer แล้วบอกบริบทของโปรเจกต์ “Wongnok”: เป็นแพลตฟอร์มแชร์สูตรอาหาร · ผู้ใช้ทั่วไปมีหน้า Landing, หน้ารวมสูตร (ค้นด้วยชื่อ กรองวันที่ กรองระดับความยาก), หน้ารายละเอียด, ให้ดาว, กดชอบ (ต้อง sign-in ก่อน), login ด้วย Keycloak, แก้โปรไฟล์ · ส่วนคนแชร์สูตรมีฟอร์มเพิ่มสูตร และแก้/ลบสูตรของตัวเองได้
- ให้ AI สร้างหน้าจอ (หน้า 22–23) — ภาพในสไลด์เป็นหน้า Landing กับหน้า All Recipes ที่ใช้สีม่วงเป็นสีหลัก · สังเกตว่าในแถบซ้ายของภาพ prompt ขอให้ออกแบบสถานะครบสี่แบบ (LOADING · EMPTY · ERROR · DEFAULT) และระบุชื่อ component ของ shadcn ที่จะใช้ด้วย (Card, Input, Select, Badge, Pagination, Skeleton, Sonner toast)
- สรุปธีมออกมาเป็น 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);
/* … */
}
อ่านจากล่างขึ้นบนจะเข้าใจง่ายกว่า:
- ชั้นที่ 1 ·
:rootและ.dark— เก็บค่าจริง เป็น CSS variable ธรรมดา · browser อ่านตรงนี้ · Tailwind ไม่ได้สร้าง class อะไรจากชั้นนี้เลย - ชั้นที่ 2 ·
@theme inline— เป็นคำสั่งของ Tailwind v4 ว่า “ให้มีสีชื่อprimaryนะ ค่าของมันคือvar(--primary)” · ชื่อที่ขึ้นต้นด้วย--color-คือสัญญาณให้ Tailwind สร้าง class สีทุกตระกูล (bg-·text-·border-·ring-…)
บรรทัดสุดท้ายในแผนภาพไม่ได้เดา — ก็อปมาจากไฟล์ CSS ที่ build ออกมาจริง
(components/ui/button.tsx ใช้ bg-primary อยู่ Tailwind จึงสร้าง rule นี้ให้)
token (ชั้น 1 ใน :root) | ชื่อใน @theme inline (ชั้น 2) | class ที่ได้ |
|---|---|---|
--primary | --color-primary | bg-primary · text-primary · border-primary |
--primary-foreground | --color-primary-foreground | text-primary-foreground |
--border | --color-border | border-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 หาค่าไม่เจอ สีก็หายเหมือนกัน
@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)· ถ้าไม่มีinlineclass จะอ้าง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 ให้เปลืองที่
- ในภาพหน้า 27 ค่าสีปนกันสองแบบ — ส่วนใหญ่เป็น hex (
--primary: #a80689) แต่--popoverกับ--chart-1..4ยังเป็นoklch(…)ค่าเดิมของ shadcn · CSS รับทั้งสองแบบ ไม่พัง แต่ถ้าจะแก้ต่อให้รู้ว่าบรรทัดพวกนั้นยังไม่ได้เปลี่ยนเป็นสีของ Wongnok - ป้ายในภาพหน้า 25 เขียนว่า “HSL + hex” แต่ค่าที่เห็นในหน้า 25–27 เป็น hex ทั้งหมด ไม่เห็นค่า HSL สักตัว
- สไลด์เปลี่ยน
--accentเป็นสีส้ม#ff9f1c· ใน shadcn ชื่อaccentมีความหมายเดิมอยู่แล้ว ผมgrepดู 4 component ที่ติดตั้งในบทก่อน (button · card · badge · skeleton) ยังไม่มีตัวไหนใช้accentแต่ component ที่ add ทีหลังอาจใช้ · ก่อนเปลี่ยนความหมายของ token เดิม ให้grep -rn accent components/uiดูทุกครั้ง
@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 *))ให้ผูกกับ classdarkไม่ใช่กับการตั้งค่าของระบบปฏิบัติการ - theming (การทำธีม)
- การเปลี่ยนหน้าตาทั้งแอปโดยเปลี่ยนแค่ค่าของ token ไม่แตะ component ·
shadcn ออกแบบมาเพื่อสิ่งนี้ — component ทุกตัวอ้างแค่ชื่อ semantic ·
ถาม senior ได้ว่า “ถ้าจะเพิ่มธีมที่สาม ต้องแก้ไฟล์ไหนบ้าง” ถ้าคำตอบคือ “แค่
globals.css” แปลว่าโครงดีแล้ว
ภารกิจ — ใส่ token สีอำพันกับหัวข้อ h1 ของ Wongnok
🎯 ภารกิจ
ป้าย “Easy” บนการ์ดสูตรในสไลด์หน้า 23 เป็นพื้นครีมตัวหนังสือสีอำพัน · ทำให้ได้แบบนั้นด้วย token:
- เพิ่ม
--accent-subtle: #fff1dcและ--accent-strong: #b36a00(ค่าจากสไลด์หน้า 25/27) ลงใน:root - ลงทะเบียนทั้งสองตัวใน
@theme inlineบล็อกเดิม ให้เกิด classbg-accent-subtleและtext-accent-strong - สร้าง
wongnok-text-h1ด้วย@utility(ขนาด2.25rem· ความสูงบรรทัด2.5rem· หนา700) - สร้างหน้า
app/recipes-demo/page.tsxที่มีหัวข้อ “All Recipes” กับป้าย “Easy” - พิสูจน์: รัน
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 ก็ถามได้
อ้างอิง
- Tailwind CSS — Theme variables —
อ่านตาราง Theme variable namespaces เพื่อดูว่าตระกูล
--color-·--font-·--radius-สร้าง class อะไรบ้าง · และหัวข้อ Referencing other variables ที่อธิบายว่าทำไมต้องใช้inlineเวลาตัวแปรใน theme อ้างตัวแปรอื่น (ตรงกับผลทดลอง dark mode ในบทนี้) - Tailwind CSS — Adding custom styles —
อ่านหัวข้อ Adding custom utilities ที่สอน
@utilityและบอกว่า class ที่สร้างแบบนี้ใช้กับ variant ได้ - shadcn/ui — Theming —
อ่านหัวข้อ Adding New Tokens ซึ่งเพิ่ม token
--warningด้วยขั้นตอนเดียวกับบทนี้ (ใส่ใน:root/.darkแล้ว map ใน@theme inline) · และดูหลักการตั้งชื่อคู่background/foreground - Tailwind CSS — Dark mode —
อ่านหัวข้อ Toggling dark mode manually ที่ใช้
@custom-variant darkผูกdark:เข้ากับ class (ในเอกสารเขียน&:where(.dark, .dark *)ส่วน shadcn เขียน&:is(.dark *)— หลักการเดียวกัน) - MDN — Using CSS custom properties —
อ่านหัวข้อ Inheritance of custom properties เพื่อเข้าใจว่าทำไมค่าที่ถูกคำนวณที่
:rootแล้วส่งลงมา จึงไม่เปลี่ยนตาม.darkที่อยู่ข้างล่าง