Skip to main content

Standardizing a Component Size Scale

A ruler and tape measure for precise measurement
Apr 7, 20263 min readReact, TypeScript, Component Library

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.

ComponentBeforeAfter
Avatarxs, sm, md, lg, xlsm, md, lg
Badge(none)sm, md, lg
Buttonsm, md, lgNo change
Spinnersm, md, lg + 6 color variantssm, md, lg + 1
ProgressBarsm, md, lg + 5 color variantssm, md, lg + 3
Modalsm, md, lg, xl + 5 variantssm, 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.