sable slots. This allows the library to provide accessible behavior while letting the consumer control the DOM structure and styling.
Rationale: Headless hooks enable reuse across different styling solutions (Tailwind, CSS Modules, Styled Components) and reduce the library's surface area. Slot composition allows consumers to inject arbitrary elements into component structures without breaking internal state management.
2. Implementation: Polymorphism and asChild
The asChild pattern is non-negotiable for modern libraries. It allows components to render as different HTML elements or third-party components while preserving their internal logic and accessibility attributes.
Code Example: Polymorphic Primitive with asChild
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
// Utility to merge refs safely
function useMergeRefs<T>(...refs: React.Ref<T>[]) {
const callbackRef = React.useCallback(
(node: T | null) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(node);
} else if (ref != null) {
(ref as React.MutableRefObject<T | null>).current = node;
}
});
},
[refs]
);
return callbackRef;
}
interface PrimitiveProps extends React.HTMLAttributes<HTMLElement> {
asChild?: boolean;
as?: React.ElementType;
}
const Primitive = React.forwardRef<HTMLElement, PrimitiveProps>(
({ asChild, as: Tag = 'div', ...props }, forwardedRef) => {
const Comp = asChild ? Slot : (Tag as React.ElementType);
const mergedRef = useMergeRefs(forwardedRef);
return <Comp ref={mergedRef} {...props} />;
}
);
Primitive.displayName = 'Primitive';
export { Primitive };
Rationale:
Slot Integration: When asChild is true, the component renders a Slot. The Slot merges props and refs from the child element, allowing the consumer to pass className, onClick, or custom attributes directly to the underlying element while the library maintains control over internal behavior.
- Ref Merging: Forwarding refs is mandatory for accessibility and focus management.
useMergeRefs ensures that internal refs used for measurement or focus trapping do not overwrite consumer refs.
- Type Safety: The
React.ElementType constraint ensures that only valid React components or HTML tags can be passed, preventing runtime errors.
3. State Management and Accessibility
Use state machines for complex interactions. This prevents prop-drilling of state and ensures that UI updates are deterministic.
Code Example: Headless Dialog Primitive
import * as React from 'react';
import { Primitive } from './Primitive';
import { useMergeRefs } from './utils';
interface DialogContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLButtonElement>;
}
const DialogContext = React.createContext<DialogContextValue | null>(null);
const useDialogContext = () => {
const context = React.useContext(DialogContext);
if (!context) throw new Error('Dialog components must be rendered within <Dialog>');
return context;
};
export const Dialog = ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement>(null);
return (
<DialogContext.Provider value={{ open, setOpen, triggerRef }}>
{children}
</DialogContext.Provider>
);
};
export const DialogTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ onClick, ...props }, ref) => {
const { setOpen, triggerRef } = useDialogContext();
const mergedRef = useMergeRefs(ref, triggerRef);
return (
<Primitive
as="button"
ref={mergedRef}
onClick={(e) => {
onClick?.(e);
setOpen(true);
}}
{...props}
/>
);
});
DialogTrigger.displayName = 'DialogTrigger';
export const DialogContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ ...props }, ref) => {
const { open, setOpen } = useDialogContext();
// Focus trap and escape key handling would be implemented here
// using a headless hook like @radix-ui/react-dialog or custom logic
if (!open) return null;
return (
<Primitive
as="div"
ref={ref}
role="dialog"
aria-modal="true"
{...props}
/>
);
});
DialogContent.displayName = 'DialogContent';
Rationale:
- Context Isolation: The context provides a single source of truth for state. Components like
DialogTrigger and DialogContent consume this context, eliminating the need for callback props passed down through the tree.
- Extensibility: Consumers can compose
DialogTrigger with any element. The Primitive ensures that if the consumer passes asChild, the button attributes are merged correctly.
- Accessibility Hooks: In production, integrate hooks that manage focus trapping,
aria-* attributes, and keyboard navigation. This logic lives in the primitive layer, ensuring 100% compliance regardless of how the component is styled.
4. Styling Architecture
Adopt a token-driven approach. The library should export design tokens (colors, spacing, typography) and allow styling via a className or style prop, but should not enforce a specific styling solution.
Rationale: Hardcoding styles limits the library's usability. By exporting tokens and supporting standard className composition, the library remains agnostic to the consumer's styling stack. Use CSS variables for theming to enable runtime customization without re-renders.
Pitfall Guide
1. The "Kitchen Sink" Prop Explosion
Mistake: Adding props for every possible variation (e.g., variant, size, color, iconPosition, isLoading, isDisabled, shape).
Impact: Component API becomes unmaintainable. Combinations lead to exponential edge cases.
Best Practice: Use composition. Allow consumers to wrap primitives or use CSS modifiers. Provide a style or className prop for arbitrary styling needs.
2. Ignoring Tree-Shaking Boundaries
Mistake: Exporting all components from a single index.ts barrel file without ensuring side-effect-free modules.
Impact: Consumers import the entire library even if they only use one component. Bundle size bloats.
Best Practice: Structure exports using the exports field in package.json. Ensure each component is in its own module. Use tsup or rollup to generate distinct entry points.
3. Ref Forwarding Failures
Mistake: Not forwarding refs or overwriting consumer refs with internal refs.
Impact: Breaks accessibility tools, focus management, and third-party integrations that rely on DOM references.
Best Practice: Always use React.forwardRef. Implement useMergeRefs to combine internal refs (for measurement/trapping) with forwarded refs.
4. Hardcoded DOM Structures
Mistake: Forcing a specific DOM hierarchy (e.g., requiring a div wrapper inside a button).
Impact: CSS selectors break. Consumers cannot apply styles correctly. Accessibility landmarks are disrupted.
Best Practice: Use the Slot pattern to allow consumers to control the DOM structure. Document the expected DOM shape for accessibility but allow flexibility.
5. Neglecting Visual Regression Testing
Mistake: Relying only on unit tests for component logic.
Impact: Styling changes or layout shifts go undetected until production.
Best Practice: Implement visual regression testing in Storybook. Capture snapshots of component states and compare against baselines on every PR.
6. Poor Versioning Strategy
Mistake: Treating breaking API changes as minor updates.
Impact: Consumer applications break unexpectedly. Trust erodes.
Best Practice: Adhere strictly to Semantic Versioning. Use changesets to automate versioning. Deprecate props before removing them. Provide codemods for major migrations.
7. Accessibility as an Afterthought
Mistake: Adding aria labels manually in consumer code rather than baking them into the library.
Impact: Inconsistent accessibility. High burden on developers. Legal risk.
Best Practice: Implement accessibility in the primitive layer. Use tools like axe-core in CI pipelines. Ensure all interactive elements are keyboard navigable and screen-reader friendly by default.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP | Use Headless Library + Tailwind | Speed of development; low maintenance; flexible styling. | Low initial cost; moderate scaling cost if design system matures. |
| Enterprise Scale | Custom Primitive-First Library | Full control over a11y, bundle size, and API; consistent DX across teams. | High initial cost; low long-term maintenance; high ROI on velocity. |
| Design System Heavy | Hybrid: Custom Tokens + Headless Core | Aligns with strict design governance while leveraging robust behavior primitives. | Moderate cost; ensures design fidelity without reinventing behavior. |
| Multi-Framework Support | Web Components / Framework Agnostic Hooks | Reusability across React, Vue, Svelte; single source of truth. | High complexity; requires polyfills and careful API design. |
Configuration Template
package.json Exports Map:
{
"name": "@codcompass/ui",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./button": {
"import": "./dist/button.js",
"types": "./dist/button.d.ts"
},
"./dialog": {
"import": "./dist/dialog.js",
"types": "./dist/dialog.d.ts"
},
"./styles.css": {
"import": "./dist/styles.css"
}
},
"sideEffects": false
}
tsup.config.ts:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: [
'src/index.ts',
'src/button.ts',
'src/dialog.ts',
'src/styles.css',
],
format: ['esm'],
dts: true,
splitting: false,
clean: true,
treeshake: true,
external: ['react', 'react-dom'],
banner: {
js: '"use client";',
},
});
Quick Start Guide
-
Initialize Monorepo:
Create a workspace using pnpm or npm. Set up a packages/ui directory for the library and apps/storybook for documentation.
-
Configure Build Tool:
Install tsup and TypeScript. Create tsup.config.ts as shown in the template. Ensure React is externalized to prevent bundling conflicts.
-
Create First Primitive:
Implement a Button component using the Primitive pattern. Export it via a dedicated entry point (src/button.ts). Verify tree-shaking by importing only the button in a test app.
-
Set Up Storybook:
Initialize Storybook in the apps/storybook directory. Configure it to point to the library source. Add a test story for the Button with args controls. Run visual regression tests.
-
Publish and Iterate:
Initialize changesets. Create a .changeset file describing the initial release. Run the publish script. Install the library in a consumer app and validate imports, tree-shaking, and TypeScript types. Iterate based on consumer feedback.