JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog
tutorialgetting-startednpmcssTailwind

Building a Component Library: Design Tokens and Components

AdminMarch 18, 202615 min read
Building a Component Library: Design Tokens and Components

On this page

  • Design Tokens and Theming: Your Library's Visual API
  • Tailwind v4's CSS-first configuration
  • Why semantic tokens matter
  • Dark mode with CSS custom properties
  • How consumers override your tokens
  • CVA deep dive: the variant API pattern
  • Deriving TypeScript types from variants
  • A note on framework portability
  • Building the Components: Five Patterns, Five Components
  • Button: The Variant Workhorse
  • Polymorphic rendering with asChild
  • Wiring up the barrel export
  • Input: Form Control Fundamentals
  • Wiring up the barrel export
  • Card: Composition and Slots
  • Wiring up the barrel export
  • Modal: Portals and Focus Management
  • Wiring up the barrel export
  • Tabs: The Compound Component Pattern
  • Wiring up the barrel export
  • Up Next

On this page

  • Design Tokens and Theming: Your Library's Visual API
  • Tailwind v4's CSS-first configuration
  • Why semantic tokens matter
  • Dark mode with CSS custom properties
  • How consumers override your tokens
  • CVA deep dive: the variant API pattern
  • Deriving TypeScript types from variants
  • A note on framework portability
  • Building the Components: Five Patterns, Five Components
  • Button: The Variant Workhorse
  • Polymorphic rendering with asChild
  • Wiring up the barrel export
  • Input: Form Control Fundamentals
  • Wiring up the barrel export
  • Card: Composition and Slots
  • Wiring up the barrel export
  • Modal: Portals and Focus Management
  • Wiring up the barrel export
  • Tabs: The Compound Component Pattern
  • Wiring up the barrel export
  • Up Next
PreviousDeploying a Next.js App with AWS Amplify, Prisma, and Neon PostgreSQLNextBuilding a Component Library: Styling, Testing and Publishing

Building a Component Library: Design Tokens and Components

This is Part 2 of a 4-part series on building a production-ready React component library with Tailwind CSS.

Part 1: Project Setup and Architecture Part 2: Design System and Components (you are here) Part 3: Integration, Testing and Publishing Part 4: CI/CD and Automated Releases

In Part 1, we set up the project: scaffolding with Vite, configuring library mode, wiring up TypeScript declarations, and establishing the cn() utility for class merging. Now it's time to build the actual library.

This part covers two things: the design token system that gives your library a consistent visual language, and five components that each teach a different React pattern.


Design Tokens and Theming: Your Library's Visual API

Design tokens are the named values that define your library's visual language: colors, spacing, radii, typography. Get them right and every component in your library looks like it belongs to the same family. Get them wrong (or skip them entirely) and you end up with a collection of components that happen to live in the same package but don't feel cohesive.

Tailwind v4's CSS-first configuration

In Tailwind v3, design tokens lived in a tailwind.config.js file. That JavaScript config worked, but it meant your tokens existed in a different language and a different file from the styles that consumed them. Tailwind v4 fixes this with the @theme directive, which lets you define all your tokens directly in CSS.

Create a theme.css file in your project root. This is the file that will ship to npm, and the exports and files fields in your package.json reference it at the root level ("./theme.css": "./theme.css"). If you prefer to author it in lib/theme.css during development, add a copy or symlink step to your build script so it ends up at the root before you publish:

css
/* theme.css */
@import "tailwindcss";
 
@theme {
  /* Colors: semantic names, not palette names */
  --color-primary: oklch(0.55 0.22 260);
  --color-primary-hover: oklch(0.48 0.22 260);
  --color-secondary: oklch(0.70 0.05 260);
  --color-secondary-hover: oklch(0.63 0.05 260);
  --color-destructive: oklch(0.55 0.22 25);
  --color-destructive-hover: oklch(0.48 0.22 25);
 
  --color-background: oklch(0.99 0 0);
  --color-foreground: oklch(0.15 0 0);
  --color-muted: oklch(0.95 0.01 260);
  --color-muted-foreground: oklch(0.45 0.02 260);
  --color-border: oklch(0.90 0.01 260);
 
  --color-input-border: oklch(0.82 0.02 260);
  --color-ring: oklch(0.55 0.22 260);
 
  /* Border Radius */
  --radius-sm: 0.25rem;
  --radius-md: 0.375rem;
  --radius-lg: 0.5rem;
  --radius-xl: 0.75rem;
 
  /* Typography */
  --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, monospace;
}

Once you define a variable inside @theme, Tailwind automatically generates utility classes for it. For example, --color-primary gives you bg-primary, text-primary, border-primary, and so on. You don't need to manually wire anything up. That's the payoff of using the right namespace prefix: --color-* maps to color utilities, --radius-* maps to border-radius utilities, --font-* maps to font-family utilities.

Why semantic tokens matter

Notice that the color tokens above are named primary, destructive, muted, not blue-500, red-600, gray-200. This is a deliberate choice that pays off in two ways.

First, it decouples your components from specific colors. Your Button component uses bg-primary and hover:bg-primary-hover. If a consumer wants to rebrand the entire library from blue to green, they override two CSS variables. They don't touch a single component file.

Second, it communicates intent rather than appearance. A developer reading bg-destructive knows immediately that this element signals danger or a destructive action. bg-red-600 only tells you the color, not why it's that color. When your team decides that destructive actions should be orange instead of red, the token name still makes sense. bg-red-600 would become a lie.

Dark mode with CSS custom properties

The cleanest dark mode strategy for a component library is to let CSS custom properties do the work. Define your light theme tokens in @theme (which applies to :root by default), then override the ones that change in a .dark selector:

css
/* theme.css (continued) */
@layer theme {
  .dark {
    --color-primary: oklch(0.72 0.18 260);
    --color-primary-hover: oklch(0.78 0.18 260);
 
    --color-background: oklch(0.15 0.02 260);
    --color-foreground: oklch(0.95 0 0);
    --color-muted: oklch(0.22 0.02 260);
    --color-muted-foreground: oklch(0.65 0.02 260);
    --color-border: oklch(0.28 0.02 260);
 
    --color-input-border: oklch(0.35 0.02 260);
  }
}

When a consumer adds the dark class to their <html> element, every component in your library switches appearance instantly. No re-renders, no JavaScript, no theme context provider. The browser just resolves different variable values.

Your components don't need to know about dark mode at all. A Button that uses bg-primary text-foreground works in both themes because those tokens resolve to the right values in each context. This is one of the biggest advantages of the CSS custom properties approach over a JavaScript-based theming system.

How consumers override your tokens

Since your tokens are CSS variables, consumers can override them at any level of specificity. They can retheme the entire library by redefining variables in their own CSS:

css
/* consumer's app CSS */
@import "tailwindcss";
@import "@yourorg/ui/theme.css";
 
@theme {
  --color-primary: oklch(0.60 0.18 145);       /* green instead of blue */
  --color-primary-hover: oklch(0.53 0.18 145);
}

Or they can scope overrides to a specific section of their page:

css
.marketing-section {
  --color-primary: oklch(0.65 0.20 330);   /* pink for the marketing page */
}

This works because CSS variables cascade. Your library defines the defaults, and consumers override whatever they need. No forking, no build-time configuration, no wrapper components.

CVA deep dive: the variant API pattern

With the token system in place, we can look at how components consume those tokens through CVA (Class Variance Authority). CVA is the bridge between your design tokens and your component props.

Here's the pattern, using a simplified Button as an example:

typescript
// lib/components/Button/variants.ts
import { cva } from 'class-variance-authority';
 
export const buttonVariants = cva(
  // Base styles applied to every button
  [
    'inline-flex items-center justify-center',
    'font-medium rounded-md',
    'transition-colors duration-150',
    'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
    'disabled:pointer-events-none disabled:opacity-50',
  ],
  {
    variants: {
      intent: {
        primary: 'bg-primary text-white hover:bg-primary-hover',
        secondary: 'bg-secondary text-foreground hover:bg-secondary-hover',
        destructive: 'bg-destructive text-white hover:bg-destructive-hover',
        ghost: 'bg-transparent text-foreground hover:bg-muted',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-sm',
        lg: 'h-12 px-6 text-base',
      },
    },
    compoundVariants: [
      {
        intent: 'destructive',
        size: 'sm',
        class: 'text-xs',
      },
    ],
    defaultVariants: {
      intent: 'primary',
      size: 'md',
    },
  }
);

Let's break down what's happening here:

Base styles are the first argument. These classes apply to every button regardless of variant. Focus ring, disabled state, transition, layout. Notice we're using ring-ring, which references our --color-ring token. Every visual choice traces back to the token system.

Variants define the axes of variation. intent controls the color scheme. size controls dimensions and text size. Each variant value maps to a set of Tailwind classes that use our semantic tokens.

Compound variants let you apply styles when multiple variants intersect. In this example, a small destructive button gets an even smaller font. This is where CVA really shines over manual conditional logic; you declare the combinations declaratively instead of nesting ternaries.

Default variants are what apply when the consumer doesn't specify a prop. A bare <Button> renders as primary, medium.

Deriving TypeScript types from variants

One of CVA's best features is VariantProps, a type helper that extracts the variant types from your config:

typescript
import { type VariantProps } from 'class-variance-authority';
 
export type ButtonVariants = VariantProps<typeof buttonVariants>;
// Equivalent to:
// {
//   intent?: 'primary' | 'secondary' | 'destructive' | 'ghost' | null;
//   size?: 'sm' | 'md' | 'lg' | null;
// }

This means you never manually define the allowed values for intent or size. They're derived from the source of truth, which is the CVA config. Add a new variant value and the type updates automatically. Rename one and TypeScript will flag every consumer that uses the old name.

This type gets merged into your component's props:

typescript
type ButtonProps = React.ComponentPropsWithoutRef<'button'> & ButtonVariants & {
  className?: string;
};

Consumers get full autocomplete and type checking when using your Button, without you maintaining a separate type definition.

A note on framework portability

Because CVA is just a function that returns a string of class names, the variant definitions are completely framework-agnostic. The buttonVariants object above works identically in React, Vue, Svelte, or Solid. If you ever need to support multiple frameworks, the variant logic can be shared directly. Only the component wrappers change.

With tokens defined and CVA configured, we have a complete styling pipeline: design tokens flow into Tailwind utilities, CVA organizes those utilities into variant combinations, and cn() handles the final class merging with consumer overrides. Now let's use all of this to build the actual components.


Building the Components: Five Patterns, Five Components

Each of the five components below is chosen to teach a specific React pattern. The implementations are production-quality, not simplified for demonstration, but they're concise enough to explain in a blog post. We'll follow the same structure for each: what pattern it demonstrates, the implementation, and the key accessibility details.

Button: The Variant Workhorse

Pattern: CVA variants, icon slots, polymorphic rendering

We already built the buttonVariants config in the previous section. Here's the full component that wraps it:

tsx
// lib/components/Button/Button.tsx
import { forwardRef } from 'react';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '../../utils/cn';
import { buttonVariants } from './variants';
 
type ButtonProps = React.ComponentPropsWithoutRef<'button'> &
  VariantProps<typeof buttonVariants> & {
    leftIcon?: React.ReactNode;
    rightIcon?: React.ReactNode;
  };
 
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, intent, size, leftIcon, rightIcon, children, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={cn(buttonVariants({ intent, size }), className)}
        {...props}
      >
        {leftIcon && <span className="mr-2 inline-flex shrink-0">{leftIcon}</span>}
        {children}
        {rightIcon && <span className="ml-2 inline-flex shrink-0">{rightIcon}</span>}
      </button>
    );
  }
);
 
Button.displayName = 'Button';

A few decisions worth calling out:

forwardRef is non-negotiable for library components. Consumers need to attach refs for focus management, scroll-into-view, form libraries, animation libraries, and measurement. If your component doesn't forward refs, it becomes a dead end in those workflows.

Icon slots use leftIcon and rightIcon props rather than detecting icon children automatically. This is explicit and predictable. The consumer knows exactly where their icon will render, and the spacing (mr-2 / ml-2) is handled by the component, not left to the consumer.

className is always the last argument to cn(). This ensures consumer overrides win over the library's defaults, thanks to tailwind-merge resolving conflicts in favor of the last class.

Accessibility: The disabled attribute on a native <button> element already handles keyboard and screen reader behavior correctly. The variant config adds disabled:pointer-events-none disabled:opacity-50 for visual feedback. The focus-visible:ring-2 in the base styles provides a visible focus indicator for keyboard users without showing the ring on mouse clicks.

Polymorphic rendering with asChild

Sometimes a Button needs to render as an <a> tag or a router's <Link> component. The asChild pattern, popularized by Radix UI, lets consumers swap the underlying element without the library needing to know about every possible element type:

tsx
// Usage
<Button asChild>
  <a href="/dashboard">Go to Dashboard</a>
</Button>

Implementing asChild requires a Slot utility that merges the Button's props onto its child element. This is a more advanced pattern, so for a first version, you can skip it and offer a simpler as prop instead. Or you can install @radix-ui/react-slot and use it directly. Either way, the important thing is that your Button doesn't hardcode <button> as the only option.

Wiring up the barrel export

Create lib/components/Button/index.ts to re-export the component from its folder:

typescript
// lib/components/Button/index.ts
export { Button } from './Button';
export { buttonVariants } from './variants';

This is the file that the top-level lib/index.ts imports from. Every component folder in the library follows this same pattern: a barrel index.ts that re-exports the public pieces. Button is already exported in lib/index.ts, so after this step your build should succeed with the Button component available.

Input: Form Control Fundamentals

Pattern: Ref forwarding, validation states, aria linkage

tsx
// lib/components/Input/Input.tsx
import { forwardRef, useId } from 'react';
import { cn } from '../../utils/cn';
 
type InputProps = React.ComponentPropsWithoutRef<'input'> & {
  label?: string;
  helperText?: string;
  error?: string;
};
 
export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ className, label, helperText, error, id: externalId, ...props }, ref) => {
    const generatedId = useId();
    const id = externalId ?? generatedId;
    const helperId = `${id}-helper`;
    const errorId = `${id}-error`;
    const describedBy = error ? errorId : helperText ? helperId : undefined;
 
    return (
      <div className="flex flex-col gap-1.5">
        {label && (
          <label htmlFor={id} className="text-sm font-medium text-foreground">
            {label}
          </label>
        )}
        <input
          ref={ref}
          id={id}
          className={cn(
            'h-10 w-full rounded-md border px-3 text-sm',
            'bg-background text-foreground placeholder:text-muted-foreground',
            'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
            'disabled:cursor-not-allowed disabled:opacity-50',
            error
              ? 'border-destructive focus-visible:ring-destructive'
              : 'border-input-border',
            className
          )}
          aria-invalid={error ? true : undefined}
          aria-describedby={describedBy}
          {...props}
        />
        {error && (
          <p id={errorId} className="text-sm text-destructive" role="alert">
            {error}
          </p>
        )}
        {!error && helperText && (
          <p id={helperId} className="text-sm text-muted-foreground">
            {helperText}
          </p>
        )}
      </div>
    );
  }
);
 
Input.displayName = 'Input';

The key patterns here:

useId() generates a stable, unique ID for linking the <label>, <input>, and helper/error text. This is a React 18+ hook that works correctly with server-side rendering. The component also accepts an external id prop so consumers can override it when they need a specific ID.

aria-invalid is set to true when an error is present. This tells screen readers that the field has a validation error. Combined with aria-describedby pointing to the error message, a screen reader user hears the input announced along with the error text.

role="alert" on the error paragraph ensures the error message is announced immediately when it appears, without the user needing to navigate to it.

The error state changes the border color and the focus ring color to destructive, providing a clear visual signal that something is wrong. This uses the same semantic token system; if a consumer overrides --color-destructive, the error styling updates automatically.

Why label, helperText, and error are props, not separate components: For an Input, colocation is more practical than composition. These elements have tight accessibility linkage (via htmlFor, aria-describedby) and conditional rendering logic (error replaces helper text). Making them separate components would force consumers to manually wire up IDs and manage show/hide logic, which most people would get wrong.

Wiring up the barrel export

Create the barrel file for the Input component, then enable its export in the library entry point:

typescript
// lib/components/Input/index.ts
export { Input } from './Input';

Then in lib/index.ts, uncomment the Input export:

typescript
export { Input } from './components/Input';

Card: Composition and Slots

Pattern: Named sub-components for flexible composition

tsx
// lib/components/Card/Card.tsx
import { forwardRef } from 'react';
import { cn } from '../../utils/cn';
 
type CardProps = React.ComponentPropsWithoutRef<'div'>;
 
export const Card = forwardRef<HTMLDivElement, CardProps>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn(
        'rounded-xl border border-border bg-background text-foreground shadow-sm',
        className
      )}
      {...props}
    />
  )
);
Card.displayName = 'Card';
 
export const CardHeader = forwardRef<HTMLDivElement, CardProps>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn('flex flex-col gap-1.5 p-6 pb-0', className)}
      {...props}
    />
  )
);
CardHeader.displayName = 'CardHeader';
 
export const CardContent = forwardRef<HTMLDivElement, CardProps>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn('p-6', className)} {...props} />
  )
);
CardContent.displayName = 'CardContent';
 
export const CardFooter = forwardRef<HTMLDivElement, CardProps>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn('flex items-center p-6 pt-0', className)}
      {...props}
    />
  )
);
CardFooter.displayName = 'CardFooter';

Usage looks like this:

tsx
<Card>
  <CardHeader>
    <h3 className="text-lg font-semibold">Card Title</h3>
    <p className="text-sm text-muted-foreground">Card description</p>
  </CardHeader>
  <CardContent>
    <p>Whatever content you need here.</p>
  </CardContent>
  <CardFooter>
    <Button>Save</Button>
  </CardFooter>
</Card>

This pattern is deliberately simple. Each sub-component is just a styled <div> with a className prop for overrides. There's no shared state, no context, no magic. The power is in the composition: consumers choose which pieces they need and arrange them however they want.

Why not a single <Card> with title, description, footer props? Because the prop-based approach falls apart quickly. What if the title needs a badge next to it? What if the footer has two buttons with different alignment? What if the content is a form instead of text? Props can't anticipate every combination. Composition can.

Accessibility: Card uses a plain <div> by default because semantic HTML for a card depends on context. If the card represents a standalone piece of content (like a blog post preview), the consumer should add <article>. If it's a section in a form layout, <section> might be appropriate. The library doesn't make that decision because it doesn't know the context.

Wiring up the barrel export

Create the barrel file for the Card components, then enable its export:

typescript
// lib/components/Card/index.ts
export { Card, CardHeader, CardContent, CardFooter } from './Card';

Then in lib/index.ts, uncomment the Card export:

typescript
export { Card, CardHeader, CardContent, CardFooter } from './components/Card';

Modal: Portals and Focus Management

Pattern: createPortal, focus trapping, keyboard handling, scroll lock

The Modal is the most complex component in our library because it touches the DOM in ways that go beyond rendering JSX. Here's a pragmatic approach that handles the essentials:

tsx
// lib/components/Modal/Modal.tsx
import { forwardRef, useEffect, useRef, useCallback, useImperativeHandle } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '../../utils/cn';
 
type ModalProps = {
  open: boolean;
  onClose: () => void;
  children: React.ReactNode;
  className?: string;
  title: string;
  description?: string;
};
 
export const Modal = forwardRef<HTMLDivElement, ModalProps>(
  ({ open, onClose, children, className, title, description }, ref) => {
    const contentRef = useRef<HTMLDivElement>(null);
    const previousActiveElement = useRef<HTMLElement | null>(null);
 
    // Expose the content div to consumers via the forwarded ref
    useImperativeHandle(ref, () => contentRef.current as HTMLDivElement);
 
    // Trap focus inside the modal
    const handleKeyDown = useCallback(
      (e: KeyboardEvent) => {
        if (e.key === 'Escape') {
          onClose();
          return;
        }
        if (e.key !== 'Tab') return;
 
        const modal = contentRef.current;
        if (!modal) return;
 
        const focusable = modal.querySelectorAll<HTMLElement>(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        const first = focusable[0];
        const last = focusable[focusable.length - 1];
 
        if (e.shiftKey && document.activeElement === first) {
          e.preventDefault();
          last?.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
          e.preventDefault();
          first?.focus();
        }
      },
      [onClose]
    );
 
    useEffect(() => {
      if (!open) return;
 
      // Save the currently focused element and lock scroll
      previousActiveElement.current = document.activeElement as HTMLElement;
      document.body.style.overflow = 'hidden';
      document.addEventListener('keydown', handleKeyDown);
 
      // Focus the first focusable element inside the modal
      requestAnimationFrame(() => {
        const focusable = contentRef.current?.querySelectorAll<HTMLElement>(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        focusable?.[0]?.focus();
      });
 
      return () => {
        document.body.style.overflow = '';
        document.removeEventListener('keydown', handleKeyDown);
        previousActiveElement.current?.focus();
      };
    }, [open, handleKeyDown]);
 
    if (!open) return null;
 
    return createPortal(
      <div className="fixed inset-0 z-50 flex items-center justify-center">
        {/* Overlay */}
        <div
          className="fixed inset-0 bg-foreground/40 backdrop-blur-sm"
          onClick={onClose}
          aria-hidden="true"
        />
        {/* Content */}
        <div
          ref={contentRef}
          role="dialog"
          aria-modal="true"
          aria-labelledby="modal-title"
          aria-describedby={description ? 'modal-description' : undefined}
          className={cn(
            'relative z-50 w-full max-w-lg rounded-xl border border-border',
            'bg-background p-6 shadow-lg',
            className
          )}
        >
          <h2 id="modal-title" className="text-lg font-semibold text-foreground">
            {title}
          </h2>
          {description && (
            <p id="modal-description" className="mt-1 text-sm text-muted-foreground">
              {description}
            </p>
          )}
          <div className="mt-4">{children}</div>
        </div>
      </div>,
      document.body
    );
  }
);
 
Modal.displayName = 'Modal';

This component has several layers worth understanding:

useImperativeHandle merges the forwarded ref with the internal contentRef. The Modal needs its own ref to the content div for focus trapping, but consumers also need ref access (e.g., to measure the dialog or integrate with animation libraries). useImperativeHandle solves this by exposing the internal ref's DOM node through the forwarded ref. Without this, the ref parameter from forwardRef would go unused and TypeScript's noUnusedLocals would flag it as an error.

createPortal renders the modal at the end of document.body instead of wherever the component sits in the React tree. This prevents parent elements with overflow: hidden or z-index stacking contexts from clipping or hiding the modal. It's essential for a modal that works reliably in any layout.

Focus trapping keeps the Tab key cycling through elements inside the modal. When the user tabs past the last focusable element, focus wraps to the first. When they shift-tab past the first, it wraps to the last. Without this, pressing Tab would move focus to elements behind the overlay, which is both confusing and an accessibility violation.

Focus restoration saves document.activeElement when the modal opens and restores focus to it when the modal closes. This means if a user opened the modal by clicking a "Delete" button, closing the modal puts focus back on that button. This is critical for keyboard users who would otherwise lose their place in the page.

Scroll lock sets overflow: hidden on <body> while the modal is open. This prevents the background content from scrolling while the user interacts with the modal. The cleanup function in the useEffect restores the original overflow value.

Accessibility: role="dialog" and aria-modal="true" tell assistive technology that this is a modal dialog. aria-labelledby points to the title, and aria-describedby points to the optional description. The overlay has aria-hidden="true" because it's purely decorative.

A note on Headless UI: If you want battle-tested focus management and animation coordination without building it yourself, Headless UI's <Dialog> component handles all of this. Using it as the foundation and styling with your Tailwind tokens is a perfectly valid approach, and it's what libraries like shadcn/ui do. We're building it from scratch here to show what's happening under the hood, but for a production library you should weigh the tradeoff between control and maintenance burden.

Wiring up the barrel export

Create the barrel file for the Modal component, then enable its export:

typescript
// lib/components/Modal/index.ts
export { Modal } from './Modal';

Then in lib/index.ts, uncomment the Modal export:

typescript
export { Modal } from './components/Modal';

Tabs: The Compound Component Pattern

Pattern: Shared state via React Context, keyboard navigation

Tabs is the most architecturally interesting component because four separate components need to share state without the consumer wiring anything up manually. This is the compound component pattern.

tsx
// lib/components/Tabs/Tabs.tsx
import {
  createContext,
  forwardRef,
  useCallback,
  useContext,
  useId,
  useState,
} from 'react';
import { cn } from '../../utils/cn';
 
// --- Context ---
type TabsContextValue = {
  activeTab: string;
  setActiveTab: (value: string) => void;
  baseId: string;
};
 
const TabsContext = createContext<TabsContextValue | null>(null);
 
function useTabsContext() {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error('Tabs compound components must be used within <Tabs>');
  }
  return context;
}
 
// --- Root ---
type TabsProps = React.ComponentPropsWithoutRef<'div'> & {
  defaultValue: string;
  value?: string;
  onValueChange?: (value: string) => void;
};
 
export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
  ({ defaultValue, value, onValueChange, className, children, ...props }, ref) => {
    const [internalValue, setInternalValue] = useState(defaultValue);
    const activeTab = value ?? internalValue;
    const baseId = useId();
 
    const setActiveTab = useCallback(
      (newValue: string) => {
        if (value === undefined) setInternalValue(newValue);
        onValueChange?.(newValue);
      },
      [value, onValueChange]
    );
 
    return (
      <TabsContext.Provider value={{ activeTab, setActiveTab, baseId }}>
        <div ref={ref} className={cn('w-full', className)} {...props}>
          {children}
        </div>
      </TabsContext.Provider>
    );
  }
);
Tabs.displayName = 'Tabs';
 
// --- TabsList ---
export const TabsList = forwardRef<
  HTMLDivElement,
  React.ComponentPropsWithoutRef<'div'>
>(({ className, children, ...props }, ref) => {
  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const triggers = Array.from(
      e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]')
    );
    const current = triggers.findIndex((t) => t === document.activeElement);
    if (current === -1) return;
 
    let next = current;
    if (e.key === 'ArrowRight') next = (current + 1) % triggers.length;
    if (e.key === 'ArrowLeft') next = (current - 1 + triggers.length) % triggers.length;
    if (e.key === 'Home') next = 0;
    if (e.key === 'End') next = triggers.length - 1;
 
    if (next !== current) {
      e.preventDefault();
      triggers[next].focus();
      triggers[next].click();
    }
  };
 
  return (
    <div
      ref={ref}
      role="tablist"
      onKeyDown={handleKeyDown}
      className={cn(
        'inline-flex items-center gap-1 rounded-lg bg-muted p-1',
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
});
TabsList.displayName = 'TabsList';
 
// --- TabsTrigger ---
type TabsTriggerProps = React.ComponentPropsWithoutRef<'button'> & {
  value: string;
};
 
export const TabsTrigger = forwardRef<HTMLButtonElement, TabsTriggerProps>(
  ({ value, className, children, ...props }, ref) => {
    const { activeTab, setActiveTab, baseId } = useTabsContext();
    const isActive = activeTab === value;
 
    return (
      <button
        ref={ref}
        role="tab"
        type="button"
        id={`${baseId}-trigger-${value}`}
        aria-selected={isActive}
        aria-controls={`${baseId}-content-${value}`}
        tabIndex={isActive ? 0 : -1}
        onClick={() => setActiveTab(value)}
        className={cn(
          'inline-flex items-center justify-center rounded-md px-3 py-1.5',
          'text-sm font-medium transition-colors',
          'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
          isActive
            ? 'bg-background text-foreground shadow-sm'
            : 'text-muted-foreground hover:text-foreground',
          className
        )}
        {...props}
      >
        {children}
      </button>
    );
  }
);
TabsTrigger.displayName = 'TabsTrigger';
 
// --- TabsContent ---
type TabsContentProps = React.ComponentPropsWithoutRef<'div'> & {
  value: string;
};
 
export const TabsContent = forwardRef<HTMLDivElement, TabsContentProps>(
  ({ value, className, children, ...props }, ref) => {
    const { activeTab, baseId } = useTabsContext();
    if (activeTab !== value) return null;
 
    return (
      <div
        ref={ref}
        role="tabpanel"
        id={`${baseId}-content-${value}`}
        aria-labelledby={`${baseId}-trigger-${value}`}
        tabIndex={0}
        className={cn('mt-2 focus-visible:outline-none', className)}
        {...props}
      >
        {children}
      </div>
    );
  }
);
TabsContent.displayName = 'TabsContent';

Usage is clean and declarative:

tsx
<Tabs defaultValue="overview">
  <TabsList>
    <TabsTrigger value="overview">Overview</TabsTrigger>
    <TabsTrigger value="analytics">Analytics</TabsTrigger>
    <TabsTrigger value="settings">Settings</TabsTrigger>
  </TabsList>
  <TabsContent value="overview">Overview content here.</TabsContent>
  <TabsContent value="analytics">Analytics content here.</TabsContent>
  <TabsContent value="settings">Settings content here.</TabsContent>
</Tabs>

Let's walk through the architecture:

TabsContext holds the active tab value and the setter function. Every child component reads from this context instead of receiving the active tab as a prop. This is what makes the consumer API so clean; you don't need to manually pass state between TabsTrigger and TabsContent.

Controlled vs uncontrolled: The Tabs root supports both patterns. If the consumer passes a value prop, the component is controlled and the consumer manages state externally. If they only pass defaultValue, the component manages its own state internally via useState. The onValueChange callback fires in both modes, so the consumer can react to changes without controlling the state.

Keyboard navigation is handled in TabsList using e.currentTarget to query the trigger buttons directly from the event target. This avoids needing a separate useRef. The ref forwarded from forwardRef is passed straight to the <div>, so consumers can access the tablist element while keyboard navigation works via the event's own target reference. Arrow keys move focus between triggers. Home jumps to the first tab. End jumps to the last. The focused trigger is also clicked immediately (activation on focus), which matches the WAI-ARIA Tabs pattern. The tabIndex management ensures that only the active trigger is in the tab order; the rest are reachable via arrow keys but skipped by Tab. This means a Tab press moves focus out of the tablist entirely rather than stepping through every trigger.

ID generation uses useId() to create unique, SSR-safe IDs for linking triggers to their panels via aria-controls and aria-labelledby. Each trigger gets an id like {baseId}-trigger-overview and each panel gets {baseId}-content-overview. This linkage is what screen readers use to announce "this tab controls that panel."

The error boundary in useTabsContext throws a helpful error message if someone renders a TabsTrigger outside of a <Tabs> wrapper. This is a small touch that saves debugging time.

Wiring up the barrel export

Create the barrel file for the Tabs components, then enable its export:

typescript
// lib/components/Tabs/index.ts
export { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs';

Then in lib/index.ts, uncomment the Tabs export:

typescript
export { Tabs, TabsList, TabsTrigger, TabsContent } from './components/Tabs';

With all five components wired up, your lib/index.ts should now have every export uncommented. Run npm run build to verify everything compiles cleanly.

That's five components, each demonstrating a different pattern. Next, we'll cover how consumers actually customize and integrate your library into their projects.


Up Next

We now have five production-quality components backed by a semantic token system and type-safe variants. In Part 3, we'll make the library consumable: defining the styling contract for consumers, writing tests with Vitest, setting up Storybook as a visual testing layer, and publishing to npm.

This is Part 2 of a 4-part series on building a production-ready React component library with Tailwind CSS.

Part 1: Project Setup and Architecture Part 2: Design System and Components (you are here) Part 3: Integration, Testing and Publishing Part 4: CI/CD and Automated Releases