on.tsx
import type { ComponentPropsWithoutRef, ElementType } from 'react';
type ButtonOwnProps = {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
as?: ElementType;
};
export type ButtonProps = ButtonOwnProps &
Omit<ComponentPropsWithoutRef<'button'>, keyof ButtonOwnProps>;
export const Button = ({
variant = 'primary',
size = 'md',
isLoading = false,
as: Tag = 'button',
children,
className,
...rest
}: ButtonProps) => {
// Implementation
};
**Architecture Decision**: Use `Omit` and intersection types to preserve native DOM attributes while extending behavior. This prevents prop API sprawl and maintains framework compatibility.
### Step 2: Implement Composition Architecture
Avoid monolithic components. Use compound components, render props, or slot patterns to expose internal structure without breaking encapsulation.
```ts
// Card.tsx
import { createContext, useContext } from 'react';
const CardContext = createContext<{ title?: string } | null>(null);
export const Card = ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className} role="group">
<CardContext.Provider value={{}}>{children}</CardContext.Provider>
</div>
);
Card.Title = ({ children }: { children: React.ReactNode }) => {
const ctx = useContext(CardContext);
if (!ctx) throw new Error('Card.Title must be used within Card');
return <h3 className="card-title">{children}</h3>;
};
Card.Body = ({ children }: { children: React.ReactNode }) => (
<div className="card-body">{children}</div>
);
Architecture Decision: Context-based composition enables internal state sharing without prop drilling. It also allows consumers to reorder or omit slots freely.
Step 3: Decouple Styling from Structure
Tight coupling between UI logic and CSS is the primary cause of component rigidity. Use design tokens, CSS variables, or runtime theming. Prefer compile-time CSS-in-JS or utility-first frameworks only when bundle size is acceptable.
/* design-tokens.css */
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--radius-md: 0.375rem;
--font-sans: system-ui, -apple-system, sans-serif;
--transition-base: 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
[data-theme="dark"] {
--color-primary: #60a5fa;
--color-primary-hover: #93c5fd;
}
// Button.tsx (styling integration)
const variantStyles = {
primary: 'bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)]',
secondary: 'bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600',
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800',
};
// Apply via className composition
className={cn(
'rounded-[var(--radius-md)] font-[var(--font-sans)] transition-[var(--transition-base)]',
variantStyles[variant]
)}
Architecture Decision: CSS variables + token-driven styling enable runtime theming without JavaScript overhead. It also simplifies dark mode, high-contrast, and brand customization.
Step 4: Enforce Accessibility & Testing
Accessibility cannot be retrofitted. Implement ARIA attributes, keyboard navigation, and focus management at the component level. Test behavior, not pixels.
// Modal.tsx (focus trap skeleton)
import { useEffect, useRef } from 'react';
export const Modal = ({ isOpen, onClose, children }: ModalProps) => {
const trapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen || !trapRef.current) return;
const focusable = trapRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
trapRef.current.addEventListener('keydown', handleKeyDown);
first?.focus();
return () => trapRef.current?.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
if (!isOpen) return null;
return (
<div ref={trapRef} role="dialog" aria-modal="true" aria-labelledby="modal-title">
{children}
</div>
);
};
Testing Strategy:
- Unit: Test prop variations, conditional rendering, event handlers.
- Integration: Test keyboard navigation, focus management, ARIA state changes.
- Visual: Snapshot only critical layout states; avoid pixel-perfect drift.
Step 5: Package, Version, and Distribute
Treat components as libraries. Use library mode bundlers, enforce tree-shaking, and adopt semantic versioning. Document usage with interactive examples.
Architecture Decision:
- Monorepo: Best for internal teams. Enables shared tooling, atomic commits, and zero-publish friction.
- Published Package: Best for cross-team or external consumption. Requires stricter API stability and changelog discipline.
- Versioning: SemVer for public packages. CalVer or commit-hash tagging for internal monorepos.
Pitfall Guide
-
Over-abstracting too early
Building a "generic" component before seeing 3 real use cases leads to leaky abstractions. Wait for pattern recurrence before extracting.
-
Coupling UI to business logic
Components should not fetch data, manage auth, or handle routing. Pass data and callbacks. Keep components presentational or behaviorally scoped.
-
Ignoring accessibility from day one
Adding aria-* later breaks component contracts. Implement focus management, keyboard navigation, and screen reader announcements during initial development.
-
Prop API sprawl (God Components)
When a component accepts 15+ props, it's trying to do too much. Split into compound components or use configuration objects with discriminated unions.
-
Missing documentation & playgrounds
Reusability dies without discoverability. Every component needs: props table, usage examples, accessibility notes, and an interactive sandbox.
-
Inconsistent versioning & breaking changes
Changing prop types or default behavior without major version bumps breaks consumers. Use deprecation warnings, codemods, and migration guides.
-
Neglecting performance
Unmemoized callbacks, unnecessary re-renders, and heavy runtime styling inflate bundle size and hurt TTI. Use React.memo, useCallback, and virtualized lists where applicable.
Production Bundle
Action Checklist
Decision Matrix
| Dimension | Monorepo (Turborepo/Nx) | Published Package | Styled (CSS-in-JS) | Headless/Composable | Runtime Theming |
|---|
| Setup Complexity | Medium | High | Low | Medium | Low |
| Cross-Team Reuse | Low | High | Medium | High | High |
| Bundle Size Impact | Minimal | Controlled | +15β30KB | Minimal | Minimal |
| Framework Lock-in | Low | Medium | High | None | None |
| Maintenance Overhead | Low | Medium | Medium | Low | Low |
| Best For | Internal product teams | Design systems, OSS | Rapid prototyping | Enterprise scale | Multi-brand apps |
Configuration Template
// package.json
{
"name": "@acme/ui",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"scripts": {
"build": "vite build && tsc --emitDeclarationOnly",
"lint": "eslint src --ext .ts,.tsx",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"vite": "^5.0.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0",
"eslint": "^8.56.0",
"prettier": "^3.2.0"
}
}
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
},
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
sourcemap: true,
emptyOutDir: true,
},
});
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"declaration": true,
"declarationDir": "./dist",
"emitDeclarationOnly": true,
"outDir": "./dist",
"strict": true,
"jsx": "react-jsx",
"moduleResolution": "bundler",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
}
Quick Start Guide
-
Scaffold the library
npm create vite@latest @acme/ui -- --template react-ts
cd @acme/ui
npm install react react-dom --save-peer
npm install -D vite typescript vitest eslint prettier
Replace vite.config.ts and tsconfig.json with the templates above.
-
Create your first component
mkdir src/components/Button
touch src/components/Button/Button.tsx src/components/Button/Button.test.tsx
Implement the component using the API-first + composition pattern. Export from src/index.ts.
-
Add tests & linting
npx vitest init
npm run lint
npm run test
Ensure all tests pass and ESLint reports zero errors. Add @testing-library/react and @testing-library/jest-dom for integration tests.
-
Build & verify
npm run build
ls dist/
# Should output: index.js, index.cjs, index.d.ts, index.js.map
Link locally with npm link or pnpm link to validate tree-shaking and peer dependency resolution.
-
Publish or integrate
For internal use, add to your monorepo workspace. For public distribution, configure npm/org scope, set up GitHub Actions for automated publishing, and document migration paths for v1 β v2.
Reusable UI components are not a design system checkbox. They are the foundation of scalable frontend engineering. When built with explicit contracts, compositional architecture, token-driven styling, and accessibility baked into the lifecycle, they compound into faster delivery, lower maintenance, and consistent user experiences. Start small, measure reuse, and treat your component library as production infrastructure.