Component Library Development for Millions of Users
2025-08-28

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.
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
- Every PR updates docs and stories
- CI runs type checks, unit tests, and Storybook build
- Changesets or similar tool writes a changelog entry
- 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 tagsAdoption 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.