🧰 ของที่ใช้ตรวจในบทนี้
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 เขียนว่า 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.json | init | บัตรประจำตัวโปรเจกต์ ที่ CLI อ่านทุกครั้งที่สั่ง add — จะวางไฟล์ที่ไหน · ใช้ style อะไร · CSS อยู่ไฟล์ไหน |
app/globals.css | init | เติม @import สามบรรทัด + ตัวแปรสีทั้งชุด (--background · --primary …) ตามภาพสไลด์หน้า 13 |
lib/utils.ts | init | ที่อยู่ของฟังก์ชัน cn() — หัวข้อ 4 |
package.json | init | เพิ่ม dependency: @base-ui/react · class-variance-authority · cn · lucide-react · shadcn · tw-animate-css |
components/ui/*.tsx | add | ตัว 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
(ว่าง — ไม่มีไฟล์เดิมไหนถูกแก้เลย)
อ่านผลสามบรรทัดนี้ให้ออก:
??แปลว่าไฟล์ใหม่ที่ git ยังไม่รู้จัก — มีไฟล์เดียวpackage.jsonไม่เปลี่ยน — ไม่มี package ชื่อ “input” ถูกติดตั้ง (input ใช้@base-ui/reactซึ่ง init ติดตั้งไว้ให้แล้วตั้งแต่แรก)components.jsonก็ไม่เปลี่ยน — shadcn ไม่ได้จดว่าเราติดตั้งอะไรไปบ้าง ไฟล์ที่อยู่ในcomponents/ui/คือบันทึกเดียวที่มี
ไฟล์ที่ได้มาหน้าตาแบบนี้ (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 ของตัวเองได้ เพื่อให้ทุกทีมaddcomponent หน้าตาเดียวกัน · ถาม 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 โชว์ 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 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/ variantclass-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”:
ComponentProps<typeof Button>— รับ prop ทุกตัวที่Buttonรับ (variant·size·onClick…) โดยไม่ต้องพิมพ์ type เอง แล้วต่อloadingที่เป็นของเราเพิ่ม...restPropsส่งต่อทุกอย่างที่เหลือ — ท่าเดียวกับ{...restProps}ใน บท 71cn(ของเรา, className)— ใส่classNameของคนเรียกไว้ทีหลัง คนที่ใช้ActionButtonจึงยังทับสีได้ถ้าจำเป็น
ผลตรวจจริง:
$ 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/
- สร้าง
components/DifficultyBadge/DifficultyBadge.tsx+index.ts - รับ prop
levelที่เป็นได้แค่"easy" | "medium" | "hard"แล้วแสดงคำว่า ง่าย / ปานกลาง / ยาก พร้อมสีเขียว / เหลือง / แดง - รับ prop อื่นทุกตัวที่
Badgeรับ (ยกเว้นchildrenเพราะข้อความมาจากlevel) และให้คนเรียกส่งclassNameมาทับได้ - ใช้ในหน้าใดหน้าหนึ่งสองครั้ง:
level="easy"และlevel="hard" className="text-base" - รัน
npx next build·npx tsc --noEmit·npx eslint .ให้ผ่านทั้งสามตัว - พิสูจน์ว่าไม่ได้แตะ ui: รัน
npx shadcn@latest add badgeแล้วต้องได้ข้อความSkippedและgit status --shortต้องไม่มีไฟล์ใต้components/ui/ - พิสูจน์ว่า 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 ใหม่” ก็เอาโจทย์มาถาม
— คำถามนี้ไม่มีคำตอบเดียว แต่มีเหตุผลที่ต้องพูดให้ได้
อ้างอิง
- shadcn/ui — Introduction — อ่านหัวข้อ Open Code กับ Composition ซึ่งอธิบายตรง ๆ ว่าทำไมมันส่งโค้ดให้แทนการส่ง package — คือแกนของหัวข้อ 1 และ 6 ในบทนี้
- shadcn/ui — CLI —
อ่านหัวข้อ add ดู flag
--overwrite·--diff·--dry-runและหัวข้อ migrate cn ถ้าเจอโปรเจกต์ที่ยังใช้clsx+tailwind-merge - shadcn/ui — components.json —
อ่านหัวข้อ aliases (โดยเฉพาะ
aliases.uiกับaliases.utils) เพื่อรู้ว่า CLI ตัดสินใจวางไฟล์ที่ไหน · ข้าม registries ไปก่อนได้ - shadcn-ui/cn บน GitHub —
อ่าน README ช่วงต้นจนถึงหัวข้อ Can I use this today?
จะเห็นว่า
cnเป็นตัวแทนclsx+tailwind-mergeและมีคำสั่ง migrate ให้ — อธิบายว่าทำไมlib/utils.tsในโปรเจกต์ใหม่ไม่เหมือนสไลด์หน้า 15