Kenapa ini penting
Membuat tombol, input, dialog, dan dropdown yang cantik, konsisten, dan aksesibel (bisa dipakai dengan keyboard dan pembaca layar) dari nol itu sulit. Dialog saja harus menangani fokus yang terkunci di dalamnya, tombol Escape, klik di luar untuk menutup, dan atribut ARIA yang benar.
shadcn/ui berbeda dari library UI biasa: ia bukan paket yang kamu install sebagai dependency. Ia adalah kumpulan komponen yang kodenya disalin ke proyekmu lewat CLI. Kamu memiliki kodenya, bebas mengubahnya, dan tampilannya memakai Tailwind CSS yang sudah kamu kuasai. Di bawahnya, komponen interaktif memakai primitive yang sudah menangani aksesibilitas. Versi CLI yang dipakai: shadcn 4.22.1, dengan Vite 8 dan Tailwind CSS 4.3.
Konsep inti
Analogi: resep vs makanan jadi
Library UI seperti MUI itu seperti membeli makanan jadi di restoran: praktis, tapi kalau ingin mengurangi garam, kamu harus "meminta" lewat opsi yang disediakan restoran. shadcn/ui itu seperti diberi resep lengkap beserta bahannya di dapurmu sendiri: kamu memasak sekali (CLI menyalin kode), lalu bebas menambah atau mengurangi bumbu sesukamu.
Kelebihan dan kekurangan
| Aspek | shadcn/ui | Library terpaket (MUI, Mantine) |
|---|---|---|
| Kepemilikan kode | Kode ada di src/components/ui |
Kode di node_modules |
| Kustomisasi | Ubah langsung file-nya | Lewat props, theme, override |
| Styling | Tailwind CSS + CSS variables | Sistem styling masing-masing |
| Update | Manual (jalankan CLI lagi / bandingkan) | npm update |
| Jumlah komponen | Cukup banyak, fokus dasar | Sangat banyak, termasuk data grid, date picker lengkap |
Setup dengan Vite + Tailwind v4 (JavaScript)
Dokumentasi resmi shadcn/ui untuk Vite memakai TypeScript, tapi shadcn mendukung JavaScript dengan opsi "tsx": false di components.json, sehingga komponen yang disalin berakhiran .jsx. Langkahnya:
- Buat proyek Vite React dan pasang Tailwind v4 (plugin Vite).
- Atur alias import
@→srcdivite.config.jsdanjsconfig.json, karena komponen shadcn mengimpor dengan@/components/...dan@/lib/utils. - Jalankan
npx shadcn@latest init. CLI akan menanyakan beberapa pilihan (gaya, warna dasar), lalu membuatcomponents.json, filesrc/lib/utils.js(mengekspor fungsicnuntuk menggabungkan class Tailwind; di versi CLI inicnberasal dari paket npm bernamacn), dan menambahkan CSS variables tema, font, serta baris@custom-variant darkkesrc/index.css. Di CLI 4.22.1 pertanyaan pertamanya adalah memilih preset (misalnya Nova), dan ada opsi--baseuntuk memilih library primitive (radixataubase). Materi ini memakai Radix (npx shadcn@latest init -b radix), karena contohDialogTrigger asChilddi bawah adalah API Radix. - Pastikan
components.jsonberisi"tsx": falsesupaya komponen yang ditambahkan berupa.jsx. Pada uji coba dengan template Vite JavaScript, CLI sudah mendeteksi ini otomatis. - Tambahkan komponen satu per satu:
npx shadcn@latest add button input label dialog.
Komponen yang kita pakai
- Button: punya prop
variant(default,destructive,outline,secondary,ghost,link) dansize. - Input dan Label: input yang sudah bergaya; Input meneruskan semua props (termasuk
refdariregister) ke elemen<input>, sehingga langsung cocok dengan React Hook Form. - Dialog: modal aksesibel. Strukturnya:
Dialog→DialogTrigger→DialogContent→DialogHeader,DialogTitle,DialogDescription,DialogFooter. Bisa dikontrol denganopendanonOpenChange. - Checkbox (opsional): komponen controlled (memakai
checkeddanonCheckedChange, bukan event input biasa), jadi dengan RHF harus memakaiController.
Fungsi cn
cn("px-4", kondisi && "bg-red-500", className) menggabungkan class dan menyelesaikan konflik Tailwind (misalnya px-2 dan px-4, yang terakhir menang). Gunakan saat membuat varian komponenmu sendiri.
Alternatif: MUI dan Mantine
- MUI (Material UI): komponen sangat lengkap mengikuti Material Design Google, termasuk data grid dan date picker. Cocok untuk dashboard admin perusahaan yang butuh banyak komponen siap pakai.
- Mantine: lengkap, dokumentasi enak, punya banyak hooks utilitas dan library form sendiri. Cocok kalau kamu ingin "semua ada" tanpa merakit sendiri.
Keduanya memakai sistem styling sendiri, jadi menggabungkannya dengan Tailwind butuh penyesuaian. Karena kamu sudah memakai Tailwind, shadcn/ui adalah pilihan paling selaras.
Contoh kode
# 1. Proyek baru Vite + React (JavaScript)
npm create vite@latest dashboard-tugas -- --template react
cd dashboard-tugas
npm install
# 2. Tailwind CSS v4 lewat plugin Vite
npm install tailwindcss @tailwindcss/vite
// vite.config.js
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)), // @ → folder src
},
},
});
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
Simpan JSON di atas sebagai jsconfig.json di root proyek (membantu editor dan CLI shadcn mengenali alias). Lalu isi src/index.css dengan:
/* src/index.css, CLI shadcn akan menambahkan import, variabel tema, dan varian dark di bawahnya */
@import "tailwindcss";
# 3. Inisialisasi shadcn/ui (Radix) lalu tambahkan komponen
npx shadcn@latest init -b radix
npx shadcn@latest add button input label dialog checkbox
# → ✔ Verifying framework. Found Vite.
# → ✔ Validating Tailwind CSS. Found v4.
# → file baru: src/components/ui/button.jsx, input.jsx, label.jsx, dialog.jsx, checkbox.jsx
# dan src/lib/utils.js yang mengekspor fungsi cn()
Kalau komponen yang dihasilkan berakhiran .tsx, buka components.json, ubah "tsx": true menjadi "tsx": false, hapus file .tsx tadi, lalu jalankan add lagi.
// src/features/todos/AddTaskDialog.jsx, Dialog + React Hook Form + Zod
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const schema = z.object({
title: z.string().trim().min(3, "Judul minimal 3 karakter"),
penting: z.boolean(),
});
export function AddTaskDialog({ onCreate }) {
const [open, setOpen] = useState(false);
const {
register,
handleSubmit,
control,
reset,
formState: { errors, isSubmitting },
} = useForm({ resolver: zodResolver(schema), defaultValues: { title: "", penting: false } });
async function onValid(data) {
await onCreate(data); // misalnya mutateAsync dari TanStack Query
reset();
setOpen(false); // tutup dialog setelah sukses
}
return (
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) reset(); // bersihkan form saat dialog ditutup
}}
>
<DialogTrigger asChild>
<Button>+ Tugas baru</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onValid)} noValidate className="space-y-4">
<DialogHeader>
<DialogTitle>Tambah tugas</DialogTitle>
<DialogDescription>Tulis tugas yang ingin kamu selesaikan.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="title">Judul</Label>
{/* Input shadcn meneruskan ref & props ke <input>, jadi register langsung bisa */}
<Input id="title" aria-invalid={!!errors.title} {...register("title")} />
{errors.title && <p className="text-sm text-destructive">{errors.title.message}</p>}
</div>
<div className="flex items-center gap-2">
{/* Checkbox shadcn adalah komponen controlled → pakai Controller */}
<Controller
name="penting"
control={control}
render={({ field }) => (
<Checkbox
id="penting"
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
/>
)}
/>
<Label htmlFor="penting">Tandai penting</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Batal
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Menyimpan..." : "Simpan"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// → Klik "+ Tugas baru": dialog terbuka, fokus otomatis masuk ke dalam dialog.
// → Tekan Escape atau klik di luar: dialog tertutup dan form dibersihkan.
// → Submit valid: onCreate({ title: "Rapat tim", penting: true }) dipanggil lalu dialog tertutup.
Kesalahan umum
- Alias
@belum diatur. Error "Failed to resolve import '@/components/ui/button'". Atur alias divite.config.jsdanjsconfig.jsonsebelumshadcn init. - Menganggap shadcn/ui sebagai paket npm. Tidak ada
import { Button } from "shadcn". Komponen diimpor dari folder proyekmu sendiri:@/components/ui/button. - Memakai
registeruntuk Checkbox/Select shadcn. Komponen itu controlled (checked/onCheckedChange,value/onValueChange), jadiregistertidak menangkap nilainya. PakaiController. - Tombol "Batal" di dalam form tanpa
type="button". Default tombol di dalam<form>adalah submit, sehingga "Batal" malah menjalankan validasi dan mengirim form. - Mengedit komponen UI tanpa sadar akan ditimpa. Menjalankan
add buttonlagi bisa menimpa perubahanmu. Kalau ingin varian sendiri, buat komponen baru yang membungkusButton, atau catat perubahanmu.
Latihan
- Mudah, Tambahkan komponen
carddengannpx shadcn@latest add card, lalu tampilkan satu tugas di dalamCarddenganCardHeaderdanCardTitle. - Sedang, Buat
ConfirmDeleteDialogyang menerimaonConfirm. Tombol pemicunya memakaivariant="destructive", dan dialog menampilkan pertanyaan "Hapus tugas ini?". - Menantang, Tambahkan varian tombol baru
success(hijau) tanpa mengubah filebutton.jsx, dengan membuat komponen pembungkus yang memakai fungsicn.
Lihat solusi
// 1.
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function TaskCard({ task }) {
return (
<Card>
<CardHeader>
<CardTitle>{task.title}</CardTitle>
</CardHeader>
<CardContent>{task.completed ? "Selesai" : "Belum selesai"}</CardContent>
</Card>
);
}
// 2.
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
export function ConfirmDeleteDialog({ onConfirm }) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">Hapus</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Hapus tugas ini?</DialogTitle>
<DialogDescription>Tindakan ini tidak bisa dibatalkan.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Batal</Button>
<Button
variant="destructive"
onClick={() => {
onConfirm();
setOpen(false);
}}
>
Ya, hapus
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// 3. Pembungkus tanpa mengubah button.jsx
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function SuccessButton({ className, ...props }) {
return (
<Button
className={cn("bg-emerald-600 text-white hover:bg-emerald-700", className)}
{...props}
/>
);
}
// <SuccessButton onClick={...}>Tandai selesai</SuccessButton>
Ringkasan
- shadcn/ui menyalin kode komponen ke proyekmu (
src/components/ui); kamu memiliki dan bebas mengubahnya. - Setup Vite: Tailwind v4 via
@tailwindcss/vite, alias@→src,npx shadcn@latest init, laluaddkomponen;"tsx": falseuntuk JavaScript. - Input shadcn langsung cocok dengan
register; komponen controlled seperti Checkbox butuhController. - Dialog bisa dikontrol dengan
opendanonOpenChange; cocok dipadukan denganresetform. - Alternatif: MUI (sangat lengkap, Material Design) dan Mantine (lengkap, banyak hooks); shadcn/ui paling selaras dengan Tailwind.