Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I22
33 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I22 · Part 2 · Intermediate

shadcn/ui

Not a component library you install, but components whose source you copy into your project and own.

5 min read7 sections2 exercises
33 of 57

In this chapter

  • What it actually is
  • The stack underneath
  • components.json
  • A component you own
  • Registries: how it is distributed

and 2 more

about 5 min
07On this page
  1. What it actually is
  2. The stack underneath
  3. components.json
  4. A component you own
  5. Registries: how it is distributed
  6. The trade, stated honestly
  7. Habits that keep it healthy

What it actually is

Most UI kits are packages: you install @mui/material, import a Button, and customise it through props and theme overrides. shadcn/ui is the opposite. There is no package to import. A command-line tool copies the component's source file into your repository, and from that moment it is your code. In its own words, it is "how you build your component library".

npx shadcn@latest init            // writes components.json, adds the utilities and CSS variables
npx shadcn@latest add button dialog   // copies components/ui/button.tsx and dialog.tsx

If you want a button to behave differently, you open button.tsx and change it. There is no override API to learn, no wrapper to write, and no waiting for a maintainer to expose a prop. That single property explains both why people like it and where its costs are.

The stack underneath

LayerWhat does it
Behaviour and accessibilityA headless primitives library — Base UI or Radix UI. Focus trapping, keyboard handling, ARIA and portals live here.
StylingTailwind CSS classes, driven by semantic CSS variables such as --background and --primary
VariantsUsually class-variance-authority (cva) for size and variant props
Class mergingA small cn() helper: clsx to combine, tailwind-merge to resolve conflicts
// lib/utils.ts
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs) {
  return twMerge(clsx(inputs));
}

cn("px-2 py-1", "px-4")    // "py-1 px-4" — the later padding wins, instead of both applying

cn exists because Tailwind classes are not ordered by where they appear in the attribute; two conflicting utilities are resolved by stylesheet order, which is arbitrary from your point of view. tailwind-merge knows which classes conflict and keeps the last.

Base UI is now the default

Since July 2026 a new project initialises with Base UI as its primitives; Radix remains fully supported and every update ships for both. Choose it with npx shadcn@latest init -b radix, and the docs show tabs for each. Existing Radix projects need no migration. The practical difference you will meet is composition: Radix components take an asChild prop to render as your own element, while Base UI uses a render prop for the same job.

components.json

{
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": { "css": "app/globals.css", "baseColor": "neutral", "cssVariables": true },
  "aliases": { "components": "@/components", "ui": "@/components/ui", "utils": "@/lib/utils" }
}

The file tells the CLI where things go and how to write them. rsc makes it add "use client" where a component needs it. The aliases decide where files land and how imports are rewritten. cssVariables: true produces semantic tokens — background, foreground, primary — which is what makes a dark theme a second set of values rather than a second set of components. The baseColor cannot be changed after initialisation.

A component you own

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium focus-visible:ring-2 disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        outline: "border bg-background hover:bg-accent",
        ghost: "hover:bg-accent",
      },
      size: { default: "h-9 px-4", sm: "h-8 px-3", lg: "h-10 px-6" },
    },
    defaultVariants: { variant: "default", size: "default" },
  }
);

function Button({ className, variant, size, ...props }) {
  return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}

The whole component is a table of class names and a function that picks from it. Adding a destructive variant is one more line in a file in your own repo. And because className goes through cn last, a caller can still override a single style at the use site.

Registries: how it is distributed

The CLI does not talk to a private list; it reads a registry, a set of JSON files describing components, their dependencies and where they install. The registries field in components.json lets you add others, including private ones with authentication. That is how a company ships its own design system to every internal app with the same add command — and it is why the project describes itself as a distribution system as much as a component set.

The trade, stated honestly

Package (MUI, Mantine, Chakra)shadcn/ui
Customisingprops, theme, overridesedit the file
Bug fixes and new featuresbump the versiondiff and merge, like any code you own
Bundletree-shaken from a dependencyonly the components you added
Accessibilitymaintained for youinherited from the primitives; your edits are on you
Consistencyenforced by the APIenforced by discipline; copies drift
Lock-inthe library's API everywherenone — it is your code

The trade is control against maintenance. It suits a product with its own design language, and a team that will actually read the diff when the upstream component changes. It suits a prototype that needs to look finished by Friday just as well, which is much of why it spread. It is a poor fit when nobody wants to own UI code, or when strict consistency across many teams matters more than flexibility — that is what a versioned package is for.

Habits that keep it healthy

  • Keep components/ui generic. Put product-specific behaviour in wrapper components beside it, so the copied files stay close to upstream and easy to update.
  • Do not delete the accessibility behaviour while restyling. The focus ring, the aria-* attributes and the keyboard handlers are why you chose primitives.
  • Re-add to update. There is no version to bump; to take an upstream fix, run add again on a branch and read the diff before accepting it.
  • Understand what you copied. The files are short. If a dialog behaves oddly, the answer is in a file you can open, which is the whole advantage.
Interview answer, one sentence

"shadcn/ui is a distribution mechanism, not a library: a CLI copies accessible components — built on Base UI or Radix primitives, styled with Tailwind and CSS variables — into my repo, so I customise by editing source rather than through overrides, and the price is that updates and drift become my job."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsA tiny class-variance-authorityintermediate
  • 8 testsImplement cn(), the class mergerintermediate
All 66 for this level →
I22

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I21Previous · I21Real-time UI
  2. I22you are here
  3. Next · A1Reconciliation & FiberRender is a pure, interruptible calculation. Commit is a synchronous mutation. Everything follows from that split.7 min read

01On this page

7 sections · 0%

  1. 01What it actually is
  2. 02The stack underneath
  3. 03components.json
  4. 04A component you own
  5. 05Registries: how it is distributed
  6. 06The trade, stated honestly
  7. 07Habits that keep it healthy

[] chapters · t top