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.
Related reading
Performance budgets belong in CI, not in a retrospective
A Lighthouse score is a snapshot. A budget that fails the build is a constraint. Here is the setup we ship with every front-end.
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.
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.
One useful email a month
Design system patterns, front-end techniques and case study breakdowns. No promotions, no digest of other people's links.


