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
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
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"
}
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 }
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>
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)