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

Tailwind CSS — เขียน CSS โดยไม่ต้องตั้งชื่อ class

~20 นาที · ควรผ่าน บทเรียน 71 (component แรก + props) และบท 59–62 (CSS) มาก่อน

ตอนบท 59–62 คุณเขียน CSS ด้วยมือ: ตั้งชื่อ class → เปิดไฟล์ .css → เขียน padding display border-radius
Tailwind ไม่ได้มาแทนสิ่งนั้น มันแค่เขียน class พวกนั้นรอไว้ให้คุณล่วงหน้าแล้ว ทีละบรรทัด ทีละ property
คุณเลยไม่ต้องตั้งชื่อ ไม่ต้องสลับไฟล์ — แต่ต้องยอมให้ className ยาวขึ้นมาก
บทนี้ไม่ได้สอนให้ท่องชื่อคลาส แต่จะพาไปเปิดดู CSS ที่ Tailwind สร้างขึ้นมาจริง ๆ เพื่อให้คุณแปลกลับเป็น CSS ที่คุณรู้จักอยู่แล้วได้เอง
แล้วปิดท้ายด้วยกับดักที่ทำให้สไตล์หายไปเงียบ ๆ โดยไม่มี error สักตัว

ตรวจด้วยอะไร — ทุกก้อน CSS ในบทนี้ไม่ได้พิมพ์จากความจำ แต่ดึงมาจากไฟล์ CSS ที่โปรเจกต์สร้างขึ้นจริง (ยิง curl ไปที่ไฟล์ .css ที่ Next.js เสิร์ฟ แล้วอ่านกฎในนั้นตรง ๆ)

โปรเจกต์ที่ใช้ตรวจ สร้างด้วย npx create-next-app@latest --ts --tailwind --eslint --app --no-src-dir

ของเวอร์ชันที่ตรวจ
next16.3.4 (Turbopack)
react / react-dom19.2.8
tailwindcss4.3.3
@tailwindcss/postcss4.3.3
typescript5
nodev24.14.0

npx tsc --noEmit ผ่าน (exit 0) และ npx next build ขึ้น ✓ Compiled successfully ทุกรอบ (ตัวเลขมิลลิวินาทีที่ต่อท้ายจะไม่เท่ากันทุกครั้ง อย่าไปยึด)

1 · ปัญหาที่ Tailwind มาแก้ ไม่ใช่ปัญหา "CSS ยาก"

ย้อนกลับไปที่บท 59–62 คุณทำแบบนี้เพื่อจะได้ปุ่มหนึ่งปุ่ม:

/* styles.css — ไฟล์หนึ่ง */
.primary-button {
  padding: 6px 10px;
  border-radius: 6px;
  border: 2px solid transparent;
}
<!-- index.html — อีกไฟล์หนึ่ง -->
<button class="primary-button">กดฉัน</button>

โค้ดนี้ไม่มีอะไรผิดเลย มันทำงานได้ดี — แต่พอโปรเจกต์โตขึ้น มันเรียกเก็บ "ค่าธรรมเนียม" สามอย่างจากคุณทุกวัน:

  1. คุณต้องคิดชื่อ — .primary-button? .btn-main? .button--primary? ชื่อไม่มีผลต่อหน้าเว็บเลยสักนิด แต่กินเวลาคิดจริง และทีมเถียงกันเรื่องนี้ได้เป็นชั่วโมง
  2. คุณต้องสลับไฟล์ไปมา — อยากรู้ว่าปุ่มนี้หน้าตายังไง ต้องไปเปิดอีกไฟล์ แล้วค้นหาชื่อคลาส
  3. คุณลบของทิ้งไม่กล้า — จะลบ .primary-button ทิ้ง ต้องมั่นใจว่าไม่มีหน้าไหนในโปรเจกต์ใช้อยู่ ผลคือไฟล์ CSS มีแต่โตขึ้น ไม่มีวันเล็กลง

Tailwind ตอบทั้งสามข้อด้วยวิธีเดียว: ไม่ต้องตั้งชื่ออะไรเลย ใช้ชื่อที่มันเตรียมไว้ให้ แล้วชื่อพวกนั้นก็คือชื่อของ CSS property ตรง ๆ

<button className="px-2.5 py-1.5 rounded-md border-2 border-transparent">
  กดฉัน
</button>

สองก้อนข้างบนนี้ให้ผลเหมือนกันเป๊ะ ต่างกันแค่ว่าก้อนหลังไม่มีไฟล์ CSS ที่คุณต้องดูแล และไม่มีชื่อที่คุณต้องคิด

🧰 เทียบให้เห็นภาพ — กล่องเครื่องเทศที่แบ่งช่องไว้แล้ว

การเขียน CSS เองคือการผสมเครื่องเทศเองทุกครั้ง แล้วตักใส่ขวดเปล่า จากนั้นต้องเขียนป้ายชื่อแปะขวด ("พริกเกลือสูตรร้าน") แล้วจำให้ได้ว่าขวดนั้นอยู่ชั้นไหน

Tailwind คือกล่องที่แบ่งช่องเล็ก ๆ ไว้ให้แล้ว ช่องละอย่างเดียว: ช่องเกลือ ช่องพริก ช่องน้ำตาล เวลาทำอาหารคุณไม่ต้องผสมล่วงหน้าและไม่ต้องเขียนป้าย — หยิบทีละช่องใส่ลงหม้อตรงหน้าเลย ใครเดินมาดูหม้อก็รู้ทันทีว่าใส่อะไรไปบ้าง ไม่ต้องไปเปิดขวดอ่านสูตร

ที่ไม่เหมือนกัน: กล่องเครื่องเทศจริงมีช่องจำกัด หมดแล้วหมดเลย แต่ Tailwind สร้างช่องใหม่ให้ตอนคุณพิมพ์ — พิมพ์ p-4 มันถึงจะมีกฎ .p-4 ถ้าไม่มีไฟล์ไหนพิมพ์คำนั้น กฎนั้นจะไม่ถูกสร้างเลย ข้อนี้ฟังดูเป็นข้อดี (ไฟล์เล็ก) แต่มันคือต้นตอของกับดักใหญ่ที่สุดในหัวข้อ 6 — จำไว้ก่อน

2 · utility class ไม่ใช่ภาษาใหม่ — มันคือ CSS ที่เขียนไว้ให้แล้ว

นี่คือประโยคสำคัญที่สุดของบทนี้: flex ไม่ใช่คำสั่งวิเศษ มันคือชื่อของกฎ CSS หนึ่งกฎ ที่หน้าตาแบบนี้เป๊ะ ๆ

.flex {
  display: flex;
}

บรรทัดข้างบนนี้ผมไม่ได้พิมพ์เอง — ผมเปิดไฟล์ CSS ที่โปรเจกต์นี้สร้างขึ้นมาจริงแล้วก็อปออกมา ทำแบบเดียวกันกับคลาสที่ใช้บ่อยที่สุด ได้ตารางนี้:

utility ที่คุณพิมพ์CSS ที่ถูกสร้างขึ้นจริง (ก็อปจากไฟล์)แปลว่าเท่าไหร่
flexdisplay: flex—
flex-colflex-direction: column—
items-centeralign-items: center—
p-4padding: calc(var(--spacing) * 4)1rem = 16px
p-8padding: calc(var(--spacing) * 8)2rem = 32px
px-2.5padding-inline: calc(var(--spacing) * 2.5)0.625rem = 10px
py-1.5padding-block: calc(var(--spacing) * 1.5)0.375rem = 6px
mt-2margin-top: calc(var(--spacing) * 2)0.5rem = 8px
gap-4gap: calc(var(--spacing) * 4)1rem = 16px
borderborder-style: var(--tw-border-style); border-width: 1px1px
border-2border-style: var(--tw-border-style); border-width: 2px2px
border-transparentborder-color: #0000โปร่งใส
rounded-mdborder-radius: var(--radius-md)0.375rem = 6px
rounded-lgborder-radius: var(--radius-lg)0.5rem = 8px
text-basefont-size: var(--text-base); line-height: var(--tw-leading, var(--text-base--line-height))1rem + line-height 1.5
text-smfont-size: var(--text-sm); …0.875rem = 14px
font-bold--tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold)700
text-red-500color: var(--color-red-500)#fb2c36
text-gray-900color: var(--color-gray-900)#101828
duration-300--tw-duration: .3s; transition-duration: .3s0.3 วินาที

อ่านตารางนี้จบแล้วคุณจะเห็นว่า ไม่มีอะไรใหม่ให้เรียนเลยสักบรรทัด ทุกฝั่งขวาคือ CSS จากบท 59–62 ทั้งหมด · สิ่งเดียวที่ต้องเรียนคือวิธีย่อ

ระวัง: p-4 ไม่ได้แปลว่า padding: 1rem ตรง ๆ

หลายบทความบอกว่า p-4 คือ padding: 1rem ซึ่งผลลัพธ์ถูก แต่กลไกผิด ของจริงที่ Tailwind 4 เขียนออกมาคือ padding: calc(var(--spacing) * 4) โดยที่ในไฟล์เดียวกันนั้นประกาศไว้ว่า:

--spacing: .25rem;

เลขหลังขีดจึงเป็นตัวคูณ ไม่ใช่หน่วย → 4 × 0.25rem = 1rem = 16px

ผมลองจริงแล้ว — เขียน @theme { --spacing: 0.2rem; } ทับลงไป แล้วดึงไฟล์ CSS ที่ถูกสร้างมาดู ได้ --spacing: .2rem; จริง ส่วนกฎ .p-4 .p-6 .mt-2 ไม่ได้ถูกแก้สักตัว เพราะมันอ้าง var(--spacing) อยู่แล้ว → ระยะห่างทั้งเว็บขยับพร้อมกันหมดจากบรรทัดเดียว นี่คือประโยชน์จริงของการเขียนเป็น calc() แทนที่จะฝังเลข 1rem ลงไปตรง ๆ

ตัวเลขที่ไม่ได้ใช้ --spacing ก็มี เช่นสีกับขนาดตัวอักษร ซึ่งเป็นตัวแปรของมันเอง:

/* ก็อปจากไฟล์ CSS ที่โปรเจกต์สร้างจริง */
--spacing: .25rem;
--text-base: 1rem;
--text-sm: .875rem;
--radius-md: .375rem;
--radius-lg: .5rem;
--font-weight-bold: 700;
--color-red-500: #fb2c36;
--color-gray-900: #101828;
--default-transition-duration: .15s;
🗣 ศัพท์ที่เพิ่งเรียนไป
utility class
คลาสที่ทำหน้าที่เดียว — .flex ตั้งค่า display อย่างเดียว จบ · ตรงข้ามกับ component class อย่าง .primary-button ที่ตั้งหลายค่าในกฎเดียว · ถาม senior ได้ว่า “อันนี้ควรเป็น utility ล้วน ๆ หรือควรยกเป็น component ไปเลย” คำถามนี้คือเส้นแบ่งที่ทีมต้องตกลงกันตั้งแต่ต้นโปรเจกต์
utility-first
คำที่เว็บ Tailwind ใช้เรียกตัวเอง แปลว่าตั้งต้นที่ utility ก่อนเสมอ แล้วค่อยยุบเป็น component ตอนที่ซ้ำจริง ๆ (ไม่ใช่ตอนที่ "รู้สึกว่ายาว") · พิมพ์บอก AI ได้ว่า “style this with Tailwind utility classes, no custom CSS file” จะได้โค้ดที่ตรงกว่าบอกแค่ “ใช้ Tailwind”
design token
ค่ากลางที่ทั้งเว็บอ้างถึง — --spacing --radius-md --color-red-500 คือ token ทั้งหมด · ประโยชน์คือแก้ที่เดียว เปลี่ยนทั้งเว็บ · ถาม senior ได้ว่า “ทีมเรามี design token ชุดไหนบ้าง แล้วมันอยู่ไฟล์ไหน” เป็นคำถามแรกที่ควรถามตอนเข้าโปรเจกต์ frontend ใหม่

3 · Tailwind เข้ามาในโปรเจกต์ยังไง — PostCSS กับ import บรรทัดเดียว

Tailwind ไม่ใช่ไฟล์ CSS สำเร็จรูปที่แจกมาแล้วคุณลิงก์เอา มันเป็นโปรแกรมที่รันตอน build แล้วเขียนไฟล์ CSS ขึ้นมาให้ โปรแกรมนั้นเสียบเข้ากับระบบชื่อ PostCSS

ในโปรเจกต์ที่ create-next-app สร้างให้ มันตั้งค่าไว้ให้เรียบร้อยแล้วสองที่ — ไฟล์แรกคือ postcss.config.mjs:

// postcss.config.mjs — ก็อปจากโปรเจกต์จริง ไม่ได้แก้อะไรเลย
const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

export default config;

ไฟล์ที่สองคือ app/globals.css ซึ่งบรรทัดแรกสุดของมันคือทั้งหมดที่ต้องมี:

/* app/globals.css — ก็อปจากโปรเจกต์จริง ตรงกับสไลด์หน้า 27 */
@import "tailwindcss";

:root {
  --background: #ffffff;
  --foreground: #171717;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --font-sans: var(--font-geist-sans);
  --font-mono: var(--font-geist-mono);
}
/* …ที่เหลือคือ CSS ปกติของคุณ เขียนต่อได้ตามใจ */

บล็อก @theme คือที่เดียวที่ใช้ตั้งค่า Tailwind ใน v4 — มันมาแทน tailwind.config.js ของ v3 ทั้งไฟล์ · อยากเปลี่ยน --spacing เปลี่ยนสี เพิ่มฟอนต์ ก็เขียนเป็น CSS variable ลงในบล็อกนี้ (คำว่า inline ที่ต่อท้ายเป็นตัวเลือกเสริม บอกให้ token อ้างตัวแปรอื่นต่อได้ ซึ่งจำเป็นตรงนี้เพราะ --background เปลี่ยนค่าตามโหมดมืด)

แล้ว globals.css ถูก import เข้ามาที่ app/layout.tsx ทีเดียว ครอบทุกหน้า (นี่คือเหตุผลที่คุณใช้ Tailwind ในหน้าไหนก็ได้โดยไม่ต้อง import ซ้ำ)

คุณพิมพ์ในไฟล์ .tsx                Tailwind (ทำงานตอน build)              เบราว์เซอร์ได้
─────────────────────           ──────────────────────────────         ─────────────────

className="p-4 flex"   ──┐
className="text-sm"    ──┤       1. อ่าน "ตัวอักษร" ในไฟล์โปรเจกต์
className="rounded-md" ──┘          หาคำที่หน้าตาเหมือนชื่อ utility
                                              │
                                              ▼
app/globals.css                  2. เห็น @import "tailwindcss"
  @import "tailwindcss";   ────►     → รู้ว่าต้องสร้าง CSS ตรงนี้
                                              │
                                              ▼
postcss.config.mjs               3. PostCSS เรียก @tailwindcss/postcss     .p-4        { … }
  "@tailwindcss/postcss"   ────►     ให้แปลงไฟล์ CSS               ────►   .flex       { … }
                                                                          .text-sm    { … }
                                                                          .rounded-md { … }
                                                                          ▲
                                     สร้าง "เฉพาะคลาสที่เจอ" เท่านั้น ────┘

ขั้นที่ 1 คือขั้นที่คนพลาดกันมากที่สุด และผมจะพิสูจน์ให้ดูในหัวข้อ 6 — Tailwind อ่านโค้ดของคุณในฐานะ "ตัวอักษร" ไม่ใช่ในฐานะ "โปรแกรมที่รันแล้ว" มันไม่รู้ว่าตัวแปรของคุณมีค่าอะไร มันรู้แค่ว่าในไฟล์มีคำว่าอะไรบ้าง

🗣 ศัพท์ที่เพิ่งเรียนไป
PostCSS
เครื่องมือที่รับไฟล์ CSS เข้าไป แล้วคายไฟล์ CSS ที่ถูกแปลงแล้วออกมา ตัวมันเองไม่ทำอะไรเลย ความสามารถทั้งหมดมาจาก plugin ที่เสียบเข้าไป · ถาม senior ได้ว่า “โปรเจกต์เรามี PostCSS plugin อะไรอยู่บ้างนอกจาก Tailwind” — มักจะเจอ autoprefixer ซ่อนอยู่ในโปรเจกต์เก่า
build step
ช่วงที่โค้ดของคุณถูกแปลงเป็นไฟล์ที่เบราว์เซอร์อ่านได้ ก่อนที่ผู้ใช้จะเปิดเว็บ · Tailwind ทำงานทั้งหมดในช่วงนี้ ไม่มีโค้ดของ Tailwind วิ่งอยู่ในเบราว์เซอร์เลย · พิมพ์บอก AI ได้ว่า “this class is generated at build time, not at runtime” เมื่อต้องอธิบายว่าทำไมคลาสที่สร้างจากตัวแปรถึงใช้ไม่ได้
plugin
โปรแกรมเสริมที่เสียบเข้ากับเครื่องมือหลัก · @tailwindcss/postcss คือ plugin ที่ทำให้ PostCSS รู้จักคำว่า @import "tailwindcss" · ถ้าลบบรรทัดนี้ออกจาก postcss.config.mjs จะไม่มี error แต่สไตล์หายทั้งเว็บ — ประโยคนี้ใช้บอก AI ได้ตรง ๆ ตอนดีบั๊ก

4 · เวอร์ชัน — สไลด์กับโปรเจกต์จริงตรงกัน แต่อินเทอร์เน็ตไม่ตรง

ข่าวดี: สไลด์หน้า 25–27 แสดงของ Tailwind v4 แล้ว ไม่ใช่ v3
สไลด์หน้า 26 โชว์ "@tailwindcss/postcss" ใน postcss.config.mjs · หน้า 27 โชว์ @import "tailwindcss"; ใน globals.css · และหน้า 25 จับภาพเว็บ tailwindcss.com ตอนที่ป้ายเวอร์ชันเขียนว่า v4.3
ทั้งสามอย่างนี้ตรงกับโปรเจกต์จริงบนเครื่องเป๊ะ ๆ (ผมตรวจแล้ว: tailwindcss 4.3.3) — ทำตามสไลด์ได้เลย

แต่บทความ วิดีโอ และคำตอบจาก AI ส่วนใหญ่ยังเป็น v3

v3 ครองอินเทอร์เน็ตอยู่หลายปี เนื้อหาที่ค้นเจอจึงมักเป็นของเก่า ถ้าเจอสองอย่างนี้เมื่อไหร่ แปลว่าคุณกำลังอ่านของ v3 แล้วต้องแปลงก่อนใช้:

เรื่องTailwind v3 (ของเก่า)Tailwind v4 — ของจริงในโปรเจกต์นี้
เอาเข้าไฟล์ CSS@tailwind base;
@tailwind components;
@tailwind utilities; (3 บรรทัด)
@import "tailwindcss"; (บรรทัดเดียว)
ไฟล์ตั้งค่าtailwind.config.jsไม่มีไฟล์นี้ — ตั้งค่าด้วย @theme ใน CSS
PostCSS plugintailwindcss + autoprefixer@tailwindcss/postcss ตัวเดียว
บอกว่าสแกนไฟล์ไหนตั้ง content: [...] ใน configหาให้เองอัตโนมัติ ไม่ต้องตั้ง
ปรับ tokenแก้ theme.extend ใน JSเขียน CSS variable ใน @theme { }
สี red-500#ef4444#fb2c36 (v4 เปลี่ยนไปใช้ระบบสีใหม่)

ผมลองรันในโปรเจกต์จริงแล้ว ว่า ls tailwind.config.* ไม่เจออะไรเลย — no matches found · ไฟล์นั้นไม่มีจริง อย่าไปหา

ถ้าเผลอเอาไวยากรณ์ v3 มาใช้ในโปรเจกต์ v4 จะเกิดอะไรขึ้น

ผมสร้างไฟล์ old.css ที่ใส่ไวยากรณ์ v3 ล้วน ๆ แล้ว import เข้าหน้าหนึ่ง แล้วเปิดหน้านั้นดู:

/* app/t72/v3style/old.css — ไวยากรณ์ v3 ในโปรเจกต์ v4 */
@tailwind base;
@tailwind components;
@tailwind utilities;

ผลจริงที่ได้ ตรงข้ามกับที่หลายคนคาด:

สิ่งที่วัดผลจริง
มี error ตอน compile ไหมไม่มี — dev server ไม่บ่นสักบรรทัด
หน้าเว็บเปิดได้ไหมได้ — HTTP 200
ได้ไฟล์ CSS ออกมาไหมได้ แต่ผอมผิดปกติ — 4,089 bytes ขณะที่หน้าปกติในโปรเจกต์เดียวกันได้ 21,648 bytes
มี .flex ไหม✅ มี
มี .p-4 ไหม❌ ไม่มี
มี --spacing ไหม❌ ไม่มี (0 ครั้ง)
มี preflight (box-sizing reset) ไหม❌ ไม่มี (0 ครั้ง)

ผลนี้แย่กว่าพังไปเลย — ถ้ามันขึ้น error คุณจะรู้ทันทีว่าต้องแก้ตรงไหน แต่มันดัน "ทำงานได้ครึ่งเดียว": คลาสที่ไม่ต้องพึ่ง token อย่าง .flex ยังมา ส่วนคลาสที่ต้องคำนวณจาก --spacing อย่าง .p-4 หายไปเงียบ ๆ
คุณจะนั่งงงว่า "ทำไม flex ติดแต่ padding ไม่ติด" อยู่ครึ่งชั่วโมง

บทเรียน: ถ้าเจอหน้าที่สไตล์มาบ้างไม่มาบ้าง ให้เช็คไวยากรณ์เวอร์ชันก่อนเช็คอย่างอื่น

5 · เอาไปใช้จริง — ใส่สไตล์ให้ Button ของบท 71

สไลด์หน้า 28 เอา Button จากบท 71 มาใส่คลาส Tailwind ผมพิมพ์ตามสไลด์แล้วรันจริง ได้แบบนี้:

// components/Bases/Button.tsx
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;

const Button = (props: ButtonProps) => {
  const { children, ...restProps } = props;
  return (
    <button
      className={`px-2.5 py-1.5
        text-base text-[#aa3bff] bg-[rgba(170,59,255,0.1)]
        border-2 hover:border-[rgba(170,59,255,0.5)] border-transparent rounded-md
        transition-colors duration-300`}
      {...restProps}
    >
      {children}
    </button>
  );
};

export default Button;

เรียกใช้แบบเดิมเป๊ะ — คนใช้ไม่ต้องรู้เลยว่าข้างในเป็น Tailwind:

<Button
  type="button"
  onClick={() => console.log("CLICKED !")}
>
  Test Button
</Button>

HTML ที่เซิร์ฟเวอร์ส่งออกมาจริง (ดึงด้วย curl):

<button class="px-2.5 py-1.5
        text-base text-[#aa3bff] bg-[rgba(170,59,255,0.1)]
        border-2 hover:border-[rgba(170,59,255,0.5)] border-transparent rounded-md
        transition-colors duration-300" type="button">Test Button</button>

สองอย่างในโค้ดนี้ที่ต้องอธิบาย

อย่างแรก — วงเล็บเหลี่ยม [...] คือ "ค่าที่ฉันขอกำหนดเอง"
Tailwind ไม่มีสีชื่อ #aa3bff เตรียมไว้ให้ คุณจึงยัดค่าดิบเข้าไปในวงเล็บเหลี่ยม แล้วมันสร้างกฎให้ตอน build เลย ของจริงในไฟล์ CSS ออกมาแบบนี้:

.text-\[\#aa3bff\] {
  color: #aa3bff;
}

.bg-\[rgba\(170\,59\,255\,0\.1\)\] {
  background-color: #aa3bff1a;
}

สังเกตว่ามันแปลง rgba(170,59,255,0.1) เป็น #aa3bff1a ให้เอง (ค่าเดียวกัน เขียนสั้นกว่า) · ส่วน \ เต็มไปหมดคือการ escape เพราะ # [ ( เป็นอักขระพิเศษใน CSS selector

อย่างที่สอง — ลำดับคลาสใน className ไม่มีความหมายเลย
ในโค้ดข้างบน hover:border-[rgba(170,59,255,0.5)] ถูกเขียนก่อน border-transparent ถ้าคิดแบบ CSS ปกติ คุณอาจกลัวว่า border-transparent จะมาทับตอน hover
แต่ไม่เกิดขึ้น เพราะสิ่งที่ตัดสินคือลำดับในไฟล์ CSS ไม่ใช่ลำดับที่คุณพิมพ์ใน HTML — ผมไปดูไฟล์จริงมา:

/* บรรทัดที่ 614 ของไฟล์ CSS ที่ผมดึงมา */
.border-transparent {
  border-color: #0000;
}

/* บรรทัดที่ 824–827 — มาทีหลังตั้ง 200 บรรทัด และมี :hover เพิ่ม specificity ด้วย */
@media (hover: hover) {
  .hover\:border-\[rgba\(170\,59\,255\,0\.5\)\]:hover {
    border-color: #aa3bff80;
  }
}

เลขบรรทัดในไฟล์ของคุณจะไม่ตรงกับของผม เพราะมันขึ้นกับว่าโปรเจกต์คุณใช้คลาสอะไรบ้าง · สิ่งที่ต้องดูคือ "อันไหนอยู่ก่อน อันไหนอยู่หลัง" ไม่ใช่ตัวเลข

Tailwind เรียงลำดับกฎให้เองตอน build โดยเอา variant อย่าง hover: ไว้ท้ายเสมอ คุณจึงพิมพ์คลาสสลับกันยังไงก็ได้ ผลเหมือนเดิม

โบนัสที่เจอตอนอ่านไฟล์: Tailwind 4 ห่อ hover: ไว้ใน @media (hover: hover) แปลว่าบนมือถือที่ไม่มีเมาส์ สไตล์ hover จะไม่ทำงานเลย — ตั้งใจให้เป็นแบบนั้น เพราะบนจอสัมผัส hover มัก "ค้าง" อยู่หลังผู้ใช้แตะแล้วปล่อย · อย่าเอาข้อมูลสำคัญไปซ่อนไว้ใต้ hover: เพราะคนใช้มือถือจะไม่เห็นเลย

🧰 เทียบให้เห็นภาพ — เสื้อผ้ากับตู้เสื้อผ้า

className="px-2.5 py-1.5 rounded-md" เหมือนบอกว่าวันนี้ใส่อะไรบ้าง (เสื้อยืดขาว กางเกงยีนส์ รองเท้าผ้าใบ) — บอกทีละชิ้น เห็นครบทันทีว่ามีอะไร
ส่วน <Button> ที่ห่อคลาสพวกนั้นไว้ข้างใน เหมือนชุดยูนิฟอร์มที่แขวนพร้อมในตู้ หยิบมาใส่ทั้งชุด ไม่ต้องเลือกทีละชิ้น และทุกคนในทีมได้ชุดเดียวกัน

ที่ไม่เหมือนกัน: ยูนิฟอร์มจริงเปลี่ยนได้ยาก ต้องสั่งตัดใหม่ทั้งล็อต แต่ Button คุณแก้ที่ไฟล์เดียว แล้วทุกปุ่มในเว็บเปลี่ยนพร้อมกันทันที — นี่คือเหตุผลทั้งหมดที่ต้องมี component ครอบ ไม่ใช่พิมพ์คลาสยาว ๆ ซ้ำทุกที่ · แต่ก็แปลว่าคุณทำพังทั้งเว็บได้ในบรรทัดเดียวเหมือนกัน

6 · กับดักที่ทำให้สไตล์หายเงียบ ๆ — ชื่อคลาสที่สร้างจากตัวแปร

นี่คือบั๊ก Tailwind อันดับหนึ่งที่คนใหม่เจอ และมันไม่มี error ให้เห็นแม้แต่ตัวเดียว
ผมเขียนหน้านี้แล้วรันจริงบน dev server ลองทายก่อนกดเฉลย:

// app/t72/dynamic/page.tsx
export default function Page() {
  const color = "blue";
  const shade = 600;
  return (
    <div className="p-4">
      <p className={`text-${color}-${shade}`}>สร้างชื่อคลาสด้วยการต่อสตริง</p>
      <p className="text-green-700">เขียนชื่อคลาสเต็ม ๆ ตรง ๆ</p>
    </div>
  );
}

เปิดหน้านี้ในเบราว์เซอร์ บรรทัดไหนจะเป็นสีน้ำเงิน บรรทัดไหนจะเป็นสีเขียว? แล้วในไฟล์ CSS ที่ถูกสร้างขึ้น จะมีกฎ .text-blue-600 กี่กฎ?

บรรทัดแรกเป็นสีดำ (สีปกติ) · บรรทัดที่สองเป็นสีเขียว และในไฟล์ CSS มีกฎ .text-blue-600 ศูนย์กฎ

ผลจริงที่วัดได้:

# HTML ที่ส่งออกมา — คลาสอยู่ครบนะ
class="text-blue-600"
class="text-green-700"

# แต่ในไฟล์ CSS ที่ Tailwind สร้าง นับจำนวนครั้งที่เจอคำนั้น
text-blue-600   -> 0
text-green-700  -> 1

# กฎของ text-green-700 ที่มีอยู่จริง
.text-green-700 { color: var(--color-green-700); }
# โดยที่ --color-green-700: #008138;

ทำไม — ย้อนไปที่ขั้นที่ 1 ของแผนภาพในหัวข้อ 3: Tailwind อ่านไฟล์ของคุณเป็นตัวอักษร ตอน build ตอนนั้นโปรแกรมของคุณยังไม่ได้รัน ตัวแปร color จึงยังไม่มีค่า สิ่งที่ Tailwind เห็นในไฟล์คือข้อความตรง ๆ ว่า text-${color}-${shade} ซึ่งไม่ใช่ชื่อ utility ที่มันรู้จัก → มันเลยไม่สร้างกฎอะไรเลย

พอถึงตอนที่โปรแกรมรันจริงในเบราว์เซอร์ React ก็ใส่ class="text-blue-600" ให้อย่างซื่อสัตย์ แต่ตอนนั้นสายเกินไปแล้ว ไฟล์ CSS ถูกสร้างเสร็จตั้งแต่ตอน build · ไม่มี error ไม่มี warning ใน terminal สักบรรทัด · หน้าเว็บตอบ HTTP 200 ปกติทุกอย่าง

วิธีแก้: เขียนชื่อคลาสให้ครบทุกตัวไว้ในโค้ด แล้วค่อยเลือกทีหลัง

❌ สร้างชื่อจากตัวแปร — สไตล์หาย

const color = "blue";
<p className={`text-${color}-600`}>

Tailwind ไม่เคยเห็นคำว่า text-blue-600 ในไฟล์ไหนเลย จึงไม่สร้างกฎให้

✅ เขียนชื่อเต็มไว้ทั้งหมด แล้วเลือก

const colors = {
  blue:  "text-blue-600",
  green: "text-green-700",
};
<p className={colors[color]}>

ชื่อเต็มปรากฏเป็นตัวอักษรในไฟล์แล้ว Tailwind สแกนเจอ จึงสร้างกฎให้ทั้งสองอัน

🗣 ศัพท์ที่เพิ่งเรียนไป
arbitrary value
ค่าที่คุณกำหนดเองในวงเล็บเหลี่ยม เช่น text-[#aa3bff] w-[158px] · ใช้ได้ แต่ทุกครั้งที่ใช้แปลว่าคุณกำลังหลุดออกจาก design token ของทีม · ถาม senior ได้ว่า “ค่านี้ควรเป็น arbitrary value หรือควรเพิ่มเข้า theme ไปเลย” ถ้าค่าเดิมโผล่เกิน 2–3 ที่ คำตอบมักเป็นอย่างหลัง
variant (prefix)
คำนำหน้าที่ตามด้วย : ซึ่งบอกว่า "ใช้กฎนี้เฉพาะตอนไหน" — hover: sm: dark: focus: · มันจะกลายเป็น pseudo-class หรือ media query ในไฟล์ CSS · พิมพ์บอก AI ได้ว่า “add a dark: variant for these classes” จะตรงกว่าบอกว่า “ทำโหมดมืด”
content scanning
ขั้นที่ Tailwind ไล่อ่านไฟล์ในโปรเจกต์เพื่อหาว่ามีชื่อ utility อะไรถูกพิมพ์ไว้บ้าง แล้วสร้างเฉพาะอันนั้น — นี่คือเหตุผลที่ไฟล์ CSS เล็ก และเป็นเหตุผลที่ชื่อคลาสจากตัวแปรใช้ไม่ได้ · ถาม senior ได้ว่า “คลาสนี้ Tailwind สแกนเจอไหม” — เป็นคำถามที่ตัดปัญหา "สไตล์ไม่ติด" ได้เร็วที่สุด

7 · ทวนความเข้าใจ

ในโปรเจกต์ Tailwind v4 นี้ ถ้าอยากเปลี่ยนค่าฐานของระยะห่างทั้งเว็บ ต้องไปแก้ที่ไหน

ตัวเลือกที่ 2 ถูก — v4 ย้ายการตั้งค่ามาอยู่ใน CSS ทั้งหมด ผ่านบล็อก @theme ซึ่งประกาศ token อย่าง --spacing --radius-md --color-red-500

ตัวเลือกที่ 1 คือวิธีของ v3 · ผมรัน ls tailwind.config.* ในโปรเจกต์จริงแล้ว ได้ no matches found — ไฟล์นั้นไม่มีอยู่

ตัวเลือกที่ 3 คือที่ที่บอกว่า "ให้ PostCSS เรียก Tailwind" ไม่ได้เกี่ยวกับค่าระยะห่าง · ตัวเลือกที่ 4 คือ content ของ v3 ซึ่ง v4 หาไฟล์ให้เองแล้ว

จำง่าย ๆ: v4 = ตั้งค่าใน CSS · v3 = ตั้งค่าใน JS

คลาส p-4 ถูกแปลงเป็น CSS อะไร ในโปรเจกต์นี้

ตัวเลือกที่ 4 ถูก — ผมก็อปมาจากไฟล์ CSS ที่โปรเจกต์สร้างจริง มันเขียนไว้อย่างนั้นเป๊ะ ๆ

ในไฟล์เดียวกันประกาศว่า --spacing: .25rem ผลสุดท้ายจึงเท่ากับ 4 × 0.25rem = 1rem = 16px

ตัวเลือกที่ 1 และ 2 ให้ค่าผิด (4px เล็กเกินไป · 4rem = 64px ใหญ่เกินไป) และที่สำคัญกว่าคือ มันเข้าใจกลไกผิด — เลขหลังขีดเป็นตัวคูณ ไม่ใช่หน่วย

ตัวเลือกที่ 3 ฟังดูสมเหตุสมผลแต่ไม่มีตัวแปรชื่อ --p-4 อยู่จริงในไฟล์ · ข้อนี้สำคัญเพราะเข้าใจถูกแล้วคุณจะรู้ว่าทำไมแก้ค่าเดียว ระยะห่างทั้งเว็บถึงขยับตาม

โค้ด <p className={`text-${color}-600`}> โดยที่ color = "blue" จะเกิดอะไรขึ้น

ตัวเลือกที่ 1 ถูก — ผมรันจริงแล้ววัดได้ว่า HTML มี class="text-blue-600" แต่ในไฟล์ CSS นับคำว่า text-blue-600 ได้ 0 ครั้ง ขณะที่ text-green-700 ที่เขียนเต็มไว้ นับได้ 1 ครั้ง

ตัวเลือกที่ 2 ผิดเพราะ Tailwind ไม่ได้รันโปรแกรมของคุณ มันแค่อ่านไฟล์เป็นตัวอักษร ตอนสแกน ตัวแปร color ยังไม่มีค่าใด ๆ ทั้งสิ้น

ตัวเลือกที่ 3 และ 4 คือสิ่งที่เราอยากให้เกิด แต่ไม่เกิด — ผมรันแล้ว dev server เงียบสนิท ไม่มี error ไม่มี warning และหน้าเว็บตอบ HTTP 200

นี่คือเหตุผลที่บั๊กนี้กินเวลาคนเยอะมาก: มันไม่ส่งเสียงเลย

ในโค้ดของ Button หัวข้อ 5 — className="border-2 hover:border-[rgba(170,59,255,0.5)] border-transparent" — ตอนเอาเมาส์ไปชี้ ขอบจะเป็นสีอะไร

ตัวเลือกที่ 3 ถูก — class ใน HTML เป็นแค่รายชื่อ ไม่ใช่ลำดับความสำคัญ เบราว์เซอร์ตัดสินจากไฟล์ CSS ล้วน ๆ

ผมเปิดไฟล์ CSS จริงมาดู: .border-transparent อยู่บรรทัด 614 ส่วน .hover\:border-[…]:hover อยู่บรรทัด 825 · hover มาทีหลัง และยังมี specificity สูงกว่าด้วยเพราะมี :hover ต่อท้าย จึงชนะสองต่อ (เลขบรรทัดของคุณจะต่างไป ดูแค่ว่าอันไหนมาก่อน)

ตัวเลือกที่ 1 และ 2 คือความเข้าใจผิดที่พบบ่อยว่า "คลาสหลังทับคลาสหน้า" — ไม่จริงเลยใน CSS

ตัวเลือกที่ 4 ใกล้เคียงแต่ผิดจุดสำคัญ: Tailwind เรียงลำดับกฎในไฟล์ CSS (เอา variant ไว้ท้าย) ไม่ได้ไปแตะสตริงใน className ของคุณเลย — HTML ที่ curl ได้ ยังเรียงตามที่พิมพ์ทุกตัว

8 · ภารกิจ — พิสูจน์ด้วยไฟล์ CSS จริง ไม่ใช่ด้วยสายตา

ภารกิจ: จับ Tailwind ให้ได้คาหนังคาเขา

ทำในโปรเจกต์ Next.js ของคุณเอง · ห้ามตัดสินด้วยการมองหน้าเว็บอย่างเดียว เพราะกับดักในบทนี้ทั้งหมด "มองแล้วดูปกติ"

  1. สร้าง app/tw-lab/page.tsx ใส่กล่องหนึ่งกล่องด้วยคลาส p-6 rounded-lg border-2 border-gray-300 text-lg แล้ว npm run dev
  2. เปิด DevTools → แท็บ Elements → เลือกกล่องนั้น → ดูช่อง Styles ฝั่งขวา แล้วจดลงกระดาษว่าแต่ละคลาสกลายเป็น CSS อะไร (คำใบ้: p-6 จะขึ้นเป็น calc(var(--spacing) * 6) ไม่ใช่ 1.5rem)
  3. ในช่อง Computed ของ DevTools ดูค่าจริงเป็น px ของ padding แล้วเช็คว่าตรงกับที่คุณคำนวณเองจาก --spacing: .25rem ไหม
  4. เขียนเทสพิสูจน์กับดัก — เพิ่มสองบรรทัดนี้ในหน้าเดียวกัน:
    const tone = "rose";
    <p className={`text-${tone}-600`}>ไดนามิก</p>
    <p className="text-rose-600">เขียนเต็ม</p>
    แล้วเปิดไฟล์ CSS ที่หน้านั้นโหลด (DevTools → Network → กรอง CSS → คลิกไฟล์ → Response) กด Ctrl/Cmd+F ค้นคำว่า text-rose-600
    เกณฑ์ผ่าน: คุณต้องตอบได้ว่าเจอกี่ครั้ง และอธิบายได้ว่าทำไมไม่ใช่สองครั้ง
  5. เทสข้อสุดท้าย — เอาคลาสใดคลาสหนึ่งที่คุณไม่ได้ใช้ที่ไหนเลย เช่น text-9xl แล้วค้นหาในไฟล์ CSS เดียวกันนั้น · ควรได้ 0 ครั้ง
    จากนั้นไปพิมพ์ text-9xl ใส่ที่ไหนสักที่ในโปรเจกต์ เซฟ แล้วค้นใหม่
    เกณฑ์ผ่าน: เห็นตัวเลขเปลี่ยนจาก 0 เป็น 1 ด้วยตาตัวเอง — ตรงนี้แหละคือ content scanning ที่คุณเพิ่งเรียนไป

ข้อ 2–3: p-6 → padding: calc(var(--spacing) * 6) → 6 × 0.25rem = 1.5rem = 24px ในช่อง Computed · rounded-lg → border-radius: var(--radius-lg) → 0.5rem = 8px · border-2 → border-width: 2px

ข้อ 4 คำตอบคือ 1 ครั้ง — มาจากบรรทัดที่เขียนชื่อเต็มเท่านั้น ส่วน `text-${tone}-600` ไม่ได้ทำให้เกิดกฎอะไรเลย เพราะตอน Tailwind สแกนไฟล์ มันเห็นแค่ตัวอักษร text-${tone}-600 ซึ่งไม่ใช่ชื่อ utility ที่มันรู้จัก
ที่หลอกตาคือสองบรรทัดนี้จะดูเหมือนกันบนหน้าเว็บถ้าคุณบังเอิญเลือกสีที่ใกล้สีตัวอักษรปกติ — เหตุผลที่ภารกิจนี้บังคับให้เปิดไฟล์ CSS ดู ไม่ใช่ให้มองเอา

ข้อ 5: ถ้าตัวเลขไม่เปลี่ยนจาก 0 เป็น 1 ให้ hard refresh (Cmd/Ctrl+Shift+R) เพราะเบราว์เซอร์ชอบ cache ไฟล์ CSS ไว้ · ผมเจอเรื่องนี้ตอนตรวจบทนี้เหมือนกัน — ครั้งแรกที่ดึงไฟล์มาได้ของเก่า เลยเห็น text-orange-600 เป็น 0 ทั้งที่มันควรมี พอดึงใหม่แบบ no-cache ถึงได้ 1

ติดตรงไหนถามได้เลย — ถ้าสไตล์ไม่ติดแล้วหาไม่เจอว่าทำไม ส่งมาสามอย่างนี้: (1) บรรทัด className ที่มีปัญหา (2) ผลค้นหาชื่อคลาสนั้นในไฟล์ CSS ว่าเจอกี่ครั้ง (3) ไวยากรณ์ใน globals.css บรรทัดแรก
สามอย่างนี้ตัดสาเหตุได้เกือบทั้งหมดของบทนี้แล้ว

อ้างอิง