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

shadcn/ui — ไลบรารีที่ก็อปโค้ดมาไว้ในโปรเจกต์เรา

~25 นาที · ควรผ่าน บท 71 (component + props) · บท 72 (Tailwind) · บท 79 (components กับ containers) มาก่อน

ถึงตอนนี้แอป Wongnok ของเรายังไม่มีปุ่มสวย ๆ สักปุ่ม · สไลด์หน้า 9–16 เลยพาไปหยิบ shadcn/ui มาใช้
คนส่วนใหญ่ได้ยินคำว่า “UI library” แล้วนึกถึงของที่ npm install แล้ว import ใช้เลย แต่ shadcn ไม่ได้ทำงานแบบนั้น — มันเป็นเครื่องมือที่ก็อป source code ของ component มาวางในโฟลเดอร์ของเรา แล้วโค้ดนั้นก็กลายเป็นของเราทันที
ความต่างข้อนี้ข้อเดียวอธิบายได้ทุกอย่างที่เหลือในบท: ทำไมต้องมี components.json · ทำไมมีฟังก์ชัน cn() โผล่มา · และทำไมสไลด์หน้า 16 ถึงเตือนว่า อย่าแก้ไฟล์ตรง ๆ ให้สร้างใหม่ตามแบบ shadcn
ผมพิสูจน์ทุกข้อด้วย git status ในโปรเจกต์จริง ไม่ได้เล่าจากเอกสาร

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

next                      16.3.5
react                     19.2.8
typescript                5.9.3
tailwindcss               4.3.3
shadcn                    4.21.0     # npx shadcn@latest --version
@base-ui/react            1.8.0
class-variance-authority  0.7.1
cn                        0.3.0      # ตัวที่ทำ cn() — ดูหัวข้อ 4
node                      v24.14.0
npm                       11.9.0

โปรเจกต์ที่ใช้ตรวจสร้างด้วย create-next-app แล้วรัน shadcn init (preset base-nova ซึ่งใช้ @base-ui/react ตรงกับที่สไลด์หน้า 15 import) และ shadcn add button card badge skeleton ไว้ก่อน แล้ว commit เก็บไว้
ทุกการทดลองในบทนี้ทำต่อจาก commit นั้น แล้วใช้ git status / git diff ดูว่าไฟล์ไหนเปลี่ยนจริง
โค้ดทุกก้อนที่ไม่ได้ติดป้ายว่าพัง ผ่าน npx next build · npx tsc --noEmit · npx eslint . ครบสามตัว · HTML ที่โชว์มาจากไฟล์ที่ next build prerender ออกมาจริง และค่า padding วัดจาก browser จริงด้วย getComputedStyle
หน้าทดสอบในโปรเจกต์อยู่ที่ app/t80/ — เห็นชื่อนี้ในผลรันให้อ่านว่าเป็นหน้าของบทนี้

⚠️ สไลด์หน้า 14 พิมพ์คำสั่งผิดหนึ่งตัวอักษร

กล่องแรกในสไลด์หน้า 14 เขียนว่า npm shadcn@latest add button — ขาดตัว x
ผลรันจริง:

$ npm shadcn@latest add button
Unknown command: "shadcn@latest"

To see a list of supported npm commands, run:
  npm help

ที่ถูกคือ npx shadcn@latest add button (เหมือนคำสั่ง init ในสไลด์หน้า 11 ซึ่งถูกอยู่แล้ว) · npm คือตัวจัดการ package · npx คือ “ดาวน์โหลดโปรแกรมนี้มารันครั้งเดียว” — สองกล่องล่างของสไลด์ (yarn dlx · pnpm dlx) คือ npx ของค่ายอื่น จึงถูกทั้งคู่

1 · shadcn ไม่ใช่ของที่ import — มันคือของที่ก็อป

ลองเทียบสองแบบนี้ดู

แบบ UI library ทั่วไป — คุณสั่ง npm install ชื่อไลบรารี โค้ดของปุ่มไปนอนอยู่ใน node_modules/ แล้วคุณเขียน import { Button } from "ชื่อไลบรารี"
อยากเปลี่ยนสีปุ่ม? ทำได้เท่าที่ไลบรารีเปิดช่องให้ · อยากแก้ข้างในจริง ๆ? ไม่ได้ เพราะมันไม่ใช่โค้ดของคุณ (แก้ใน node_modules/ ได้ แต่ติดตั้งใหม่ครั้งหน้าก็หาย)

แบบ shadcn — คุณสั่ง npx shadcn@latest add button แล้วมันเขียนไฟล์ components/ui/button.tsx ลงในโปรเจกต์ของคุณ เป็น source code ธรรมดา ๆ 57 บรรทัด อ่านได้ทุกบรรทัด
คุณ import มันจาก @/components/ui/button — จากโฟลเดอร์ของตัวเอง ไม่ใช่จาก package

// โปรเจกต์นี้มี "shadcn" อยู่ใน package.json จริง ๆ (init ใส่ให้)
// งั้นลอง import ปุ่มจากมันตรง ๆ
import { Button } from "shadcn";
export const X = Button;

npx tsc --noEmit ผ่านไหม?

ไม่ผ่าน · ผลรันจริง:

probe-import.ts(1,10): error TS2305: Module '"shadcn"' has no exported member 'Button'.

package shadcn ที่อยู่ใน package.json ไม่มีปุ่มสักตัว — ผมเปิด node_modules/shadcn/package.json ดูแล้ว สิ่งที่มัน export คือ ตัวหลัก . (ฟังก์ชันอ่าน registry เช่น getRegistry) · ./registry · ./schema · ./mcp · ./utils · ./icons · ./preset และ ./tailwind.css ซึ่งเป็นของสำหรับตัว CLI กับไฟล์ CSS ที่ app/globals.css บรรทัด 3 ดึงไปใช้ (@import "shadcn/tailwind.css"; — ตรงกับสไลด์หน้า 13)
ตัวปุ่มจริงมีที่เดียว คือไฟล์ที่ถูกก็อปมาไว้ใน components/ui/

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

UI library ทั่วไปเหมือนสั่งอาหารกล่องสำเร็จรูป — อุ่นแล้วกินได้ทันที แต่อยากลดเค็มหรือเพิ่มผัก ทำไม่ได้ เพราะกล่องปิดผนึกมาจากโรงงาน
shadcn เหมือนเว็บสูตรอาหาร (อย่าง Wongnok ที่เรากำลังสร้างนี่แหละ) — คุณกด “บันทึกสูตร” แล้วสูตรทั้งหน้าถูกคัดลอกลงสมุดของคุณ จากนั้นจะลดน้ำตาล เปลี่ยนหมูเป็นไก่ ขีดเขียนยังไงก็ได้ เพราะสมุดเป็นของคุณ

ที่ไม่เหมือนกัน: สูตรที่ลอกมายังต้องซื้อวัตถุดิบจากร้าน — button.tsx ที่ถูกก็อปมายัง import @base-ui/react · class-variance-authority · cn ซึ่งเป็น npm package จริง ๆ อยู่ใน node_modules/
และที่ต่างกว่านั้น: สมุดสูตรจริงไม่มีใครมาเปิดเขียนทับ แต่ CLI ของ shadcn เขียนทับสมุดคุณได้ ถ้าคุณสั่งมัน (หัวข้อ 5 จะโชว์ว่าเกิดอะไรขึ้น)

2 · init — เตรียมสมุดก่อนก็อปสูตรแรก

สไลด์หน้า 11 ให้คำสั่งนี้ (ถูกต้อง รันได้):

$ npx shadcn@latest init -t next

ภาพ terminal ในสไลด์หน้า 11 โชว์ว่ามันถามสองคำถาม: Select a component library (ในภาพเลือก Base UI) แล้วตามด้วย Which preset would you like to use? (Nova · Vega · Maia · Lyra · Mira · Luma · Sera · Rhea · Custom) — preset คือชุดหน้าตาสำเร็จ ส่วนสไลด์หน้า 12 คือหน้า ui.shadcn.com/create ที่ให้ปรับ style / สี / ฟอนต์ / radius เองแล้วกด Get Code

init ไม่ได้ก็อป component ให้สักตัว — มันเตรียมโปรเจกต์ให้พร้อมรับ · นี่คือ git show --stat ของ commit ที่ผมรัน init + add สี่ตัวไว้ (ตัดบรรทัด lockfile ออก):

$ git show --stat HEAD
 app/globals.css            |  132 +-
 components.json            |   25 +
 components/ui/badge.tsx    |   51 +
 components/ui/button.tsx   |   57 +
 components/ui/card.tsx     |  102 ++
 components/ui/skeleton.tsx |   13 +
 lib/utils.ts               |    1 +
 package.json               |    8 +-

แยกให้ดูว่าแต่ละไฟล์มาทำอะไร:

ไฟล์มาจากหน้าที่
components.jsoninitบัตรประจำตัวโปรเจกต์ ที่ CLI อ่านทุกครั้งที่สั่ง add — จะวางไฟล์ที่ไหน · ใช้ style อะไร · CSS อยู่ไฟล์ไหน
app/globals.cssinitเติม @import สามบรรทัด + ตัวแปรสีทั้งชุด (--background · --primary …) ตามภาพสไลด์หน้า 13
lib/utils.tsinitที่อยู่ของฟังก์ชัน cn() — หัวข้อ 4
package.jsoninitเพิ่ม dependency: @base-ui/react · class-variance-authority · cn · lucide-react · shadcn · tw-animate-css
components/ui/*.tsxaddตัว component จริง — source code ที่ถูกก็อปมา

components.json ของจริงในโปรเจกต์ (ตัด $schema บรรทัดแรก กับ menuColor · menuAccent · registries ท้ายไฟล์ออก):

{
  "style": "base-nova",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "",                   // ว่าง = Tailwind v4 ไม่มี tailwind.config.js
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": ""
  },
  "iconLibrary": "lucide",
  "rtl": false,
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",        // ← add จะวางไฟล์ลงตรงนี้
    "lib": "@/lib",
    "hooks": "@/hooks"
  }
}

(ในรูปสไลด์หน้า 13 และ 15 จะเห็น components.json ติดตัว U สีเขียว — VS Code บอกว่าเป็นไฟล์ใหม่ที่ git ยังไม่เคยเห็น ตรงกับที่เราเจอ)

3 · add — พิสูจน์ว่ามันก็อปไฟล์จริง

โปรเจกต์ตั้งต้นมี button · card · badge · skeleton แล้ว ผมเลือก input ซึ่งยังไม่มี · commit ทุกอย่างให้สะอาดก่อน แล้วสั่ง:

$ npx shadcn@latest add input
✔ Checking registry.
✔ Created 1 file:
  - components/ui/input.tsx

$ git status --short
?? components/ui/input.tsx

$ git diff --stat
(ว่าง — ไม่มีไฟล์เดิมไหนถูกแก้เลย)

อ่านผลสามบรรทัดนี้ให้ออก:

ไฟล์ที่ได้มาหน้าตาแบบนี้ (class ยาวมาก ผมตัดตรงกลางเป็น …):

// components/ui/input.tsx — 19 บรรทัด ก็อปมาจาก registry ของ shadcn
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "cn"

function Input({ className, type, ...props }: React.ComponentProps<"input">) {
  return (
    <InputPrimitive
      type={type}
      data-slot="input"
      className={cn(
        "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 … dark:aria-invalid:ring-destructive/40",
        className
      )}
      {...props}
    />
  )
}

export { Input }

สังเกตว่ามันคือ component ธรรมดาแบบที่เราเขียนเป็นแล้วใน บท 71 — รับ className · กระจาย ...props ที่เหลือ · ครอบตัว InputPrimitive จาก Base UI (ตัวที่จัดการพฤติกรรมกับ accessibility) แล้วแปะ class ของ Tailwind ให้สวย
ไม่มีเวทมนตร์อะไรเลย · คุณเปิดอ่านได้ทุกบรรทัด เพราะมันอยู่ในโปรเจกต์คุณ

🗣 ศัพท์ที่เพิ่งเรียนไป
open code
แนวคิดที่ shadcn ใช้เรียกตัวเอง — ส่งโค้ดของ component ให้คุณเป็นเจ้าของ แทนการส่ง package ปิดผนึก · ภาพหน้าเว็บ ui.shadcn.com ในสไลด์หน้า 10 ก็ปิดท้ายคำโปรยว่า “Open Source. Open Code.” · หน้าแรกของเอกสาร shadcn เขียนไว้ว่ามัน “ไม่ใช่ component library แต่เป็นวิธีสร้าง component library ของคุณเอง” · พิมพ์บอก AI ได้ว่า “this project uses shadcn/ui, components live in components/ui and are owned by us” AI จะได้ไม่แนะนำให้ไป import จาก package
registry
คลังกลางที่ CLI ไปดึงโค้ดมา (บรรทัด ✔ Checking registry. ในผลรัน) · องค์กรใหญ่ทำ registry ของตัวเองได้ เพื่อให้ทุกทีม add component หน้าตาเดียวกัน · ถาม senior ได้ว่า “ทีมเรามี private registry ไหม หรือใช้ของ shadcn ตรง ๆ”
components.json
ไฟล์ตั้งค่าที่ CLI อ่าน — ตัวสำคัญคือ aliases (วางไฟล์ที่ไหน) กับ style · ถ้าย้ายโฟลเดอร์ components/ui ต้องแก้ที่นี่ด้วย ไม่งั้น add ครั้งหน้าจะวางผิดที่
primitive / headless component
component ที่ทำแค่พฤติกรรม ไม่มีหน้าตา — เช่น @base-ui/react/button จัดการคีย์บอร์ด focus และ disabled ให้ถูกต้อง ส่วนสีและขนาดเป็นหน้าที่ของ class ที่ shadcn แปะ · ภาพสไลด์หน้า 11 เลือก Base UI · โปรเจกต์ shadcn จำนวนมากบนเน็ตใช้อีกค่ายคือ Radix (เอกสาร CLI มีคำสั่ง migrate radix ไว้ด้วย) · ถามได้ว่า “โปรเจกต์นี้ใช้ primitive ของ Base UI หรือ Radix” ก่อนก็อปตัวอย่างโค้ดจากเน็ตมาใช้

4 · cn() — ตัวรวม class ที่รู้ว่า class ไหนชนกัน

⚠️ ของจริงต่างจากสไลด์หน้า 15 และจากบทความเก่า ๆ บนเน็ต

สไลด์หน้า 15 โชว์ button.tsx บรรทัด 4 ว่า import { cn } from "@/lib/utils"; และบทความส่วนใหญ่บนเน็ตจะบอกว่า lib/utils.ts ข้างในเป็น clsx + tailwind-merge
แต่ในโปรเจกต์ที่ผมสร้างด้วย shadcn 4.21.0 ของจริงเป็นแบบนี้:

$ cat lib/utils.ts
export { cn } from "cn"

$ grep cn components/ui/button.tsx | head -1
import { cn } from "cn"

cn กลายเป็น npm package ชื่อ cn (ทีม shadcn ทำเอง · README ของมันบอกว่าเป็นตัวแทนของ clsx + tailwind-merge ใช้ API เดียวกัน) · และ component ที่ add มา import จาก "cn" ตรง ๆ ไม่ผ่าน @/lib/utils
ผลต่อคุณ: เขียน import { cn } from "@/lib/utils" ในโค้ดของตัวเองได้เหมือนเดิม (ผม build ผ่านด้วยวิธีนี้ในหัวข้อ 6) · แต่ถ้าโปรเจกต์ของเพื่อนสร้างด้วย shadcn เวอร์ชันเก่า ไฟล์ lib/utils.ts จะหน้าตาไม่เหมือนของคุณ — ไม่ใช่ใครทำผิด แค่คนละรุ่น

ปัญหาที่ cn() แก้คือแบบนี้: button.tsx มี class ของตัวเองอยู่แล้ว เช่น bg-primary · แล้วคุณส่ง className="bg-orange-500" เข้าไป
ถ้าเอาสองสตริงมาต่อกันเฉย ๆ จะได้ "bg-primary bg-orange-500" — มีพื้นหลังสองสีในแท็กเดียว
คุณคงคิดว่า “ตัวหลังชนะ” เหมือนเขียน prop ซ้ำ… ลองทายดู

// ต่อสตริงเอง ไม่ใช้ cn — ตั้งใจให้ px-2 (ตัวหลัง) ชนะ
<div className="px-4 px-2">ต่อสตริงเอง</div>

// px-2 = padding ซ้ายขวา 8px · px-4 = 16px

เปิดใน browser แล้ว padding ซ้ายจริง ๆ เป็นเท่าไหร่?

ผลวัดจริงใน browser ด้วย getComputedStyle:

ต่อสตริงเอง => pad-left 16px

16px — px-4 ชนะ ทั้งที่เขียนไว้ก่อน
เพราะลำดับ class ในแท็ก HTML ไม่มีผลเลย · CSS ตัดสินจากว่ากฎไหนอยู่ทีหลังในไฟล์ CSS ผมเปิดไฟล์ CSS ที่ next build สร้างออกมาดู:

.px-2{padding-inline:calc(var(--spacing) * 2)}   ← เจอก่อนในไฟล์
.px-4{padding-inline:calc(var(--spacing) * 4)}   ← เจอทีหลัง (ห่างกัน 97 ตัวอักษร) จึงชนะ

Tailwind เป็นคนเรียงลำดับในไฟล์ CSS เอง คุณคุมไม่ได้
นี่คือเหตุผลที่ต้องมี cn(): มันลบ class ที่ชนกันทิ้งตั้งแต่ในสตริง ให้เหลือแต่ตัวหลังตัวเดียว — HTML จะไม่มีสองตัวให้ CSS ต้องไปตัดสินอีก

ผลรันจริงของ cn() (node รันไฟล์ที่ import { cn } from "cn"):

$ node cn-probe.mjs
// cn("px-2", "px-4")
"px-4"
// cn("px-2 py-1", "p-4")        p-4 คุมทั้งสี่ด้าน จึงกลืน px/py ที่มาก่อน
"p-4"
// cn("p-4", "px-2")             px-2 มาทีหลัง ทับได้แค่ซ้ายขวา p-4 จึงยังอยู่
"p-4 px-2"
// cn("text-red-500", "text-lg") สีกับขนาด คนละเรื่อง ไม่ชนกัน เก็บทั้งคู่
"text-red-500 text-lg"
// cn("rounded-lg", isActive && "bg-blue-500", { "text-white": isActive, "border": true })  โดย isActive = false
"rounded-lg border"
// "px-2" + " " + "px-4"         ต่อสตริงเอง
"px-2 px-4"

cn() ทำสองงานในตัวเดียว: (1) รวม — รับได้ทั้งสตริง, false (ทิ้งให้), และ object แบบ { "class": เงื่อนไข } · (2) ตัดสินการชน — รู้ว่า text-red-500 กับ text-lg ขึ้นต้นเหมือนกันแต่คนละเรื่อง ส่วน px-2 กับ px-4 คือเรื่องเดียวกัน

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

cn() เหมือนพนักงานรับออร์เดอร์ที่อ่านโน้ตเป็น
ลูกค้าเขียนโน้ตแรกว่า “เผ็ดกลาง” แล้วเขียนเพิ่มทีหลังว่า “ไม่เผ็ด” — พนักงานธรรมดาจะส่งกระดาษทั้งสองแผ่นเข้าครัว แล้วครัวเลือกเอาเองตามอารมณ์ (นี่คือการต่อสตริงเอง แล้ว CSS เลือกตามลำดับไฟล์)
พนักงานที่อ่านเป็นจะขีดฆ่า “เผ็ดกลาง” ทิ้ง เพราะรู้ว่าเป็นเรื่องเดียวกัน แต่ถ้าโน้ตสองคือ “ไม่ใส่ผักชี” เขาจะเก็บไว้ทั้งสองแผ่น เพราะคนละเรื่อง

ที่ไม่เหมือนกัน: พนักงานจริงเข้าใจภาษาคน แต่ cn() รู้จักแค่ชื่อ class ของ Tailwind — มันไม่ได้เปิดอ่านไฟล์ CSS จริง
class ที่คุณตั้งเองมันไม่รู้ว่าชนกับอะไร ผลรันจริง: cn("text-lg", "text-brand") ได้ "text-lg text-brand" · cn("recipe-card", "recipe-card-big") ได้ "recipe-card recipe-card-big" — เก็บไว้ทั้งคู่ แล้วโยนให้ CSS ตัดสินตามลำดับไฟล์เหมือนเดิม

🗣 ศัพท์เรื่อง class
class conflict
สอง utility class ที่ตั้งค่า CSS ตัวเดียวกันในแท็กเดียว เช่น bg-primary กับ bg-orange-500 · ผลลัพธ์ขึ้นกับลำดับในไฟล์ CSS ไม่ใช่ลำดับที่คุณเขียน · บอก AI ได้ว่า “use cn() to merge className so later Tailwind classes override conflicting ones”
clsx / tailwind-merge
สองไลบรารีที่ cn() รุ่นเก่าเอามาต่อกัน — clsx ทำงาน “รวม” · tailwind-merge ทำงาน “ตัดสินการชน” · ถ้าเห็นในโปรเจกต์อื่นให้รู้ว่าคือของเดียวกัน · เอกสาร CLI ของ shadcn มีคำสั่ง migrate cn ไว้ย้ายจากแบบเก่ามาแบบใหม่
cva / variant
class-variance-authority — ตัวที่ button.tsx ใช้ประกาศว่าปุ่มมีกี่แบบ (variant: default · outline · secondary · ghost · destructive · link และ size) แล้วคืนสตริง class ตามที่เลือก · ในภาพสไลด์หน้า 15 คือก้อน const buttonVariants = cva(…) · ถาม senior ได้ว่า “อยากได้ปุ่มแบบใหม่ ควรเพิ่ม variant หรือห่อ component ใหม่” — หัวข้อถัดไปคือคำตอบของคำถามนี้

5 · ทำไมสไลด์หน้า 16 บอกว่า “อย่าแก้ไฟล์ตรง ๆ”

ถึงตรงนี้คุณอาจคิดว่า “ไฟล์เป็นของเราแล้ว งั้นอยากได้ปุ่มสีส้มก็เปิด button.tsx แก้เลยสิ”
ได้จริง — แต่ลองดูว่าเกิดอะไรขึ้นถ้าวันหนึ่งมีคน (หรือ AI) สั่ง add button ซ้ำ
ผมแก้ variant default ของปุ่มให้เป็นสีส้ม:

$ git diff
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -7,7 +7,7 @@ const buttonVariants = cva(
   {
     variants: {
       variant: {
-        default: "bg-primary text-primary-foreground hover:bg-primary/80",
+        default: "bg-orange-500 text-white hover:bg-orange-600",
# แก้ button.tsx ไว้แล้ว (ยังไม่ commit) แล้วสั่งซ้ำ
$ npx shadcn@latest add button

สไลด์หน้า 14 โชว์ข้อความ Skipped 1 file: (files might be identical, use --overwrite …) — คิดว่าเราจะเห็นข้อความเดียวกันไหม?

ไม่เหมือน · บน shadcn 4.21.0 ไฟล์ที่ถูกแก้ทำให้มันหยุดถาม:

✔ Checking registry.
? The file button.tsx already exists. Would you like to overwrite? › (y/N)

ข้อความ “Skipped” แบบในสไลด์เกิดกับไฟล์ที่ยังไม่ถูกแก้ — ผมลองสั่ง add input ซ้ำกับ input.tsx ที่ไม่ได้แตะ ได้แบบนี้เป๊ะ:

ℹ Skipped 1 file: (files might be identical, use --overwrite to overwrite)
  - components/ui/input.tsx

แปลว่าภาพในสไลด์หน้า 14 น่าจะถ่ายตอนที่ button.tsx มีอยู่แล้วและยังไม่ถูกแก้
(ผมลอง --yes ด้วย เพราะ help บอกว่า “skip confirmation prompt” — มันก็ยังถามเรื่อง overwrite อยู่ดี · ตอนรันแบบไม่มีคนตอบ ไฟล์ที่แก้ไว้ยังอยู่ครบ)

คำถามนั้นถามแค่ครั้งเดียว · คนที่ไม่ได้อ่าน หรือสคริปต์/AI ที่ใส่ --overwrite มาให้ จะเจอแบบนี้:

$ npx shadcn@latest add button --overwrite
✔ Checking registry.
ℹ Updated 1 file:
  - components/ui/button.tsx

$ git status --short
(ว่าง)

$ grep -n 'default: "bg-' components/ui/button.tsx
10:        default: "bg-primary text-primary-foreground hover:bg-primary/80",

ปุ่มสีส้มหายเกลี้ยง ไม่มีคำเตือน · git status ว่างเพราะไฟล์กลับไปเหมือน commit เดิมทุกตัวอักษร
ในโปรเจกต์นี้โชคดีที่งานยังไม่ได้ commit เลยเสียแค่หนึ่งบรรทัด · ในโปรเจกต์จริงที่แก้ button.tsx ไว้ 20 จุดตลอดครึ่งปี แล้วมีคนสั่ง add button --overwrite เพื่อ “อัปเดตให้เป็นรุ่นล่าสุด” — คุณจะต้องไล่หาใน git history ว่าเคยแก้อะไรไว้บ้าง

ก่อนสั่ง overwrite ให้ดูก่อนเสมอ: --diff บอกว่าจะเปลี่ยนอะไรโดยไม่เขียนไฟล์ · ผลรันจริงกับปุ่มสีส้ม (ตัดกรอบออกบางส่วน):

$ npx shadcn@latest add button --diff components/ui/button.tsx
┌ shadcn add button (dry run)
├ components/ui/button.tsx (overwrite)
│ -        default: "bg-orange-500 text-white hover:bg-orange-600",
│ +        default: "bg-primary text-primary-foreground hover:bg-primary/80",
└ Run without --dry-run to apply.

แก้ components/ui/button.tsx ตรง ๆ

  • เร็วที่สุดในวันแรก
  • add button --overwrite ครั้งเดียว งานหายหมด
  • เทียบกับ shadcn รุ่นใหม่ยาก เพราะของเราปนกับของเขาในไฟล์เดียว
  • ทุกปุ่มทั้งแอปเปลี่ยนตาม แม้หน้าที่ไม่ได้อยากให้เปลี่ยน

สร้าง component ใหม่ที่ห่อ Button

  • components/ui/ คงสภาพต้นฉบับ · overwrite เมื่อไหร่ก็ไม่เสียอะไร
  • ของที่เราเพิ่มอยู่คนละไฟล์ อ่านรู้ทันทีว่าอะไรเป็นของเรา
  • ใช้ Button เดิมกับ ActionButton ใหม่คู่กันได้
  • ต้องเขียนเพิ่มหนึ่งไฟล์

6 · สร้างใหม่ตามแบบ shadcn — ห่อ Button แทนการแก้

“create a new by following Shadcn” ในสไลด์หน้า 16 แปลเป็นโค้ดได้แบบนี้ · วางตามโครง components/ComponentA/ComponentA.tsx + index.ts ที่สไลด์หน้า 8 ให้ไว้ (บท 79)
(ในภาพสไลด์หน้า 15 ก็เห็นโฟลเดอร์ components/Bases วางคู่กับ components/ui — ภาพไม่ได้กางให้เห็นว่าข้างในมีอะไร แต่ตำแหน่งตรงกับแนวคิดนี้)

// components/ActionButton/ActionButton.tsx
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type ActionButtonProps = ComponentProps<typeof Button> & {
  loading?: boolean;
};

const ActionButton = ({
  loading = false,
  disabled,
  className,
  children,
  ...restProps
}: ActionButtonProps) => {
  return (
    <Button
      className={cn("bg-orange-500 text-white hover:bg-orange-600", className)}
      disabled={disabled || loading}
      {...restProps}
    >
      {loading ? "กำลังบันทึก..." : children}
    </Button>
  );
};

export default ActionButton;
// components/ActionButton/index.ts
export { default } from "./ActionButton";
// app/t80/page.tsx
import ActionButton from "@/components/ActionButton";
import { Button } from "@/components/ui/button";

export default function Page() {
  return (
    <main className="flex gap-2 p-8">
      <Button>ปุ่มเดิมของ shadcn</Button>
      <ActionButton>บันทึกสูตร</ActionButton>
      <ActionButton loading>บันทึกสูตร</ActionButton>
      <ActionButton variant="outline" className="bg-white text-orange-600">ยกเลิก</ActionButton>
    </main>
  );
}

สามอย่างที่ทำให้ตัวห่อนี้ “ตามแบบ shadcn”:

ผลตรวจจริง:

$ npx next build
▲ Next.js 16.3.5 (Turbopack)
✓ Compiled successfully in 3.1s
  Running TypeScript ...
  Finished TypeScript in 1028ms ...
✓ Generating static pages using 6 workers (5/5) in 240ms

Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /t80

$ npx tsc --noEmit; echo "tsc rc=$?"
tsc rc=0

$ npx eslint . ; echo "eslint rc=$?"
eslint rc=0

และนี่คือ class ที่ออกมาใน HTML จริง (ไฟล์ .next/server/app/t80.html) · ผมตัด class ส่วนหัวที่เหมือนกันทุกปุ่มออกเป็น … เหลือแต่ท่อนท้ายที่ต่างกัน:

<!-- Button เดิม -->
class="… bg-primary text-primary-foreground hover:bg-primary/80 h-8 gap-1.5 px-2.5 …"

<!-- ActionButton -->
class="… h-8 gap-1.5 px-2.5 … bg-orange-500 text-white hover:bg-orange-600"

<!-- ActionButton loading — ได้ disabled="" มาด้วย และข้อความเปลี่ยน -->
<button type="button" data-disabled="" tabindex="0" disabled="" data-slot="button" class="… bg-orange-500 text-white hover:bg-orange-600">กำลังบันทึก...</button>

<!-- ActionButton variant="outline" className="bg-white text-orange-600" -->
class="… border-border hover:text-foreground aria-expanded:bg-muted … hover:bg-orange-600 bg-white text-orange-600"

ดูปุ่มที่สอง: bg-primary text-primary-foreground hover:bg-primary/80 หายไปทั้งสามตัว — cn() ข้างใน button.tsx ตัดทิ้งเพราะชนกับของเรา
ปุ่มสุดท้ายยิ่งชัด: variant outline มี bg-background hover:bg-muted — โดน bg-white กับ hover:bg-orange-600 ทับหาย · text-white ของเราก็โดน text-orange-600 ของคนเรียกทับอีกชั้น
วัดใน browser จริง: ปุ่ม “บันทึกสูตร” ได้พื้นหลัง lab(64.272 57.1788 90.3583) (สีส้ม) · ปุ่ม “ยกเลิก” ได้ rgb(255, 255, 255)

ทดสอบซ้ำ: พอมี ActionButton แล้ว ผมสั่ง add button --overwrite อีกรอบ แล้ว next build ใหม่ — button.tsx กลับเป็นต้นฉบับ แต่ HTML ของ ActionButton ยังลงท้ายด้วย bg-orange-500 text-white hover:bg-orange-600 ครบ เพราะสีส้มอยู่ในไฟล์ของเรา ไม่ได้อยู่ในไฟล์ที่ shadcn เป็นคนเขียน · นี่คือความหมายของสไลด์หน้า 16

🗣 ศัพท์เรื่องการดูแลโค้ดที่ก็อปมา
wrapper component / composition
สร้าง component ใหม่ที่ใช้ component เดิมข้างใน แทนการแก้ของเดิม · React ถนัดท่านี้มาก และหน้าเอกสาร shadcn ก็มีหัวข้อ Composition · พิมพ์บอก AI ได้ว่า “don't modify components/ui, create a wrapper component in components/ instead” — ประโยคนี้กัน AI ไปแก้ไฟล์ของ shadcn ได้ดีมาก
--overwrite / --diff / --dry-run
สาม flag ของ shadcn add ที่ต้องรู้ (ดูครบได้ด้วย npx shadcn@latest add --help) — --overwrite เขียนทับไม่ถาม · --diff โชว์ว่าจะเปลี่ยนอะไร · --dry-run ลองทำโดยไม่เขียนไฟล์ · ถาม senior ได้ว่า “ก่อน overwrite component ใน ui ทีมเราดู diff กันยังไง”
upstream
ต้นฉบับที่เราก็อปโค้ดมา — ในที่นี้คือ registry ของ shadcn · เวลาต้นฉบับอัปเดต โค้ดของเราไม่เปลี่ยนตามเอง (ต่างจาก npm package ที่แค่ขยับเลขเวอร์ชัน) · ราคาที่จ่ายของ open code คือต้องดึงของใหม่มาเองและรับผิดชอบเอง · ประโยคที่ใช้ได้: “component ใน ui ตาม upstream รุ่นไหน มีใครแก้ local ไว้ไหม”

ภารกิจ — ป้ายระดับความยากของสูตรอาหาร

🎯 สร้าง DifficultyBadge ที่ห่อ components/ui/badge

การ์ดสูตรอาหารใน Wongnok ต้องมีป้ายบอกความยาก · ห้ามแตะไฟล์ใน components/ui/

  1. สร้าง components/DifficultyBadge/DifficultyBadge.tsx + index.ts
  2. รับ prop level ที่เป็นได้แค่ "easy" | "medium" | "hard" แล้วแสดงคำว่า ง่าย / ปานกลาง / ยาก พร้อมสีเขียว / เหลือง / แดง
  3. รับ prop อื่นทุกตัวที่ Badge รับ (ยกเว้น children เพราะข้อความมาจาก level) และให้คนเรียกส่ง className มาทับได้
  4. ใช้ในหน้าใดหน้าหนึ่งสองครั้ง: level="easy" และ level="hard" className="text-base"
  5. รัน npx next build · npx tsc --noEmit · npx eslint . ให้ผ่านทั้งสามตัว
  6. พิสูจน์ว่าไม่ได้แตะ ui: รัน npx shadcn@latest add badge แล้วต้องได้ข้อความ Skipped และ git status --short ต้องไม่มีไฟล์ใต้ components/ui/
  7. พิสูจน์ว่า type กันพลาดได้จริง: ลองเขียน level="spicy" แล้ว tsc ต้องฟ้อง
// components/DifficultyBadge/DifficultyBadge.tsx
import type { ComponentProps } from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";

type Difficulty = "easy" | "medium" | "hard";

const LABEL: Record<Difficulty, string> = {
  easy: "ง่าย",
  medium: "ปานกลาง",
  hard: "ยาก",
};

const COLOR: Record<Difficulty, string> = {
  easy: "bg-green-100 text-green-800",
  medium: "bg-yellow-100 text-yellow-800",
  hard: "bg-red-100 text-red-800",
};

type DifficultyBadgeProps = Omit<ComponentProps<typeof Badge>, "children"> & {
  level: Difficulty;
};

const DifficultyBadge = ({ level, className, ...restProps }: DifficultyBadgeProps) => {
  return (
    <Badge className={cn(COLOR[level], className)} {...restProps}>
      {LABEL[level]}
    </Badge>
  );
};

export default DifficultyBadge;
// components/DifficultyBadge/index.ts
export { default } from "./DifficultyBadge";

// app/t80/badge/page.tsx
import DifficultyBadge from "@/components/DifficultyBadge";

export default function Page() {
  return (
    <main className="flex gap-2 p-8">
      <DifficultyBadge level="easy" />
      <DifficultyBadge level="hard" className="text-base" />
    </main>
  );
}

ผลตรวจจริงทุกข้อ:

$ npx next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /t80
└ ○ /t80/badge

$ npx tsc --noEmit; echo "tsc rc=$?"
tsc rc=0

$ npx eslint . ; echo "eslint rc=$?"
eslint rc=0

$ npx shadcn@latest add badge
✔ Checking registry.
ℹ Skipped 1 file: (files might be identical, use --overwrite to overwrite)
  - components/ui/badge.tsx

$ git status --short
?? app/t80/
?? components/ActionButton/
?? components/DifficultyBadge/

# ไฟล์ทดสอบ components/DifficultyBadge/bad.tsx (ลบทิ้งหลังลอง):
#   import DifficultyBadge from "./DifficultyBadge";
#   export const Bad = () => <DifficultyBadge level="spicy" />;
$ npx tsc --noEmit
components/DifficultyBadge/bad.tsx(2,43): error TS2322: Type '"spicy"' is not assignable to type 'Difficulty'.

HTML จริงของป้ายทั้งสอง (ตัดส่วนหัวของ class เป็น …):

<span data-slot="badge" data-variant="default" class="… px-2 py-0.5 text-xs font-medium … [a]:hover:bg-primary/80 bg-green-100 text-green-800">ง่าย</span>

<span data-slot="badge" data-variant="default" class="… px-2 py-0.5 font-medium … [a]:hover:bg-primary/80 bg-red-100 text-red-800 text-base">ยาก</span>

สองจุดที่ควรสังเกตในป้าย “ยาก”:
· text-xs หายไป เพราะชนกับ text-base (ขนาดตัวอักษรเรื่องเดียวกัน) · แต่ text-red-800 ยังอยู่ เพราะเป็นเรื่องสี คนละเรื่องกับขนาด — ตรงกับผล cn("text-red-500", "text-lg") ในหัวข้อ 4
· bg-primary text-primary-foreground ของ variant default หายไปทั้งคู่ เพราะโดน bg-red-100 text-red-800 ทับ · ส่วน [a]:hover:bg-primary/80 ยังค้างอยู่ เพราะ cn() มองว่าเป็นคนละเงื่อนไข (ใช้เฉพาะตอน badge เป็นลิงก์) — ถ้าวันหนึ่งคุณ render badge เป็น <a> ต้องใส่ hover ของตัวเองเพิ่ม
git status ที่มีแต่ ?? สามโฟลเดอร์ของเรา คือหลักฐานว่า components/ui/ ไม่ถูกแตะเลย

ลองตอบดู

โปรเจกต์ที่ commit สะอาดแล้ว สั่ง npx shadcn@latest add input แล้ว git status --short จะเห็นอะไร?

ตัวเลือกที่ 2 ถูก · ผลรันจริงคือ ?? components/ui/input.tsx และ git diff --stat ว่าง
ตัวเลือกที่ 1 คือการคิดแบบ UI library ทั่วไป — package.json ไม่เปลี่ยน เพราะ input พึ่ง @base-ui/react ที่ init ติดตั้งไว้แล้ว · ตัวเลือกที่ 3 ผิดเพราะ node_modules/shadcn ไม่มี component สักตัว (import { Button } from "shadcn" ได้ TS2305) · ตัวเลือกที่ 4 ผิดเพราะ components.json เก็บแค่การตั้งค่า ไม่ได้จดรายชื่อ component
จุดที่ต้องจำ: shadcn add = เขียนไฟล์ source ใหม่ลง components/ui/ · ดูด้วย git status ได้เสมอว่ามันทำอะไรกับโปรเจกต์

cn("p-4", "px-2") คืนค่าอะไร?

ตัวเลือกที่ 4 ถูก · ผลรันจริง "p-4 px-2" — บน-ล่างยังเป็น 16px จาก p-4 ส่วนซ้าย-ขวาเป็น 8px จาก px-2
ตัวเลือกที่ 1 ผิดเพราะถ้าลบ p-4 ทิ้ง padding บนล่างจะหายไปด้วย ซึ่งไม่มีใครสั่ง · ตัวเลือกที่ 2 คือผลของการสลับลำดับ cn("px-2 py-1", "p-4") ที่ได้ "p-4" — ตัวที่กว้างกว่ามาทีหลังถึงจะกลืน · ตัวเลือกที่ 3 ผิดเพราะ cn ไม่เรียงใหม่ มันคงลำดับตามที่ส่งมา
จุดที่ต้องจำ: cn() ลบเฉพาะส่วนที่ถูกทับจริง · ตัวที่มาทีหลังชนะ แต่ชนะได้แค่ขอบเขตของตัวเอง

แก้สีปุ่มใน components/ui/button.tsx ไว้ (ยังไม่ commit) แล้วเพื่อนสั่ง npx shadcn@latest add button --overwrite — เกิดอะไรขึ้น?

ตัวเลือกที่ 1 ถูก · ผลรันจริงคือ ℹ Updated 1 file: - components/ui/button.tsx แล้ว git status --short ไม่เหลืออะไร
ตัวเลือกที่ 2 ผิดสองชั้น — ข้อความ Skipped เกิดกับไฟล์ที่ไม่ได้แก้ และถ้าไม่ใส่ --overwrite กับไฟล์ที่แก้แล้ว มันจะถาม (y/N) ไม่ใช่ข้าม · ตัวเลือกที่ 3 ผิดเพราะ CLI เขียนทับทั้งไฟล์ ไม่ได้ merge · ตัวเลือกที่ 4 ผิดเพราะไม่มีไฟล์สำรองโผล่ใน git status เลย
จุดที่ต้องจำ: ของที่อยากคงไว้ อย่าเก็บใน components/ui/ — ห่อเป็น component ใหม่ แล้วดู --diff ก่อน overwrite ทุกครั้ง

พิมพ์ตามสไลด์หน้า 14 กล่องแรกเป๊ะ: npm shadcn@latest add button — ได้ผลอะไร?

ตัวเลือกที่ 3 ถูก · ผลรันจริงบน npm 11.9.0 คือ Unknown command: "shadcn@latest" ตามด้วยคำแนะนำให้ดู npm help
ตัวเลือกที่ 1 ผิดเพราะ npm ต้องการชื่อคำสั่งย่อย (install · run …) ส่วน npx ต้องการชื่อโปรแกรม — คนละรูปแบบ · ตัวเลือกที่ 2 ผิดเพราะ npm ไม่รู้จักคำสั่ง จึงไม่ติดตั้งอะไรเลย · ตัวเลือกที่ 4 ผิดเพราะ npm ไม่ได้ถามอะไร จบทันที
จุดที่ต้องจำ: คำสั่งของ shadcn ขึ้นต้นด้วย npx (หรือ pnpm dlx / yarn dlx) เสมอ · สไลด์หน้า 11 ถูก หน้า 14 กล่องแรกขาดตัว x

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

ถ้าโปรเจกต์ของคุณ lib/utils.ts หน้าตาไม่เหมือนในบทนี้ · หรือ add แล้วได้ข้อความไม่ตรง — เอาผล npx shadcn@latest --version กับ git status --short มาคุยได้เลย ส่วนใหญ่เป็นเรื่องรุ่นของ shadcn
ถ้าไม่แน่ใจว่าอยากได้ของใหม่ควร “เพิ่ม variant ใน button.tsx” หรือ “ห่อ component ใหม่” ก็เอาโจทย์มาถาม — คำถามนี้ไม่มีคำตอบเดียว แต่มีเหตุผลที่ต้องพูดให้ได้

อ้างอิง