Build a reusable Button component with variants and sizes (like MUI)
Polymorphic Button with intent + size + state variants via CVA (class-variance-authority), accessible defaults (real `<button>`, focus ring, disabled semantics, loading state with aria-busy), `asChild` for polymorphism, forwarded ref, icon left/right slots, and theme via CSS variables. Avoid the boolean-prop explosion.
A Button looks trivial — it isn't. Real-world buttons need variants, sizes, loading state, icons, polymorphism (render as <a>), accessibility, and theme integration. The right shape is variant-based, not a prop per state.
Variant API with CVA
import { cva, type VariantProps } from "class-variance-authority";
const button = cva(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none",
{
variants: {
intent: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
link: "text-primary underline-offset-4 hover:underline",
},
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",
},
},
defaultVariants: { intent: "primary", size: "md" },
},
);
type Props = VariantProps<typeof button> &
React.ButtonHTMLAttributes<HTMLButtonElement> & {
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
loading?: boolean;
asChild?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, Props>(
({ intent, size, leftIcon, rightIcon, loading, disabled, asChild, className, children, ...rest }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
type={asChild ? undefined : "button"}
disabled={disabled || loading}
aria-busy={loading || undefined}
className={cn(button({ intent, size }), className)}
{...rest}
>
{loading ? <Spinner /> : leftIcon}
{children}
{!loading && rightIcon}
</Comp>
);
},
);Accessibility checklist
- Real
<button type="button">(not a div with onClick). - Focus-visible ring distinct from hover.
- Disabled sets
disabled(skips tab order, prevents click). - Loading uses
aria-busy, keeps disabled to prevent re-click. - Icon-only button must have
aria-label. - Color contrast AA minimum (4.5:1 for text).
Polymorphism
asChild (Radix pattern) lets consumers render any element with button styles:
<Button asChild>
<Link href="/dashboard">Go</Link>
</Button>Better than a href prop that switches between button and anchor.
Theming
Token-driven via CSS variables (--primary, --ring). Brand swaps by overriding tokens, not classes.
Anti-patterns
- 30 boolean props (
isPrimary,isSecondary,isSmall,isLoading, ...). Variants solve this. - Custom div + onClick "for flexibility". Loses keyboard activation + ARIA.
- Wrapping icon inside the button without a screen-reader label.
- Mixing button and link without thinking about navigation semantics.
Compound variants
compoundVariants: [
{ intent: "destructive", size: "lg", className: "shadow-lg" }
]For rare style combos.
Interview framing
"Variant-based API via CVA — intent + size + state — not a prop per style. Real <button> element, focus-visible ring, aria-busy for loading, aria-label if icon-only. Polymorphism via asChild (Radix Slot) so it can render as <a> or any element. forwardRef'd. Icon slots left/right. Themed via CSS variables. The big anti-patterns are boolean prop explosion and <div onClick>. Adopt Radix Slot + shadcn-style for new projects rather than building this from scratch."
Follow-up questions
- •Why asChild over an href prop?
- •How would you support an icon-only button accessibly?
- •How do compound variants work?
Common mistakes
- •<div onClick> instead of <button>.
- •Boolean prop per variant.
- •Icon-only without aria-label.
- •Disabled state without focus reasoning.
Performance considerations
- •Memoize className compute if rendering many; not usually necessary. Tree-shake CVA imports.
Edge cases
- •Loading + disabled interactions (don't lose focus).
- •Form submit buttons need type='submit'.
- •Anchor styled as button + keyboard activation differences (Space vs Enter).
Real-world examples
- •shadcn/ui Button, Radix + Tailwind, MUI Button, Chakra Button.