Building a Custom UI Kit with Tailwind CSS

By Maulik Paghdal

07 Nov, 2025

•  9 minutes to Read

Building a Custom UI Kit with Tailwind CSS

Introduction

When I first started using Tailwind CSS, I thought it was just another utility-first framework. But after building a few projects, I realized something: copy-pasting the same button classes across components gets old fast. That's when I decided to build my own UI kit on top of Tailwind.

In this article, I'll walk you through creating a reusable, maintainable UI kit using Tailwind CSS. We'll cover everything from setting up a design system to creating actual components you can drop into any project.

Why Build a Custom UI Kit?

Before we dive in, let's talk about why you'd want to do this in the first place.

Tailwind gives you utility classes, but it doesn't give you components. You could use a library like DaisyUI or Headless UI, but sometimes you need something specific to your brand or project requirements. Building your own kit means:

  • Consistency across projects: Define your button styles once, use them everywhere
  • Faster development: No more hunting for that perfect shadow or border radius
  • Easy theming: Change colors site-wide with a config update
  • Better collaboration: Designers and developers speak the same language

Setting Up Your Foundation

Let's start with a solid Tailwind configuration. This is where your design system lives.

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Now, open tailwind.config.js and extend the default theme with your custom values:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#eff6ff',
          100: '#dbeafe',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
        },
        secondary: {
          500: '#8b5cf6',
          600: '#7c3aed',
        },
      },
      spacing: {
        '128': '32rem',
        '144': '36rem',
      },
      borderRadius: {
        'xl': '1rem',
        '2xl': '1.5rem',
      },
      fontSize: {
        'xs': ['0.75rem', { lineHeight: '1rem' }],
        'sm': ['0.875rem', { lineHeight: '1.25rem' }],
        'base': ['1rem', { lineHeight: '1.5rem' }],
      },
    },
  },
  plugins: [],
}

💡 Tip: Keep your color palette limited. I typically use 2-3 brand colors plus grays. More than that and decision fatigue kicks in.

Creating Base Component Styles

Here's where it gets interesting. We'll use Tailwind's @layer directive to create reusable component classes. Create a styles.css file:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  /* Button Base */
  .btn {
    @apply inline-flex items-center justify-center px-4 py-2 
           font-medium rounded-lg transition-all duration-200 
           focus:outline-none focus:ring-2 focus:ring-offset-2 
           disabled:opacity-50 disabled:cursor-not-allowed;
  }

  .btn-primary {
    @apply btn bg-primary-600 text-white hover:bg-primary-700 
           focus:ring-primary-500;
  }

  .btn-secondary {
    @apply btn bg-secondary-600 text-white hover:bg-secondary-700 
           focus:ring-secondary-500;
  }

  .btn-outline {
    @apply btn border-2 border-primary-600 text-primary-600 
           hover:bg-primary-50 focus:ring-primary-500;
  }

  /* Input Base */
  .input {
    @apply w-full px-4 py-2 border border-gray-300 rounded-lg 
           focus:outline-none focus:ring-2 focus:ring-primary-500 
           focus:border-transparent disabled:bg-gray-100 
           disabled:cursor-not-allowed;
  }

  .input-error {
    @apply input border-red-500 focus:ring-red-500;
  }

  /* Card Base */
  .card {
    @apply bg-white rounded-xl shadow-sm border border-gray-200 
           overflow-hidden;
  }

  .card-body {
    @apply p-6;
  }
}

Now you can use these classes directly in your HTML:

<button class="btn-primary">Save Changes</button>
<input type="text" class="input" placeholder="Enter email" />
<div class="card">
  <div class="card-body">
    <h3>Card Title</h3>
    <p>Card content goes here</p>
  </div>
</div>

⚠️ Warning: Don't go overboard with @layer components. If you're defining too many custom classes, you're fighting against Tailwind's utility-first approach. Use it for truly reusable patterns.

Building Component Variants

Let's create a proper button component with multiple variants. I'll show you both the CSS approach and a React component approach.

CSS-Only Approach

@layer components {
  /* Size variants */
  .btn-sm {
    @apply btn text-sm px-3 py-1.5;
  }

  .btn-lg {
    @apply btn text-lg px-6 py-3;
  }

  /* State variants */
  .btn-loading {
    @apply btn relative text-transparent pointer-events-none;
  }

  .btn-loading::after {
    content: '';
    @apply absolute inset-0 flex items-center justify-center;
    background: inherit;
    border-radius: inherit;
  }
}

React Component Approach

For dynamic applications, wrapping your styles in components gives you better control:

// Button.jsx
import React from 'react';

const Button = ({ 
  children, 
  variant = 'primary', 
  size = 'md', 
  loading = false,
  disabled = false,
  ...props 
}) => {
  const baseClasses = 'btn';
  
  const variants = {
    primary: 'btn-primary',
    secondary: 'btn-secondary',
    outline: 'btn-outline',
  };

  const sizes = {
    sm: 'btn-sm',
    md: '',
    lg: 'btn-lg',
  };

  const classes = [
    baseClasses,
    variants[variant],
    sizes[size],
    loading && 'btn-loading',
  ].filter(Boolean).join(' ');

  return (
    <button 
      className={classes} 
      disabled={disabled || loading}
      {...props}
    >
      {loading ? (
        <>
          <svg className="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
            <circle className="opacity-25" cx="12" cy="12" r="10" 
                    stroke="currentColor" strokeWidth="4" fill="none" />
            <path className="opacity-75" fill="currentColor" 
                  d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
          </svg>
          Loading...
        </>
      ) : children}
    </button>
  );
};

export default Button;

Usage:

<Button variant="primary" size="lg">Click Me</Button>
<Button variant="outline" loading>Processing...</Button>

📌 Note: The component approach is more flexible for complex UIs, but CSS-only works great for marketing sites or simple apps.

Creating a Complete Form System

Forms are where UI kits really shine. Let's build input, label, and error message components that work together:

// Input.jsx
const Input = ({ 
  label, 
  error, 
  hint,
  required = false,
  ...props 
}) => {
  const inputClasses = error ? 'input-error' : 'input';

  return (
    <div className="space-y-1">
      {label && (
        <label className="block text-sm font-medium text-gray-700">
          {label}
          {required && <span className="text-red-500 ml-1">*</span>}
        </label>
      )}
      <input className={inputClasses} {...props} />
      {hint && !error && (
        <p className="text-sm text-gray-500">{hint}</p>
      )}
      {error && (
        <p className="text-sm text-red-600 flex items-center gap-1">
          <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
            <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
          </svg>
          {error}
        </p>
      )}
    </div>
  );
};
// Usage
<Input 
  label="Email Address"
  type="email"
  required
  hint="We'll never share your email"
  error={errors.email}
/>

Common Component Patterns

Here's a table of components you'll want in most UI kits:

ComponentKey FeaturesCommon Pitfalls
ButtonVariants, sizes, loading state, icon supportForgetting disabled styles, poor loading UX
InputLabel, error state, helper text, validation stylingNot handling autofill styles, missing focus states
CardHeader, body, footer sections, hover effectsInconsistent padding, missing overflow handling
ModalBackdrop, close button, scroll lock, focus trapNot preventing body scroll, poor mobile UX
BadgeColors, sizes, dismissible variantText overflow issues, inconsistent spacing
AlertTypes (success, error, warning), dismissible, iconAccessibility issues, poor color contrast

Managing Component Complexity

As your kit grows, organization matters. Here's a structure that's worked for me:

src/
├── components/
   ├── ui/
   ├── Button/
   ├── Button.jsx
   ├── Button.test.jsx
   └── index.js
   ├── Input/
   ├── Card/
   └── index.js (barrel export)
   └── ...
├── styles/
   ├── base.css
   ├── components.css
   └── utilities.css
└── tailwind.config.js

Your barrel export (components/ui/index.js) should look like:

export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Card } from './Card';
// ... more exports

This lets you import cleanly:

import { Button, Input, Card } from '@/components/ui';

Dark Mode Support

Dark mode is expected nowadays. Tailwind makes this easy:

// tailwind.config.js
module.exports = {
  darkMode: 'class', // or 'media'
  // ... rest of config
}

Update your component styles:

@layer components {
  .btn-primary {
    @apply btn bg-primary-600 text-white hover:bg-primary-700
           dark:bg-primary-500 dark:hover:bg-primary-600;
  }

  .card {
    @apply bg-white dark:bg-gray-800 
           border-gray-200 dark:border-gray-700;
  }

  .input {
    @apply border-gray-300 dark:border-gray-600
           bg-white dark:bg-gray-800
           text-gray-900 dark:text-gray-100;
  }
}

💡 Tip: Use darkMode: 'class' instead of 'media' so users can toggle dark mode manually. Implement the toggle with a simple React hook or vanilla JS that adds/removes a dark class on the <html> element.

Documentation and Storybook

A UI kit without docs is just a folder of components. You don't need anything fancy. A simple README with examples works:

# Button Component

## Usage

import { Button } from '@/components/ui';

<Button variant="primary">Click me</Button>

## Props

- `variant`: 'primary' | 'secondary' | 'outline'
- `size`: 'sm' | 'md' | 'lg'
- `loading`: boolean
- `disabled`: boolean

## Examples

<Button variant="outline" size="lg">Large Outline</Button>
<Button loading>Processing...</Button>

If your team is bigger, Storybook is worth setting up:

npx storybook init

Create a story file:

// Button.stories.jsx
import Button from './Button';

export default {
  title: 'UI/Button',
  component: Button,
};

export const Primary = () => <Button variant="primary">Primary</Button>;
export const Loading = () => <Button loading>Loading</Button>;

Best Practices I've Learned

After building a few UI kits, here are things I wish I knew earlier:

Keep it minimal at first. Start with 5-6 components (Button, Input, Card, Modal, Alert). Add more as you need them.

Composition over configuration. Instead of a component with 20 props, build smaller components that compose together:

// Good
<Card>
  <CardHeader>Title</CardHeader>
  <CardBody>Content</CardBody>
</Card>

// Avoid
<Card title="Title" content="Content" hasHeader hasFooter />

Test on real projects. Your UI kit will reveal its flaws when you use it. Don't build in isolation.

Version your kit properly. If you're sharing across projects, use semantic versioning. Breaking changes to a Button component can break multiple apps.

Accessibility isn't optional. Use semantic HTML, add ARIA labels where needed, and test with a keyboard. Your future self (and users) will thank you.

Common Mistakes to Avoid

Here are issues I've run into:

Over-abstracting too early. You don't need a component for everything. Three similar buttons? Copy-paste is fine. Ten similar buttons? Time for a component.

Ignoring responsive design. Your components should work on mobile. Test different screen sizes. Use Tailwind's responsive prefixes:

<Button className="w-full md:w-auto">Responsive Button</Button>

Hardcoding colors. Always use theme colors from your config, never raw hex values. This makes theming possible later.

Not handling edge cases. What happens with really long text? Disabled state? Loading state? Think through these scenarios.

Wrapping Up

Building a custom UI kit with Tailwind isn't about reinventing the wheel. It's about creating a consistent design language that makes you faster and your code more maintainable.

Start small. Build the components you actually need. Refine them as you go. Before you know it, you'll have a solid kit that saves hours of development time.

The code examples here should give you a solid foundation. Take them, modify them, make them yours. That's the whole point.

About Author

I'm Maulik Paghdal, the founder of Script Binary and a passionate full-stack web developer. I have a strong foundation in both frontend and backend development, specializing in building dynamic, responsive web applications using Laravel, Vue.js, and React.js. With expertise in Tailwind CSS and Bootstrap, I focus on creating clean, efficient, and scalable solutions that enhance user experiences and optimize performance.