Skip to content

Your component API is a contract, so write it down

Variant tables, prop naming and the boolean that should have been an enum. Notes from maintaining component libraries other people have to use.

Sofia Lindqvist

Sofia Lindqvist

Staff Frontend Engineer

6 min read
Share

A component library fails in a predictable way. Not all at once — one prop at a time.

Someone needs a slightly different button. They add isCompact. Someone else needs a different one and adds variant="small". Six months later the component takes fourteen props, four of which interact in ways nobody has documented, and everyone has quietly gone back to writing their own.

The fix is to treat the component's visual API as a contract that is written down in one place and enforced.

The variant table

Every component should declare its full surface as data, not as branching logic scattered through the template.

const button = tv({
  base: 'inline-flex items-center justify-center font-medium',
  variants: {
    variant: {
      primary: 'bg-primary text-primary-foreground',
      outline: 'border border-border-strong bg-transparent',
      ghost: 'text-foreground-secondary hover:bg-surface-hover',
    },
    size: {
      sm: 'h-9 px-3.5 text-sm',
      md: 'h-11 px-5 text-sm',
      lg: 'h-12 px-6 text-md',
    },
  },
  defaultVariants: { variant: 'primary', size: 'md' },
})

The value here is not the terseness. It is that the table is exhaustive. If a variant is not in it, it does not exist, and a request for a new one is a visible decision rather than an inline class override that nobody reviews.

This is the single highest-leverage thing you can do to stop a library from decaying.

Booleans are enums that have not grown up yet

isCompact seems fine until you need a third density. Then you get isCompact and isSpacious and the undefined behaviour when both are true.

If a property has any chance of gaining a third value, make it an enum on day one:

// Will break
defineProps<{ isCompact?: boolean }>()

// Will not
defineProps<{ density?: 'compact' | 'default' | 'comfortable' }>()

Booleans are correct for genuinely binary states — disabled, loading, required. Everything describing appearance is a candidate for growth.

Name props after what they do

Two habits that pay for themselves:

Name the role, not the implementation. trailingIcon survives a switch from an icon font to inline SVG. iconRightSvg does not.

Match the platform where one exists. If the DOM calls it disabled, call it disabled. Every renamed standard property is a thing every consumer has to learn.

Slots for content, props for configuration

The rule that keeps components from growing forever: if it is content, it belongs in a slot; if it changes behaviour or appearance, it is a prop.

<UiButton variant="primary" size="lg">
  <template #leading>
    <Avatar :src="user.avatar" size="xs" />
  </template>
  Continue as {{ user.name }}
</UiButton>

Without the slot, someone eventually asks for an avatarSrc prop, and then an avatarSize prop, and the button is now responsible for rendering avatars.

Provide a default inside the slot so the common case stays one line:

<slot name="leading">
  <Icon v-if="icon" :name="icon" />
</slot>

Let consumers override, but make it lose predictably

A component that cannot be adjusted gets forked. A component that can be adjusted arbitrarily stops being a system. The middle path is to accept a class prop, apply it last, and resolve conflicts deterministically:

cx(base, variantClasses, props.class)

With tailwind-merge in cx, a consumer passing h-14 beats the variant's h-11 instead of both landing in the class list and the winner depending on Tailwind's internal ordering. Overrides become predictable, which means people use them for genuine one-offs rather than forking.

Accessibility belongs in the primitive

If focus rings, ARIA wiring and reduced-motion handling live in the consumer, they will be forgotten. Not sometimes — reliably, under deadline.

Put them in the primitive. A UiField that owns its own id, aria-describedby and error announcement means no form in the codebase can ship an undescribed input, because the wiring is not the consumer's job.

The test

Ask someone who did not build the library to add a new page with it, and watch without helping.

Every time they open the component's source to work out what a prop does, that is a documentation failure. Every time they write a custom class to get something the variant table should have offered, that is an API failure. Both are cheap to fix in week one and expensive in month nine.

  • #Vue
  • #Components
  • #API Design
Share

Related reading

Design Systems7 min read

Design tokens that survive a rebrand

Most token systems break the first time the brand changes. The fix is a layer most teams skip — and it costs about a day to add.

Elena MarshElena Marsh
AI8 min read

Designing for models that are wrong

Every AI feature has a failure rate. Most interfaces are designed as though it were zero, and users learn to distrust the whole product.

Kwame BoatengKwame Boateng

One useful email a month

Design system patterns, front-end techniques and case study breakdowns. No promotions, no digest of other people's links.

Unsubscribe anytime. We never share your address.