สัปดาห์ที่ 11

Shadcn/ui + Tailwind CSS: ออกแบบ UI ที่สวยงามและ Responsive

CLO4
📖 ทฤษฎี

1. Component Libraries คืออะไร — Shadcn vs MUI vs Chakra

Component Library คือชุด UI Components สำเร็จรูปที่นักพัฒนาสามารถนำมาใช้ในโปรเจกต์ได้ทันที เช่น ปุ่ม (Button), การ์ด (Card), Modal, Input, Table และอีกมากมาย แทนที่จะเขียน CSS และ HTML ทุกอย่างจากศูนย์ เราสามารถ import Component เหล่านี้มาใช้ได้เลย ซึ่งช่วยประหยัดเวลา รักษา Consistency ของ UI และมั่นใจได้ว่า Component ผ่านการทดสอบ Accessibility แล้ว

Library ยอดนิยมในระบบนิเวศ React มีหลายตัว แต่ละตัวมีแนวทางที่แตกต่างกัน:

  • Material UI (MUI) — ใช้หลักการออกแบบของ Google Material Design มี Component ครบครัน Customize ได้ผ่าน Theme แต่ Bundle size ค่อนข้างใหญ่ เหมาะสำหรับโปรเจกต์ขนาดใหญ่ที่ต้องการความสม่ำเสมอของ Design System
  • Chakra UI — API ที่ใช้งานง่าย มี Style Props (เช่น p={4} แทน padding) รองรับ Dark Mode ออกนอกกล่อง มี Accessibility ดีมาก แต่ต้องเรียนรู้ API เฉพาะของมัน
  • Shadcn/ui — แนวคิดใหม่ที่ไม่ใช่ Library แบบปกติ แต่เป็นการ copy โค้ด Component เข้ามาในโปรเจกต์โดยตรง ทำให้ Customize ได้ 100% ใช้ Tailwind CSS สำหรับ Styling และ Radix UI สำหรับ Behavior ไม่มี Dependency ล็อกในเวอร์ชันของ Library
  แนวทางการ Styling ของแต่ละ Library
  ──────────────────────────────────────────────────
  MUI        → Emotion (CSS-in-JS) + MUI Theme
  Chakra UI  → Emotion + Style Props + Chakra Theme
  Shadcn/ui  → Tailwind CSS + CSS Variables
  ──────────────────────────────────────────────────

  การติดตั้ง Component
  ──────────────────────────────────────────────────
  MUI        → npm install @mui/material        (ได้ทุก Component)
  Chakra UI  → npm install @chakra-ui/react     (ได้ทุก Component)
  Shadcn/ui  → npx shadcn@latest add button     (copy เฉพาะที่ต้องการ)
  ──────────────────────────────────────────────────
เคล็ดลับ — ทำไม Shadcn/ui ถึงได้รับความนิยมสูง

Shadcn/ui เป็นเพียงเครื่องมือสำหรับ copy โค้ด Component เข้าโปรเจกต์ ไม่ใช่ npm package ดังนั้นถ้า Library ออก Breaking Change ในเวอร์ชันใหม่ โปรเจกต์ของเราก็ไม่ได้รับผลกระทบ และสามารถแก้ไข Component ได้ตามต้องการโดยไม่ต้องรอ Maintainer ของ Library

2. Tailwind CSS — Utility-First CSS Framework

Tailwind CSS เป็น CSS Framework แนวทาง Utility-First ซึ่งหมายความว่าแทนที่จะเขียน CSS Class ที่มีความหมายเช่น .card หรือ .btn-primary เราใช้ Class เล็กๆ ที่ทำสิ่งเดียวโดยตรง เช่น p-4 (padding 1rem), text-blue-500 (สีน้ำเงิน), flex (display: flex), rounded-lg (border-radius ขนาดใหญ่)

ข้อดีหลักของ Tailwind:

  • ไม่ต้องตั้งชื่อ CSS Class — ปัญหาคลาสสิคในการพัฒนาเว็บคือการตั้งชื่อ Class ด้วย Tailwind เราไม่ต้องคิดว่าจะตั้งชื่อว่าอะไร เพราะใช้ Utility Class ของ Tailwind ได้เลย
  • ขนาดไฟล์ CSS เล็ก — Tailwind ใช้เทคโนโลยี Purge/Content Scanning ลบ Class ที่ไม่ได้ใช้ออก ทำให้ CSS ที่ Deploy จริงมีขนาดเล็กมาก
  • Responsive Design ง่าย — ใช้ Prefix เช่น md:, lg: เช่น grid-cols-1 md:grid-cols-2 lg:grid-cols-3 แสดง 1 คอลัมน์บนมือถือ 2 คอลัมน์บน tablet 3 คอลัมน์บน Desktop
  • Dark Mode — เพิ่ม dark: prefix เช่น bg-white dark:bg-gray-900
  Responsive Breakpoints ของ Tailwind
  ─────────────────────────────────────────
  (ไม่มี prefix) → mobile first (base)
  sm:            → min-width: 640px
  md:            → min-width: 768px
  lg:            → min-width: 1024px
  xl:            → min-width: 1280px
  2xl:           → min-width: 1536px

  ตัวอย่าง: <div class="text-sm md:text-base lg:text-lg">
  → มือถือ: text-sm | tablet: text-base | Desktop: text-lg
หมายเหตุ — Tailwind กับ React

ใน React ใช้ className แทน class เหมือนปกติ และสามารถใช้ Library เช่น clsx หรือ cn() จาก Shadcn เพื่อจัดการ Class แบบเงื่อนไขได้สะดวก เช่น cn('base-class', isActive && 'active-class')

3. Shadcn/ui Setup กับ Vite + React

การติดตั้ง Shadcn/ui ต้องทำหลายขั้นตอนเพราะมันไม่ใช่ npm package ธรรมดา แต่ละขั้นตอนสำคัญและต้องทำตามลำดับ:

  1. ติดตั้ง Tailwind CSS — ต้องติดตั้ง tailwindcss, postcss และ autoprefixer เป็น dev dependencies แล้วรัน npx tailwindcss init -p เพื่อสร้างไฟล์ config
  2. ตั้งค่า tailwind.config.js — กำหนด content ให้ Tailwind สแกนไฟล์ JSX/TSX เพื่อหา Class ที่ใช้จริง
  3. เพิ่ม Tailwind directives ใน CSS — เพิ่ม @tailwind base;, @tailwind components;, @tailwind utilities; ใน src/index.css
  4. รัน Shadcn initnpx shadcn@latest init จะถามตัวเลือกต่างๆ เช่น Style (Default/New York), Color scheme, และ CSS variables
  5. เพิ่ม Component ทีละตัวnpx shadcn@latest add button จะ copy โค้ด Component มาไว้ที่ src/components/ui/button.jsx
ข้อควรระวัง — Path Alias

Shadcn ใช้ @/ เป็น Path Alias สำหรับ src/ ต้องตั้งค่าใน vite.config.js โดยเพิ่ม resolve: { alias: { '@': path.resolve(__dirname, './src') } } ถ้าไม่ตั้งค่าจะเกิด error "Cannot find module '@/components/ui/button'"

4. Components ที่ใช้บ่อย: Button, Card, Input, Dialog, Badge, Table

Shadcn/ui มี Component พร้อมใช้หลายสิบตัว แต่ที่ใช้บ่อยที่สุดในแอปทั่วไปได้แก่:

  • Button — รองรับหลาย variant: default, destructive, outline, secondary, ghost, link และหลาย size: sm, default, lg, icon ใช้ prop asChild เพื่อ Render เป็น Element อื่นได้ เช่น <Link>
  • Card — ประกอบด้วย Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter ใช้จัดวาง Content ในรูปแบบ Card ที่มี shadow และ border radius
  • Input — ใช้แทน <input> ธรรมดา มี style ที่สอดคล้องกับ Design System และรองรับ Accessibility เช่น aria attributes
  • Dialog — Modal window ที่ Accessible ประกอบด้วย Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter จัดการ Focus Trap และ Keyboard Navigation อัตโนมัติ
  • Badge — Tag เล็กๆ สำหรับแสดงสถานะหรือ Label variant: default, secondary, destructive, outline
  • Table — ประกอบด้วย Table, TableHeader, TableBody, TableRow, TableHead, TableCell เหมาะสำหรับแสดงข้อมูลรายการ เช่น รายการจอง
  โครงสร้าง Card Component
  ─────────────────────────────────────────────────
  <Card>                   ← wrapper หลัก
    <CardHeader>           ← ส่วนหัว (title + description)
      <CardTitle>          ← หัวข้อหลัก
      <CardDescription>   ← คำอธิบายย่อ
    </CardHeader>
    <CardContent>          ← เนื้อหาหลัก
    </CardContent>
    <CardFooter>           ← ส่วนท้าย (actions)
    </CardFooter>
  </Card>

5. Theming และ CSS Variables ใน Shadcn

Shadcn/ui ใช้ CSS Variables เป็นพื้นฐานของ Theming ทำให้สามารถสลับ Theme (เช่น Light/Dark mode) ได้โดยไม่ต้องแก้ไข Component โดยตรง ไฟล์ src/index.css จะมี CSS Variables ที่กำหนดสีหลักทั้งหมด เช่น --background, --foreground, --primary, --secondary, --muted, --accent

Tailwind Class ที่ Shadcn ใช้จะอ้างอิงถึง CSS Variables เหล่านี้ เช่น bg-background = background-color: hsl(var(--background)) text-primary = color: hsl(var(--primary)) ดังนั้นเมื่อเปลี่ยนค่า CSS Variable ทุก Component ที่ใช้ Class นั้นจะเปลี่ยนสีตามอัตโนมัติ

ในการทำ Dark Mode ใน Shadcn เพียงเพิ่ม class dark ที่ <html> element CSS Variables ใน :root จะถูก override โดย Variables ใน .dark ซึ่ง Shadcn สร้างให้อัตโนมัติตอน init

  CSS Variables ใน index.css (ตัวอย่าง)
  ─────────────────────────────────────────────────
  :root {
    --background: 0 0% 100%;       ← สีขาว (Light mode)
    --foreground: 222.2 84% 4.9%;  ← สีเข้ม
    --primary: 222.2 47.4% 11.2%;  ← สีหลัก
    --primary-foreground: 210 40% 98%;
    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;
  }

  .dark {
    --background: 222.2 84% 4.9%;  ← สีเข้ม (Dark mode)
    --foreground: 210 40% 98%;     ← สีอ่อน
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
  }
เคล็ดลับ — เลือก Color Scheme ตอน Init

ตอนรัน npx shadcn@latest init ให้เลือก Style: Default และ Color: Slate เพราะ Slate เป็นสีเทาที่ดูมืออาชีพและเข้ากันได้ดีกับสีอื่น ถ้าต้องการเปลี่ยน Color Scheme ภายหลัง แก้ CSS Variables ใน src/index.css ได้เลย โดยอ้างอิงจาก ui.shadcn.com/themes

💻 โค้ดตัวอย่าง
Terminal — ติดตั้ง Tailwind + Shadcn ใน Vite Project Bash
# snippet 1: ติดตั้ง Tailwind + Shadcn ใน Vite project
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

# ติดตั้ง Shadcn
npx shadcn@latest init
# เลือก: Style=Default, Color=Slate, CSS variables=yes

# เพิ่ม components ที่ต้องการ
npx shadcn@latest add button card input dialog badge
src/components/ServiceCard.jsx — ใช้ Shadcn Components JSX
// snippet 2: ใช้ Shadcn components ใน ServiceCard
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Link } from '@tanstack/react-router';

function ServiceCard({ id, name, description, price, duration_min, image_url }) {
  return (
    <Card className="hover:shadow-lg transition-shadow">
      {image_url && (
        <img src={image_url} alt={name} className="w-full h-48 object-cover rounded-t-lg" />
      )}
      <CardHeader>
        <CardTitle>{name}</CardTitle>
        <Badge variant="secondary">{duration_min} นาที</Badge>
      </CardHeader>
      <CardContent>
        <p className="text-muted-foreground text-sm">{description}</p>
      </CardContent>
      <CardFooter className="flex justify-between items-center">
        <span className="text-xl font-bold text-primary">{price} ฿</span>
        <Button asChild>
          <Link to="/services/$serviceId" params={{ serviceId: id }}>จองเลย</Link>
        </Button>
      </CardFooter>
    </Card>
  );
}
src/components/BookingDialog.jsx — Dialog สำหรับ Booking Form JSX
// snippet 3: Dialog สำหรับ Booking Form
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

function BookingDialog({ service }) {
  const [open, setOpen] = useState(false);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button>จองบริการ: {service.name}</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>จอง {service.name}</DialogTitle>
        </DialogHeader>
        <form className="space-y-4">
          <Input type="date" name="date" required />
          <select className="w-full border rounded px-3 py-2">
            <option>09:00</option>
            <option>10:00</option>
            <option>11:00</option>
          </select>
          <Button type="submit" className="w-full">ยืนยันการจอง</Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}

🧪 ปฏิบัติการ Lab 10 — BookEasy: รีดีไซน์ UI ด้วย Shadcn

นำ Shadcn/ui และ Tailwind CSS มารีดีไซน์หน้าตา BookEasy ทั้งหมดให้สวยงาม สม่ำเสมอ และ Responsive

ต่อยอดจาก Lab 9

ต่อยอดจาก Lab 9 — รีดีไซน์ UI ทั้งหมดของ BookEasy ให้สวยงามด้วย Shadcn/ui + Tailwind

1
ติดตั้ง Tailwind + Shadcn

ติดตั้ง Tailwind CSS และ Shadcn/ui เข้าสู่โปรเจกต์ BookEasy แล้วเพิ่ม Components ที่จะใช้ในสัปดาห์นี้

  • ติดตั้ง tailwindcss, postcss, autoprefixer ตาม snippet 1 ด้านบน
  • รัน npx shadcn@latest init เลือก Style=Default, Color=Slate, CSS variables=yes
  • เพิ่ม components: button card input dialog badge table avatar โดยใช้ npx shadcn@latest add button card input dialog badge table avatar
  • ตรวจสอบว่าโฟลเดอร์ src/components/ui/ มีไฟล์ Component ทั้งหมดครบถ้วน
  • เพิ่ม Path Alias @ ใน vite.config.js: resolve: { alias: { '@': path.resolve(__dirname, './src') } }
เกณฑ์การผ่าน

รัน npm run dev ได้โดยไม่มี error โฟลเดอร์ src/components/ui/ มีไฟล์ button.jsx, card.jsx, input.jsx, dialog.jsx, badge.jsx, table.jsx, avatar.jsx และแอปยังแสดงหน้าเดิมได้ถูกต้อง

คำแนะนำ

ถ้าเกิด error เรื่อง Path Alias ให้ติดตั้ง @types/node ก่อน: npm install -D @types/node แล้วเพิ่ม import path from 'path' ที่ด้านบนของ vite.config.js

2
รีดีไซน์ ServiceCard

แทนที่ HTML div ธรรมดาใน ServiceCard ด้วย Component ของ Shadcn ตาม snippet 2

  • เปิดไฟล์ src/components/ServiceCard.jsx แทนที่ <div className="service-card"> ด้วย <Card> ของ Shadcn
  • ใช้ <CardHeader>, <CardTitle>, <CardContent>, <CardFooter> จัดโครงสร้างเนื้อหาใน Card
  • ใช้ <Badge variant="secondary"> แสดงระยะเวลา (duration_min)
  • ใช้ <Button asChild><Link></Link></Button> สำหรับปุ่มจอง เพื่อให้ Render เป็น Link แต่มีหน้าตาเป็น Button
  • เพิ่ม Tailwind classes เพื่อ Hover Effect: hover:shadow-lg transition-shadow
src/components/ServiceCard.jsx — รีดีไซน์ด้วย Card JSX
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Link } from '@tanstack/react-router';

export function ServiceCard({ id, name, description, price, duration_min, image_url }) {
  return (
    <Card className="hover:shadow-lg transition-shadow overflow-hidden">
      {image_url ? (
        <img src={image_url} alt={name} className="w-full h-48 object-cover" />
      ) : (
        <div className="w-full h-48 bg-muted flex items-center justify-center text-muted-foreground">
          ไม่มีรูปภาพ
        </div>
      )}
      <CardHeader>
        <CardTitle>{name}</CardTitle>
        <Badge variant="secondary">{duration_min} นาที</Badge>
      </CardHeader>
      <CardContent>
        <p className="text-muted-foreground text-sm">{description}</p>
      </CardContent>
      <CardFooter className="flex justify-between items-center">
        <span className="text-xl font-bold text-primary">{price} ฿</span>
        <Button asChild>
          <Link to="/services/$serviceId" params={{ serviceId: id }}>จองเลย</Link>
        </Button>
      </CardFooter>
    </Card>
  );
}
เกณฑ์การผ่าน

หน้าแสดงบริการแสดง ServiceCard ที่มีรูปแบบใหม่ด้วย Shadcn Card แต่ละ Card มี Badge แสดงระยะเวลา และ Button จองเลย เมื่อ Hover ที่ Card จะมี shadow เพิ่มขึ้น

คำแนะนำ

เพิ่ม overflow-hidden ใน <Card> เพื่อให้รูปภาพ Crop ตาม Border Radius ของ Card ได้ถูกต้อง ถ้าไม่ใส่ มุมของรูปจะล้นออกนอก Card

3
รีดีไซน์ BookingForm เป็น Dialog

แทนที่ Booking Form ธรรมดาด้วย Dialog ของ Shadcn เพื่อ UX ที่ดีขึ้น

  • สร้างไฟล์ src/components/BookingDialog.jsx ใช้ <Dialog> จาก Shadcn ตาม snippet 3 ด้านบน
  • ใช้ <DialogTrigger asChild> ห่อ Button เพื่อให้เปิด Dialog เมื่อกด
  • ใช้ <Input> จาก Shadcn แทน <input> ธรรมดา สำหรับ field วันที่ในฟอร์ม
  • นำ <BookingDialog> ไปใช้ใน ServiceCard หรือ Service Detail Page แทนที่ปุ่มหรือ form เดิม
เกณฑ์การผ่าน

กดปุ่ม "จองบริการ" → Dialog เปิดขึ้นพร้อม Form กด Escape หรือคลิกนอก Dialog → Dialog ปิดลง กรอกวันที่และเวลา → กด "ยืนยันการจอง" → Form ส่งข้อมูลได้

คำแนะนำ

Shadcn Dialog ใช้ Radix UI ภายใน ซึ่งจัดการ Focus Trap และ Keyboard Navigation อัตโนมัติ ไม่ต้อง Implement เองเหมือน Modal ธรรมดา ถ้า Dialog ไม่ปิดเมื่อกด Escape ให้ตรวจสอบว่า import ถูก Component จาก '@/components/ui/dialog'

4
รีดีไซน์ Header และ Navigation

ใช้ Tailwind Classes สร้าง Responsive Navbar และเพิ่ม Avatar แสดงรูปโปรไฟล์ผู้ใช้

  • เปิดไฟล์ src/components/Header.jsx (หรือ Navbar) เพิ่ม Tailwind classes สำหรับ Responsive Layout: flex items-center justify-between px-6 py-4 border-b bg-background
  • ใช้ <Avatar> จาก Shadcn แสดงรูปโปรไฟล์ผู้ใช้ ถ้าไม่มีรูป ใช้ <AvatarFallback> แสดงตัวอักษรย่อของชื่อแทน
  • เพิ่ม Responsive สำหรับ Navigation: ซ่อน Link บน mobile ด้วย hidden md:flex และแสดง Hamburger Menu แทน (ใช้ state toggle ได้)
  • ใช้ Tailwind Utility classes แทน CSS custom ที่เคยเขียนไว้เพื่อให้โค้ดสะอาดขึ้น
src/components/Header.jsx — Responsive Header ด้วย Tailwind + Avatar JSX
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Link } from '@tanstack/react-router';
import { useAuth } from '../contexts/AuthContext';

export function Header() {
  const { user, signOut } = useAuth();
  const initials = user?.email?.slice(0, 2).toUpperCase() ?? 'U';

  return (
    <header className="flex items-center justify-between px-6 py-4 border-b bg-background">
      <Link to="/" className="text-xl font-bold text-primary">
        BookEasy
      </Link>

      <nav className="hidden md:flex items-center gap-4">
        <Link to="/services" className="text-sm hover:text-primary transition-colors">บริการ</Link>
        <Link to="/bookings" className="text-sm hover:text-primary transition-colors">การจองของฉัน</Link>
      </nav>

      <div className="flex items-center gap-3">
        {user ? (
          <>
            <Avatar>
              <AvatarImage src={user.user_metadata?.avatar_url} />
              <AvatarFallback>{initials}</AvatarFallback>
            </Avatar>
            <Button variant="outline" size="sm" onClick={signOut}>ออกจากระบบ</Button>
          </>
        ) : (
          <Button asChild size="sm">
            <Link to="/login">เข้าสู่ระบบ</Link>
          </Button>
        )}
      </div>
    </header>
  );
}
เกณฑ์การผ่าน

Header แสดงถูกต้องทั้งบน Desktop และ Mobile เมื่อ Login แล้วจะเห็น Avatar และปุ่มออกจากระบบ เมื่อยังไม่ Login จะเห็นปุ่มเข้าสู่ระบบ บน Mobile (ความกว้างน้อยกว่า 768px) เมนู Navigation จะซ่อนไป

คำแนะนำ

AvatarFallback แสดงเมื่อ AvatarImage โหลดไม่สำเร็จหรือไม่มี src ใช้ตัวอักษรย่อชื่อผู้ใช้ เช่น user.email.slice(0, 2).toUpperCase() เพื่อให้ Fallback ดูมีความหมายแทนที่จะแสดงพื้นที่สีเทาเปล่า