kodingup.camp

Beranda/Tailwind Mastery

Integrasi Styling Utility dengan Aplikasi Berbasis Komponen

Modul 16 dari 163 menit bacaUtility Styling Integration with Component-Based Applications

Isi modul

    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

    1. Komponen primitif (Button, Input, Badge, Card) menyimpan daftar class dan variant. Halaman tidak perlu tahu detail class tombol.
    2. Variant lewat props, bukan lewat class dari luar: <Button variant="danger">, bukan <Button className="bg-red-600">.
    3. Tetap terima className untuk penyesuaian kecil (misalnya margin atau lebar), digabung dengan cn() agar konflik terselesaikan.
    4. Teruskan props lain (...props) agar onClick, type, disabled, aria-* tetap bekerja.
    5. 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:

    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

    1. Menulis class alih-alih className di JSX. React akan memberi peringatan; gunakan className.
    2. Mengimpor @import "tailwindcss" di banyak file CSS komponen. Seluruh CSS Tailwind terduplikasi. Satu file utama saja; file lain pakai @reference bila perlu @apply.
    3. Mengatur variant lewat className dari luar (<Button className="bg-red-600">). Buat variant danger di komponen agar konsisten.
    4. Lupa meneruskan ...props, sehingga type="submit", aria-label, atau onClick tidak sampai ke <button>.
    5. Menyusun class dari variabel (`bg-${warna}`) di komponen. Gunakan peta objek atau cva dengan class lengkap.

    Latihan

    1. Mudah, Tambahkan variant success (hijau) ke buttonVariants dan pakai di App.jsx.
    2. Sedang, Buat komponen Card yang menerima title, children, dan className, dengan dukungan dark mode dan padding yang bisa ditimpa dari luar.
    3. Menantang, Buat komponen Input dengan prop error (string). Kalau error ada, border menjadi merah dan pesan error tampil di bawahnya. Teruskan semua props lain ke <input>, dan hubungkan pesan error dengan aria-describedby.
    Lihat solusi
    1. 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>
    
    1. 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
    
    1. 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

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.