logo

Component Library Development for Millions of Users

2025-08-28

component library
react
next.js
typescript
design tokens
storybook
accessibility
testing
scalability
ci/cd
Component Library Development for Millions of Users

Great libraries feel small but scale huge. Keep components focused, push state up, expose headless hooks for control, harden accessibility and theming, and treat the library like a product with docs, tests, and versioning. This guide shows the patterns and guardrails I used while building reusable UI that powered high traffic launches.

What you will learn

  • How to design non-greedy components that stay flexible
  • Controlled vs headless patterns and when to use each
  • Theme and brand support with CSS vars and a ConfigProvider
  • How to document with Storybook for real adoption
  • Testing strategy that catches regressions without slowing teams
  • Release and versioning flow that keeps clients safe

Why libraries fail and how to avoid that

Most libraries collapse under their own weight. APIs grow until every prop conflicts with another. Internal state fights the needs of real screens. The result is bypasses and one-off forks.

The fix starts with a mindset: make components small, opinionated about structure, and generous about control. Keep visuals in the component, put business logic in the parent, and expose clear escape hatches.

Principles I follow

  • Single responsibility: visuals and DOM shape live in the component, orchestration lives above it
  • Prefer controlled inputs and callbacks to hidden internal state
  • Headless first for complex behaviors, then add a skinned wrapper
  • A11y is part of the API, not a post-check
  • Design tokens and CSS vars for brand flexibility without rebuilds

Anatomy of a non-greedy component

Non-greedy means the component does not hoard concerns. It renders structure and style, exposes the smallest set of props to operate, and lets parents own side effects, data fetching, and timing.

Why this works

  • No internal loading timers or fetch calls
  • Parent decides semantics and side effects
  • Small surface area that avoids prop explosions
1type ButtonProps = { 2 children: React.ReactNode; 3 onPress?: (e: React.MouseEvent) => void; 4 disabled?: boolean; 5 as?: "button" | "a"; 6 href?: string; // only used when as="a" 7 variant?: "primary" | "secondary" | "ghost"; 8 iconLeft?: React.ReactNode; 9 iconRight?: React.ReactNode; 10 "aria-label"?: string; 11}; 12 13export function Button({ 14 as = "button", 15 href, 16 disabled, 17 onPress, 18 variant = "primary", 19 iconLeft, 20 iconRight, 21 children, 22 ...rest 23}: ButtonProps) { 24 const As = as; 25 return ( 26 <As 27 {...rest} 28 {...(as === "a" ? { href } : {})} 29 data-variant={variant} 30 aria-disabled={disabled || undefined} 31 onClick={disabled ? undefined : onPress} 32 className={`ui-button ui-${variant} `} 33 > 34 {iconLeft && <span className="ui-button__iconL">{iconLeft}</span>} 35 <span className="ui-button__label">{children}</span> 36 {iconRight && <span className="ui-button__iconR">{iconRight}</span>} 37 </As> 38 ); 39}

Lift state up: controlled and headless patterns

Use controlled components when the UI is a thin wrapper around a single value or selection. Go headless for compound behaviors like menus, comboboxes, and dialogs.

Controlled example

The parent owns the value and passes an update callback. The component only renders and forwards events.

1// parent 2const [checked, setChecked] = useState(false); 3<Switch checked={checked} onChange={setChecked} />; 4 5// library 6type SwitchProps = { checked: boolean; onChange: (v: boolean) => void; id?: string }; 7export function Switch({ checked, onChange, id }: SwitchProps) { 8 return ( 9 <button 10 role="switch" 11 aria-checked={checked} 12 id={id} 13 onClick={() => onChange(!checked)} 14 className={checked ? "ui-switch is-on" : "ui-switch"} 15 /> 16 ); 17}

Headless example

Expose behavior with a hook so consumers control DOM and styling. Ship a skinned version for convenience.

1// library: behavior only 2export function useDisclosure(initial = false) { 3 const [open, setOpen] = useState(initial); 4 const toggle = () => setOpen(v => !v); 5 const getTriggerProps = (p: pInterface = {}) => ({ 6 "aria-expanded": open, 7 "aria-controls": p["aria-controls"], 8 onClick: (...args: argsInterface[]) => { p.onClick?.(...args); toggle(); }, 9 }); 10 const getContentProps = (id: string) => ({ id, hidden: !open }); 11 return { open, toggle, getTriggerProps, getContentProps }; 12} 13 14// consumer controls structure 15const d = useDisclosure(); 16<button {...d.getTriggerProps({ "aria-controls": "sect" })}>Toggle</button> 17<section {...d.getContentProps("sect")}>Content</section>

Theme and brand support without rewrites

Use CSS variables set at the root by a provider. This allows brand swaps and dark mode without recompiling.

Benefits

  • Brand changes ship instantly through CSS variable updates
  • No prop drilling for palette values
  • Light and dark mode are simple attribute flips
1// library 2export type ThemeVars = { 3 "app-default-color"?: string; 4 "app-secondary-color"?: string; 5 "main-text-color"?: string; 6}; 7 8const Ctx = React.createContext<ThemeVars | null>(null); 9 10export function ConfigProvider({ config, children }: { config: ThemeVars; children: React.ReactNode }) { 11 React.useEffect(() => { 12 Object.entries(config).forEach(([k, v]) => { 13 if (v) document.documentElement.style.setProperty(`--${k}`, String(v)); 14 }); 15 }, [config]); 16 return <Ctx.Provider value={config}>{children}</Ctx.Provider>; 17} 18 19export const useConfig = () => React.useContext(Ctx) ?? {};
1/* tokens.scss */ 2:root { 3 --app-default-color: #3498db; 4 --app-secondary-color: #2ecc71; 5 --main-text-color: #34495e; 6} 7 8.ui-button { 9 background: var(--app-default-color); 10 color: var(--main-text-color); 11} 12[data-theme="dark"] .ui-button { filter: brightness(0.9); }

Showcase: ButtonWithIcon from my mock library

Here is a version of a button component. It keeps visuals local and leaves behavior to the parent.

Live demo

External Resources:
1export interface IButtonWithIconProps { 2 label: string; 3 icon: React.ReactElement; 4 onClick?: () => void; 5 buttonTag?: "button" | "a"; 6 theme?: "light" | "dark"; 7 isDisabled?: boolean; 8} 9 10export default function ButtonWithIcon({ 11 onClick, 12 label, 13 buttonTag = "button", 14 isDisabled = false, 15 theme = "light", 16 icon, 17}: IButtonWithIconProps) { 18 const As = buttonTag; 19 return ( 20 <As 21 data-theme={theme} 22 role={buttonTag} 23 className={`ui-button ${isDisabled ? "is-disabled" : ""}`} 24 onClick={!isDisabled ? onClick : undefined} 25 > 26 <span className="ui-button__icon">{icon}</span> 27 {label} 28 </As> 29 ); 30}
1.ui-button { 2 display: inline-flex; align-items: center; gap: 6px; 3 padding: 8px 16px; border-radius: 8px; 4 background: var(--background-secondary); color: var(--text-primary); 5 border: 1px solid var(--border-color); transition: transform .2s; 6} 7.ui-button:hover { transform: translateY(-2px); } 8.ui-button.is-disabled { cursor: not-allowed; opacity: .5; transform: none; }

Accessibility by default

Put roles and aria attributes into the component where possible and test with a screen reader. If a behavior is complex, expose a headless hook that returns the right attributes so consumers cannot forget them.

Checks to automate

  • Keyboard trap tests for modals and menus
  • Focus ring visibility in both themes
  • Contrast ratios for tokens
1// storybook a11y test example 2export const ButtonA11y = () => ( 3 <Button aria-label="Save changes" onPress={() => {}}>Save</Button> 4);

Document with Storybook that people actually read

Treat Storybook as your product site. Include usage, do and do not examples, accessibility notes, and edge cases. Co-locate stories with the component and run them in CI.

1import { Meta, StoryFn } from "@storybook/react"; 2import ButtonWithIcon, { IButtonWithIconProps } from "./ButtonWithIcon"; 3 4export default { 5 title: "Components/Buttons/ButtonWithIcon", 6 component: ButtonWithIcon, 7} as Meta; 8 9const Template: StoryFn<IButtonWithIconProps> = args => <ButtonWithIcon {...args} />; 10 11export const Primary = Template.bind({}); 12Primary.args = { label: "Primary", icon: <svg aria-hidden="true" /> }; 13 14export const Disabled = Template.bind({}); 15Disabled.args = { label: "Disabled", icon: <svg aria-hidden="true" />, isDisabled: true };

Test the public contract, not implementation details

Write fast unit tests around behavior and accessibility. Add one visual regression suite for critical pieces. Snapshots should cover structure that consumers depend on.

1import { render, screen } from "@testing-library/react"; 2import userEvent from "@testing-library/user-event"; 3import ButtonWithIcon from "./ButtonWithIcon"; 4 5test("fires onClick when enabled", async () => { 6 const spy = vi.fn(); 7 render(<ButtonWithIcon label="Save" icon={<svg />} onClick={spy} />); 8 await userEvent.click(screen.getByRole("button", { name: "Save" })); 9 expect(spy).toHaveBeenCalledTimes(1); 10}); 11 12test("does not fire when disabled", async () => { 13 const spy = vi.fn(); 14 render(<ButtonWithIcon label="Save" icon={<svg />} isDisabled onClick={spy} />); 15 await userEvent.click(screen.getByRole("button", { name: "Save" })); 16 expect(spy).not.toHaveBeenCalled(); 17});

Release, versioning, and CI

Treat the library like a product. Use semantic versioning and keep consumers safe with changelogs and pre-release channels.

Suggested flow

  1. Every PR updates docs and stories
  2. CI runs type checks, unit tests, and Storybook build
  3. Changesets or similar tool writes a changelog entry
  4. Publish as canary for large clients, then stable
1# example scripts 2pnpm build # typecheck + rollup 3pnpm test # unit 4pnpm storybook:build # docs 5pnpm changeset # generate notes 6pnpm release # publish with tags

Adoption playbook inside a large org

A solid library still needs human work. Run short workshops, offer copy-paste examples in Storybook, and keep the migration path clear.

What moved the needle for me

  • Starter pages that show real screens made with only library components
  • Lint rules that flag private UI usage when a library part exists
  • Office hours to unblock teams fast

Common pitfalls

Avoid these

  • Hiding fetches and timers inside components
  • Prop explosions to satisfy every use case
  • Mixing layout and content responsibilities
  • Skipping a11y and hoping to retrofit later
  • Publishing without a changelog and migration notes

Conclusion

Keep components small, predictable, and accessible. Push state and orchestration up. Use tokens and CSS variables for brand-safe theming. Document the happy path and the escape hatches. Do this, and your library will stay useful as traffic, teams, and requirements grow.

Let's work together

I'm always excited to take on new challenges and collaborate on innovative projects.

About Me

I'm a senior software engineer focusing on frontend and full-stack development. I specialize in ReactJS, TypeScript, and Next.js, always seeking growth and new challenges.

© 2026, anasroud.com