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

Button หลายหน้าตาในที่เดียว — variants ด้วย CVA

~25 นาที · ควรผ่าน บทเรียน 81 (Design Tokens) และ บทเรียน 71 (component และ props) มาก่อน

เปิดหน้า Component Library ของ Wongnok (สไลด์หน้า 28) แล้วนับปุ่มดู — สีม่วงทึบ · ขอบม่วง · ขอบแดง · ส้ม · จาง · มีวงกลมหมุน · และมีสามขนาด
ทั้งหมดนี้เป็น component ตัวเดียวกัน ชื่อ Button ต่างกันแค่ props ที่ส่งเข้าไป
บทนี้จะเริ่มจากปัญหาก่อน: เขียน className ด้วย ternary ซ้อนกันให้เห็นว่ามันรกและพังเงียบ ๆ ได้ยังไง (ผมรันแล้วเจอบั๊กจริง) · จากนั้นเปลี่ยนมาใช้ cva() ที่สไลด์หน้า 31–32 แนะนำ แล้วเรียก buttonVariants() จริงใน node ให้เห็น string class ที่ออกมาทุกกรณี · ปิดท้ายด้วยการให้ TypeScript ฟ้องเมื่อส่ง variant ผิด และ loading state ที่กดซ้ำไม่ได้

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

# เวอร์ชันจริงใน 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)ตอบคำถามว่า
Variantscontained / outlinedทาสีทึบทั้งปุ่ม หรือแค่ขอบ
Colorsprimary / accent / error / grayใช้สีไหน (ม่วง · ส้ม · แดง · เทา)
Sizessm 34px / md 40px / lg 48pxสูงเท่าไหร่ (ตัวเลขจากป้ายในภาพ)
Statedisabled / 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() รับของสองก้อน:

  1. class ฐาน — ของที่ทุกปุ่มต้องมีเสมอ ไม่ว่าจะเลือกอะไร
  2. 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>;

สามจุดที่ควรสังเกต:

ผลรันจริง — เรียก 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 มีสองหน้าที่ ไม่ใช่แค่วงกลมหมุน:

เขียน 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 ของ propsButtonHTMLAttributes & VariantProps<typeof buttonVariants>ButtonPrimitive.Props & VariantProps<typeof buttonVariants> — ใช้ปุ่มของ @base-ui/react แทนแท็ก HTML
รวม classNamecn(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
exportexport default Buttonexport ทั้ง 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:

  1. เพิ่มแกน boolean ชื่อ block ใน variants: true → "w-full" · false → "" · และตั้ง default เป็น false
  2. เพิ่มกฎใน compoundVariants: เมื่อ variant: "outlined" และ size: "lg" ให้ต่อ "border-3"
  3. แก้ Button.tsx ให้ดึง block ออกจาก props แล้วส่งเข้า buttonVariants
  4. เขียนไฟล์ scratch/task.mts (อย่าลืมเพิ่ม "scratch" ใน exclude ของ tsconfig.json) เรียก buttonVariants 4 แบบ: outlined+lg · outlined+md · contained+lg · { block: true } แล้วรัน node scratch/task.mts · ชี้ให้ได้ว่า border-3 โผล่เฉพาะบรรทัดไหน
  5. เขียน <Button block="yes"> ไว้ในไฟล์ใดไฟล์หนึ่ง แล้วรัน npx tsc --noEmit ก็อป error มาเก็บไว้ · จากนั้นลบบรรทัดนั้นแล้วรันอีกรอบให้ exit 0
  6. รัน 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 ของคุณมาพร้อมผลที่รันได้ จะตอบได้ตรงกว่า

อ้างอิง