Skip to content

Styling API Design

advanced22 min read

The Styling API Is Your Component's UX

When someone imports your Button component, the first thing they want to know is: how do I style it? How do I change the color? How do I make it bigger? How do I add custom styles without fighting the component?

The answer to these questions is your styling API. Get it wrong and developers will curse your library. Get it right and they will barely notice it — which is the highest compliment an API can receive.

Mental Model

Think of styling APIs like clothing sizes. Some stores use S/M/L/XL (variants) — simple, limited, predictable. Some let you enter exact measurements (style props) — flexible, but overwhelming for most customers. Some give you a base garment and let you bring your own accessories (className) — a balanced middle ground. The best approach depends on your customers: a uniform supplier uses variants, a bespoke tailor uses measurements, and most clothing stores use a mix.

Approach 1: className (The Escape Hatch)

The simplest approach: forward className to the root element and let consumers apply whatever CSS they want.

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  children: React.ReactNode;
}

function Button({ className, children, ...props }: ButtonProps) {
  return (
    <button
      className={`btn-base ${className ?? ''}`}
      {...props}
    >
      {children}
    </button>
  );
}
<Button className="bg-blue-600 text-white px-4 py-2 rounded-lg">
  Submit
</Button>

Pros: Maximum flexibility, zero learning curve, works with any CSS approach.

Cons: No guardrails. The consumer can apply conflicting styles, break the component's visual integrity, and create accessibility issues (like removing focus styles). Every instance looks different.

Quiz
Your Button component has base styles including font-size: 14px. A consumer passes className='text-lg' (which sets font-size: 18px in Tailwind). Which font size wins?

Approach 2: Variants with CVA

Class Variance Authority (CVA) is a utility for defining component variants as a typed API. Instead of consumers writing arbitrary CSS, they pick from pre-defined options.

import { cva, type VariantProps } from 'class-variance-authority';

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline-blue-600',
        destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600',
        outline: 'border border-gray-300 bg-transparent hover:bg-gray-50',
        ghost: 'hover:bg-gray-100',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-sm',
        lg: 'h-12 px-6 text-base',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'md',
    },
  }
);

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof buttonVariants>;

function Button({ variant, size, className, ...props }: ButtonProps) {
  return (
    <button
      className={buttonVariants({ variant, size, className })}
      {...props}
    />
  );
}

Usage is clean and typed:

<Button>Default medium</Button>
<Button variant="destructive" size="lg">Delete</Button>
<Button variant="ghost" size="sm">Cancel</Button>

CVA gives you:

  • Type-safe variants — TypeScript knows the valid options
  • Sensible defaults — works with zero props
  • ComposabilityclassName is still accepted for edge cases
  • Zero runtime — CVA is a build-time utility, no CSS-in-JS overhead
Quiz
What happens when you pass both variant='destructive' and className='bg-green-500' to a CVA-based Button?

CVA + tw-merge: The Complete Solution

tw-merge intelligently resolves Tailwind class conflicts by understanding which classes target the same CSS property:

import { twMerge } from 'tailwind-merge';
import { cva, type VariantProps } from 'class-variance-authority';

function cn(...inputs: (string | undefined | null | false)[]) {
  return twMerge(inputs.filter(Boolean).join(' '));
}

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors',
  {
    variants: {
      variant: {
        default: 'bg-blue-600 text-white hover:bg-blue-700',
        destructive: 'bg-red-600 text-white hover:bg-red-700',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-sm',
      },
    },
    defaultVariants: { variant: 'default', size: 'md' },
  }
);

function Button({ variant, size, className, ...props }: ButtonProps) {
  return (
    <button
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    />
  );
}

Now <Button variant="destructive" className="bg-green-500"> correctly resolves to bg-green-500 because tw-merge knows that bg-red-600 and bg-green-500 target the same property and keeps the last one.

Approach 3: CSS Modules

CSS Modules scope styles to components by generating unique class names at build time:

/* Button.module.css */
.base {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-md);
  font-weight: 500;
  transition: background-color 150ms;
}

.primary {
  background-color: var(--color-primary);
  color: var(--color-on-primary);
}

.primary:hover {
  background-color: var(--color-primary-hover);
}

.sizeMd {
  height: 40px;
  padding: 0 16px;
  font-size: 14px;
}
import styles from './Button.module.css';

function Button({ variant = 'primary', size = 'md', className, ...props }: ButtonProps) {
  return (
    <button
      className={`${styles.base} ${styles[variant]} ${styles[`size${capitalize(size)}`]} ${className ?? ''}`}
      {...props}
    />
  );
}

Pros: True style isolation (no global conflicts), works everywhere (Vite, Next.js, Webpack), no runtime overhead, uses standard CSS.

Cons: Verbose class composition, no built-in variant system (you build it manually), harder to override from outside (specificity of generated selectors is unpredictable).

Approach 4: Style Props (Chakra-Style)

Style props expose CSS properties directly as component props:

<Box
  display="flex"
  alignItems="center"
  gap={4}
  padding={6}
  bg="blue.500"
  color="white"
  borderRadius="md"
>
  Content
</Box>

This approach is polarizing. Fans love the co-location and speed. Critics dislike the massive prop surface area and the implicit dependency on a theme object.

Pros: Extremely fast prototyping, responsive props (padding={{ base: 4, md: 6 }}), theme-aware values.

Cons: Huge component API surface, non-standard (every library does it differently), runtime overhead (values computed to CSS at render time), hard to extract shared styles.

ApproachType SafetyFlexibilityPerformanceLearning Curve
classNameNoneMaximumZero overheadNone
CVA variantsExcellentConstrained + escape hatchZero overheadLow
CSS ModulesNoneGood (scoped)Zero overheadLow
Style propsGoodMaximumRuntime costMedium
Vanilla ExtractExcellentGoodZero overheadMedium-High
Panda CSSExcellentMaximumZero runtimeMedium
Quiz
You are building a design system for a large organization. Different product teams use different styling approaches (Tailwind, CSS Modules, styled-components). Which styling API for your component library maximizes adoption?

Approach 5: Zero-Runtime CSS-in-JS

The latest evolution: CSS-in-JS that extracts to static CSS at build time. Zero runtime JavaScript, full type safety, co-located styles.

Vanilla Extract

// Button.css.ts
import { style, styleVariants } from '@vanilla-extract/css';
import { recipe, type RecipeVariants } from '@vanilla-extract/recipes';

export const button = recipe({
  base: {
    display: 'inline-flex',
    alignItems: 'center',
    justifyContent: 'center',
    borderRadius: 'var(--radius-md)',
    fontWeight: 500,
    transition: 'background-color 150ms',
  },
  variants: {
    variant: {
      primary: {
        backgroundColor: 'var(--color-primary)',
        color: 'var(--color-on-primary)',
      },
      destructive: {
        backgroundColor: 'var(--color-danger)',
        color: 'white',
      },
    },
    size: {
      sm: { height: 32, paddingInline: 12, fontSize: 14 },
      md: { height: 40, paddingInline: 16, fontSize: 14 },
      lg: { height: 48, paddingInline: 24, fontSize: 16 },
    },
  },
  defaultVariants: {
    variant: 'primary',
    size: 'md',
  },
});

export type ButtonVariants = RecipeVariants<typeof button>;
// Button.tsx
import { button, type ButtonVariants } from './Button.css';

function Button({ variant, size, className, ...props }: ButtonProps & ButtonVariants) {
  return (
    <button
      className={`${button({ variant, size })} ${className ?? ''}`}
      {...props}
    />
  );
}

At build time, this compiles to static CSS classes. Zero JavaScript ships to the browser for styling.

Panda CSS

Panda CSS combines the DX of style props with zero-runtime extraction:

import { css } from '../styled-system/css';

function Card({ children }: { children: React.ReactNode }) {
  return (
    <div className={css({
      padding: '6',
      borderRadius: 'lg',
      bg: 'bg.primary',
      border: '1px solid',
      borderColor: 'border',
      _hover: { shadow: 'md' },
    })}>
      {children}
    </div>
  );
}

Panda statically analyzes your code at build time and extracts all css() calls into an atomic CSS stylesheet. You write what looks like runtime CSS-in-JS, but it compiles away completely.

Common Trap

Vanilla Extract requires a build plugin (Vite, webpack, esbuild, or Next.js). If your component library consumers do not use a compatible bundler, they cannot use your library. This is a real adoption blocker for libraries that need to work everywhere. CVA with Tailwind has no bundler dependency — it is just strings — which is why it has won the "default choice" battle for most component libraries.

The Decision Framework

How do you choose? Here is the decision tree used by teams at scale:

Key Rules
  1. 1Always forward className — it is the universal escape hatch every consumer expects
  2. 2CVA + tw-merge is the current best practice for Tailwind-based component libraries
  3. 3Zero-runtime CSS-in-JS (Vanilla Extract, Panda) is excellent for internal libraries with controlled build pipelines
  4. 4Style props maximize prototyping speed but add runtime cost and API surface
  5. 5External libraries should use className + data attributes for maximum compatibility
  6. 6Never use CSS specificity hacks (!important, deep nesting) to force styles — they make overriding impossible
What developers doWhat they should do
Using CVA without tw-merge
CVA concatenates classes without resolving conflicts. Without tw-merge, consumer className overrides are unpredictable
Always pair CVA with tw-merge for Tailwind projects
Exposing internal class names as public API
Internal class names can change between versions. Data attributes are an explicit, stable API contract that consumers can target with CSS selectors
Use data attributes for styling hooks (data-state, data-variant)
Using CSS-in-JS with runtime overhead in a performance-critical library
Runtime CSS-in-JS adds JavaScript execution on every render. For a design system used across many components on a page, this overhead compounds
Choose zero-runtime solutions (Vanilla Extract, Panda) or static approaches (CVA, CSS Modules)
Building a custom variant system from scratch
Variant logic (defaults, compound variants, responsive variants) has many edge cases. Libraries handle them correctly, your custom code might not
Use CVA or Vanilla Extract recipes — they are battle-tested and well-typed
The Slots Pattern for Multi-Element Components

Some components have multiple elements that consumers might want to style: a Card with a header, body, and footer. The slots pattern exposes named styling targets:

const cardVariants = {
  root: cva('rounded-lg border', {
    variants: {
      variant: {
        default: 'bg-white border-gray-200',
        elevated: 'bg-white shadow-md border-transparent',
      },
    },
  }),
  header: cva('px-6 py-4 border-b', {
    variants: {
      variant: {
        default: 'border-gray-200',
        elevated: 'border-gray-100',
      },
    },
  }),
  body: cva('px-6 py-4'),
  footer: cva('px-6 py-4 border-t border-gray-200'),
};

This gives consumers fine-grained control over each part of the component while keeping the API structured and type-safe. Libraries like Panda CSS and Vanilla Extract have first-class support for this pattern through "slot recipes."