React

Tailwind CSS + shadcn/ui: Building a Design System

dev.prakah2011 March 5, 2025 2 min read
⚛️

shadcn/ui has changed how React developers build component libraries. Rather than installing a black-box npm package, you copy components directly into your codebase — they’re yours to own, modify, and style. Paired with Tailwind CSS, the result is a design system that’s both flexible and consistent.

What is shadcn/ui?

shadcn/ui is a collection of accessible, unstyled components built on Radix UI primitives. Instead of npm install shadcn-ui, you run a CLI that copies the source directly into your project — giving you full control over every pixel.

# Initialise in a Next.js project
npx shadcn-ui@latest init

# Add individual components
npx shadcn-ui@latest add button
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add data-table

Setting Up Your Token System

The real power comes from combining shadcn/ui’s CSS variable system with a custom Tailwind config. Define your brand tokens once:

/* globals.css */
:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --primary: 221.2 83.2% 53.3%;      /* Brand blue */
  --primary-foreground: 210 40% 98%;
  --accent: 210 40% 96.1%;
  --radius: 0.5rem;
}

.dark {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  --primary: 217.2 91.2% 59.8%;
}
// tailwind.config.ts
export default {
  theme: {
    extend: {
      colors: {
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: {
          DEFAULT: 'hsl(var(--primary))',
          foreground: 'hsl(var(--primary-foreground))',
        },
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
      },
    },
  },
};

Composing a Custom Button Variant

// components/ui/button.tsx (after shadcn init)
import { cva } from 'class-variance-authority';

const buttonVariants = cva(
  'inline-flex items-center justify-content-center rounded-md font-medium transition-colors focus-visible:outline-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        // Add your own brand variant
        brand: 'bg-gradient-to-r from-violet-600 to-indigo-600 text-white hover:opacity-90',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
      },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  }
);

Dark Mode with next-themes

// app/layout.tsx
import { ThemeProvider } from 'next-themes';

export default function Layout({ children }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </ThemeProvider>
  );
}

The biggest win from this setup isn’t the visual consistency — it’s that every developer on your team has the same vocabulary. “Use the brand button variant” is unambiguous. That clarity pays dividends as your codebase scales.

dev.prakah2011
dev.prakah2011

Developer & author at DevForge Agency.

Related Articles

⚛️
React

Next.js 15 App Router: Everything You Need to Know