Back to Machine Coding
Machine Coding
easy
mid

How would you build a reusable Button component with variants and sizes like Material UI?

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.

4 min read·~20 min to think through

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

tsx
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:

tsx
<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

ts
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.

Senior engineer discussion

Seniors push toward CVA + Radix Slot composition. They scrutinize a11y in code review — icon-only buttons missing labels, divs masquerading as buttons.

Related questions