Kenapa ini penting
Di dunia kerja, Tailwind hampir selalu dipakai bersama framework komponen seperti React (atau Next.js yang dibangun di atas React). Di sinilah semua yang kamu pelajari menyatu: token tema di @theme, variant, responsif, dark mode, dan komposisi class dengan cn() dan cva. Kalau integrasinya tidak dirancang dengan baik, proyek cepat berantakan: class tersebar tanpa pola, komponen tidak konsisten, dan sulit diubah.
Modul ini membangun proyek React + Vite + Tailwind v4 dari awal, membuat komponen Button dengan variant, menangani class kondisional, dan menyusun struktur folder yang rapi. Versi yang kami verifikasi: React 19.3.1, Vite 8.3.5, Tailwind CSS 4.3.3.
Konsep inti
Setup React + Vite + Tailwind v4
Langkahnya sama seperti modul 2, hanya dengan template React. Di React, atribut class ditulis className. Tailwind memindai file .jsx/.tsx secara otomatis.
Prinsip komponen yang baik dengan Tailwind
- Komponen primitif (Button, Input, Badge, Card) menyimpan daftar class dan variant. Halaman tidak perlu tahu detail class tombol.
- Variant lewat props, bukan lewat class dari luar:
<Button variant="danger">, bukan<Button className="bg-red-600">. - Tetap terima
classNameuntuk penyesuaian kecil (misalnya margin atau lebar), digabung dengancn()agar konflik terselesaikan. - Teruskan props lain (
...props) agaronClick,type,disabled,aria-*tetap bekerja. - Token dari
@theme, bukan warna hex acak, supaya rebranding cukup mengubah satu file.
Analogi: komponen primitif itu seperti bumbu dasar di dapur (bumbu putih, bumbu merah). Koki cukup memakai bumbu dasar itu untuk berbagai masakan, tidak meracik ulang dari bawang setiap kali.
Class kondisional di React
Pola yang umum:
- Operator
&&di dalamcn():cn("px-4", aktif && "bg-indigo-600"). - Objek di
cn():cn({ "line-through text-gray-400": selesai }). - Peta objek untuk banyak nilai:
const warnaStatus = { lunas: "...", tertunda: "..." }. - Atribut
data-*+ variant Tailwind:data-[aktif=true]:bg-indigo-600. Pola ini enak karena state terlihat di DOM dan mudah di-debug.
Ingat aturan deteksi class: jangan pernah menyusun nama class dari potongan string.
Struktur proyek
src/
components/
ui/ ← komponen primitif: Button, Input, Card, Badge
layout/ ← Navbar, Sidebar, Footer
features/ ← komponen per fitur: TodoList, CheckoutForm
lib/
cn.js ← helper twMerge + clsx
index.css ← @import "tailwindcss", @theme, @custom-variant
App.jsx
main.jsx
Satu file CSS utama untuk seluruh token dan konfigurasi. Hindari banyak file CSS kecil yang masing-masing mengimpor tailwindcss, karena itu akan menduplikasi seluruh CSS Tailwind.
React 19 dan ref
Di React 19, ref bisa diteruskan sebagai prop biasa ke komponen fungsi, jadi komponen Button tidak perlu lagi dibungkus forwardRef. Cukup sertakan ref di dalam ...props.
Contoh kode
# Proyek React + Vite + Tailwind v4
npm create vite@latest toko-kita -- --template react
cd toko-kita
npm install
npm install tailwindcss @tailwindcss/vite clsx tailwind-merge class-variance-authority
npm install -D prettier prettier-plugin-tailwindcss
npm run dev
# → VITE v8.x ready, Local: http://localhost:5173/
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
/* src/index.css, satu-satunya file CSS utama */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-primary: oklch(0.55 0.2 265);
--color-primary-hover: oklch(0.48 0.2 265);
--color-danger: oklch(0.58 0.22 27);
--radius-control: 0.625rem;
}
// src/components/ui/Button.jsx
import { cva } from "class-variance-authority";
import { cn } from "../../lib/cn";
const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 rounded-control font-medium transition-colors " +
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary " +
"disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-primary text-white hover:bg-primary-hover",
outline: "border border-gray-300 bg-white text-gray-900 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700",
ghost: "text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800",
danger: "bg-danger text-white hover:opacity-90",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4",
lg: "h-12 px-6 text-lg",
icon: "size-10",
},
},
defaultVariants: { variant: "primary", size: "md" },
}
);
export function Button({ variant, size, loading = false, className, children, ...props }) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
disabled={loading || props.disabled}
{...props}
>
{loading && (
<span className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:animate-none" />
)}
{children}
</button>
);
}
// src/features/TodoList.jsx, class kondisional dengan cn() dan data-attribute
import { useState } from "react";
import { cn } from "../lib/cn";
import { Button } from "../components/ui/Button";
const awal = [
{ id: 1, teks: "Belajar flexbox", selesai: true },
{ id: 2, teks: "Bikin komponen Button", selesai: false },
{ id: 3, teks: "Pasang dark mode", selesai: false },
];
export function TodoList() {
const [todos, setTodos] = useState(awal);
const [filter, setFilter] = useState("semua");
const tampil = todos.filter((t) =>
filter === "semua" ? true : filter === "selesai" ? t.selesai : !t.selesai
);
function toggle(id) {
setTodos((lama) => lama.map((t) => (t.id === id ? { ...t, selesai: !t.selesai } : t)));
}
return (
<section className="mx-auto max-w-md rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-900">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Tugas Hari Ini</h2>
{/* Tab filter: state aktif dibaca lewat data-attribute */}
<div className="mt-4 flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
{["semua", "aktif", "selesai"].map((f) => (
<button
key={f}
data-aktif={filter === f}
onClick={() => setFilter(f)}
className="flex-1 cursor-pointer rounded-md py-1.5 text-sm capitalize text-gray-600 transition
data-[aktif=true]:bg-white data-[aktif=true]:font-semibold data-[aktif=true]:text-gray-900 data-[aktif=true]:shadow-xs
dark:text-gray-400 dark:data-[aktif=true]:bg-gray-700 dark:data-[aktif=true]:text-white"
>
{f}
</button>
))}
</div>
<ul className="mt-4 divide-y divide-gray-100 dark:divide-gray-800">
{tampil.map((t) => (
<li key={t.id} className="flex items-center gap-3 py-3">
<input
type="checkbox"
checked={t.selesai}
onChange={() => toggle(t.id)}
className="size-4 accent-primary"
/>
<span
className={cn(
"flex-1 text-gray-800 dark:text-gray-200",
t.selesai && "text-gray-400 line-through dark:text-gray-500"
)}
>
{t.teks}
</span>
<Button variant="ghost" size="sm" onClick={() => setTodos((l) => l.filter((x) => x.id !== t.id))}>
Hapus
</Button>
</li>
))}
</ul>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setTodos(awal)}>Reset</Button>
<Button className="w-full sm:w-auto">Simpan</Button>
</div>
</section>
);
}
// → tab "aktif" berlatar putih; tugas selesai dicoret abu-abu; tombol Simpan penuh lebar di HP
// src/App.jsx
import { TodoList } from "./features/TodoList";
import { Button } from "./components/ui/Button";
export default function App() {
const gantiTema = () => document.documentElement.classList.toggle("dark");
return (
<div className="min-h-dvh bg-gray-50 px-4 py-10 font-sans dark:bg-gray-950">
<div className="mx-auto mb-6 flex max-w-md justify-end">
<Button variant="outline" size="sm" onClick={gantiTema}>Ganti tema</Button>
</div>
<TodoList />
</div>
);
}
// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);
Kesalahan umum
- Menulis
classalih-alihclassNamedi JSX. React akan memberi peringatan; gunakanclassName. - Mengimpor
@import "tailwindcss"di banyak file CSS komponen. Seluruh CSS Tailwind terduplikasi. Satu file utama saja; file lain pakai@referencebila perlu@apply. - Mengatur variant lewat
classNamedari luar (<Button className="bg-red-600">). Buat variantdangerdi komponen agar konsisten. - Lupa meneruskan
...props, sehinggatype="submit",aria-label, atauonClicktidak sampai ke<button>. - Menyusun class dari variabel (
`bg-${warna}`) di komponen. Gunakan peta objek ataucvadengan class lengkap.
Latihan
- Mudah, Tambahkan variant
success(hijau) kebuttonVariantsdan pakai diApp.jsx. - Sedang, Buat komponen
Cardyang menerimatitle,children, danclassName, dengan dukungan dark mode dan padding yang bisa ditimpa dari luar. - Menantang, Buat komponen
Inputdengan properror(string). Kalauerrorada, border menjadi merah dan pesan error tampil di bawahnya. Teruskan semua props lain ke<input>, dan hubungkan pesan error denganaria-describedby.
Lihat solusi
- Variant success:
// tambahkan di dalam variants.variant pada buttonVariants
success: "bg-green-600 text-white hover:bg-green-700",
// App.jsx
<Button variant="success">Bayar</Button>
- Komponen Card:
import { cn } from "../../lib/cn";
export function Card({ title, className, children }) {
return (
<div className={cn("rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-900", className)}>
{title && <h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>}
<div className="text-gray-700 dark:text-gray-300">{children}</div>
</div>
);
}
// <Card title="Profil" className="p-10">...</Card> → p-10 menggantikan p-6 berkat twMerge
- Komponen Input:
import { useId } from "react";
import { cn } from "../../lib/cn";
export function Input({ label, error, className, ...props }) {
const id = useId();
const errorId = `${id}-error`;
return (
<div className="grid gap-1.5">
{label && <label htmlFor={id} className="text-sm font-medium text-gray-700 dark:text-gray-300">{label}</label>}
<input
id={id}
aria-invalid={Boolean(error)}
aria-describedby={error ? errorId : undefined}
className={cn(
"h-10 rounded-control border border-gray-300 bg-white px-3 text-gray-900 placeholder:text-gray-400",
"focus:border-primary focus:ring-2 focus:ring-primary/30 focus:outline-hidden",
"dark:border-gray-600 dark:bg-gray-800 dark:text-white",
error && "border-red-500 focus:border-red-500 focus:ring-red-500/30",
className
)}
{...props}
/>
{error && <p id={errorId} className="text-sm text-red-600">{error}</p>}
</div>
);
}
// <Input label="Email" type="email" error="Email wajib diisi" />
// → border merah dan pesan error di bawah, dibacakan screen reader
Ringkasan
- Setup: Vite +
@vitejs/plugin-react+@tailwindcss/vite, satu file CSS utama dengan@import "tailwindcss". - Komponen primitif (Button, Input, Card) menyimpan class; variant diatur lewat props dengan
cva. - Terima
classNamedan gabungkan dengancn(); teruskan...propske elemen asli. - Class kondisional:
cn()dengan&&/objek, peta objek, ataudata-*+data-[...]:variant. - Struktur folder
components/ui,features,lib, dan token di@thememembuat proyek mudah dirawat.