shadcn/ui is a component collection built on Radix UI primitives and Tailwind CSS. The key thing: there's no npm package. You run npx shadcn add button and the source lands in your project. You own the code from that point.
112k stars as of now. Actively maintained with commits this week.
Why I starred it
The "no package" model is the interesting part. Most component libraries ship a versioned package you depend on — you get updates by bumping a semver, and you fight the library's API whenever you need to customize beyond its props surface. shadcn inverts this: you take ownership of the code upfront, and updates become a deliberate choice (via shadcn diff).
I wanted to see how the CLI actually implements this — specifically, how it handles component dependencies, CSS variable merging, and the newer multi-registry namespace system.
How it works
The CLI lives in packages/shadcn/src/. The entry point for adding a component is src/commands/add.ts, and the heavy lifting happens in src/registry/.
When you run npx shadcn add button, the resolver in src/registry/resolver.ts calls resolveRegistryTree(). This function:
- Fetches the requested items from the registry API
- Recursively resolves
registryDependencieson each item - Builds a dependency graph and runs Kahn's topological sort algorithm before writing anything
The topological sort in resolver.ts is explicit — there's a topologicalSortRegistryItems() function that constructs an adjacency list, computes in-degree counts, and processes a queue. This matters because a component like a dialog might depend on overlay which depends on portal, and they need to be written in the right order to avoid partial states.
// packages/shadcn/src/registry/resolver.ts
function topologicalSortRegistryItems(items, sourceMap) {
const inDegree = new Map<string, number>()
const adjacencyList = new Map<string, string[]>()
// Build graph, compute in-degrees...
// Kahn's algorithm
const queue: string[] = []
inDegree.forEach((degree, hash) => {
if (degree === 0) queue.push(hash)
})
while (queue.length > 0) {
const currentHash = queue.shift()!
sorted.push(itemMap.get(currentHash)!)
adjacencyList.get(currentHash)!.forEach((dep) => {
const newDegree = inDegree.get(dep)! - 1
inDegree.set(dep, newDegree)
if (newDegree === 0) queue.push(dep)
})
}
}
The registry URL routing is handled in src/registry/builder.ts via buildUrlAndHeadersForRegistryItem(). The built-in registry maps to @shadcn:
// src/registry/constants.ts
export const BUILTIN_REGISTRIES = {
"@shadcn": `${REGISTRY_URL}/styles/{style}/{name}.json`,
}
The {style} and {name} placeholders get filled at fetch time based on your project config. This same URL template system works for third-party registries — you configure @my-org in your components.json and it resolves through the same builder.
CSS variable merging uses deepmerge across the full dependency tree. After the topological sort, resolveRegistryTree() walks the payload and deep-merges cssVars, tailwind, and css from every resolved item:
let cssVars = {}
payload.forEach((item) => {
cssVars = deepmerge(cssVars, item.cssVars ?? {})
})
This means installing dialog automatically pulls in the correct CSS vars for its overlay and animation tokens — you don't manage that manually.
The fetcher in src/registry/fetcher.ts stores in-flight Promise objects in a module-level Map rather than resolved values. If two concurrent calls request the same URL, the second gets the same promise — one network round trip, deduplication for free.
There's also a --dry-run flag that runs the full resolution pipeline but skips writes, printing a diff of what would change. The formatter in src/utils/dry-run-formatter.ts is separate from the runner, so the diff output is consistently structured regardless of which command triggers it.
Using it
Init a new project:
npx shadcn@latest init
This prompts for style (New York or Default), base color, and whether you want CSS variables. It writes a components.json to your project root.
Add components individually:
npx shadcn add button dialog form
Or preview before committing:
npx shadcn add button --dry-run
Check what's drifted from the upstream registry after customization:
npx shadcn diff
Third-party registries use the @namespace/component syntax:
npx shadcn add @my-company/data-table
With the corresponding entry in components.json:
{
"registries": {
"@my-company": {
"url": "https://components.my-company.com/r/{name}.json",
"headers": {
"Authorization": "Bearer ${MY_REGISTRY_TOKEN}"
}
}
}
}
The header interpolation supports environment variable expansion via ${VAR_NAME} syntax — useful for private registries without baking secrets into the config file.
Rough edges
The test coverage is decent at the registry layer (resolver.test.ts, fetcher.test.ts, builder.test.ts all exist) but the CLI commands themselves don't have much test surface. apply.test.ts is the one command with real tests.
The monorepo structure means the docs app (apps/v4/) is bundled alongside the CLI package. The docs are thorough but occasionally lag the CLI when features ship — the llms.txt file had documented routes that returned 404s for a while (fixed in a recent commit).
There's no TypeScript strict mode on the internal CLI code. z.any() shows up in the registry resolver schema for config fields — pragmatic but means Zod validation won't catch malformed registry responses in those spots.
The multi-registry namespace system is powerful but the error messages when a registry isn't configured are minimal. If you add @org/component and forget to add the registry config, you get a RegistryNotConfiguredError with just the registry name — no hint about where to configure it.
Bottom line
The model of owning your component source is the right one for production apps that actually need to customize UI. The CLI is a surprisingly complete piece of infrastructure — dependency resolution, CSS merging, topological sort, multi-registry auth — built to support what looks like a simple copy-paste workflow.
