Button Component Variants in Tailwind: Primary, Ghost, Icon, Loading
Build a production-ready button system in Tailwind CSS — primary, ghost, icon, and loading variants with React, TypeScript, and class-variance-authority.
Why Button Variants Actually Matter
A button is the most-clicked element in any UI. That's not hyperbole — it's why getting button variants wrong costs you in ways you don't immediately see: inconsistent hover states, inaccessible focus rings, loading states that block the whole page. You've shipped that bug. We've all shipped that bug.
The good news is Tailwind CSS (especially since v3.4, where data-* variants landed) makes it genuinely easy to build a coherent button system without a CSS-in-JS runtime. You get atomic utility classes, a predictable mental model, and zero runtime overhead. Honestly, the ergonomics in 2026 are the best they've ever been.
What you're building in this article: a <Button> React component with four variants — primary, ghost, icon, and loading — all typed with TypeScript and composed with class-variance-authority (CVA). No copy-paste soup. A real, extensible component you'd actually put in a design system.
Worth noting: we're assuming Tailwind v3.4+ throughout. If you're on v2, several of the focus-visible: and aria-* utilities won't be available, and you'll need the JIT mode workarounds.
Setting Up class-variance-authority
CVA is the missing piece between Tailwind and proper variant logic. Without it, you end up with a mess of ternary operators inside className props that nobody wants to maintain. With it, you define your variants once in a structured config and let the library compose the class strings.
Install it alongside clsx and tailwind-merge — you need all three for a solid setup:
``bash
npm install class-variance-authority clsx tailwind-merge
`
Then wire up a cn() utility. This is the standard pattern the entire React/Tailwind ecosystem uses, including shadcn/ui:
`ts
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
``
The reason you need tailwind-merge specifically: Tailwind generates single-purpose utilities, but when you combine them naively with clsx, conflicting classes like px-4 and px-6 both end up in the string. twMerge resolves those conflicts intelligently so only the last-applied value wins. That's the difference between a 48px button and a 24px button depending on render order.
In practice, you'll use cn() everywhere in your component code. One more thing — if you're using the Empire UI component library, this utility already ships with it. No need to write it from scratch.
Building the Base Button Component
Here's the full CVA definition for your button variants. This is the most important 60 lines of code in your design system:
``tsx
// components/ui/button.tsx
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { type ButtonHTMLAttributes, forwardRef } from 'react'
const buttonVariants = cva(
// Base styles shared by ALL variants
[
'inline-flex items-center justify-center gap-2',
'rounded-md text-sm font-medium',
'transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
],
{
variants: {
variant: {
primary: [
'bg-indigo-600 text-white',
'hover:bg-indigo-700 active:bg-indigo-800',
'focus-visible:ring-indigo-500',
],
secondary: [
'bg-zinc-100 text-zinc-900',
'hover:bg-zinc-200 active:bg-zinc-300',
'dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700',
'focus-visible:ring-zinc-500',
],
ghost: [
'bg-transparent text-zinc-700',
'hover:bg-zinc-100 active:bg-zinc-200',
'dark:text-zinc-300 dark:hover:bg-zinc-800',
'focus-visible:ring-zinc-500',
],
destructive: [
'bg-red-600 text-white',
'hover:bg-red-700 active:bg-red-800',
'focus-visible:ring-red-500',
],
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
icon: 'h-10 w-10 p-0',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || isLoading}
aria-busy={isLoading}
{...props}
>
{children}
</button>
)
}
)
Button.displayName = 'Button'
``
A few things to unpack. First, the base styles array uses Tailwind's focus-visible:ring-* utilities rather than the old focus:ring-* pattern. This is crucial for keyboard users — focus-visible only shows the ring for keyboard navigation, not mouse clicks. WCAG 2.1 AA requires a 3:1 contrast ratio on focus indicators, and a 2px ring at the right color clears that bar.
Second, notice aria-busy={isLoading} on the button element. Screen readers announce this attribute, so a user who can't see your spinner still knows something is happening. The disabled || isLoading pattern prevents double-submits without needing separate state management.
That said, the forwardRef wrapper might look like boilerplate overhead. It's not. Without it, you can't pass a ref to your button — which breaks anything using Radix UI primitives, Floating UI, or any other library that programmatically manages focus. Always wrap with forwardRef.
Ghost and Icon Variants in Detail
Ghost buttons are tricky to get right. The transparent background means hover state contrast is the only visual signal the user gets before clicking. That 8px padding on each side in the base md size isn't enough to create a decent hit target — the h-10 (40px tall) keeps you above the WCAG 2.5.5 minimum of 44x44px if you include a bit of layout spacing, but you should test this on mobile.
Icon buttons are their own beast. That icon size sets h-10 w-10 p-0 — you get a square 40px button with no text padding. The key is always pairing it with an aria-label:
``tsx
<Button variant="ghost" size="icon" aria-label="Close dialog">
<XIcon className="h-4 w-4" />
</Button>
`
Without aria-label`, screen reader users get an anonymous button with no announced purpose. This is one of the most common accessibility bugs in component libraries — easy to miss, significant impact.
Look, a lot of teams reach for the ghost variant too aggressively. Ghost buttons work when they appear next to a more prominent primary button — they provide visual hierarchy. Alone on a page, ghost buttons are practically invisible to non-technical users. Use them intentionally. For a deeper look at how these interact with design systems, check out the tailwind component patterns article — it covers composition strategies that apply directly here.
Quick aside: if you're building an icon button that opens a tooltip, you don't need aria-label — the tooltip's content should be linked via aria-describedby. But if there's no tooltip, aria-label is non-negotiable. Two different patterns, both correct in their context.
The Loading State — Done Right
Most loading button implementations just slap a spinner next to the label and call it done. That works visually, but misses the full picture. Here's a proper loading variant that handles the spinner, the label, and the ARIA announcement:
``tsx
// components/ui/spinner.tsx
const Spinner = () => (
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12" cy="12" r="10"
stroke="currentColor" strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 22 6.477 22 12h-4z"
/>
</svg>
)
// Usage in your Button component children:
<button disabled aria-busy="true">
<Spinner />
<span>Saving...</span>
</button>
``
The aria-hidden="true" on the SVG is intentional — the spinner is decorative. The adjacent text (Saving...) carries the semantic meaning. Without this, a screen reader might try to announce the SVG's path data, which is useless noise.
Honestly, the visual weight of your spinner should match your button size. The h-4 w-4 (16px) spinner looks fine in a md button, but looks tiny and sad in a lg button. Size it relative to the text, not as a fixed constant. If you're looking for pre-built animated button examples to riff on, the animated button React article has several patterns worth borrowing.
One pattern worth considering: preserve the button's dimensions during loading. If you replace "Submit" with "Saving..." and a spinner, the button width changes and the layout jumps. Fix it by making your loading label match the original label width, or use a fixed-width container:
``tsx
<Button isLoading={saving}>
<span className="opacity-0 absolute">Submit</span>
{saving ? (
<><Spinner /><span>Saving...</span></>
) : (
<span>Submit</span>
)}
</Button>
``
This invisible phantom text keeps the button from reflowing during state transitions. Small detail, significant polish.
Composing Variants for Your Design System
Once you have the base component, the real power is composition. You can extend buttonVariants in child components without touching the original:
``tsx
// A specialized 'CTA' button for landing pages
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
const CtaButton = ({ children, className, ...props }) => (
<a
className={cn(
buttonVariants({ variant: 'primary', size: 'lg' }),
'rounded-full shadow-lg shadow-indigo-500/30',
'hover:shadow-xl hover:shadow-indigo-500/40',
className
)}
{...props}
>
{children}
</a>
)
``
That shadow-indigo-500/30 trick uses Tailwind's opacity modifier to create a colored drop shadow — a technique that shows up a lot in glassmorphism components designs. The shadow tracks the button's brand color, so it reads as a natural glow rather than a generic grey box-shadow.
For a neobrutalism variant, you'd swap the rounded corners and shadow for a hard offset:
``tsx
const BrutalistButton = ({ children, ...props }) => (
<button
className={cn(
buttonVariants({ variant: 'primary' }),
'rounded-none border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]',
'hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]',
'active:translate-x-[4px] active:translate-y-[4px] active:shadow-none',
'transition-all duration-75'
)}
{...props}
>
{children}
</button>
)
`
The shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]` is Tailwind's arbitrary value syntax — it lets you set exact pixel values without a custom config. That 4px offset is the canonical neobrutalism button feel. Browse more neobrutalism patterns in the Empire UI neobrutalism hub.
The point of building on top of CVA's buttonVariants export rather than copying classes is that when you change the base primary color from indigo-600 to something else, all composed variants inherit that change automatically. That's the design system contract working as intended.
Testing and Accessibility Checklist
Before shipping this to production, run through this checklist. It's not exhaustive, but it catches 90% of the common issues teams miss:
Keyboard navigation: Tab to the button, press Enter and Space — both should trigger onClick. Focus ring must be visible against both light and dark backgrounds. The focus-visible:ring-2 focus-visible:ring-offset-2 combo we set up handles this, but verify the ring color has sufficient contrast (3:1 minimum against the surrounding background).
Disabled state: pointer-events-none in our base styles means disabled buttons don't fire click events, but they also don't show a cursor: not-allowed. Add cursor-not-allowed to your disabled styles if your UX spec calls for it — many do:
``ts
// Add to the base array in buttonVariants:
'disabled:cursor-not-allowed'
``
Whether to show a tooltip explaining *why* something is disabled is a design decision, not a code one. But the button itself should not just silently do nothing.
Color contrast: The indigo-600 (#4F46E5) on white background gives you a 4.54:1 contrast ratio — passes AA but not AAA. White text on indigo-600 gives 5.27:1, which clears AA for normal text and AA for large text. If you need AAA everywhere, shift to indigo-700 (#4338CA). The box shadow generator tool on Empire UI also helps you preview button shadow styles in different color contexts.
Loading state regression test: Make sure isLoading buttons can't be submitted twice. Unit test it:
``tsx
it('prevents double-submit when loading', async () => {
const onClick = vi.fn()
render(<Button isLoading onClick={onClick}>Submit</Button>)
await userEvent.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
})
`
The disabled attribute we set during loading means userEvent.click won't fire onClick` — that assertion should pass with zero extra code.
FAQ
CVA if you have 3+ variants or size combinations — it gives you TypeScript autocomplete and a single source of truth. Plain cn() works fine for one-off button styles that don't need a full variant API.
Export buttonVariants from CVA and apply them to an <a> tag directly — or use the asChild pattern from Radix UI's Slot primitive. Don't render a <button> with an href; it's invalid HTML and breaks screen readers.
Ghost buttons rely on hover:bg for visual feedback. In dark mode, zinc-100 hover backgrounds disappear against dark surfaces. Add dark:hover:bg-zinc-800 to your ghost variant — we included this in the code above.
Import buttonVariants and pass the classes you need to cn() alongside the existing variant string. You get composition without mutation — the base component stays stable.