The inconsistency
The shared-ui library had grown past 20 components, and every one of them had
invented its own size scale. Avatar offered five sizes (xs through xl),
Badge had no size prop at all, and Button, Spinner, and ProgressBar each defined
their own sm | md | lg type locally. Nothing was shared, nothing was enforced,
and each component's Storybook told a slightly different story about what "small"
even meant.
The problem isn't aesthetic. When a developer drops a Badge next to an Avatar and
finds there's no way to make them the same size, they hardcode a className
override; do that enough times and the design system quietly demotes itself to a
suggestion.
The audit
I listed every component with a size or variant prop and checked two things for each: how many options it offered, and whether anyone actually used them all.
| Component | Before | After |
|---|---|---|
| Avatar | xs, sm, md, lg, xl | sm, md, lg |
| Badge | (none) | sm, md, lg |
| Button | sm, md, lg | No change |
| Spinner | sm, md, lg + 6 color variants | sm, md, lg + 1 |
| ProgressBar | sm, md, lg + 5 color variants | sm, md, lg + 3 |
| Modal | sm, md, lg, xl + 5 variants | sm, md, lg, xl |
Avatar's xs and xl sizes showed up in exactly zero places outside Storybook.
Spinner had six color variants (accent, success, warning, error, info,
foreground), and every single call site used accent. ProgressBar and Modal
carried the same kind of phantom variants that only ever existed in stories.
The shared type
The fix started with one tiny, two-line file:
/** Standard 3-point size scale for interactive components. */
export type ComponentSize = 'sm' | 'md' | 'lg';It's exported from the library's public API, and every component that takes a size prop now imports this type instead of rolling its own:
import type { ComponentSize } from './types';
const sizeStyles: Record<ComponentSize, string> = {
sm: 'h-8 w-8 text-xs',
md: 'h-10 w-10 text-sm',
lg: 'h-12 w-12 text-base',
};When Badge later gained a size prop, it dropped straight into the existing scale
on its own. There was no debate about whether to add xs or xl; the type had
already made that call.
Trimming without breaking
Removing unused variants is the easy part: delete the option, run the type
checker, fix whatever turns red. Avatar's cut from five sizes to three did mean
confirming that no call site leaned on xs or xl, but a quick grep showed they
were Storybook-only, so I updated the stories and pulled the sizes.
The riskier trim was Spinner, where six color variants collapsed down to one.
Every call site in the app used the default accent variant, so the rest were
dead weight: they made Storybook look thorough while doing nothing for actual
consumers.
The radius token
While I was auditing sizes, I tripped over a related border-radius inconsistency.
Checkbox, Kbd, and Skeleton text lines all wanted a radius smaller than
--radius-sm (0.375rem), but no such token existed, so each component improvised
with rounded, rounded-sm, or a raw pixel value.
I added --radius-xs (0.25rem) to the theme and pointed all three components at
it. Same move as the size scale: name the thing, keep it in one place, reference
it everywhere.
The takeaway
A component library's API surface is a product in its own right. Every prop,
variant, and size option is one more decision you're handing the consumer. Three
points cover the common cases without making anyone squint at the difference
between md, lg, and xl. When a component genuinely needs more range it can
break from the shared type, but the burden of proof sits with the exception, not
the rule.
