How to Build a Design System with shadcn/ui, Tailwind & Radix
- shadcn/ui gives you source ownership β treat it as a codebase to maintain, not a package to consume
- Token synchronization between CSS variables and Tailwind is non-negotiable β automate with a build script
- Radix handles accessibility primitives β your job is to not break them through customization
- shadcn/ui is a copy-paste component library built on Radix Primitives and Tailwind CSS
- Radix provides accessible, unstyled primitives; Tailwind handles styling; shadcn/ui ships the integration
- Design tokens live in CSS custom properties and @theme directives for consistent theming
- Components are installed via
npx shadcn@latest addβ copied into your codebase, not as node_modules dependency - This architecture gives you full ownership: modify any component without fighting upstream updates
- Biggest mistake: treating shadcn/ui like a black-box npm package instead of a codebase you own
Tailwind classes not applying to component
grep -r '@source' src/globals.css || cat tailwind.config.js | grep contentnpx @tailwindcss/cli -i ./src/globals.css --watchRadix Dialog not trapping focus
grep -r 'Dialog.Portal' src/grep -r 'FocusScope' src/CSS variables not resolving in production
grep -r ':root' src/app/globals.cssOpen DevTools > Elements > :root > Computed propertiesProduction Incident
Production Debug GuideCommon symptoms when shadcn/ui components misbehave in production
Design systems fail when teams choose between flexibility and consistency. Most component libraries force a tradeoff: accept their design decisions or fight the abstraction layer. shadcn/ui, Radix Primitives, and Tailwind CSS eliminate this tradeoff.
Radix handles accessibility, keyboard navigation, and focus management β the hardest parts of UI component engineering. Tailwind provides utility-first styling with a configurable design token layer. shadcn/ui merges both into production-ready components you install directly into your source tree.
This guide covers building a scalable design system from these primitives for 2025+. We will architect tokens for Tailwind v3 and v4, extend components for RSC, enforce consistency, and handle the production failures teams hit when scaling beyond a prototype.
Architecture: Why shadcn/ui Differs from Traditional Component Libraries
Traditional component libraries like Material UI or Chakra ship as npm packages. You import them, configure a theme provider, and accept their component API surface. When the library updates, you update. When their design decisions conflict with yours, you fight the abstraction.
shadcn/ui inverts this model. The CLI copies component source code directly into your project. You own every line. There is no node_modules dependency to update β you run npx shadcn@latest diff to see what changed upstream, then merge manually.
This architecture has three implications for design systems:
- Full ownership: Every component is yours to modify, extend, or replace
- No version lock-in: You control when and how to incorporate upstream changes
- Explicit dependencies: Radix Primitives and Tailwind are your only runtime dependencies
The tradeoff is maintenance burden. You are responsible for keeping components current. But for teams building a design system that must outlive any single library's roadmap, this tradeoff favors long-term control.
# Install shadcn/ui components into your project npx shadcn@latest init # Components are copied to your components/ui directory npx shadcn@latest add button npx shadcn@latest add dialog npx shadcn@latest add card # Check for upstream changes without applying npx shadcn@latest diff button
- Traditional libraries: you consume an API surface and fight abstraction leaks
- shadcn/ui: you own the source code and merge upstream changes selectively
- This is closer to forking an OSS project than installing a package
- The maintenance cost is real but the control is absolute
npx shadcn@latest diff in CI to surface upstream changes before they become surprises.Token Architecture: Building the Foundation
A design system is only as strong as its token layer. Tokens are the atomic decisions β colors, spacing, typography, radii β that propagate through every component. Get tokens wrong and you will fight inconsistencies forever.
With shadcn/ui and Tailwind, tokens live in CSS custom properties. Tailwind v4 uses @theme directives, v3 uses tailwind.config.js β both must reference the same variables.
Tailwind v4 (recommended 2025+): ```css / app/globals.css / @import "tailwindcss"; @source "../components/*/.{ts,tsx}";
@theme { --color-background: hsl(0 0% 100%); --color-foreground: hsl(222.2 84% 4.9%); --color-primary: hsl(222.2 47.4% 11.2%); --color-primary-foreground: hsl(210 40% 98%); }
@layer base { :root { --radius: 0.5rem; } .dark { --color-background: hsl(222.2 84% 4.9%); } } ```
Tailwind v3 (legacy): ``css / globals.css / @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; } } ` `javascript // tailwind.config.js theme: { extend: { colors: { background: 'hsl(var(--background))', primary: 'hsl(var(--primary))', } } } ``
This dual-layer approach means one CSS variable change propagates to both shadcn/ui internals and your Tailwind utilities simultaneously.
// @thecodeforge/design-tokens β centralized token definitions // Single source of truth that generates both CSS and Tailwind config export const designTokens = { colors: { background: { light: '0 0% 100%', dark: '222.2 84% 4.9%' }, foreground: { light: '222.2 84% 4.9%', dark: '210 40% 98%' }, primary: { light: '222.2 47.4% 11.2%', dark: '210 40% 98%' }, destructive: { light: '0 84.2% 60.2%', dark: '0 62.8% 30.6%' }, }, radii: { DEFAULT: '0.5rem', sm: 'calc(var(--radius) - 4px)', md: 'calc(var(--radius) - 2px)', lg: 'var(--radius)', }, } as const; // Build script generates: // 1. CSS custom properties for globals.css // 2. @theme block for Tailwind v4 // 3. TypeScript types for autocomplete
Extending shadcn/ui Components for Your Design System
Raw shadcn/ui components are starting points, not finished products. A real design system requires variants, compound components, and design-system-specific APIs that match your team's mental model.
The pattern for extending components:
- Keep the Radix primitive as the foundation
- Add variant support using class-variance-authority (cva)
- Expose a clean API that hides implementation details
- Add 'use client' directive for Next.js App Router compatibility
The key insight: your design system components should communicate intent, not implementation. A <Button variant="destructive"> tells the developer what the button means. A <Button className="bg-red-500"> tells them what color it is. Your API should enforce the former.
'use client'; // @thecodeforge/design-system β Extended Button with semantic variants import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; import { Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; const buttonVariants = cva( 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { primary: 'bg-primary text-primary-foreground hover:bg-primary/90', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline', }, size: { sm: 'h-9 rounded-md px-3', md: 'h-10 px-4 py-2', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', }, }, defaultVariants: { variant: 'primary', // Note: shadcn default is 'default' β we use semantic 'primary' size: 'md', }, } ); export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean; loading?: boolean; leftIcon?: React.ReactNode; rightIcon?: React.ReactNode; } const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant, size, asChild = false, loading = false, leftIcon, rightIcon, children, disabled, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; return ( <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} disabled={disabled || loading} {...props}> {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {!loading && leftIcon && <span className="mr-2">{leftIcon}</span>} {children} {!loading && rightIcon && <span className="ml-2">{rightIcon}</span>} </Comp> ); } ); Button.displayName = 'Button'; export { Button, buttonVariants };
- variant="destructive" communicates meaning β the developer knows what it does
- className="bg-red-500" communicates appearance β the developer must infer intent
- Good APIs make correct usage easy and incorrect usage impossible
- cva maps semantic variants to Tailwind classes β that indirection is your consistency layer
Accessibility: What Radix Gives You and What You Must Maintain
Radix Primitives handle the hardest accessibility problems: focus trapping, keyboard navigation, ARIA attribute management, and screen reader announcements. This is why shadcn/ui builds on Radix β the accessibility foundation is production-grade.
But accessibility is not a feature you install once. It degrades through customization. Common failure modes:
- Overriding focus styles β removing
focus-visible:ring-2because it looks ugly, then keyboard users lose focus indicators - Breaking semantic HTML β wrapping a button's content in a div that breaks screen reader traversal
- Ignoring color contrast β customizing tokens without verifying WCAG AA ratios
- Dismissing portal behavior β moving Dialog.Content outside Dialog.Portal to fix a z-index issue, breaking focus management
Your design system must enforce accessibility as a constraint, not a suggestion. Build accessibility checks into your component development workflow.
'use client'; import * as React from 'react'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; interface FormFieldProps { label: string; htmlFor: string; error?: string; hint?: string; required?: boolean; children: React.ReactElement; className?: string; } export function AccessibleFormField({ label, htmlFor, error, hint, required, children, className }: FormFieldProps) { const hintId = hint ? `${htmlFor}-hint` : undefined; const errorId = error ? `${htmlFor}-error` : undefined; const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined; return ( <div className={cn('space-y-2', className)}> <Label htmlFor={htmlFor}> {label} {required && <span className="text-destructive ml-1" aria-hidden="true">*</span>} </Label> {hint && <p id={hintId} className="text-sm text-muted-foreground">{hint}</p>} {React.cloneElement(children, { id: htmlFor, 'aria-describedby': describedBy, 'aria-invalid': error ? true : undefined, 'aria-required': required || undefined, })} {error && <p id={errorId} className="text-sm text-destructive" role="alert">{error}</p>} </div> ); }
Theming: Light, Dark, and Beyond
shadcn/ui ships with a CSS-variable-based theming system that supports light and dark modes out of the box. But production design systems need more: brand themes, high-contrast modes, or customer-specific overrides.
The architecture that scales:
- Define all color values as CSS custom properties
- Use
hsl(var(--token))or @theme variables so utilities resolve correctly - Toggle themes by changing a class on the root element
- Critical: Apply theme before first paint to prevent FOUC
For Next.js App Router, add a blocking script in your root layout:
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" suppressHydrationWarning> <head> <script dangerouslySetInnerHTML={{ __html: ` try { const theme = localStorage.getItem('theme') || 'system'; const resolved = theme === 'system' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme; document.documentElement.classList.add(resolved); } catch {} `, }} /> </head> <body> <ThemeProvider>{children}</ThemeProvider> </body> </html> ); }
- Store theme preference in localStorage for persistence
- Always resolve 'system' to 'light' or 'dark' before applying classes
- Use next-themes package β it implements this pattern correctly for RSC
- Blocking script adds ~1ms to first paint but eliminates FOUC completely
Component Documentation and Governance
A design system without documentation is a component graveyard. Teams will not use components they cannot discover, understand, or trust.
Documentation for a shadcn/ui-based design system should cover:
- Component API β props, variants, slots, and their intended usage
- Design rationale β why this component exists and when to use it
- Accessibility notes β what Radix handles automatically and what consumers must maintain
- Composition patterns β how to combine components for common workflows
- Anti-patterns β what not to do and why
Storybook is the standard tool for component documentation. It provides isolated rendering, interactive prop controls, and accessibility auditing via the a11y addon. But Storybook alone is not governance β you need lint rules that enforce design system usage.
// Enforce design system usage module.exports = { plugins: ['tailwindcss'], rules: { // Prevent direct imports from raw shadcn/ui β use design system wrappers 'no-restricted-imports': [ 'error', { patterns: [{ group: ['@/components/ui/*'], message: 'Import from @/components/design-system/* instead of raw ui components.', }], }, ], // Enforce design tokens (requires eslint-plugin-tailwindcss) 'tailwindcss/no-custom-classname': ['warn', { whitelist: ['bg-background', 'text-foreground', 'bg-primary', 'bg-destructive'] }], 'tailwindcss/enforces-shorthand': 'error', }, };
| Aspect | shadcn/ui + Radix | Material UI / MUI | Custom Radix Primitives |
|---|---|---|---|
| Source ownership | You own the code | npm dependency | You own the code |
| Accessibility | Radix handles primitives | Built-in, opinionated | You implement everything |
| Styling approach | Tailwind utilities | Emotion / styled-components | Your choice |
| Customization depth | Unlimited β source access | Theme API + overrides | Unlimited β from scratch |
| Maintenance burden | Medium β merge upstream changes | Low β update package | High β build everything |
| Bundle size control | Full β import only what you use | Partial β tree-shaking helps | Full β minimal by default |
| Learning curve | Moderate β Radix + Tailwind | Low β comprehensive docs | High β deep Radix knowledge |
| RSC Compatible | Yes β with 'use client' | Partial | Yes β with 'use client' |
π― Key Takeaways
- shadcn/ui gives you source ownership β treat it as a codebase to maintain, not a package to consume
- Token synchronization between CSS variables and Tailwind is non-negotiable β automate with a build script
- Radix handles accessibility primitives β your job is to not break them through customization
- Enforce design system usage with lint rules (eslint-plugin-tailwindcss), not just documentation
- Run
npx shadcn@latest diffin CI to catch upstream changes before they cause surprises - Always use 'use client' directive and blocking theme script for Next.js App Router
β Common Mistakes to Avoid
Interview Questions on This Topic
- QHow does shadcn/ui's component distribution model differ from traditional component libraries like Material UI?Mid-levelReveal
- QWhy must CSS custom properties and Tailwind config stay synchronized in a shadcn/ui design system?Mid-levelReveal
- QWhat accessibility responsibilities does Radix handle automatically, and what must developers maintain when customizing shadcn/ui components?SeniorReveal
- QHow would you enforce design system token usage across a large engineering organization?SeniorReveal
- QWhat is the purpose of class-variance-authority (cva) in a shadcn/ui design system?Mid-levelReveal
Frequently Asked Questions
Can I use shadcn/ui with Vue or Svelte?
shadcn/ui is React-only because it wraps Radix Primitives which are React components. However, community ports exist: shadcn-svelte for Svelte, shadcn-vue for Vue. The core architectural pattern β copying source code into your project β applies to any framework via Ark UI primitives.
How do I handle shadcn/ui updates when I have customized components?
Run npx shadcn@latest diff button to see what changed upstream. This shows a git-style diff of every component. Review each change and decide whether to merge. Since you own the source code, you control the merge β there is no forced upgrade. Maintain a changelog of your customizations so you can resolve conflicts intelligently.
Should I re-export shadcn/ui components or modify them in place?
For a design system, modify in place. Re-exporting adds an abstraction layer that obscures the component's actual implementation and makes debugging harder. The shadcn/ui model assumes you will modify the source β that is why it copies the code into your project. If you need to add variants or props, modify the component file directly.
How do I test shadcn/ui components in my design system?
Use three testing layers: (1) Unit tests with React Testing Library for component logic and prop handling. (2) Visual regression tests with Chromatic or Percy to catch unintended style changes. (3) Accessibility tests with axe-core integrated into your test runner. Storybook provides a natural home for all three β write stories that serve as both documentation and test cases.
What is the performance impact of using CSS custom properties for theming?
Negligible in practice. CSS custom property resolution happens during style computation, which is a fraction of a millisecond per element. The alternative β generating separate CSS bundles per theme β adds build complexity without meaningful performance gains. For most applications, the CSS custom property approach adds less than 1ms to total page render time.
Does this work with Tailwind CSS v4?
Yes, but the configuration changes. Tailwind v4 uses CSS-first configuration with @theme directives instead of tailwind.config.js. shadcn/ui components still work because they reference CSS variables. Update your globals.css to use @theme { --color-primary: ... } and @source directives instead of content paths. The token architecture remains the same β just the Tailwind configuration surface changes.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.