Build a Headless React UI Library: A Practical Tabs Tutorial

Build a headless React UI component library step by step—accessibility, state, TypeScript, testing, and packaging—using a fully featured Tabs example.

ASOasis
9 min read
Build a Headless React UI Library: A Practical Tabs Tutorial

Image used for representation purposes only.

What “headless UI” means in React

Headless UI components provide state, accessibility, and behavior—but no visual styling. You wire them to your own markup and CSS (Tailwind, CSS Modules, vanilla-extract, etc.). This separation lets design evolve independently from logic while keeping accessibility and keyboard support consistent across products.

In this tutorial, you’ll build a small, type-safe, accessible, headless React component—Tabs—then see how to compose, style, test, and package it for reuse in a design system.

Prerequisites and project setup

  • Familiarity with React 18+, TypeScript, and hooks
  • Node 18+

Quick start with Vite and pnpm (use npm/yarn if you prefer):

pnpm create vite my-headless-lib --template react-ts
cd my-headless-lib
pnpm i
pnpm i -D @testing-library/react @testing-library/user-event vitest tsup

We’ll develop the component in src/lib and add a demo in src/App.tsx.

Design principles for a headless library

  • Accessibility first: ship correct roles, ARIA attributes, and keyboard interactions by default.
  • Composition over configuration: expose hooks and prop getters or small primitives—let consumers pick markup and styles.
  • Controlled/uncontrolled support: allow both external state and internal state with onChange callbacks.
  • Predictable IDs and relationships: ensure elements are linked via aria-* and id.
  • Escape hasty abstractions: start with one component, factor patterns once they repeat.

Accessibility checklist for Tabs

  • role=“tablist” on the container, role=“tab” on each tab, role=“tabpanel” on panels
  • Each tab controls a panel via aria-controls; each panel references its tab via aria-labelledby
  • Only one tab selected at a time; the selected tab has aria-selected=“true” and tabIndex=0; others have tabIndex=-1 (roving tabindex)
  • Keyboard: Left/Right (or Up/Down for vertical) move focus between tabs; Home/End jump to first/last
  • Activation mode: automatic (select on focus) or manual (select on Enter/Space)

Implementing a headless useTabs hook

We’ll expose a single hook that returns prop-getter functions to attach to your DOM. It supports both controlled and uncontrolled modes and a11y out of the box.

// src/lib/useControllableState.ts
import { useCallback, useRef, useState } from 'react';

export function useControllableState<T>(
  controlled: T | undefined,
  defaultValue: T,
  onChange?: (v: T) => void
) {
  const [uncontrolled, setUncontrolled] = useState<T>(defaultValue);
  const isControlled = controlled !== undefined;
  const value = isControlled ? (controlled as T) : uncontrolled;

  const set = useCallback(
    (next: T) => {
      if (!isControlled) setUncontrolled(next);
      onChange?.(next);
    },
    [isControlled, onChange]
  );

  return [value, set] as const;
}
// src/lib/useTabs.ts
import { useCallback, useId, useMemo, useRef } from 'react';
import { useControllableState } from './useControllableState';

type Orientation = 'horizontal' | 'vertical';
type ActivationMode = 'automatic' | 'manual';

export interface UseTabsOptions {
  selectedIndex?: number;            // controlled
  defaultIndex?: number;             // uncontrolled
  onChange?: (index: number) => void;
  orientation?: Orientation;         // a11y: arrow key mapping
  activationMode?: ActivationMode;   // select on focus or on Enter/Space
  idBase?: string;                   // customize id prefix if desired
}

export function useTabs(options: UseTabsOptions = {}) {
  const {
    selectedIndex: controlled,
    defaultIndex = 0,
    onChange,
    orientation = 'horizontal',
    activationMode = 'automatic',
    idBase,
  } = options;

  const [selectedIndex, setSelectedIndex] = useControllableState<number>(
    controlled,
    defaultIndex,
    onChange
  );

  const reactId = useId();
  const baseId = idBase ?? `tabs-${reactId}`;

  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const setTabRef = (i: number) => (el: HTMLButtonElement | null) => {
    tabRefs.current[i] = el;
  };

  const focusTab = (i: number) => {
    const el = tabRefs.current[i];
    el?.focus();
  };

  const getRootProps = useCallback<() => Record<string, any>>(() => ({
    'data-headless-tabs-root': '',
  }), []);

  const getTabListProps = useCallback<() => Record<string, any>>(() => ({
    role: 'tablist',
    'aria-orientation': orientation,
  }), [orientation]);

  const getTabProps = useCallback(
    (index: number) => {
      const selected = index === selectedIndex;
      const tabId = `${baseId}-tab-${index}`;
      const panelId = `${baseId}-panel-${index}`;

      const onKeyDown = (e: React.KeyboardEvent) => {
        const isHorizontal = orientation === 'horizontal';
        const key = e.key;
        const count = tabRefs.current.length;
        const clamp = (n: number) => (n + count) % count;

        let next = -1;
        if ((isHorizontal && key === 'ArrowRight') || (!isHorizontal && key === 'ArrowDown')) next = clamp(index + 1);
        if ((isHorizontal && key === 'ArrowLeft')  || (!isHorizontal && key === 'ArrowUp'))   next = clamp(index - 1);
        if (key === 'Home') next = 0;
        if (key === 'End') next = count - 1;

        if (next !== -1) {
          e.preventDefault();
          focusTab(next);
          if (activationMode === 'automatic') setSelectedIndex(next);
        }

        if (activationMode === 'manual' && (key === 'Enter' || key === ' ')) {
          e.preventDefault();
          setSelectedIndex(index);
        }
      };

      const onClick = () => {
        setSelectedIndex(index);
      };

      return {
        role: 'tab',
        id: tabId,
        'aria-selected': selected,
        'aria-controls': panelId,
        tabIndex: selected ? 0 : -1,
        ref: setTabRef(index),
        onKeyDown,
        onClick,
      } as const;
    },
    [activationMode, baseId, orientation, selectedIndex, setSelectedIndex]
  );

  const getPanelProps = useCallback(
    (index: number) => {
      const selected = index === selectedIndex;
      const tabId = `${baseId}-tab-${index}`;
      const panelId = `${baseId}-panel-${index}`;
      return {
        role: 'tabpanel',
        id: panelId,
        'aria-labelledby': tabId,
        hidden: !selected,
        tabIndex: 0,
      } as const;
    },
    [baseId, selectedIndex]
  );

  return useMemo(
    () => ({
      selectedIndex,
      setSelectedIndex,
      getRootProps,
      getTabListProps,
      getTabProps,
      getPanelProps,
    }),
    [selectedIndex, setSelectedIndex, getRootProps, getTabListProps, getTabProps, getPanelProps]
  );
}

Consuming the headless hook with your own markup/styles

You can style however you want—Tailwind, CSS Modules, or plain CSS. Here’s a simple example with Tailwind classes (replace with your system of choice).

// src/App.tsx
import { useTabs } from './lib/useTabs';

export default function App() {
  const tabs = useTabs({ defaultIndex: 1, activationMode: 'automatic' });

  return (
    <div {...tabs.getRootProps()} className="max-w-xl mx-auto p-6">
      <div {...tabs.getTabListProps()} className="flex gap-2 border-b">
        {['Overview', 'API', 'Examples'].map((label, i) => (
          <button
            key={label}
            {...tabs.getTabProps(i)}
            className={
              `px-3 py-2 -mb-px border-b-2 focus:outline-none ` +
              (tabs.selectedIndex === i
                ? 'border-blue-600 text-blue-700 font-medium'
                : 'border-transparent text-slate-600 hover:text-slate-800')
            }
          >
            {label}
          </button>
        ))}
      </div>

      <section {...tabs.getPanelProps(0)} className="py-4">
        <h2 className="text-lg font-semibold">Overview</h2>
        <p>Unstyled, accessible tabs you can theme to your brand.</p>
      </section>

      <section {...tabs.getPanelProps(1)} className="py-4">
        <h2 className="text-lg font-semibold">API</h2>
        <pre className="bg-slate-50 p-3 rounded text-sm overflow-auto">
{`useTabs({
  selectedIndex?, defaultIndex?, onChange?,
  orientation?: 'horizontal' | 'vertical',
  activationMode?: 'automatic' | 'manual',
  idBase?: string
})`}
        </pre>
      </section>

      <section {...tabs.getPanelProps(2)} className="py-4">
        <h2 className="text-lg font-semibold">Examples</h2>
        <p>Try arrow keys, Home/End, and toggling activationMode.</p>
      </section>
    </div>
  );
}

Making the API ergonomic: prop getters vs. compound components

Two popular patterns:

  • Prop getters (used above): a single hook returns getXProps functions. This scales well to “headless” scenarios and is tree-shakeable.
  • Compound components: using React Context. This is ergonomic for consumers but can be heavier. You can provide both by building compound components on top of the hook.

Here’s how a minimal compound layer could look:

// src/lib/Tabs.tsx
import React, { createContext, useContext } from 'react';
import { useTabs, UseTabsOptions } from './useTabs';

const TabsCtx = createContext<ReturnType<typeof useTabs> | null>(null);
const useTabsCtx = () => {
  const ctx = useContext(TabsCtx);
  if (!ctx) throw new Error('Tabs components must be used within <Tabs>');
  return ctx;
};

export function Tabs(props: React.PropsWithChildren<UseTabsOptions>) {
  const api = useTabs(props);
  return <div {...api.getRootProps()}>{/* provider wraps children */}
    <TabsCtx.Provider value={api}>{props.children}</TabsCtx.Provider>
  </div>;
}

export function TabList(props: React.HTMLAttributes<HTMLDivElement>) {
  const api = useTabsCtx();
  return <div {...api.getTabListProps()} {...props} />;
}

export function Tab({ index, ...rest }: { index: number } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
  const api = useTabsCtx();
  return <button {...api.getTabProps(index)} {...rest} />;
}

export function TabPanel({ index, ...rest }: { index: number } & React.HTMLAttributes<HTMLElement>) {
  const api = useTabsCtx();
  return <section {...api.getPanelProps(index)} {...rest} />;
}

Consumers can now choose either the hook or the compound components while still controlling markup and classes.

Controlled vs. uncontrolled usage

  • Uncontrolled: defaultIndex initializes internal state; the hook manages selection.
  • Controlled: pass selectedIndex and onChange. State lives outside, useful when syncing with URL or analytics.
// Controlled example
const [tab, setTab] = useState(0);
const tabs = useTabs({ selectedIndex: tab, onChange: setTab });

Testing accessibility and behavior

Use React Testing Library and user-event to validate roles and keyboard navigation.

// src/lib/useTabs.test.tsx
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import { useTabs } from './useTabs';
import React from 'react';

function Demo() {
  const t = useTabs({ defaultIndex: 0 });
  return (
    <div>
      <div {...t.getTabListProps()}>
        <button {...t.getTabProps(0)}>One</button>
        <button {...t.getTabProps(1)}>Two</button>
      </div>
      <section {...t.getPanelProps(0)}>P1</section>
      <section {...t.getPanelProps(1)}>P2</section>
    </div>
  );
}

test('arrow keys move focus and select (automatic)', async () => {
  render(<Demo />);
  const [one, two] = screen.getAllByRole('tab');
  one.focus();
  await user.keyboard('{ArrowRight}');
  expect(two).toHaveFocus();
  expect(two).toHaveAttribute('aria-selected', 'true');
  const p2 = screen.getByRole('tabpanel', { name: /two/i });
  expect(p2).toBeVisible();
});

Run tests with:

pnpm vitest

Packaging for reuse

Bundle as an ESM-first package with TypeScript types. tsup offers a great DX:

// package.json (relevant parts)
{
  "name": "@acme/headless",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    "./useTabs": {
      "types": "./dist/useTabs.d.ts",
      "import": "./dist/useTabs.js"
    },
    "./Tabs": {
      "types": "./dist/Tabs.d.ts",
      "import": "./dist/Tabs.js"
    },
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsup src/lib/**/*.{ts,tsx} --dts --format esm --clean"
  }
}
// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/lib/index.ts'],
  format: ['esm'],
  dts: true,
  treeshake: true,
  sourcemap: true,
  clean: true,
  target: 'es2020',
  minify: true,
});

Tip: publish only the dist output and types; keep source maps for dev ergonomics. Provide per-entry exports so consumers can cherry-pick for optimal tree-shaking.

Extending the pattern to other components

Once the Tabs pattern feels solid, you can replicate it across:

  • Disclosure/Accordion: roving focus within headers; Enter/Space toggles; aria-expanded and aria-controls
  • Menu/Select/Listbox: focus management, typeahead, Esc to close, roving tabindex, aria-activedescendant or getItemProps
  • Dialog/Modal: aria-modal, labelledby/ describedby links, focus trap, return focus on close (consider a tiny, dependency-free focus trap)
  • Combobox: filtered list with active descendant, input value control, clear button, loading state
  • Tooltip/Popover: accessible triggers, hover/focus semantics, Arrow positioning (Floating UI is a solid, lightweight choice)

Each component should expose:

  • A headless hook (state + prop getters)
  • Optional compound primitives that wrap the hook
  • Strong TypeScript types and generics where meaningful
  • Predictable keyboard and ARIA behaviors documented in JSDoc

Performance and DX considerations

  • Avoid unnecessary renders by memoizing prop getters and callbacks.
  • Don’t re-create large arrays/objects on every render—prefer useMemo and stable handler references.
  • Keep dependencies minimal; add optional peer dependencies for advanced features (e.g., positioning).
  • Document behavior thoroughly with Storybook or Ladle demos; include a11y stories and interaction tests.

Common pitfalls to avoid

  • Forgetting to set tabIndex=-1 for unfocused tabs breaks roving focus.
  • Omitting id/aria-labelledby/aria-controls breaks screen reader relationships.
  • Moving selection on Arrow keys in “manual” activation mode; only move focus until Enter/Space.
  • Styling that relies on :focus-visible but not providing a fallback.
  • Leaking internal state in controlled mode (always respect the source of truth).

Wrapping up

You built a production-quality, headless Tabs component with accessibility and keyboard support, then explored usage patterns, testing, and packaging. This approach scales to an entire headless design system—where behavior is consistent, a11y is first-class, and teams remain free to brand and iterate on visuals without fighting the component’s internals.

Next steps: add a Menu and Dialog using the same API shape; publish to a private registry; and wire into Storybook with a11y checks to keep quality high as your library grows.

Related Posts