shadcn/ui Component Patterns: Why Copy-Paste Beats npm Install
How shadcn/ui's copy-paste model changes frontend development — component ownership, customization patterns, and why this approach produces better UIs than traditional component libraries.
Strategic Systems Architect & Enterprise Software Developer
The Component Library Problem
Every frontend developer has lived through this cycle. You adopt a component library — Material UI, Vuetify, Ant Design, whatever the ecosystem's flagship happens to be. For the first few weeks, it feels like a cheat code. Buttons, modals, data tables, form inputs: all done, all consistent, all documented.
Then reality sets in.
A designer hands you a mockup where the button has slightly different padding. The dropdown needs a custom trigger element. The modal needs an animation that doesn't match the library's default. You start writing style overrides. Then you're fighting specificity wars. Then you're reading source code to figure out which internal class name to target because the library doesn't expose the prop you need.
This is the fundamental tension of traditional component libraries: they give you speed at the cost of control. The component is a black box. You can configure it through the props the author anticipated, but the moment you need something the author didn't anticipate, you're fighting the abstraction instead of building your product.
There's a version problem too. Major version bumps in large component libraries are migration projects in themselves. I've spent entire sprints upgrading Vuetify from v2 to v3 — not because the business logic changed, but because the component API surface changed underneath code that was working fine.
shadcn/ui's Philosophy: Own Your Components
Shadcn/ui took a position that felt counterintuitive the first time I encountered it: instead of installing a package that owns your components, you copy the component source code into your project and own it yourself.
There's no npm install shadcn-ui. There's no version to track. There's no breaking change that cascades through your codebase when the upstream library ships a major release. You run a CLI command, it copies well-structured component files into your project, and from that moment forward, they're your code.
The components are built on Radix UI primitives (React) or Radix Vue (Vue), which handle the hard accessibility and interaction logic. Shadcn/ui provides the styling layer on top — Tailwind CSS classes that you can read, understand, and change in seconds.
This isn't a new idea. It's how components worked before the ecosystem consolidated around monolithic libraries. But shadcn/ui made it ergonomic by providing a CLI, consistent patterns, and a catalog that covers the most common UI needs.
Setting Up: React and the Nuxt Equivalent
In React, the setup is straightforward. You initialize shadcn/ui in an existing project and start adding components:
npx shadcn@latest init
npx shadcn@latest add button dialog dropdown-menu
This generates files in your components/ui/ directory. A button component looks like this:
import * as React from "react"
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 focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
Export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
Const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
}
)
Every line is readable. Every class is a Tailwind utility you already know. If the designer says "make the destructive button darker," you change one string. No theme provider. No override object. No documentation lookup.
In the Vue/Nuxt ecosystem, Nuxt UI shares the same philosophy — components built on Radix Vue primitives, styled with Tailwind, and designed for full customization. I chose Nuxt for this portfolio partly because the component story aligns so well. Nuxt UI ships as a module, but the components use the same Tailwind-first, variant-driven patterns. You can inspect and override anything through the app.config.ts theme layer or by extending the component directly.
<template>
<UButton
color="primary"
variant="solid"
size="lg"
:ui="{ base: 'font-semibold tracking-wide uppercase' }"
>
Get Started
</UButton>
</template>
Both approaches treat Tailwind as the styling API rather than hiding it behind a proprietary theme system. That matters more than it sounds — it means every developer on the team already knows how to customize the components.
Component Customization Patterns
The real power of owning your components shows up when you need to extend them. Here are the patterns I use most.
Variant Extension
The class-variance-authority (CVA) pattern that shadcn/ui uses makes adding variants trivial. Need a "brand" variant for your primary CTA style? Add it to the variants object:
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
brand: "bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-lg hover:shadow-xl",
// ... Existing variants
}
}
You didn't fork a library. You didn't file an issue asking the maintainer to support gradients. You edited your own code.
Composition Over Configuration
Rather than adding every possible prop to a base component, build specialized components that compose the primitives:
function SubmitButton({ loading, children, ...props }: SubmitButtonProps) {
return (
<Button type="submit" disabled={loading} {...props}>
{loading ? <Spinner className="mr-2 h-4 w-4 animate-spin" /> : null}
{children}
</Button>
)
}
This keeps the base Button clean and creates purpose-built components for specific use cases. The SubmitButton knows about loading states. The IconButton knows about icon sizing. The base Button stays generic.
The cn() Utility
The cn() function (a thin wrapper around clsx and tailwind-merge) is the glue that makes all of this work. It lets consumers pass additional classes that merge cleanly with the component's default classes, without specificity conflicts:
<Button className="w-full rounded-full" variant="outline">
Full Width Pill Button
</Button>
This is the correct way to handle style extension in a Tailwind component system. The consumer's classes merge with and override the defaults where they conflict, and coexist where they don't. No !important. No CSS modules. No style objects.
Building Compound Components
Where shadcn/ui really earns its keep is compound components — multi-part UI patterns built by composing primitives. A command palette, for example, combines Dialog, Command (combobox), and individual list items:
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-0">
<Command>
<CommandInput placeholder="Search actions..." />
<CommandList>
<CommandGroup heading="Navigation">
<CommandItem onSelect={() => navigate("/dashboard")}>
<LayoutDashboard className="mr-2 h-4 w-4" />
Dashboard
</CommandItem>
<CommandItem onSelect={() => navigate("/settings")}>
<Settings className="mr-2 h-4 w-4" />
Settings
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
Each piece — the dialog, the command input, the list items — is a standalone component you own. The compound pattern emerges from composition, not from a monolithic <CommandPalette> component with forty props. If you need the command list without the dialog wrapper, you use Command directly. If you need a custom trigger, you swap DialogTrigger for whatever element you want.
This composability is what produces good UI architecture. Small components. Clear responsibilities. Explicit composition. It's the same principle that makes good performance possible — you ship exactly the code each page needs, nothing more.
When Traditional Libraries Still Make Sense
I'm not arguing that shadcn/ui is universally correct. There are real scenarios where a traditional component library is the better call.
Large teams with a shared design system. If you have 15 developers across multiple applications who all need to use the same components with the same behavior, a published package with versioned releases gives you centralized control. Shadcn/ui's copy-paste model means each project has its own copy that can drift independently. That's a feature for solo developers and small teams, but a liability at scale unless you build internal tooling around it.
Data-heavy enterprise dashboards. If your app is primarily tables, charts, and complex forms, a library like AG Grid or PrimeVue gives you months of work for free. Building a sortable, filterable, virtualized data table from Radix primitives and Tailwind is possible, but it's not a good use of time when the problem is already solved.
Rapid prototyping with no design input. If you're building an internal tool where aesthetics don't matter and you just need functional UI fast, something like Vuetify or Chakra UI gets you there with less setup than customizing shadcn/ui components to look presentable.
The decision framework is simple: if you need control over how things look and behave, own your components. If you need volume and consistency across a large surface area, use a managed library.
My Workflow: shadcn + Tailwind + Radix Primitives
Here's how I build UI for client projects and my own portfolio:
- Start with Radix primitives for any interactive pattern — dialogs, dropdowns, tooltips, tabs. These handle focus management, keyboard navigation, and ARIA attributes. I never build these from scratch.
- Use shadcn/ui (or Nuxt UI) as the starting point for styled components. The CLI generates a well-structured base. I treat the generated code as a first draft, not a finished product.
- Extend with CVA variants for project-specific needs. Every project develops its own visual language — brand colors, specific border radii, animation preferences. CVA makes these variant additions clean and type-safe.
- Compose compound components for recurring patterns. A
<ConfirmDialog>, a<SearchableSelect>, a<FormField>that wires up label, input, and error message — these are project-level components built on the primitives. - Keep
components/ui/untouched when possible. I try to extend rather than modify the base components. When I do modify them, I leave a comment explaining why. This makes it easy to pull in new shadcn/ui components later without wondering what I've changed.
The result is a component layer that's readable by any developer who knows Tailwind, customizable without fighting an abstraction, and free from the upgrade treadmill that plagues traditional libraries. It's more work upfront than npm install vuetify. But the time you save in the long run — not fighting overrides, not debugging theme providers, not migrating between major versions — more than pays for it.
The best component library is the one you understand completely. With shadcn/ui, that's the whole point.