🧰 ของที่ใช้ตรวจในบทนี้
# เวอร์ชันจริงใน node_modules ของโปรเจกต์ที่ใช้ตรวจ
next 16.3.5
react 19.2.8
typescript 5.9.3
tailwindcss 4.3.3
class-variance-authority 0.7.1
@base-ui/react 1.8.0
shadcn 4.21.0
node v24.14.0
# npx next build
▲ Next.js 16.3.5 (Turbopack)
✓ Compiled successfully in 5.1s
Running TypeScript ...
Finished TypeScript in 1052ms ...
✓ Generating static pages using 6 workers (5/5) in 331ms
โปรเจกต์ตั้งต้นคือ create-next-app + npx shadcn init -d (preset base-nova) +
shadcn add button ·
· string class ทุกบรรทัดที่ขึ้นว่า "ผลรันจริง" ได้จากการรันไฟล์ .mts ด้วย node 24 ที่ import buttonVariants ตัวจริงเข้ามาเรียก
· error TS2322 ทุกบรรทัดก็อปมาจาก npx tsc --noEmit
· HTML ในหัวข้อ 6 ได้จาก npx next start แล้ว curl หน้า /buttons
· ผลรัน node ผมใส่ JSON.stringify ครอบไว้ จะได้เห็นว่ามีช่องว่างเกินหรือเปล่า
1 · วิเคราะห์ปุ่มก่อนเขียนโค้ด — ปุ่มหนึ่งปุ่มมีกี่ "แกน"
สไลด์หน้า 28 เป็นหน้า Component Library ของ Wongnok UI · หัวข้อ Button เขียนกำกับว่า "6 variants · 3 sizes" และมีโค้ดตัวอย่างการใช้งานแปะไว้ใต้ภาพ:
// สไลด์หน้า 28–29 (กล่องโค้ดใต้ภาพปุ่ม)
<Button>Browse Recipes</Button>
<Button variant="outline">Share Your Recipe</Button>
<Button variant="ghost">Cancel</Button>
<Button variant="destructive-outline">Delete recipe</Button>
<Button variant="accent" size="lg">Sign in</Button>
<Button disabled>Disabled</Button>
<Button loading>Saving…</Button>
หน้า 29 ไล่ชื่อ variant ในแบบนั้นไว้ (default · outlined · ghost · destructive-outline · accent) · แต่พอถึงหน้า 30 สไลด์หัวข้อ "CRAFT VERSION" — แบบที่เราจะสร้างเองในคอร์สนี้ — แยกปุ่มออกเป็น 4 แกนที่เป็นอิสระต่อกัน:
| แกน | ค่าที่เป็นไปได้ (สไลด์หน้า 30) | ตอบคำถามว่า |
|---|---|---|
| Variants | contained / outlined | ทาสีทึบทั้งปุ่ม หรือแค่ขอบ |
| Colors | primary / accent / error / gray | ใช้สีไหน (ม่วง · ส้ม · แดง · เทา) |
| Sizes | sm 34px / md 40px / lg 48px | สูงเท่าไหร่ (ตัวเลขจากป้ายในภาพ) |
| State | disabled / loading | ตอนนี้กดได้หรือยัง |
ทำไมต้องแยกเป็นแกน? ลองนับดู: 2 × 4 × 3 = 24 หน้าตา (ยังไม่รวม state)
ถ้าตั้งชื่อ variant เป็นก้อนเดียวแบบหน้า 28 (destructive-outline) ทุกครั้งที่อยากได้ปุ่มแบบใหม่
ต้องคิดชื่อใหม่ · อยากได้ "ขอบส้ม" ก็ต้องเพิ่ม accent-outline อีกชื่อ ·
แต่ถ้าแยกแกน variant="outlined" color="accent" ได้มาฟรีโดยไม่ต้องเพิ่มอะไร
⚠️ ชื่อในสไลด์ไม่ตรงกันเองสองจุด — อย่าก็อปปนกัน
1. หน้า 29 รายการด้านซ้ายเขียน outlined แต่กล่องโค้ดด้านขวาเขียน variant="outline" (ไม่มี d)
2. ชื่อชุดหน้า 28–29 (outline, ghost, destructive-outline) คือชุดสไตล์ shadcn ·
ส่วนหน้า 30–32 ใช้ชุด craft version (contained/outlined + color)
บทนี้ยึดชุดหน้า 30–32 เพราะเป็นชุดที่โค้ด compoundVariants หน้า 32 ใช้ ·
หัวข้อ 5 จะให้ดูว่าถ้าเผลอส่ง variant="ghost" แบบหน้า 28 เข้าไป TypeScript ว่ายังไง
2 · ปัญหา — เขียน className ด้วย ternary ซ้อนกัน
ถ้ายังไม่รู้จักเครื่องมืออะไรเลย คนส่วนใหญ่จะเขียนฟังก์ชันคืน className แบบนี้
(ต่อยอดจาก getVariantStyles ใน บทเรียน 71)
· class ที่ใช้เป็นชื่อ token จาก บทเรียน 81 (bg-primary, bg-accent …)
ส่วน error ผมผูกกับ token destructive และ gray ผูกกับ muted ที่ shadcn สร้างไว้ให้:
// components/Bases/naive-button-class.ts — วิธีเก่าที่แย่ (tsc ผ่าน)
type Variant = "contained" | "outlined";
type Color = "primary" | "accent" | "error" | "gray";
type Size = "sm" | "md" | "lg";
export function getButtonClass(
variant: Variant = "contained",
color: Color = "primary",
size: Size = "md",
disabled = false,
) {
return (
"inline-flex items-center justify-center rounded-md font-medium " +
(size === "sm" ? "h-8.5 px-3 text-sm" : size === "lg" ? "h-12 px-6 text-base" : "h-10 px-4 text-sm") +
" " +
(variant === "contained"
? color === "primary"
? "bg-primary text-primary-foreground hover:bg-primary/90"
: color === "accent"
? "bg-accent text-accent-foreground hover:bg-accent/90"
: color === "error"
? "bg-destructive text-white hover:bg-destructive/90"
: "bg-muted text-foreground hover:bg-muted/80"
: "border-2 bg-transparent" +
(color === "primary"
? "border-primary text-primary"
: color === "accent"
? "border-accent text-accent"
: color === "error"
? "border-destructive text-destructive"
: "border-border text-foreground")) +
(disabled ? " opacity-50 pointer-events-none" : "")
);
}
แค่ 4 แกนก็ซ้อนกันลึก 4 ชั้นแล้ว · อ่านแล้วตอบไม่ได้ทันทีว่า "ปุ่มขอบแดงขนาดเล็กได้ class อะไร" ต้องไล่วงเล็บทีละชั้น · และที่แย่กว่าความรกคือมันพังได้โดยไม่มีใครฟ้อง
ไฟล์ข้างบน npx tsc --noEmit ผ่าน (exit 0) · ทีนี้เรียกสองครั้งใน node:
import { getButtonClass } from "../components/Bases/naive-button-class.ts";
console.log(getButtonClass());
console.log(getButtonClass("outlined", "error", "sm"));
บรรทัดที่สองจะได้ปุ่มขอบแดงหน้าตาถูกต้องไหม? ลองไล่วงเล็บดูก่อนกดเฉลย
ผลรันจริง (node scratch/naive.mts):
inline-flex items-center justify-center rounded-md font-medium h-10 px-4 text-sm bg-primary text-primary-foreground hover:bg-primary/90
inline-flex items-center justify-center rounded-md font-medium h-8.5 px-3 text-sm border-2 bg-transparentborder-destructive text-destructive
บรรทัดแรกถูก แต่บรรทัดที่สอง สอง class ติดกันเป็นคำเดียว:
bg-transparentborder-destructive · สาเหตุคือ "border-2 bg-transparent"
ลืมเว้นวรรคท้าย string ก่อน +
ผลคือ Tailwind ไม่รู้จักชื่อนี้ ปุ่มจะไม่มีสีขอบแดงและพื้นไม่โปร่ง · TypeScript ไม่ฟ้องเพราะมันเป็น string ที่ถูกต้องตาม type ทุกประการ · และบั๊กโผล่เฉพาะ 4 ใน 24 หน้าตา (ทุกปุ่มที่เป็น outlined) — ถ้าหน้าที่คุณเทสมีแต่ปุ่มทึบ คุณจะไม่มีวันเห็น
ternary ซ้อนเหมือนบาริสต้าที่จำสูตรเป็นประโยคยาว ๆ:
"ถ้าร้อน ถ้าแก้วเล็ก ถ้าหวานน้อย ก็ทำแบบนี้ ไม่งั้นถ้าแก้วใหญ่…" ·
เพิ่มตัวเลือกนมโอ๊ตเข้ามาทีเดียว ต้องแก้ทุกกิ่งของประโยค
cva() คือใบสั่งแบบติ๊กช่อง: ช่องอุณหภูมิ · ช่องขนาด · ช่องความหวาน ·
แต่ละช่องมีสูตรของตัวเองแยกกัน แล้วบาริสต้าเอามาต่อกันตามลำดับ
ส่วนกรณีพิเศษ เช่น "เย็น และ แก้วใหญ่ ให้ใส่น้ำแข็งเพิ่ม" เขียนเป็นกฎแยกอีกบรรทัด ·
นั่นคือ compoundVariants
ที่ไม่เหมือนกัน: บาริสต้าจริงเห็นใบสั่งที่ติ๊ก "ร้อน + ปั่น" ก็จะทักว่าทำไม่ได้ ·
แต่ cva() ไม่ตรวจอะไรตอนรันเลย — มันแค่เอา string มาต่อกัน ·
ถ้าคุณส่งค่าที่ไม่มีในเมนู มันก็แค่ไม่ต่ออะไรให้เงียบ ๆ ·
คนที่ทักจริงคือ TypeScript ตอน compile (หัวข้อ 5) ไม่ใช่ cva
- variant (ในความหมายของ design system)
- หน้าตาแบบหนึ่งของ component เดียวกัน · ในบทนี้ใช้คำนี้สองระดับ: ชื่อแกน
variant(contained/outlined) และคำรวม ๆ ว่า "variants" ที่หมายถึงทุกแกน · ถาม senior ได้ว่า “ปุ่มในทีมนี้แยกแกนยังไง variant กับ color แยกกันหรือรวมเป็นชื่อเดียว” - combinatorial explosion
- จำนวนกรณีที่พุ่งขึ้นแบบคูณเมื่อเพิ่มแกน (2 × 4 × 3 = 24) · เป็นเหตุผลที่ไม่ควรตั้งชื่อ variant แบบรวมก้อน · พิมพ์บอก AI ได้ว่า “split this button's variants into independent axes to avoid combinatorial explosion”
- class string concatenation bug
- บั๊กจากการต่อ string class ด้วย
+หรือ template literal แล้วช่องว่างหาย/เกิน หรือได้คำว่าundefined· TypeScript จับไม่ได้ · ถาม senior ได้ว่า “ทีมนี้ใช้อะไรรวม className — cn, clsx หรือ cva”
3 · cva() — เมนูแบบติ๊กช่อง
สไลด์หน้า 31 แนะนำ Class Variance Authority (CVA) ว่าเป็นไลบรารีที่
"จัดการ variant ของ UI แบบ type-safe โดยที่คุณไม่ต้องต่อชื่อ class เอง" ·
ไม่ต้องติดตั้งเพิ่ม เพราะ shadcn init ใส่ class-variance-authority ลง package.json ไว้ให้แล้ว
⚠️ ชื่อเต็มในสไลด์หน้า 31 สะกดต่างจากชื่อแพ็กเกจจริง — หัวสไลด์เขียน "Class Variant Authority" ·
แต่ชื่อแพ็กเกจที่ติดตั้งอยู่จริงคือ class-variance-authority (บรรทัด import ในสไลด์หน้า 15 ก็สะกดแบบนี้) ·
เวลาค้นเอกสารหรือพิมพ์ import ให้ใช้ variance
cva() รับของสองก้อน:
- class ฐาน — ของที่ทุกปุ่มต้องมีเสมอ ไม่ว่าจะเลือกอะไร
- config — มีสามช่องหลัก:
variants(เมนูแต่ละแกน) ·compoundVariants(กฎพิเศษเมื่อหลายแกนตรงพร้อมกัน) ·defaultVariants(ค่าที่ใช้เมื่อไม่ได้ส่งมา)
แล้วมันคืนฟังก์ชันกลับมา — เรียกฟังก์ชันนั้นพร้อมตัวเลือก ได้ string class ออกมา · นี่คือไฟล์เดียวกับหัวข้อ 2 เขียนใหม่ด้วย cva:
// components/Bases/button-variants.ts
import { cva, type VariantProps } from "class-variance-authority";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
contained: "",
outlined: "border-2 bg-transparent",
},
color: {
primary: "",
accent: "",
error: "",
gray: "",
},
size: {
sm: "h-8.5 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
},
compoundVariants: [
{ variant: "contained", color: "primary", className: "bg-primary text-primary-foreground hover:bg-primary/90" },
{ variant: "contained", color: "accent", className: "bg-accent text-accent-foreground hover:bg-accent/90" },
{ variant: "contained", color: "error", className: "bg-destructive text-white hover:bg-destructive/90" },
{ variant: "contained", color: "gray", className: "bg-muted text-foreground hover:bg-muted/80" },
{ variant: "outlined", color: "primary", className: "border-primary text-primary hover:bg-primary/10" },
{ variant: "outlined", color: "accent", className: "border-accent text-accent hover:bg-accent/10" },
{ variant: "outlined", color: "error", className: "border-destructive text-destructive hover:bg-destructive/10" },
{ variant: "outlined", color: "gray", className: "border-border text-foreground hover:bg-muted" },
],
defaultVariants: {
variant: "contained",
color: "primary",
size: "md",
},
},
);
export type ButtonVariantProps = VariantProps<typeof buttonVariants>;
สามจุดที่ควรสังเกต:
-
ค่าใน
colorเป็น string ว่างทั้งหมด — เพราะสีอย่างเดียวไม่พอจะรู้ว่าต้องใช้ class อะไร (ทึบใช้bg-primary· ขอบใช้border-primary) · ต้องรู้ทั้ง variant และ color พร้อมกัน → จึงไปอยู่ในcompoundVariants· ที่ต้องประกาศช่องว่างไว้ก็เพื่อให้ cva (และ TypeScript) รู้ว่ามีค่าprimary/accent/… อยู่ในเมนู -
ขนาดใช้
h-8.5/h-10/h-12ให้ตรงป้าย 34 / 40 / 48px ในสไลด์หน้า 30 · Tailwind v4 คำนวณจาก--spacingคูณตัวเลข · ผมเปิดไฟล์ CSS ที่ build ออกมาจริงแล้วเจอ.h-8\.5{height:calc(var(--spacing) * 8.5)}และ--spacing:.25rem→ 0.25rem × 8.5 = 2.125rem = 34px (เมื่อ font-size ของ root เป็น 16px) — ไม่ต้องใช้h-[34px] -
disabled ไม่ต้องมีแกนของตัวเอง — class ฐานมี
disabled:opacity-50 disabled:pointer-events-noneอยู่แล้ว · ใส่ attributedisabledบนแท็กเมื่อไหร่ Tailwind จางให้เอง (ต่างจาก ternary ในหัวข้อ 2 ที่ต้องส่ง boolean มาต่อ string เอง)
ผลรันจริง — เรียก buttonVariants ตัวข้างบนใน node:
buttonVariants()
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 h-10 px-4 text-sm bg-primary text-primary-foreground hover:bg-primary/90"
buttonVariants({ variant: "contained", color: "accent" })
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 h-10 px-4 text-sm bg-accent text-accent-foreground hover:bg-accent/90"
buttonVariants({ variant: "outlined" })
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 border-2 bg-transparent h-10 px-4 text-sm border-primary text-primary hover:bg-primary/10"
buttonVariants({ variant: "outlined", color: "error", size: "sm" })
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 border-2 bg-transparent h-8.5 px-3 text-sm border-destructive text-destructive hover:bg-destructive/10"
อ่านผลออกมาได้สามเรื่อง:
1. ลำดับคงที่เสมอ: class ฐาน → variant → color → size → compoundVariants ต่อท้ายสุด
2. string ว่างไม่ทิ้งช่องว่างซ้อน — ไม่มี " " สองช่องสักที่ (ผมครอบ JSON.stringify ไว้ดูแล้ว)
3. เคสเดียวกับที่ ternary พังในหัวข้อ 2 (outlined + error + sm) ตอนนี้ได้ bg-transparent h-8.5 … border-destructive แยกคำถูกต้อง
4 · compoundVariants และ defaultVariants — พิสูจน์ว่าต่อท้าย "เมื่อครบเงื่อนไขเท่านั้น"
สไลด์หน้า 32 นิยามไว้ประโยคเดียว: "variants ที่มีผลเมื่อเงื่อนไขของ variant อื่นหลายตัวเป็นจริงพร้อมกัน" และแปะโค้ดไว้ 12 บรรทัด — ถอดมาตามจริงทุกตัวอักษร:
// สไลด์หน้า 32 บรรทัด 1–12
compoundVariants: [
{
variant: "contained",
color: "primary",
className: "bg-primary text-primary-foreground hover:bg-primary/90",
},
{
variant: "contained",
color: "accent",
className: "bg-accent text-accent-foreground hover:bg-accent/90",
},
],
สไลด์ให้มาแค่ท่อนนี้ (ไม่มีส่วน variants และมีแค่สองกฎ) ·
เพื่อพิสูจน์พฤติกรรมให้ชัด ผมห่อมันด้วย cva ที่เล็กที่สุด —
class ฐานเป็นคำว่า btn คำเดียว · มีแค่แกน variant กับ color ·
และใช้ compoundVariants แค่สองกฎตามสไลด์เป๊ะ:
const buttonVariants = cva("btn", {
variants: {
variant: { contained: "", outlined: "border-2" },
color: { primary: "", accent: "", error: "", gray: "" },
},
compoundVariants: [ /* สองกฎของสไลด์หน้า 32 */ ],
defaultVariants: { variant: "contained", color: "primary" },
});
เรียกหกแบบนี้ ทายว่าบรรทัดไหนได้ class bg-… ต่อท้าย บรรทัดไหนไม่ได้:
buttonVariants({ variant: "contained", color: "primary" })
buttonVariants({ variant: "outlined", color: "primary" })
buttonVariants({ variant: "contained", color: "error" })
buttonVariants({ color: "accent" })
buttonVariants({ variant: undefined })
buttonVariants({ variant: null })
สองบรรทัดสุดท้ายต่างกันแค่ undefined กับ null — ได้ผลเหมือนกันไหม?
ผลรันจริง (node scratch/slide32.mts):
{ variant: "contained", color: "primary" } → "btn bg-primary text-primary-foreground hover:bg-primary/90"
{ variant: "outlined", color: "primary" } → "btn border-2"
{ variant: "contained", color: "error" } → "btn"
{ color: "accent" } → "btn bg-accent text-accent-foreground hover:bg-accent/90"
{ variant: undefined } → "btn bg-primary text-primary-foreground hover:bg-primary/90"
{ variant: null } → "btn"
- บรรทัด 1 ครบทั้งสองเงื่อนไข → ต่อท้าย
- บรรทัด 2 color ตรง แต่ variant ไม่ตรง → ไม่ต่อ ได้แค่
border-2ของแกน variant เอง (ปุ่มขอบที่ไม่มีสีขอบ — ถึงต้องมีกฎ outlined อีกสี่ข้อในไฟล์เต็มของหัวข้อ 3) - บรรทัด 3 ไม่มีกฎไหนเขียนถึง
contained + error→ ได้แค่btnเปล่า ๆ ไม่มี error ไม่มีเตือน - บรรทัด 4 ไม่ได้ส่ง variant แต่
defaultVariantsเติมcontainedให้ → ค่า default นับเป็นเงื่อนไขของ compound ด้วย → ต่อท้าย - บรรทัด 5
undefined= "ไม่ได้ส่ง" → ใช้ default ทั้งสองแกน → ต่อท้าย - บรรทัด 6
null= "ตั้งใจไม่เอาค่าใดเลย" → cva ไม่ใช้ default ของแกนนั้น → ไม่ครบเงื่อนไข → ได้btnเปล่า
ข้อสุดท้ายนี่คือกับดัก: undefined กับ null ไม่เท่ากันใน cva ·
ถ้าข้อมูลมาจาก API ที่ส่ง null มา ปุ่มจะเสียสีเงียบ ๆ
variants คือราคาปกติของแต่ละอย่าง: ข้าวหนึ่งจาน · น้ำหนึ่งแก้ว — สั่งอะไรก็จ่ายอันนั้น ·
compoundVariants คือโปรโมชัน "สั่งข้าวมันไก่ คู่กับ น้ำซุป ได้ไข่ต้มฟรี" —
ต้องสั่งครบทั้งสองอย่างถึงจะได้ไข่ สั่งอย่างเดียวไม่ได้
defaultVariants คือ "ถ้าไม่บอกขนาด ร้านให้จานธรรมดา" · และถ้าจานธรรมดาเข้าเงื่อนไขโปร ก็ได้ไข่ด้วย
ที่ไม่เหมือนกัน: ร้านจริงได้ของแถม "เพิ่ม" จากของเดิม ·
แต่ compound class ไม่ได้ลบ class ของแกนอื่นออก — มันแค่ต่อท้าย ·
ถ้า compound ใส่ border-3 แต่แกน variant ใส่ border-2 ไว้แล้ว string จะมีทั้งสองตัว ·
ตัวที่ตัดสินให้ว่าอันไหนชนะคือ cn() ในหัวข้อ 6 ไม่ใช่ cva
- cva (Class Variance Authority)
- ไลบรารีเล็ก ๆ ที่รับ "เมนู class" แล้วคืนฟังก์ชันสร้าง string class · ไม่ได้สร้าง CSS เอง Tailwind ยังเป็นคนสร้าง CSS · พิมพ์บอก AI ได้ว่า “refactor this className ternary into a cva() with variants and defaultVariants”
- compoundVariants
- รายการกฎที่ต่อ class ท้ายสุดเฉพาะเมื่อทุกเงื่อนไขในกฎตรงพร้อมกัน (รวมค่าจาก default) · ถาม senior ได้ว่า “สีของปุ่มขึ้นกับ variant ด้วย ควรใส่ใน compoundVariants หรือแยก component”
- defaultVariants
- ค่าที่ใช้เมื่อแกนนั้นเป็น
undefined· ส่งnull= ปิด default ของแกนนั้น · ตรงกับ design token ของทีมคือ "ปุ่มปกติหน้าตาเป็นยังไง"
5 · VariantProps — ให้ TypeScript ฟ้องเมื่อส่ง variant ผิด
บรรทัดสุดท้ายของไฟล์ในหัวข้อ 3:
export type ButtonVariantProps = VariantProps<typeof buttonVariants>;
อ่านเป็นภาษาคน: "ไปอ่านเมนูใน buttonVariants แล้วสร้าง type ของ props ให้เอง" ·
ไม่ต้องเขียน type Variant = "contained" | "outlined" ซ้ำเหมือนหัวข้อ 2 ·
เพิ่มค่าในเมนูเมื่อไหร่ type เปลี่ยนตามทันที ·
จากนั้นเอาไปบวกกับ props ของปุ่ม HTML ด้วย & แบบที่เรียนใน
บทเรียน 71:
// components/Bases/Button.tsx
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { buttonVariants, type ButtonVariantProps } from "./button-variants";
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
ButtonVariantProps & {
loading?: boolean;
};
const Button = (props: ButtonProps) => {
const {
variant,
color,
size,
loading = false,
disabled,
className,
children,
...restProps
} = props;
return (
<button
className={cn(buttonVariants({ variant, color, size }), className)}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...restProps}
>
{loading && <Loader2Icon className="size-4 animate-spin" aria-hidden="true" />}
{children}
</button>
);
};
export default Button;
สังเกตว่า variant · color · size · loading
ถูกดึงออกก่อนทุกตัว ไม่ปล่อยให้ตกลงไปใน restProps —
กับดักเดียวกับบทเรียน 71 หัวข้อ 6 ที่ของไหลไปโผล่บน DOM
ทีนี้ลองก็อปชื่อ variant จากกล่องโค้ดสไลด์หน้า 28 มาใช้กับ Button ของเรา แล้วส่งค่าผิดอีกสองแบบ:
// app/buttons/bad.tsx
import Button from "@/components/Bases/Button";
export function Bad() {
return (
<>
<Button variant="ghost">Cancel</Button>
<Button color="red">Delete</Button>
<Button size="xl">Sign in</Button>
</>
);
}
รัน npx tsc --noEmit — ฟ้องกี่บรรทัด? และ type ที่ขึ้นใน error ของ color จะหน้าตาเหมือนของ variant กับ size ไหม?
ผลรันจริง:
app/buttons/bad.tsx(6,15): error TS2322: Type '"ghost"' is not assignable to type '"contained" | "outlined" | null | undefined'.
app/buttons/bad.tsx(7,15): error TS2322: Type '"red"' is not assignable to type '"primary" | "accent" | "error" | "gray" | undefined'.
app/buttons/bad.tsx(8,15): error TS2322: Type '"xl"' is not assignable to type '"sm" | "md" | "lg" | null | undefined'.
ฟ้องครบทั้งสามบรรทัด (exit code 2) และบอกชัดว่าค่าที่ถูกมีอะไรบ้าง —
ไม่ต้องไปเปิดไฟล์ button-variants.ts ดูเลย · editor ก็ขึ้นขีดแดงให้ตั้งแต่ตอนพิมพ์
แต่สังเกตให้ดี: type ของ variant และ size มี null (ตรงกับหัวข้อ 4 ที่ cva รับ null) ·
ส่วน color ไม่มี null ·
สาเหตุคือ React.ButtonHTMLAttributes มี attribute ชื่อ color?: string อยู่แล้ว (มาจาก HTML เก่า) ·
พอเอามา & กัน TypeScript ต้องหาค่าที่ถูกทั้งสองฝั่ง → null ไม่ใช่ string เลยถูกตัดทิ้ง
ผลข้างเคียงที่ดี: ส่ง color={null} ไม่ได้ ·
ผลข้างเคียงที่ต้องระวัง: ถ้าลืมดึง color ออกจาก props มันจะผ่าน type แล้วไหลไปเป็น
attribute color="primary" บน <button> จริง
6 · ใช้งานจริงบนหน้าเว็บ — loading state และ cn()
สร้างหน้า app/buttons/page.tsx วางปุ่มแบบเดียวกับสไลด์หน้า 29–30:
import Button from "@/components/Bases/Button";
export default function ButtonsPage() {
return (
<main className="flex flex-wrap items-center gap-3 p-8">
<Button>Browse Recipes</Button>
<Button variant="outlined">Share Your Recipe</Button>
<Button variant="outlined" color="error">Delete recipe</Button>
<Button color="accent" size="lg">Sign in</Button>
<Button disabled>Disabled</Button>
<Button loading>Saving...</Button>
<Button size="sm">sm · 34px</Button>
<Button size="md">md · 40px</Button>
<Button size="lg">lg · 48px</Button>
</main>
);
}
npx next build ผ่าน · เปิด npx next start แล้ว curl หน้า /buttons
ตัดมาเฉพาะปุ่ม disabled กับ loading (ผลรันจริง):
<button class="inline-flex … h-10 px-4 text-sm bg-primary text-primary-foreground hover:bg-primary/90" disabled="">Disabled</button>
<button class="inline-flex … h-10 px-4 text-sm bg-primary text-primary-foreground hover:bg-primary/90" disabled="" aria-busy="true"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader-circle lucide-loader-2 size-4 animate-spin" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg>Saving...</button>
(… คือ class ฐานที่ยาวเหมือนกันทุกปุ่ม ผมตัดออกให้อ่านง่าย ส่วนอื่นไม่ได้แก้)
loading state ในสไลด์หน้า 30 มีสองหน้าที่ ไม่ใช่แค่วงกลมหมุน:
- ห้ามกดซ้ำ —
disabled={disabled || loading}ทำให้ปุ่มได้ attributedisabledจริง · เบราว์เซอร์ไม่ยิง click ให้ปุ่มที่ disabled → ผู้ใช้ที่ใจร้อนกด "บันทึก" สามครั้งจะไม่ส่งสูตรอาหารซ้ำสามรอบ · และได้ความจางจากdisabled:opacity-50มาฟรี (ในภาพสไลด์ปุ่ม Saving… ก็จางกว่าปุ่มปกติ) - บอกว่ากำลังทำงาน — spinner (
Loader2Iconจากlucide-reactที่ shadcn ติดตั้งไว้ +animate-spin) สำหรับตา ·aria-busy="true"สำหรับโปรแกรมอ่านหน้าจอ · ส่วนaria-hiddenบน svg กันไม่ให้มันอ่านไอคอนออกเสียง
เขียน aria-busy={loading || undefined} ไม่ใช่ ={loading} เพราะอยากให้ปุ่มปกติไม่มี attribute นี้เลย ·
ใน HTML ข้างบนปุ่ม Disabled ก็ไม่มี aria-busy จริง
ทำไม className ของคนใช้ต้องผ่าน cn()
สมมติหน้าไหนอยากได้ปุ่มเล็กแต่สูงพิเศษ เลยส่ง className="h-12 bg-accent" เข้ามา ·
class ใหม่ชนกับของเดิม (h-8.5, bg-primary) · ผมรันเทียบสามวิธี (ผลรันจริง):
ต่อ string เฉย ๆ:
"inline-flex … disabled:opacity-50 h-8.5 px-3 text-sm bg-primary text-primary-foreground hover:bg-primary/90 h-12 bg-accent"
ผ่าน cn():
"inline-flex … disabled:opacity-50 px-3 text-sm text-primary-foreground hover:bg-primary/90 h-12 bg-accent"
cva รับ className เอง (ไม่ผ่าน cn):
"inline-flex … disabled:opacity-50 h-8.5 px-3 text-sm bg-primary text-primary-foreground hover:bg-primary/90 h-12 bg-accent"
แบบแรกกับแบบที่สามมีทั้ง h-8.5 และ h-12 อยู่ด้วยกัน ·
อันไหนชนะขึ้นกับว่า Tailwind เขียน rule ไหนไว้ทีหลังในไฟล์ CSS —
ไม่ใช่ลำดับใน attribute class · ทายผลไม่ได้จากการอ่านโค้ด ·
cn() รู้ว่า h-8.5 กับ h-12 เป็นเรื่องเดียวกัน จึงลบตัวเก่าทิ้ง เก็บตัวที่มาทีหลัง
7 · เทียบกับ button.tsx ที่ shadcn สร้างให้ — ท่าเดียวกันเป๊ะ
สไลด์หน้า 15 เปิด components/ui/button.tsx ที่ได้จาก shadcn add button ·
ตอนนั้นอาจยังอ่านไม่ออก ตอนนี้อ่านออกแล้ว · นี่คือไฟล์จริงบนเครื่องที่ใช้ตรวจ ตัดเหลือโครง
(… คือ class ยาว ๆ ที่ตัดออก):
// components/ui/button.tsx — สร้างโดย shadcn 4.21.0 (preset base-nova)
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg … disabled:pointer-events-none disabled:opacity-50 …",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: "…", secondary: "…", ghost: "…", destructive: "…",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 …", xs: "h-6 …", sm: "h-7 …", lg: "h-9 …",
icon: "size-8", "icon-xs": "…", "icon-sm": "…", "icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
| เรื่อง | Button ของเรา | shadcn button.tsx |
|---|---|---|
| สร้าง class ด้วย | cva(ฐาน, { variants, compoundVariants, defaultVariants }) | cva เหมือนกัน · ไม่มี compoundVariants เพราะรวมสีไว้ในชื่อ variant แล้ว (destructive) |
| type ของ props | ButtonHTMLAttributes & VariantProps<typeof buttonVariants> | ButtonPrimitive.Props & VariantProps<typeof buttonVariants> — ใช้ปุ่มของ @base-ui/react แทนแท็ก HTML |
| รวม className | cn(buttonVariants({…}), className) | cn(buttonVariants({ variant, size, className })) — ส่ง className ให้ cva ต่อท้ายก่อน แล้ว cn ค่อยจัดการชนกัน · ผลลัพธ์เท่ากัน |
| แกน | variant × color × size (2 × 4 × 3) | variant × size (6 × 8) · ขนาดปกติ h-8 = 32px ไม่ใช่ 40px ของ Wongnok |
| export | export default Button | export ทั้ง Button และ buttonVariants — ให้เอา class ปุ่มไปแปะบน <a> ได้ด้วย |
⚠️ ไฟล์ที่คุณได้อาจไม่ตรงภาพสไลด์หน้า 15 ทุกตัวอักษร — ภาพในสไลด์ class ฐานเป็น rounded-4xl
และบรรทัดที่ 4 เป็น import { cn } from "@/lib/utils" ·
แต่ shadcn 4.21.0 บนเครื่องที่ตรวจสร้าง rounded-lg และ import { cn } from "cn"
(แพ็กเกจ cn 0.3.0 ที่คำอธิบายในแพ็กเกจบอกว่าเป็นตัวแทน clsx + tailwind-merge) ·
ส่วน lib/utils.ts เหลือแค่บรรทัดเดียว export { cn } from "cn" —
import จากทางไหนก็ได้ cn ตัวเดียวกัน ·
ท่า cva + VariantProps ซึ่งเป็นเรื่องของบทนี้เหมือนกันทุกประการ
8 · ทบทวน
ใช้ cva แบบย่อในหัวข้อ 4 (มี compoundVariants แค่สองกฎตามสไลด์หน้า 32) · เรียก buttonVariants({ variant: "outlined", color: "primary" }) ได้อะไร?
ตัวเลือกที่ 3 ถูก · ผลรันจริงคือ "btn border-2" — กฎทั้งสองข้อในสไลด์ต้องการ variant: "contained"
แต่เราส่ง outlined → ไม่ครบเงื่อนไข → ไม่ต่อ
ตัวเลือกที่ 1 ผิดเพราะคิดว่าแค่ color ตรงก็พอ · compound ต้องตรงทุกช่องในกฎ ·
ตัวเลือกที่ 2 ผิดสองชั้น: ทั้งต่อ class ที่ไม่ควรต่อ และทำ border-2 ของแกน variant หาย ·
ตัวเลือกที่ 4 ผิดเพราะ cva ไม่เคยโยน error เรื่องนี้ ไม่มีกฎที่ตรงก็แค่ไม่ต่อ
จุดที่ต้องจำ: compoundVariants ต่อท้ายเมื่อทุกเงื่อนไขตรงเท่านั้น · ไม่มีกฎ = ไม่มี class = ไม่มีคำเตือน
API ส่งข้อมูลปุ่มมาเป็น { variant: null } แล้วเราส่งต่อเข้า buttonVariants แบบย่อในหัวข้อ 4 · ได้ผลต่างจาก { variant: undefined } ไหม?
ตัวเลือกที่ 2 ถูก · ผลรันจริง: { variant: undefined } ได้ "btn bg-primary …"
แต่ { variant: null } ได้ "btn" เปล่า
ตัวเลือกที่ 1 คือสิ่งที่คนส่วนใหญ่เดา แต่ผลรันไม่ได้เป็นแบบนั้น ·
ตัวเลือกที่ 3 ผิดเพราะ string ที่ได้ไม่มีคำว่า null (ปัญหาคำขยะแบบนี้เป็นของ template literal ในบทเรียน 71 ไม่ใช่ของ cva) ·
ตัวเลือกที่ 4 ผิดเพราะ error ในหัวข้อ 5 แสดงว่า type ของ variant คือ "contained" | "outlined" | null | undefined — รับ null ได้
จุดที่ต้องจำ: ใน cva undefined = "ใช้ค่า default" · null = "ไม่เอาค่าไหนเลย"
Button ของเราเขียน disabled={disabled || loading} · ถ้าเปลี่ยนเป็นแค่แสดง spinner ตอน loading แต่ไม่ใส่ disabled จะเสียอะไร?
ตัวเลือกที่ 4 ถูก · attribute disabled เป็นตัวที่ทำให้เบราว์เซอร์ไม่ส่ง click ·
และ disabled:opacity-50 ใน class ฐานจะทำงานก็ต่อเมื่อแท็กมี attribute นั้นจริง
ตัวเลือกที่ 1 ผิดเพราะ animate-spin ไม่ขึ้นกับสถานะปุ่ม ·
ตัวเลือกที่ 2 ผิดเพราะ loading?: boolean เป็นแค่ช่อง optional ไม่ได้ผูกกับอะไร tsc ผ่าน ·
ตัวเลือกที่ 3 ผิดเพราะ Button ของเราไม่มีแกน disabled ใน cva — disabled:opacity-50 อยู่ใน class ฐานทุกปุ่มตลอด (ดูผลรันในหัวข้อ 3)
มันแค่มีผลเมื่อ attribute มา
จุดที่ต้องจำ: loading state = disabled (กันกดซ้ำ) + spinner (บอกว่ากำลังทำ) — ต้องมีทั้งคู่
เรียก cn(buttonVariants({ size: "sm" }), "h-12") · class ความสูงใน string ผลลัพธ์เหลืออะไร?
ตัวเลือกที่ 1 ถูก · ผลรันจริงในหัวข้อ 6: ผ่าน cn() แล้ว h-8.5 หายไป เหลือ h-12 ตัวที่มาทีหลัง
ตัวเลือกที่ 2 กลับด้าน — ตัวที่มาทีหลัง (className ของคนใช้) ชนะ ·
ตัวเลือกที่ 3 คือพฤติกรรมของการต่อ string เฉย ๆ หรือส่ง className ให้ cva ตรง ๆ ไม่ใช่ของ cn ·
ตัวเลือกที่ 4 ผิดเพราะ cn ไม่ลบทิ้งทั้งคู่ มันเก็บตัวสุดท้ายไว้
จุดที่ต้องจำ: cva สร้าง class · cn ตัดสินว่า class ไหนชนะเมื่อชนกัน — Button ที่รับ className จากข้างนอกต้องใช้คู่กัน
9 · ภารกิจ — เพิ่มแกนใหม่แล้วพิสูจน์ด้วยผลรัน
🛠 เพิ่ม block (เต็มความกว้าง) และขอบหนาเฉพาะปุ่มขอบใหญ่
ดีไซเนอร์ขอสองอย่าง: หน้า login อยากได้ปุ่มกว้างเต็มแถว ·
และปุ่ม outlined ขนาด lg ขอบ 2px ดูบางไป อยากได้ 3px ·
ทำตามลำดับนี้ใน components/Bases/button-variants.ts และ Button.tsx:
- เพิ่มแกน boolean ชื่อ
blockในvariants:true→"w-full"·false→""· และตั้ง default เป็นfalse - เพิ่มกฎใน
compoundVariants: เมื่อvariant: "outlined"และsize: "lg"ให้ต่อ"border-3" - แก้
Button.tsxให้ดึงblockออกจาก props แล้วส่งเข้าbuttonVariants - เขียนไฟล์
scratch/task.mts(อย่าลืมเพิ่ม"scratch"ในexcludeของtsconfig.json) เรียกbuttonVariants4 แบบ:outlined+lg·outlined+md·contained+lg·{ block: true }แล้วรันnode scratch/task.mts· ชี้ให้ได้ว่าborder-3โผล่เฉพาะบรรทัดไหน - เขียน
<Button block="yes">ไว้ในไฟล์ใดไฟล์หนึ่ง แล้วรันnpx tsc --noEmitก็อป error มาเก็บไว้ · จากนั้นลบบรรทัดนั้นแล้วรันอีกรอบให้ exit 0 - รัน
npx next buildให้ผ่าน
ก่อนกดเฉลย ตอบให้ได้: string ของ outlined+lg มีทั้ง border-2 และ border-3 ไหม?
แล้วบนหน้าเว็บจริงปุ่มจะได้ขอบเท่าไหร่ ทำไม?
ส่วนที่เพิ่มใน button-variants.ts:
variants: {
// … variant / color / size เหมือนเดิม
block: {
true: "w-full",
false: "",
},
},
compoundVariants: [
{
variant: "outlined",
size: "lg",
className: "border-3",
},
// … กฎสีแปดข้อเหมือนเดิม
],
defaultVariants: {
variant: "contained",
color: "primary",
size: "md",
block: false,
},
ใน Button.tsx เพิ่ม block, ในรายการที่ดึงออก และเปลี่ยนเป็น buttonVariants({ variant, color, size, block })
ผลรันจริง ข้อ 4:
{ variant: "outlined", size: "lg" }
"inline-flex … disabled:opacity-50 border-2 bg-transparent h-12 px-6 text-base border-3 border-primary text-primary hover:bg-primary/10"
{ variant: "outlined", size: "md" }
"inline-flex … disabled:opacity-50 border-2 bg-transparent h-10 px-4 text-sm border-primary text-primary hover:bg-primary/10"
{ variant: "contained", size: "lg" }
"inline-flex … disabled:opacity-50 h-12 px-6 text-base bg-primary text-primary-foreground hover:bg-primary/90"
{ block: true }
"inline-flex … disabled:opacity-50 h-10 px-4 text-sm w-full bg-primary text-primary-foreground hover:bg-primary/90"
ผลรันจริง ข้อ 5 — boolean ของ cva ถูกแปลงเป็น type ให้เองด้วย:
app/buttons/bad.tsx(7,15): error TS2322: Type 'string' is not assignable to type 'boolean | null | undefined'.
คำตอบคำถาม: string ของ outlined+lg จาก buttonVariants มีทั้ง border-2 และ border-3
(compound แค่ต่อท้าย ไม่ลบของเดิม — ตรงกับท่อน "ที่ไม่เหมือนกัน" ในหัวข้อ 4) ·
แต่ Button ของเราห่อด้วย cn() · ผมรัน cn(buttonVariants({ variant: "outlined", size: "lg" })) จริงแล้วได้:
"inline-flex … disabled:opacity-50 bg-transparent h-12 px-6 text-base border-3 border-primary text-primary hover:bg-primary/10"
border-2 หายไป เหลือ border-3 → หน้าเว็บได้ขอบ 3px แน่นอน ·
ถ้าไม่มี cn ขอบจะหนาเท่าไหร่ขึ้นกับลำดับ rule ในไฟล์ CSS ซึ่งทายจากโค้ดไม่ได้ ·
นี่คือเหตุผลที่ shadcn ห่อ buttonVariants ด้วย cn ทุกครั้ง
✅ ถ้าตอบสี่ข้อนี้ได้ แปลว่าบทนี้ผ่านแล้ว
1. ทำไมสีของปุ่มในบทนี้ไปอยู่ใน compoundVariants แทนที่จะอยู่ในแกน color ตรง ๆ?
2. buttonVariants({ color: "accent" }) ได้สีส้มไหม ทั้งที่ไม่ได้ส่ง variant?
3. VariantProps<typeof buttonVariants> ช่วยอะไร ถ้าไม่มีต้องเขียนอะไรซ้ำ?
4. loading state ต้องทำสองอย่างอะไรบ้าง?
ติดข้อไหน ถามกลับมาได้เลย — ยกโค้ด cva ของคุณมาพร้อมผลที่รันได้ จะตอบได้ตรงกว่า
อ้างอิง
- cva — Variants —
อ่านหัวข้อ Compound Variants และ Default Variants
ให้เห็นตัวอย่างทางการของสองช่องที่หัวข้อ 4 รันพิสูจน์ · สังเกตว่าในกฎ compound ใช้ได้ทั้ง
classและclassName - cva — TypeScript —
อ่านส่วนที่อธิบาย
VariantProps· สั้นมาก และเป็นท่าเดียวกับบรรทัดexport type ButtonVariantPropsในหัวข้อ 3 - shadcn/ui — Button —
ดูหัวข้อ Usage และตัวอย่างที่ใช้
buttonVariantsกับลิงก์ เพื่อเข้าใจว่าทำไม shadcn exportbuttonVariantsออกมาด้วย (ตารางหัวข้อ 7 แถวสุดท้าย) - Tailwind CSS — height —
ดูตาราง
h-<number>ที่เขียนว่าคำนวณจากcalc(var(--spacing) * <number>)ซึ่งเป็นเหตุผลที่h-8.5ได้ 34px ตรงป้ายสไลด์หน้า 30 - MDN — <button> —
อ่าน attribute
disabledในรายการ Attributes · และ MDN — aria-busy สำหรับเหตุผลของaria-busyบนปุ่ม loading