, and apps/storybook. This enforces dependency boundaries and enables incremental builds. 2. **Headless Primitives:** Implement logic-only hooks and context providers. No DOM rendering or styling in the primitive layer. This ensures reusability and testability. 3. **Composable API:** Use the Compound Component pattern with React Context. This allows consumers to compose complex UIs from primitives while maintaining shared state. 4. **Polymorphism:** Support asChild` props to allow components to render as different HTML elements or merge props with underlying components (e.g., Radix UI pattern).
5. Strict TypeScript: Enforce strict typing with discriminated unions for variants and generics for polymorphic components.
Step-by-Step Implementation
1. Foundation: The Primitive Hook
Start with logic. A useDropdown hook manages open state, focus management, and keyboard navigation.
// packages/hooks/src/use-dropdown.ts
import { useState, useCallback, useEffect, useRef } from 'react';
import { createScope } from '@codcompass/scope';
interface UseDropdownProps {
initialOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function useDropdown({ initialOpen = false, onOpenChange }: UseDropdownProps = {}) {
const [open, setOpen] = useState(initialOpen);
const triggerRef = useRef<HTMLButtonElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const toggle = useCallback(() => {
const nextOpen = !open;
setOpen(nextOpen);
onOpenChange?.(nextOpen);
}, [open, onOpenChange]);
const close = useCallback(() => {
setOpen(false);
onOpenChange?.(false);
}, [onOpenChange]);
// Keyboard navigation and click-outside logic omitted for brevity
// In production, use `@floating-ui/react` for positioning and `roving-tabindex` for focus.
return {
open,
toggle,
close,
triggerRef,
contentRef,
};
}
2. Context and Compound Components
Create a context to share state between primitives.
// packages/ui/src/dropdown/context.ts
import { createContext, useContext } from 'react';
import { useDropdown } from '@codcompass/hooks';
const DropdownContext = createContext<ReturnType<typeof useDropdown> | null>(null);
export function useDropdownContext() {
const ctx = useContext(DropdownContext);
if (!ctx) throw new Error('Dropdown components must be rendered within a Dropdown provider.');
return ctx;
}
export { DropdownContext };
3. Composable Components
Build components that consume context. Use React.forwardRef and support asChild.
// packages/ui/src/dropdown/dropdown-trigger.tsx
import { forwardRef } from 'react';
import { useDropdownContext } from './context';
import { Slot } from '@radix-ui/react-slot'; // Or custom implementation
interface TriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
export const DropdownTrigger = forwardRef<HTMLButtonElement, TriggerProps>(
({ asChild, children, ...props }, ref) => {
const { toggle, triggerRef } = useDropdownContext();
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={(node) => {
// Merge refs
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
triggerRef.current = node;
}}
onClick={toggle}
aria-expanded={useDropdownContext().open}
{...props}
>
{children}
</Comp>
);
}
);
4. The API Surface
Expose a clean, predictable API.
// packages/ui/src/dropdown/index.ts
export { DropdownTrigger } from './dropdown-trigger';
export { DropdownContent } from './dropdown-content';
export { DropdownItem } from './dropdown-item';
export { useDropdown } from '@codcompass/hooks';
// Compound usage example
// <Dropdown>
// <Dropdown.Trigger>Menu</Dropdown.Trigger>
// <Dropdown.Content>
// <Dropdown.Item>Option A</Dropdown.Item>
// </Dropdown.Content>
// </Dropdown>
Demonstrate strict typing and variant handling without prop-drilling.
import { cva, type VariantProps } from 'class-variance-authority';
import { forwardRef } from 'react';
import { Slot } from '@radix-ui/react-slot';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
{
variants: {
variant: { primary: 'bg-blue-600 text-white', ghost: 'bg-transparent hover:bg-gray-100' },
size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4' },
},
defaultVariants: { variant: 'primary', size: 'md' },
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={buttonVariants({ variant, size, className })}
ref={ref}
{...props}
/>
);
}
);
Pitfall Guide
1. The Variant Trap
Mistake: Creating components with 20+ boolean props or variant combinations.
Fix: Use composable APIs. Instead of <Button isPrimary isLarge isLoading />, use <Button variant="primary" size="lg" /> and separate loading state via composition or slots.
2. Vendor Lock-in via Styling
Mistake: Tying components to a specific CSS-in-JS library (e.g., Emotion/Styled-components).
Fix: Use CSS Modules, Tailwind, or expose a className API. If using runtime styling, ensure tree-shaking and SSR support are configured correctly. Prefer static extraction where possible.
3. Ref Leaks and Merge Issues
Mistake: Overwriting consumer refs or failing to forward refs in composed components.
Fix: Always use forwardRef. When using asChild, ensure refs are merged correctly using utility functions or libraries like @radix-ui/react-slot.
4. Missing Peer Dependency Management
Mistake: Bundling React or utility libraries, causing duplicate instances in the consumer app.
Fix: Mark react, react-dom, and major utilities as peerDependencies. Configure build tools to externalize these.
5. Inconsistent Accessibility
Mistake: Treating a11y as an afterthought or adding aria-labels inconsistently.
Fix: Build accessibility into primitives. Use established patterns (WAI-ARIA). Implement automated accessibility testing in CI (e.g., axe-core).
6. Bundle Size Neglect
Mistake: Exporting everything from a single entry point, killing tree-shaking.
Fix: Use sideEffects: false in package.json. Structure exports with sub-paths. Analyze bundle size with tools like rollup-plugin-visualizer in CI.
7. Documentation Drift
Mistake: Documentation becomes outdated as the API evolves.
Fix: Generate documentation from TypeScript types. Use Storybook with Args and Controls. Automate API documentation extraction from JSDoc comments.
Production Bundle
Action Checklist
- Audit & Prune: Review existing components. Remove duplicates and deprecated APIs. Establish a "Golden Path" for new components.
- Define API Contract: Create a
CONTRIBUTING.md detailing prop naming conventions, event handling standards, and accessibility requirements.
- Setup Monorepo: Initialize Turborepo/Nx with shared TSConfig, ESLint, and Prettier configs.
- Implement Headless Primitives: Refactor logic-heavy components into hooks. Ensure hooks are framework-agnostic where possible.
- Configure Build Pipeline: Set up
tsup or vite for building. Ensure ESM/CJS dual output with proper package.json exports map.
- Add Visual Regression Testing: Integrate Chromatic or Playwright for visual diffs. Catch UI regressions before merge.
- Automate Semantic Versioning: Use tools like
changesets to manage versioning and changelogs automatically.
- Performance Budgeting: Set bundle size limits per component. Fail CI if limits are exceeded.
Decision Matrix
| Decision | Option A | Option B | Recommendation | Rationale |
|---|
| Build Tool | Webpack | Vite / tsup | Vite / tsup | Faster DX, native ESM, better tree-shaking support. |
| Styling | CSS-in-JS | CSS Modules / Tailwind | CSS Modules / Tailwind | Zero runtime cost, better SSR, predictable bundles. |
| Structure | Flat | Atomic / Domain | Atomic | Enforces dependency hierarchy; primitives cannot depend on composites. |
| Testing | Jest | Vitest | Vitest | Native Vite integration, faster execution, ESM support. |
| Docs | Custom Site | Storybook | Storybook | Industry standard, interactive playground, addon ecosystem. |
Configuration Template
tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: true,
clean: true,
treeshake: true,
external: ['react', 'react-dom'],
banner: {
js: '"use client";', // For Next.js 13+ compatibility if needed
},
});
package.json Exports Map
{
"name": "@codcompass/ui",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./hooks/*": {
"import": {
"types": "./dist/hooks/*.d.ts",
"default": "./dist/hooks/*.mjs"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
}
Quick Start Guide
- Initialize Monorepo:
npx create-turbo@latest codcompass-ui
cd codcompass-ui
- Create First Primitive:
cd packages/ui
mkdir src/button
# Implement useButton hook and Button component
- Publish Alpha:
npm run build
npm publish --tag alpha
- Consume & Iterate:
Import into a test app. Validate API ergonomics. Refine based on feedback. Repeat.
Final Note: A component library is not a static artifact; it is a living product that requires governance, performance monitoring, and continuous refinement. By adopting the Headless-Composable pattern and enforcing strict architectural boundaries, you transform your library from a maintenance burden into a force multiplier for your engineering organization.