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).
  • variants map each variant name to its specific Tailwind classes.
  • defaultVariants provide fallback values when consumers omit props.
  • cn(buttonVariants({ … })) merges generated classes with any extra className passed 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

File Role Link
apps/v4/lib/utils.ts – cn helper Merges class strings safely https://github.com/shadcn-ui/ui/blob/main/apps/v4/lib/utils.ts
apps/v4/registry/new-york-v4/ui/button.tsx – Button implementation Shows a full cva + cn pattern https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/ui/button.tsx
templates/monorepo-next/packages/ui/src/lib/utils.ts – Alternative cn definition (used in templates) Same utility in a different workspace https://github.com/shadcn-ui/ui/blob/main/templates/monorepo-next/packages/ui/src/lib/utils.ts
deprecated/www/tailwind.config.cjs – Registry Tailwind config Demonstrates how the registry is added to Tailwind’s content https://github.com/shadcn-ui/ui/blob/main/deprecated/www/tailwind.config.cjs
packages/shadcn/src/utils/templates.ts – Component scaffolding logic Generates component files for the CLI, helpful to see the intended structure https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/src/utils/templates.ts
apps/v4/registry/new-york-v4/lib/utils.ts – Same cn in the V4 app Shows the alias resolution (@/lib/utils) https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/lib/utils.ts

Summary

  • Tailwind CSS provides the design tokens and scans the registry folder for utility classes.
  • cva (class-variance-authority) encapsulates component-level variants in a type-safe map, separating base styles from variant-specific overrides.
  • cn combines clsx and tailwind-merge to merge generated classes with user-supplied className props while eliminating duplicates and resolving Tailwind conflicts.
  • To create custom styles, add a new component file under the registry, define a cva map for your variants, and export a component that uses cn to merge classes.
  • Extend existing components by re-importing their base cva instances 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →