EduFlow

Section 3 · Components

Component APIs: Props, Variants and Slots

22 min4 min readCode playground

Lesson at a glance

Design component APIs with enum variants instead of boolean flags, consistent prop vocabulary, native attributes passed through, and slots for content, using Northwind’s Button as the example.

  1. 1Enums for choices, booleans for states
  2. 2One vocabulary across the system
  3. 3Don’t block the platform
  4. 4Slots for content
  5. 5Variants in CSS: map props to attributes
  6. 6Review the API before you build it

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Northwind’s 2021 Button had nine props: primary, secondary, danger, small, large, outline, block, icon and onPress. The seven booleans alone allow 128 combinations, and teams found most of them. The production app contained <Button primary danger small large>. It rendered something, and nobody could say what it was meant to be. A component’s API is a contract with every team that uses it, and like any contract, the hard part is what it allows.

Enums for choices, booleans for states

The first rule is about the shape of props. If options are mutually exclusive, they belong in one enum prop. If something is on or off, it’s a boolean.

Button.tsx
type ButtonProps = React.ComponentProps<'button'> & {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
  startIcon?: React.ReactNode;
  endIcon?: React.ReactNode;
};

With variant as an enum, primary and danger can’t both be true; the type system rejects it before code review. loading stays a boolean because a button is either loading or not, independently of its variant.

One vocabulary across the system

The second rule is consistency across components. If Button uses size="sm", so does Input, Select, Tabs and Badge, with the same three values. If one component calls its change handler onChange with the new value, they all do. Northwind keeps a one-page API glossary of shared prop names, and new components reuse those names unless there is a strong reason not to.

This matters more than any single prop decision. An engineer who has used three Northwind components should be able to guess the fourth’s API correctly. Every inconsistency is a small tax paid by everyone, every day.

Don’t block the platform

The third rule: the component must never take away what HTML already gives. A Button is a <button>, so it should accept everything a <button> accepts, aria-* attributes included, and pass it through:

Button.tsx
export function Button({
  variant = 'secondary',
  size = 'md',
  loading = false,
  startIcon,
  endIcon,
  type = 'button',
  children,
  ...rest
}: ButtonProps) {
  return (
    <button
      {...rest}
      type={type}
      className={['nw-button', rest.className].filter(Boolean).join(' ')}
      data-variant={variant}
      data-size={size}
      aria-busy={loading || undefined}
    >
      {startIcon}
      <span className="nw-button__label">{children}</span>
      {endIcon}
    </button>
  );
}

Because ref is a regular prop for function components in React 19, it travels through ...rest with everything else, so focus management and tooltips work without extra wrapping. onClick, disabled, form and aria-describedby work for the same reason.

Slots for content

A slot is a place where the consumer passes content instead of configuration. children is the main slot; startIcon and endIcon are named slots. Compare two ways to add an icon:

TSX
<Button icon="truck" iconPosition="left">Assign</Button>        // configuration
<Button startIcon={<TruckIcon />}>Assign</Button>               // slot

The configuration version needs the Button to know every icon name and every position, and it grows a prop each time someone wants something new. The slot version accepts any element, so teams can pass a different icon set or a small avatar without a new release. The next lesson takes this idea much further.

Variants in CSS: map props to attributes

The Button writes its props into data-variant and data-size attributes, so the CSS mirrors the API one to one. Each variant changes only component tokens:

CSS
.nw-button[data-variant="primary"] {
  --nw-button-bg: var(--nw-color-action-bg);
  --nw-button-text: var(--nw-color-action-text);
  --nw-button-border: transparent;
}

Data attributes keep the styling hooks readable in DevTools, where you see data-variant="primary" instead of a hashed class name, and they can’t be combined into nonsense the way classes like .primary.danger can.

Review the API before you build it

Northwind writes the usage code before the component code. Every new component proposal starts with three snippets: the most common usage, the most complicated usage anyone has asked for, and one usage that should be impossible. The review then asks four questions. Do the prop names match the API glossary? Can any combination of props describe something meaningless? Does the component pass through everything its native element supports? And what will the second and third teams to use it need that the first team didn’t?

That review takes about thirty minutes. Changing an API after twenty teams depend on it takes a major release, a migration guide and months of follow-up, which is what Section 4 is about.

In the exercise, you write the variant and size rules for this Button using only component tokens. Next, you see what happens when a component outgrows props entirely.

Key takeaways

  • Use one enum prop such as variant for mutually exclusive options; separate booleans allow combinations that make no sense.
  • Keep prop names and values identical across components, so size="sm" means the same thing on a Button, an Input and a Select.
  • Pass native attributes and ref through to the underlying element, so the component never blocks what HTML already does.
  • Defaults are design decisions; choose the safe, common option, such as type="button" and a secondary variant.

Further reading

Check your understanding

3 questions

Question 1 of 3

Northwind’s old Button accepts primary, danger and outline booleans. A team writes <Button primary danger>. What’s the underlying API problem?

Question 2 of 3

A Button inside a <form> keeps submitting the form when people click “Add stop”. The Button renders <button> with no type. What’s the best fix in the design system?

Question 3 of 3

Which prop design is most consistent across a system’s components?

Practice

Code playground

Style the Button's variants with component tokens

The Button renders its props as data-variant and data-size attributes. The base .nw-button rule is the secondary variant. Add three rules that only set --nw-button-… component tokens (no background, color or padding declarations):

  • data-variant="primary": action background and text from the semantic tokens, transparent border.
  • data-variant="danger": danger background and text from the semantic tokens, transparent border.
  • data-size="sm": padding 4px vertically and 12px horizontally.

The small primary button must be both small and primary.

<div class="row">
  <button class="nw-button" type="button" id="b-default">Cancel</button>
  <button class="nw-button" type="button" data-variant="primary" id="b-primary">Assign driver</button>
  <button class="nw-button" type="button" data-variant="danger" id="b-danger">Delete route</button>
  <button class="nw-button" type="button" data-variant="primary" data-size="sm" id="b-small">Add stop</button>
</div>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • A button with no variant stays secondary (white with a grey border)
  • data-variant="primary" uses the action colors with a transparent border
  • data-variant="danger" uses the danger colors with a transparent border
  • data-size="sm" shrinks the padding and combines with primary
  • The variant and size rules set only --nw-button-… tokens