"I installed a component library but end up fighting with customization anyway" — ending that drain is shadcn's philosophy.
1. Why "copy and own"
Traditional UI libraries (npm dependencies) had, in exchange for convenience, the following pains.
- The customization wall. You just want to change a small detail of the look, but you fight the library's internal circumstances.
- Version lock. A major update breaks the API, and migration cost balloons.
- A black box. You don't know what's happening inside, and debugging is hard.
shadcn/ui solves this with the approach of "bringing the component source into your own repository via the CLI." Once brought in, it's your code, so you can freely read it, change it if needed, and aren't tossed around by external versions. The trade-off is bearing "the responsibility to maintain it yourself." But owning the code is the greatest weapon for long-term maintainability (ETC).
# 初期化(style: new-york, base color: neutral, CSS変数を有効化)
npx shadcn@latest init
# プリミティブを取り込む
npx shadcn@latest add button card input
Settings are consolidated in components.json, recording aliases and styles.
2. cn(): why you need both clsx and tailwind-merge
The foundation of all primitives is the cn() utility. It's a short function, but it simultaneously solves two different problems.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
clsx: assembling conditional classes. Add classes by truthiness likecn("base", isActive && "bg-primary").tailwind-merge: resolving conflicting classes. Normalizecn("px-2", "px-4")topx-4. Without this, last-wins override doesn't take effect, and bothpx-2 px-4remain, causing accidents.
This two-stage approach establishes the extensibility that "the caller can override with className." The order (clsx first, twMerge after) also has meaning.
3. cva: design variants declaratively
With class-variance-authority (cva), declare a component's visual derivations (variant / size) as configuration, not JSX branching. Let's look at the actual button primitive.
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 gap-2 rounded-md text-sm font-medium transition-[color,background-color,box-shadow,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
When you want a new look, don't add an if to the JSX; add one line to variants — this is the iron rule. The type is auto-derived with VariantProps<typeof buttonVariants>, making <Button variant="ghost" size="sm" /> type-safe (a non-existent value is a compile error).
Design tip: putting the focus ring and disabled styles in the base class means all variants automatically have a11y and interaction states. It also suits SRP (consolidating the visual responsibility here).
4. asChild and Slot: polymorphism that avoids prop explosion
"I want the look of a button but the substance to be a link (a)" — a common request. Solving it naïvely proliferates as props or an isLink flag. shadcn solves it with Radix's Slot.
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) => {
// asChild なら、自分は描画せず「子要素」にクラスとpropsを委譲する
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = "Button";
// 見た目はボタン、実体は Next.js の Link(正しい <a> としてレンダリング)
<Button asChild>
<Link href="/contact">お問い合わせ</Link>
</Button>
asChild is a mechanism that "transplants the style and behavior to the child." With this, you can share the look while keeping the semantically correct element (a link is a, an operation is button). It's the right answer for both a11y and prop design (accessibility details).
5. Theme: name "meaning" with CSS variables
shadcn handles colors not with concrete values like bg-blue-600 but with role (semantic) tokens like bg-primary / bg-muted / bg-destructive. The substance is CSS variables, so light/dark and rebranding complete with a change in one place.
:root {
--primary: 222 47% 11%;
--primary-foreground: 210 20% 98%;
--muted-foreground: 215 19% 42%; /* AA を満たす淡色テキスト */
}
.dark {
--primary: 210 20% 98%;
--primary-foreground: 222 47% 11%;
}
Holding color in HSL channels lets you add transparency afterward like bg-primary/90, meshing cleanly with Tailwind v4's @theme mapping. The key points of a design where dark mode switches at runtime are detailed in the Tailwind v4 guide (beware the @theme inline trap).
6. The practice of extension: don't edit the original, "compose"
This is the dividing point in team operation. Directly rewriting an imported primitive breaks updates (shadcn diff) and lowers reusability too. The principle is "don't touch the original, compose with a wrapper."
// ❌ button.tsx を直接編集して案件固有のスタイルを足す(更新が地獄に)
// ✅ 呼び出し側ドメインでラップして合成する
import { Button, type ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function SubmitButton({ className, ...props }: ButtonProps) {
return (
<Button
type="submit"
className={cn("min-w-32", className)} // 追加スタイルは合成。cn が衝突を解決
{...props}
/>
);
}
Correspondence with design principles:
- DRY: consolidate common look in
cvaand common behavior in the wrapper. Don't create duplication. - SRP: the primitive bears only "the general-purpose look" and doesn't bring in business logic (auth, analytics, API).
- ETC: since the original is preserved, you can safely follow the latest with
npx shadcn@latest diff. - One-way dependency: the primitives (
components/ui/*) don't depend on domain components. Keep the direction of dependency one-way.
7. Using it in an organization: scale with a custom registry
To reuse the same parts across multiple products, stand up your own registry and distribute internal components with npx shadcn@latest add. You can operate the design system as "the single source of truth to copy from," and each repository imports and owns it.
- Distribute brand colors, spacing, and typography as common tokens.
- Update parts on the registry side, and each project imports the diff.
- Evolve it with "explicit diff application," not "library version hell."
But don't over-build from the start (YAGNI). Consolidating once duplication actually occurs across 2–3 products is healthy.
8. Testability and quality assurance
- Automated accessibility inspection. Verify in CI with
@axe-core/playwrightthat there are no missing labels or insufficient contrast on screens after primitive composition (this site applies it to all pages too). - Keyboard-operation tests. Even riding on Radix's guarantee, confirm Tab / Enter / Escape to check you didn't break it in composition.
- Visual regression. Snapshot-compare to check that adding a variant didn't break the existing look.
- Type tests. With
VariantProps, specifying a non-existent variant fails at compile time (a defense needing no runtime test).
9. Antipatterns
- ❌ Editing
components/ui/*directly for a one-off. Updates break. Compose with a wrapper. - ❌ Overriding
variantwith string concatenation in JSX. Extend thecvaconfiguration. - ❌ Putting business logic (auth, analytics, fetch) in a primitive. Generality dies. An SRP violation.
- ❌ Concatenating
classNamedirectly withoutcn(). Tailwind's conflicting classes aren't resolved and override doesn't take effect. - ❌ Stripping Radix's a11y attributes for visual reasons. Keyboard operation and screen reading break.
- ❌ Designing a giant common registry from the start. Extract after duplication appears (YAGNI).
10. FAQ
Q. Is shadcn/ui a library you install with npm?
A. No. You use the CLI to copy the component source into your own repository and own it. It doesn't reside in node_modules as a runtime dependency (peers like Radix are installed).
Q. What's the difference from existing libraries like MUI / Chakra? A. The biggest difference is "ownership." Existing libraries configure on top of an abstraction, whereas shadcn has the source itself, so customization freedom and debuggability are on a different level. In exchange, maintenance responsibility comes to you.
Q. How do I update components?
A. Check the diff with upstream via npx shadcn@latest diff <name> and import only the necessary parts. That's exactly why the practice of "don't edit the original directly" is important.
Q. Is it OK to use it with Tailwind v4?
A. Rather, it's the premise. Since shadcn uses a CSS-variable theme, it integrates naturally with Tailwind v4's @theme token design.
Q. Is the design system complete with shadcn alone? A. It becomes the foundation of primitives, but it becomes a "design system that doesn't break down" only when you set up token design, composition conventions, a11y criteria, and a test regime. The tool and the operation are different things.
Conclusion: "own" components and grow them with composition
shadcn/ui's value is in its design philosophy, not the prettiness of the look. Own the code, manage the look declaratively with cva, safely allow override with cn(), keep meaning with Slot, and extend with composition — this discipline makes a design system that doesn't break down long-term.
- The ownership model frees you from version lock and the black box.
- With
cva/cn()/ Radix at the core, make type-safe, extensible, accessible parts. - Extend by composition (a wrapper) and preserve the original to keep it updatable (DRY, SRP, ETC).
- With a CSS-variable theme, consolidate light/dark and rebranding in one place.
- Protect a11y, types, and visuals in CI to keep quality continuous.
A well-ordered design system lifts development speed, consistency, and maintainability all at once. It's also the foundation of the trust of "a carefully made product."
If you need design-system design, building a component foundation based on shadcn/ui, or refactoring an existing UI, feel free to consult me. The case study below introduces the process of designing and implementing many UI parts needed for a business system, emphasizing consistency, type safety, and maintainability.