DEV Community

Ibrahim Pima
Ibrahim Pima

Posted on

Shadcn button Component from Scratch

How I Built a Fully Customizable Button Component Like Shadcn

Today, I’m going to walk you through how I created a fully customizable button component in React, inspired by how Shadcn structures theirs. If you're building modern UI with reusable patterns and want clean Tailwind class management, this will help you a lot.


Step 1: Scaffold a React App with Vite

To start, create a new React project using Vite. It's fast and lightweight, making it a great choice for UI-focused development.

npm create vite@latest
Enter fullscreen mode Exit fullscreen mode

Choose your project name and select React + TypeScript (or JavaScript if you prefer).


Step 2: Install Dependencies

Here are the essential packages you'll need:

npm install class-variance-authority @radix-ui/react-slot tailwind-merge
Enter fullscreen mode Exit fullscreen mode

Make sure your package.json includes something like this under dependencies:

"dependencies": {
  "@radix-ui/react-slot": "^1.2.3",
  "class-variance-authority": "^0.7.1",
  "clsx": "^2.1.1",
  "motion": "^12.23.0",
  "next": "15.3.4",
  "react": "^19.0.0",
  "react-dom": "^19.0.0",
  "save": "^2.9.0",
  "tailwind-merge": "^3.3.1"
}
Enter fullscreen mode Exit fullscreen mode

If you're using Next.js instead of Vite, these dependencies will still work seamlessly.


Step 3: Why These Dependencies?

Let me explain the core tools we're using:

Class Variance Authority (CVA)

More info: cva.style/docs
CVA allows you to define and manage Tailwind variants in a much cleaner way. Instead of writing long conditional class strings, you can define your variants in a centralized config. This improves maintainability and scales better for larger design systems.

@radix-ui/react-slot

This package allows you to pass your button’s children through a Slot so you can use the button as a wrapper for custom components while preserving props and accessibility features.

tailwind-merge (twMerge)

This utility merges Tailwind classes without conflicts. It ensures that if there are multiple definitions of the same property (like bg-*), only the last one survives.


Step 4: Create the Customizable Button Component

Here’s the actual implementation:

import React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/app/lib/utils'

const buttonVariants = cva(
  `
    inline-flex items-center justify-center 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
  `,
  {
    variants: {
      variant: {
        dark: 'bg-slate-900 text-white',
        primary: 'bg-indigo-600 hover:bg-indigo-700 text-white',
        secondary: 'bg-indigo-500 hover:bg-indigo-700',
        destructive: 'bg-red-500 text-white hover:bg-red-700',
        ok: 'bg-green-500 hover:bg-green-700',
        ghost: 'bg-gray-50 hover:bg-gray-100 text-gray-700',
        link: 'bg-transparent hover:bg-transparent text-indigo-600',
        outline: 'bg-transparent hover:bg-gray-100 text-gray-700 border border-gray-300',
      },
      size: {
        default: 'px-9 py-3',
        sm: 'px-4 py-2',
        lg: 'px-14 py-4',
        xl: 'px-16 py-4',
        icon: 'w-12 h-12',
        full: 'w-full h-12',
        auto: 'w-auto h-auto',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'default',
    },
  }
)

interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const CustomButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button'
    return (
      <Comp
        ref={ref}
        className={cn(buttonVariants({ variant, size, className }))}
        {...props}
      />
    )
  }
)

CustomButton.displayName = 'CustomButton'

export { CustomButton, buttonVariants }
Enter fullscreen mode Exit fullscreen mode

Step 5: How to Use It

You can now use your CustomButton anywhere in your project like this:

<CustomButton variant="destructive" size="lg">
  Delete
</CustomButton>

<CustomButton asChild>
  <a href="/profile" className="custom-link">
    Go to Profile
  </a>
</CustomButton>
Enter fullscreen mode Exit fullscreen mode

Thanks to asChild, the second button renders as an anchor tag while keeping all the styling and behavior of the CustomButton.


Final Thoughts

This approach gives you total control over your button's design while keeping the code clean, reusable, and scalable. It mirrors the way Shadcn builds their UI components: elegant, composable, and Tailwind-first.

You can take this same architecture and apply it to other components like Input, Card, or Alert.

Let me know if you’d like to see a full component library built using this style in a future post!

Top comments (0)