---
title: "shadcn/ui Best Practices"
slug: "shadcn"
category: "frontend"
tags: ["frontend", "shadcn", "react", "tailwind"]
status: "stable"
last_updated: 2026-05-14
summary: "Own the component layer, install via CLI, theme with CSS variables, compose with Radix primitives, fork when needed."
related: ["[[frontend/react]]", "[[frontend/tailwind]]", "[[frontend/nextjs]]"]
---

## Overview

shadcn/ui is not a component library in the npm sense. It is a CLI that copies accessible components built on Radix and Tailwind into your repo. You own the source, you edit the source, and there is no package to upgrade. This page covers the conventions that follow from that model.

## Own the source, do not depend on the library

The first principle of shadcn is that the components live in your codebase under `components/ui/`. Treat them like any other file you wrote: read them, edit them, audit them in PRs.

- Do not add `shadcn` (or `shadcn-ui`) as a runtime dependency. The CLI is a devDependency at most.
- Commit `components/ui/*.tsx` to the repo. They are first-party code.
- When the upstream component changes, port the diff manually. There is no `npm update`.

This is the trade: you trade upgrade convenience for total control over the markup, the classes, and the behavior.

## Install via the CLI per component

Add components one at a time as you need them. The CLI writes a single file and any peer dependencies it needs (Radix primitives, `class-variance-authority`, `tailwind-merge`).

```bash
npx shadcn@latest init        # one time per project
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add data-table
```

Do not run `add` for every component in the catalog. Add only what the next feature uses. Unused components are dead weight in a layer you own.

## Theme with CSS variables in `globals.css`

shadcn ships a token system as CSS variables on `:root` and `.dark`. Edit those values; do not patch components to hardcode colors.

```css
@layer base {
  :root {
    --background: oklch(1 0 0);
    --foreground: oklch(0.2 0 0);
    --primary: oklch(0.55 0.18 264);
    --primary-foreground: oklch(0.98 0 0);
    --radius: 0.5rem;
  }
  .dark {
    --background: oklch(0.15 0 0);
    --foreground: oklch(0.95 0 0);
  }
}
```

With Tailwind v4, expose the variables through `@theme` so utilities like `bg-primary` and `text-foreground` map to them. See [[frontend/tailwind]] for the v4 CSS-first config.

## Compose with compound components

Most shadcn components are exported as a namespace with sub-components. Compose, do not pass props soup.

```tsx
<Dialog>
  <DialogTrigger asChild>
    <Button>Open</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Confirm</DialogTitle>
      <DialogDescription>This action cannot be undone.</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <Button variant="outline">Cancel</Button>
      <Button variant="destructive">Delete</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
```

The `asChild` prop slot-projects styles onto a child element so a `<Button>` can be the trigger without nesting `<button>` inside `<button>`. Use it whenever a wrapper would break semantics.

## Accessibility comes from Radix; do not break it

The interactive primitives (Dialog, Popover, Combobox, Menu, Tabs) wrap Radix UI. Radix handles focus trap, keyboard navigation, ARIA wiring, and dismissal. Your job is to not break it.

- Do not replace a Radix `Dialog` with a div and a portal. You will lose focus trapping and `Esc` handling.
- Do not strip `aria-*` props the components forward.
- Test keyboard navigation: every interactive flow should work with Tab, Shift+Tab, Enter, Space, Esc, and arrow keys.

If you cannot achieve a design with the primitive, fork the file rather than reinventing the behavior elsewhere.

## Fork the component when the design pulls hard

Editing in place is cheap; that is the point. Fork when:

- The default variants (size, intent) do not cover your design system.
- The markup needs a wrapping element for a layout reason.
- You need a new sub-component that fits naturally next to the existing ones.

Use as-is when the only change is colors or spacing; that is the theme's job, not the component's. Do not fork to override one class string; edit the file directly.

## Related

- [[frontend/react]]
- [[frontend/tailwind]]
- [[frontend/nextjs]]
