shadcn/ui: Komponen Copy-Paste sebagai Sebuah Arsitektur

Foto oleh Tombartal on Wikimedia Commons
shadcn/ui tidak dirilis sebagai paket npm yang diimpor saat runtime. CLI-nya menyalin kode sumber komponen langsung ke repositori kita sendiri, sehingga tidak ada dependensi bersama yang perlu diversi, ditambal, atau dilawan lewat theme API. Kita akhirnya mengedit file biasa, bukan meng-override kotak hitam.
Radix UI Primitives menyediakan bagian tersulit dari sebuah komponen, yaitu logika interaksi yang tidak berstyle namun aksesibel mengikuti pola WAI-ARIA untuk navigasi keyboard, manajemen fokus, dan peran pembaca layar. shadcn/ui membungkus primitive tersebut dengan class Tailwind, sehingga perilaku aksesibilitas diwariskan dari Radix sementara gaya visualnya tetap bisa diedit penuh di kode yang sudah kita salin.
Theming berjalan lewat CSS variable, bukan objek theme JavaScript. Konfigurasi Tailwind memetakan utility class seperti bg-primary ke variable seperti hsl(var(--primary)), sehingga mengubah satu CSS variable memperbarui setiap komponen yang membacanya. Mode gelap bekerja dengan cara yang sama dengan menukar set variable aktif saat class dark diaktifkan.
Bisa, dan itu salah satu keuntungan utama memiliki kode sendiri. Karena komponen tersebut adalah file biasa di repositori kita, bukan ekspor dari sebuah paket, kita bisa membuang class-variance-authority, Radix Slot, atau bagian lain dan menggantinya dengan logika sendiri tanpa menunggu rilis dari upstream.
Tidak selalu. Model ini cocok untuk tim yang mengharapkan kustomisasi visual yang dalam dan memiliki pemilik design system yang mereview perubahan pada komponen bersama. Model ini kurang cocok untuk tim yang membutuhkan perbaikan keamanan atau aksesibilitas menyebar instan ke semua aplikasi konsumen, karena skenario itu lebih diuntungkan oleh dependensi yang dinaikkan versinya dibanding file hasil salinan yang ditambal manual.

Foto oleh Tombartal on Wikimedia Commons
Setiap beberapa tahun sebuah component library merilis versi besar, dan setiap tim yang bergantung padanya mewarisi sore yang sama: membaca changelog, menulis ulang theme override, lalu menyadari tombol yang tidak pernah disentuh kini tampil bergeser dua piksel. Itulah biaya dari menginstal keputusan desain orang lain sebagai dependensi.
shadcn/ui mengambil posisi yang berbeda. Alih-alih mengirimkan paket yang diimpor, ia memberikan kode sumber asli setiap komponen dan membiarkan sebuah CLI menyalin kode itu langsung ke repositori kita sendiri. Hasilnya bukan sekadar library yang lebih ringkas, melainkan arsitektur yang sepenuhnya berbeda, dibangun di atas primitive Radix UI untuk perilaku komponen dan token Tailwind CSS untuk theming, dan ini mengubah cara sebuah design system berkembang seiring bertambahnya ukuran codebase.
Dokumentasi shadcn/ui menyatakan posisinya secara terang-terangan: ini bukan component library, ini adalah cara membangun component library milik kita sendiri. Menjalankan perintah add pada CLI tidak menambahkan entri di package.json yang mengarah ke bundel terkompilasi. Perintah itu menulis sebuah file TypeScript biasa ke folder components kita, sepenuhnya dapat dibaca, sepenuhnya milik kita, tanpa versi yang perlu dilacak dan tanpa kotak hitam yang harus dilawan ketika kebutuhan desain tidak sesuai dengan asumsi library tersebut.
Library tradisional seperti Material UI atau Ant Design menyelesaikan masalah yang berbeda: perilaku yang konsisten di antara ribuan pengguna, dicapai lewat prop API dan objek theme yang harus tetap backward compatible. Batasan backward compatibility itu justru yang membuat proses upgrade menyakitkan. shadcn/ui menghindari batasan tersebut dengan tidak pernah menjanjikan kompatibilitas sejak awal, karena setelah langkah penyalinan tidak ada lagi paket bersama yang perlu dijaga kompatibilitasnya.
CLI tetap berguna meski kode sudah menjadi milik kita. Perlakukan sebagai alat scaffolding yang dijalankan sekali per komponen, bukan package manager yang dijalankan setiap kali install. Menjalankan ulang add pada komponen yang sudah dikustomisasi akan menimpa perubahan kita, jadi commit dulu sebelum menjalankannya lagi.
Logika interaksi adalah bagian tersulit dari sebuah komponen, bukan gaya visualnya. Radix UI Primitives menyediakan logika itu sebagai building block yang tidak berstyle namun aksesibel, mengikuti pola penulisan WAI-ARIA, menangani navigasi keyboard, penjebakan fokus di dalam dialog, dan peran yang benar untuk pembaca layar. shadcn/ui membungkus setiap primitive Radix dengan utility class Tailwind, sehingga pekerjaan aksesibilitas diwariskan bukan diimplementasikan ulang, sementara lapisan visualnya tetap sepenuhnya dapat diedit.
Tombol di bawah ini menunjukkan pola tersebut dalam skala kecil. Ia tidak langsung terhubung ke primitive Radix, tetapi memperlihatkan dua elemen yang selalu menjadi sandaran setiap komponen shadcn/ui: Slot dari Radix, yang memungkinkan komponen dirender sebagai elemen bawaan yang berbeda tanpa node pembungkus tambahan, dan class-variance-authority, yang mengubah prop variant menjadi lookup bertipe dari string class Tailwind, bukan sekumpulan ekspresi kondisional.
// components/ui/button.tsx (installed via CLI, then edited freely)
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button"
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />
)
}
export { Button, buttonVariants }Karena file ini berada di repositori kita, menambahkan variant baru, misalnya kombinasi destructive-outline yang tidak pernah dirilis desain aslinya, hanyalah sebuah edit lokal dan pull request, bukan feature request ke roadmap orang lain. Hal yang sama berlaku untuk menghapus dependensi yang tidak diinginkan: buang class-variance-authority dan tulis langsung string class jika tim lebih menyukai lebih sedikit bagian yang bergerak.
Komponen shadcn/ui tidak pernah menulis kode warna hex secara langsung. Setiap properti visual melewati sekumpulan kecil CSS variable, background, foreground, primary, primary foreground, dan beberapa lainnya, dinyatakan sebagai triple HSL agar bisa dirujuk di dalam fungsi warna Tailwind. Konfigurasi Tailwind kemudian memetakan class seperti bg-primary ke hsl(var(--primary)), yang berarti nama class yang sama menghasilkan piksel berbeda tergantung variable mana yang sedang aktif.
/* app/globals.css — tokens consumed by every shadcn/ui component */
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
}Lapisan indireksi tunggal itulah yang membuat rebranding tetap dapat dikelola dalam skala besar. Mengubah warna aksen sebuah produk berarti mengedit satu CSS variable sekali saja, bukan mencari kode hex yang tercecer di setiap file komponen. Mode gelap bekerja dengan cara yang sama: mengaktifkan class dark pada elemen html menukar set variable yang aktif, dan setiap komponen yang sudah membaca var(--background) otomatis mendapat palet baru tanpa perubahan kode di tingkat komponen.
Lapisan token bersifat bersama, artinya kesalahan di lapisan itu juga tersebar bersama. Jika pasangan warna foreground-di-atas-background yang dipilih gagal memenuhi kontras WCAG, setiap komponen yang dibangun di atas token itu langsung mewarisi kegagalan tersebut. Periksa rasio kontras untuk set variable terang maupun gelap sebelum merilis perubahan token, bukan hanya set terang yang kebetulan sedang dilihat.
Perbandingan yang jujur terhadap model dependensi tradisional bukanlah soal pakai library atau tidak, melainkan siapa yang menanggung biaya pemeliharaan dan ke mana bobot bundel itu mengalir. Tabel di bawah membandingkan tiga dimensi yang paling relevan dalam praktik.
| Dimensi | Dependensi tradisional | Copy-paste shadcn/ui |
|---|---|---|
| Update | Kenaikan versi bisa mengubah perilaku atau gaya tanpa tim kita memilih untuk mengubah apa pun. | Tidak ada yang berubah sampai tim kita mengedit file itu sendiri, sesuai jadwal kita. |
| Ukuran bundel | Tetap membawa abstraksi library meski sudah tree-shaking, karena selalu ada sisa kode runtime yang menempel. | Hanya membawa komponen spesifik yang kita salin, tanpa logika variant yang tidak terpakai ikut terbundel. |
| Kedalaman kustomisasi | Terbatas pada apa yang diekspos oleh theme API atau prop override. | Terbatas hanya oleh seberapa jauh kita bersedia menulis ulang file sumbernya. |
Konsekuensinya, tanggung jawab pemeliharaan berpindah dari maintainer library ke tim kita sendiri. Perbaikan terkait keamanan pada Radix tetap datang lewat package manager kita, tetapi perbaikan pada kode wrapper milik shadcn/ui sendiri harus diterapkan secara manual, file demi file, di setiap komponen tempat kode itu disalin. Itu harga yang wajar untuk tim dengan disiplin code review dan pemilik design system yang jelas, sekaligus risiko nyata bagi proyek solo yang tidak memiliki keduanya.
Kepemilikan copy-paste hanya tetap terkelola bila tim memperlakukan kode yang disalin dengan disiplin yang sama seperti internal library lainnya. Beberapa kebiasaan berikut mencegahnya berubah menjadi drift yang tidak terkendali di puluhan komponen.
Versi CLI shadcn/ui yang lebih baru mendukung registry ternamakan, memungkinkan sebuah tim mempublikasikan set komponen internalnya sendiri dengan cara yang sama seperti registry publik shadcn/ui bekerja. Ini menutup kesenjangan terbesar dari model copy-paste: alih-alih setiap repositori menyalin ulang dan lambat laun menyimpang dari tombol yang sama, sebuah perusahaan bisa memelihara satu registry internal dan setiap produk menarik darinya dengan perintah add yang sama.
Arsitektur ini adalah trade-off yang nyata, bukan opsi yang selalu lebih baik, dan lebih cocok untuk beberapa situasi dibanding yang lain.
Dalam praktiknya, kebanyakan tim produk berada di tengah-tengah: mereka menyalin primitive shadcn/ui demi kendali penuh atas lapisan visual, sambil tetap menarik Radix UI sendiri sebagai dependensi npm yang sesungguhnya untuk logika interaksi yang kritis bagi aksesibilitas di baliknya. Kepemilikan di tempat kustomisasi penting, dependensi di tempat kebenaran perilaku penting.
shadcn/ui tidak berusaha memenangkan kontes bundel paling kecil melawan library tradisional, ia mengusulkan bahwa sebuah design system seharusnya menjadi kode yang benar-benar dimiliki tim kita, bukan permukaan API yang dikendalikan orang lain. Radix menyediakan perilaku yang aksesibel, token Tailwind menyediakan tema, dan CLI menyediakan proses penyerahan satu kali. Apa yang dilakukan tim terhadap kode itu setelah penyerahan tersebutlah yang sebenarnya menentukan apakah arsitektur ini bisa berkembang.
Sumber dan bacaan lebih lanjut: