The Journal

Design Systems3 min read

How to Make Shadcn UI Components Actually Yours

Shadcn UI gives you great components out of the box, but the magic happens when you customize them. Here is how to make them fit your design without breaking everything.

shadcn isn’t a dependency you install and forget — it’s source code that lands in your repo. That’s the whole point, and it’s also why most people under-use it. The trick is knowing which layer to change, because the wrong one turns every future update into a merge conflict.

Re-theme with variables

Before you touch a single component, open globals.css. shadcn wires every color and radius to a CSS variable, so re-skinning the whole set is a few lines in one place. Most of the “customization” people struggle with is just a theme they never bothered to set.

The four tokens worth setting first are --background, --foreground, --primary, and --radius. Change those and every button, card, and input updates at once. Because they’re plain custom properties, you can even swap them per tenant at runtime.

Extend variants with cva

Each component defines its looks in a cva call. To add a new style you don’t fork anything — you add a key.

button.tsx
const button = cva(
  "inline-flex items-center justify-center rounded-md font-medium",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground",
        subtle: "bg-muted text-foreground hover:bg-muted/80",
      },
      size: { sm: "h-8 px-3", md: "h-9 px-4", xl: "h-12 px-8 text-base" },
    },
    defaultVariants: { variant: "default", size: "md" },
  },
);

That adds a subtle variant and an xl size in place. cva merges them with the defaults and TypeScript infers the new props, so a subtle extra-large button is fully type-checked with zero new files.

Need another intent later? Add one more key — the pattern scales without touching call sites, and every existing button keeps working.

Wrap, don’t rewrite

For app-specific behavior — say a submit button that shows a spinner while a form is pending — don’t edit the primitive. Wrap it instead.

Build a SubmitButton that renders shadcn’s Button inside and owns the loading logic. The primitive stays pristine, so your next npx shadcn add run merges cleanly instead of overwriting hand edits.

The same move works for composition — bundle a Card, its header, and a default action into one AppCard your team imports everywhere.

When to fork

Fork only when you’re fighting the structure itself — a different root element, different internals, or accessibility wiring the primitive doesn’t expose. That’s genuinely rare. When you do, copy the file, rename it, and note why in a comment.

The rule: re-theme with variables, extend with cva, wrap for behavior, and fork only as a last resort. Follow that order and most of your customization never touches the original component.

Frequently asked questions

How do you customize shadcn/ui components?
Follow an order: re-theme with CSS variables in globals.css, extend styles by adding a key to the component's cva call, wrap the primitive for app-specific behavior, and fork only as a last resort. shadcn is source code in your repo, so you customize by changing the right layer rather than fighting updates.
How do you re-theme shadcn/ui without editing every component?
Open globals.css and change the CSS variables shadcn wires every color and radius to. The four tokens worth setting first are --background, --foreground, --primary, and --radius; change those and every button, card, and input updates at once. Because they're custom properties, you can even swap them per tenant at runtime.
When should you add a variant versus fork a shadcn component?
Add a variant by adding a key to the component's cva call, which TypeScript infers as a new prop with zero new files. Fork only when you're fighting the structure itself, such as a different root element or accessibility wiring the primitive doesn't expose. For app-specific behavior, wrap the primitive instead of editing it.