How to Understand the shadcn Style System and Create Custom Components
The shadcn style system combines Tailwind CSS with two utility functions—cva (class-variance-authority) for type-safe variant definitions and cn for intelligent class merging—to let you build custom, themeable components without leaving your codebase.
The shadcn-ui/ui repository ships a Tailwind-first component library that centralizes design tokens while keeping every component editable in your own project. Understanding how the shadcn style system orchestrates cva, cn, and Tailwind’s configuration allows you to extend existing patterns or craft entirely bespoke UI elements.
Core Architecture of the shadcn Style System
Tailwind Configuration and Content Scanning
Tailwind’s configuration lives at the project root, but the shadcn style system extends it via registry-specific configs to ensure all component files are scanned for class extraction. In deprecated/www/tailwind.config.cjs, the registry folder is added to the content array:
// deprecated/www/tailwind.config.cjs
const baseConfig = require("../../tailwind.config.cjs")
module.exports = {
...baseConfig,
content: [
...baseConfig.content,
"content/**/*.mdx",
"registry/**/*.{ts,tsx}",
],
}
This setup means any custom component you place under apps/v4/registry/… or registry/… is automatically picked up by Tailwind’s JIT engine without additional configuration changes.
The cn Utility: Class Merging with twMerge
The cn function is a thin wrapper around clsx (for conditional class concatenation) and tailwind-merge (for deduplication and conflict resolution). Defined in apps/v4/lib/utils.ts, it ensures deterministic class output:
// apps/v4/lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Every shadcn component uses cn to merge the result of a cva call with user-provided className props, preventing duplicate or conflicting Tailwind utilities.
Variant Definitions with cva
Components encode visual variants using cva (class-variance-authority). The cva call returns a function that, given a variant object, produces a string of Tailwind classes. The Button component in apps/v4/registry/new-york-v4/ui/button.tsx demonstrates the full pattern:
// apps/v4/registry/new-york-v4/ui/button.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({ className, variant = "default", size = "default", asChild = false, ...props }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
Key architectural points:
- Base string contains always-present utilities (layout, focus states, disabled states).
variantsmap each variant name to its specific Tailwind classes.defaultVariantsprovide fallback values when consumers omit props.cn(buttonVariants({ … }))merges generated classes with any extraclassNamepassed by the caller.
Creating Custom Styles in the shadcn Style System
Adding a New Component File
Place your component under the registry (e.g., apps/v4/registry/custom/ui/alert.tsx). This folder structure mirrors existing components like those in new-york-v4, ensuring Tailwind automatically tracks your new files.
// apps/v4/registry/custom/ui/alert.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { X } from "lucide-react"
const alertVariants = cva(
"relative w-full rounded-md border p-4",
{
variants: {
variant: {
default: "bg-background text-foreground border-muted",
destructive: "bg-destructive text-destructive-foreground border-destructive/50",
},
},
defaultVariants: { variant: "default" },
}
)
interface AlertProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof alertVariants> {
/** Optional icon component */
icon?: React.ReactNode
}
export function Alert({ className, variant = "default", icon, children, ...props }: AlertProps) {
return (
<div className={cn(alertVariants({ variant, className }))} {...props}>
{icon && <span className="mr-2">{icon}</span>}
{children}
</div>
)
}
Compared to the button.tsx implementation, this example defines only a variant dimension (no size), uses a simpler base class string appropriate for alerts, and accepts an optional icon prop for flexibility.
Consuming Your Custom Component
Import and use your component exactly like any other shadcn component:
import { Alert } from "@/registry/custom/ui/alert"
import { X } from "lucide-react"
export function Demo() {
return (
<Alert variant="destructive" icon={<X className="h-4 w-4" />}>
Something went terribly wrong.
</Alert>
)
}
Because the component uses cn internally, any additional className you pass merges cleanly without duplicate utilities:
<Alert className="shadow-lg" />
Extending Existing Variant Sets
When you need a new size or variant for an existing component (like Button), re-export the original cva instance and append your custom variants:
// apps/v4/registry/custom/ui/button-extended.tsx
import { buttonVariants as baseButtonVariants } from "@/registry/new-york-v4/ui/button"
import { cva } from "class-variance-authority"
const buttonVariants = cva(baseButtonVariants, {
variants: {
size: {
...baseButtonVariants?.options?.variants?.size,
xl: "h-12 px-8 text-lg",
},
},
})
export { buttonVariants }
This approach preserves the original variant definitions while adding an xl size option. Any component that imports buttonVariants from this extended file will recognize the new size without duplicating the entire variant map.
Practical Tips for the shadcn Style System
| Situation | Recommended Approach |
|---|---|
| Add a brand-specific color | Extend Tailwind’s color palette in tailwind.config.cjs, then reference the new token inside a cva variant (e.g., bg-brand-primary). |
| Create a shared layout component | Keep it in registry/shared/ui/… so every app can import it via the alias @/registry/shared/ui/.... |
| Override a component for a single project | Duplicate the component file in your app’s src/components/…, import the original cva for consistency, and adjust only the variant map you need. |
| Debug generated class strings | Log buttonVariants({ variant, size }) in the component; the output will be a plain string you can copy-paste into browser dev-tools to verify the Tailwind utilities. |
Key Files to Explore in the shadcn-ui Repository
Summary
- Tailwind CSS provides the design tokens and scans the
registryfolder for utility classes. cva(class-variance-authority) encapsulates component-level variants in a type-safe map, separating base styles from variant-specific overrides.cncombinesclsxandtailwind-mergeto merge generated classes with user-suppliedclassNameprops while eliminating duplicates and resolving Tailwind conflicts.- To create custom styles, add a new component file under the registry, define a
cvamap for your variants, and export a component that usescnto merge classes. - Extend existing components by re-importing their base
cvainstances and appending new variants, preserving the original design system while adding bespoke options.
Frequently Asked Questions
What is the difference between cva and cn in the shadcn style system?
cva (class-variance-authority) is responsible for defining and generating variant-based class strings according to a schema you define (e.g., mapping variant="destructive" to specific background and text colors). cn is a runtime utility that merges arbitrary class strings—including the output of cva—while removing duplicates and resolving Tailwind CSS conflicts using tailwind-merge. You typically call cn(buttonVariants({ variant }), className) to combine generated variants with user overrides.
How do I add a new color token to the shadcn style system?
Extend your tailwind.config.cjs (or tailwind.config.ts) to include the new color in the theme extension, then reference it inside your cva variant definitions. For example, add brand: { primary: "#3b82f6" } to theme.extend.colors, then use bg-brand-primary or text-brand-primary within your component’s cva map. Because the registry folder is included in Tailwind’s content array, any new utilities will be automatically generated.
Can I override shadcn component styles without forking the repository?
Yes. Copy the component file (e.g., button.tsx) from the registry into your application’s local component directory (e.g., src/components/ui/button.tsx), then modify the cva variant map or the component logic directly. Import the original cva definition if you only need to add variants rather than replace them. Because shadcn components are installed directly into your codebase—not consumed as a npm package—you maintain full control over styling without maintaining a fork.
Where should I place custom components in a shadcn project?
Place custom components under the registry path used by your application, typically apps/v4/registry/[theme]/ui/ for the v4 application or registry/ in older structures. This ensures Tailwind’s JIT engine scans your files for utility classes. Use path aliases (e.g., @/registry/custom/ui/alert) to import components consistently across your application. If you are working in a monorepo, consider a shared registry folder (e.g., registry/shared/ui/) that multiple applications can reference.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →