The Tabs component in our design system once had 23 props: tabClassName, activeTabClassName, renderTabLabel, iconPosition, showBadge... Every new screen needed one more, because the component both managed behavior (which tab is selected, keyboard, ARIA) and dictated markup. When a team needed vertical tabs with a badge on the right, I realized the problem wasn't a missing prop; we had packed two different things into one place. This post is how I redesigned it the headless way: the component owns behavior, the consumer owns the UI.

Behavior and rendering are two different contracts#

Every interactive component has two parts. The behavior contract covers state, state transitions, focus management, keyboard shortcuts, ARIA attributes and callbacks. The rendering contract covers which HTML elements, which classes, where the icon goes. The first is hard to get right and should be identical everywhere. The second changes with every product.

Headless means packaging the first and handing over the second. I split it into three layers:

LayerExampleWho uses it
Headless hookuseTabs() returns state and prop gettersPeople who need full control of markup
Compound componentsunstyled Tabs.Root, Tabs.List, Tabs.TriggerThe design system team
Styled componentsSettingsTabs with design system tokens and classesFeature developers

The most important point: accessibility belongs to the behavior layer, not something consumers bolt on later. If the hook returns the wrong aria-selected or the keyboard doesn't work, no amount of CSS will save it.

Layer 1: headless hooks and prop getters#

The simplest approach is a hook that returns state and a props object. But if the hook returns { onClick, onKeyDown } and the consumer also wants an onClick for analytics, who wins? Spread it last and it overwrites; spread it first and it gets overwritten. Prop getters, the pattern Downshift popularized with getInputProps and getItemProps, solve this by taking the consumer's props and returning merged props:

TypeScript
import type { SyntheticEvent } from "react";

export function composeHandlers<E extends SyntheticEvent>(
  external: ((event: E) => void) | undefined,
  internal: (event: E) => void,
) {
  return (event: E) => {
    external?.(event);
    if (!event.defaultPrevented) internal(event);
  };
}

The consumer's handler runs first, and if it calls event.preventDefault(), the component's default behavior is skipped. That escape hatch lets people customize without forking the component. useTabs builds on it:

TypeScript
import { useId, type ComponentPropsWithRef, type KeyboardEvent } from "react";
import { composeHandlers } from "./compose-handlers";
import { useControllableState } from "./use-controllable-state";

export type UseTabsOptions = {
  value?: string;
  defaultValue: string;
  onValueChange?: (value: string) => void;
  orientation?: "horizontal" | "vertical";
  activationMode?: "automatic" | "manual";
  dir?: "ltr" | "rtl";
};

type TabInput = ComponentPropsWithRef<"button"> & { value: string };
type PanelInput = ComponentPropsWithRef<"div"> & { value: string };

export function useTabs({
  value,
  defaultValue,
  onValueChange,
  orientation = "horizontal",
  activationMode = "automatic",
  dir = "ltr",
}: UseTabsOptions) {
  const [selectedValue, setSelectedValue] = useControllableState({
    value,
    defaultValue,
    onChange: onValueChange,
  });
  const baseId = useId();
  const tabId = (tabValue: string) => `${baseId}-tab-${tabValue}`;
  const panelId = (tabValue: string) => `${baseId}-panel-${tabValue}`;

  function findTargetTab(current: HTMLElement, key: string) {
    const tabList = current.closest('[role="tablist"]');
    const tabs = Array.from(tabList?.querySelectorAll<HTMLElement>('[role="tab"]:not([disabled])') ?? []);
    const index = tabs.indexOf(current);
    const forwardKey = orientation === "vertical" ? "ArrowDown" : dir === "rtl" ? "ArrowLeft" : "ArrowRight";
    const backwardKey = orientation === "vertical" ? "ArrowUp" : dir === "rtl" ? "ArrowRight" : "ArrowLeft";
    if (key === forwardKey) return tabs[(index + 1) % tabs.length];
    if (key === backwardKey) return tabs[(index - 1 + tabs.length) % tabs.length];
    if (key === "Home") return tabs[0];
    if (key === "End") return tabs.at(-1);
    return undefined;
  }

  function handleTabKeyDown(event: KeyboardEvent<HTMLElement>) {
    const target = findTargetTab(event.currentTarget, event.key);
    if (!target) return;
    event.preventDefault();
    target.focus();
    const targetValue = target.dataset.value;
    if (activationMode === "automatic" && targetValue) setSelectedValue(targetValue);
  }

  return {
    selectedValue,
    setSelectedValue,
    getTabListProps: (props: ComponentPropsWithRef<"div"> = {}) => ({
      ...props,
      role: "tablist",
      "aria-orientation": orientation,
    }),
    getTabProps: ({ value: tabValue, onClick, onKeyDown, ...props }: TabInput) => {
      const isSelected = tabValue === selectedValue;
      return {
        ...props,
        id: tabId(tabValue),
        role: "tab",
        tabIndex: isSelected ? 0 : -1,
        "aria-selected": isSelected,
        "aria-controls": panelId(tabValue),
        "data-value": tabValue,
        "data-state": isSelected ? "active" : "inactive",
        onClick: composeHandlers(onClick, () => setSelectedValue(tabValue)),
        onKeyDown: composeHandlers(onKeyDown, handleTabKeyDown),
      };
    },
    getPanelProps: ({ value: tabValue, ...props }: PanelInput) => ({
      ...props,
      id: panelId(tabValue),
      role: "tabpanel",
      tabIndex: 0,
      hidden: tabValue !== selectedValue,
      "aria-labelledby": tabId(tabValue),
    }),
  };
}

A few details I learned from real bugs:

  • Tab order comes from the DOM, not from an array registered in state. Tabs may be rendered conditionally, wrapped in other components, or reordered; the DOM is always the order the user sees. The trade-off is one DOM query per key press, which is negligible for a few dozen tabs.
  • Getters don't attach an internal ref. If a getter returns ref: internalRef, the consumer's ref is silently overwritten. Either merge refs or avoid needing one, as here with closest.
  • data-state lets people style with CSS ([data-state="active"]) without yet another className prop.
  • A tab's value ends up in an id, so it must be slug-like, with no whitespace.

Controlled and uncontrolled with useControllableState#

A good component supports both: defaultValue when you don't care about the state, value plus onValueChange when you need to drive it from outside, for example syncing tabs with the URL. Instead of repeating that logic in every component, I put it in one hook:

TypeScript
import { useCallback, useEffect, useRef, useState, type SetStateAction } from "react";

type UseControllableStateParams<T> = {
  value?: T;
  defaultValue: T;
  onChange?: (value: T) => void;
};

export function useControllableState<T>({ value, defaultValue, onChange }: UseControllableStateParams<T>) {
  const [internalValue, setInternalValue] = useState(defaultValue);
  const isControlled = value !== undefined;
  const currentValue = isControlled ? value : internalValue;

  const wasControlled = useRef(isControlled);
  useEffect(() => {
    if (process.env.NODE_ENV !== "production" && wasControlled.current !== isControlled) {
      console.warn("useControllableState: component switched between controlled and uncontrolled.");
    }
    wasControlled.current = isControlled;
  }, [isControlled]);

  const setValue = useCallback(
    (next: SetStateAction<T>) => {
      const resolved = typeof next === "function" ? (next as (previous: T) => T)(currentValue) : next;
      if (Object.is(resolved, currentValue)) return;
      if (!isControlled) setInternalValue(resolved);
      onChange?.(resolved);
    },
    [currentValue, isControlled, onChange],
  );

  return [currentValue, setValue] as const;
}

Where it's easy to go wrong:

  • undefined means "not controlled". So for "nothing selected yet" state, use null rather than undefined, or the component will silently drop into uncontrolled mode.
  • Don't call onChange when the value didn't change. Clicking the already-selected tab and still firing onValueChange makes the outside refetch or push an extra history entry.
  • Don't call onChange inside a setState updater. Updaters must be pure; in Strict Mode React calls them twice and your callback fires twice.
  • Functional updaters read the current render's value. Calling setValue((v) => v + 1) twice in one event only increments once. For tabs, selects or disclosures that doesn't matter; for a counter it does, and that's when useReducer deserves a look.

Keyboard and ARIA are part of the API#

I write the keyboard table before the code, based on the Tabs pattern in the WAI-ARIA Authoring Practices, and treat it as the spec:

KeyBehavior
TabEntering the tablist focuses the selected tab; pressing again moves to the panel
Left/Right arrows (Up/Down if vertical)Previous or next tab, wrapping at both ends, reversed in RTL
Home / EndFirst or last tab
Enter / SpaceActivates the focused tab in manual mode

This is why roving tabindex matters: only the selected tab has tabIndex={0}, the others get -1, so the whole tablist is a single Tab stop. An edge case that bit me: if selectedValue matches no tab (the tab was hidden by permissions), no tab has tabIndex={0} and keyboard users can't reach the tablist at all. The component should fall back to the first valid tab.

On automatic vs manual activation: the APG recommends automatic (focus selects) when panels display without noticeable latency. If every panel fetches data, arrowing across five tabs fires five requests and makes focus sluggish; manual is the better fit there.

If you can't yet write the keyboard table and ARIA attribute list for a component, you don't have an API design yet. Tests should follow the same contract: query with getByRole("tab", { name: "Billing" }), press keys and assert aria-selected, not class names.

Layer 2: compound components over context#

Prop getters are flexible but verbose. Most consumers just want tidy JSX, so I wrap the hook in compound components. Context holds the result of useTabs and each part pulls the getter it needs. The snippet uses React 19 syntax: rendering TabsContext directly with a value prop instead of TabsContext.Provider, and use() to read context:

TSX
"use client";

import { createContext, use, type ComponentPropsWithRef, type ReactNode } from "react";
import { Slot } from "radix-ui";
import { useTabs, type UseTabsOptions } from "./use-tabs";

const TabsContext = createContext<ReturnType<typeof useTabs> | null>(null);

function useTabsContext(partName: string) {
  const context = use(TabsContext);
  if (!context) throw new Error(`<Tabs.${partName}> must be rendered inside <Tabs.Root>`);
  return context;
}

export function Root({ children, ...options }: UseTabsOptions & { children: ReactNode }) {
  const tabs = useTabs(options);
  return <TabsContext value={tabs}>{children}</TabsContext>;
}

export function List(props: ComponentPropsWithRef<"div">) {
  const { getTabListProps } = useTabsContext("List");
  return <div {...getTabListProps(props)} />;
}

type TriggerProps = ComponentPropsWithRef<"button"> & { value: string; asChild?: boolean };

export function Trigger({ asChild = false, ...props }: TriggerProps) {
  const { getTabProps } = useTabsContext("Trigger");
  const Component = asChild ? Slot.Root : "button";
  return <Component type={asChild ? undefined : "button"} {...getTabProps(props)} />;
}

export function Panel(props: ComponentPropsWithRef<"div"> & { value: string }) {
  const { getPanelProps } = useTabsContext("Panel");
  return <div {...getPanelProps(props)} />;
}

Consumers import with import * as Tabs from "@/components/tabs", the same convention as Radix:

TSX
<Tabs.Root defaultValue="profile">
  <Tabs.List aria-label="Account settings" className="tab-list">
    <Tabs.Trigger value="profile">Profile</Tabs.Trigger>
    <Tabs.Trigger value="billing" asChild>
      <Button variant="ghost">Billing</Button>
    </Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="profile">
    <ProfileForm />
  </Tabs.Panel>
  <Tabs.Panel value="billing">
    <BillingSummary />
  </Tabs.Panel>
</Tabs.Root>

Two things to weigh with context. First, the object returned by useTabs is new on every render, so every part that reads the context re-renders when Root does. For tabs that's fine. For a listbox with thousands of options, I split the context: one for rarely changing values (ids, configuration), one for frequently changing state, or let each item compare isSelected itself and memoize. Second, a clear error when a part is used in the wrong place ("Tabs.Trigger must be rendered inside Tabs.Root") saves consumers far more debugging time than a vague undefined error.

asChild, render props and the cost of polymorphism#

In the example above, asChild lets a tab render as the design system's Button instead of a bare <button>. Radix's Slot renders no element of its own; it merges its props into its single child: handlers are chained (the child's runs first), refs are composed, className and style are combined. The requirement is that the child spreads the props it receives onto the DOM and accepts a ref. In React 19, ref is a regular prop, so forwardRef is no longer needed, but there's still a classic trap: Button sets its own className after the spread, overwriting the className Slot merged.

There are three ways to solve "render as a different element":

ApproachExampleProsCons
as propas="label"Easy to understandHeavy generic types, unreadable errors, hard to wrap further
asChildRadixSimple types, natural JSXImplicit runtime prop merging, single child only
Render propBase UI's render propExplicit, also receives stateSlightly longer JSX

A correctly typed as prop looks like this:

TSX
import type { ComponentPropsWithRef, ElementType } from "react";

type TextOwnProps = { tone?: "default" | "muted" };

type TextProps<C extends ElementType> = TextOwnProps & { as?: C } & Omit<
    ComponentPropsWithRef<C>,
    keyof TextOwnProps | "as"
  >;

export function Text<C extends ElementType = "span">({ as, tone = "default", ...props }: TextProps<C>) {
  const Component: ElementType = as ?? "span";
  return <Component data-tone={tone} {...props} />;
}

It works: Text with as="label" accepts htmlFor and is type-checked correctly. But the cost shows at scale: Omit over a generic's props makes TypeScript noticeably slower in a large design system, error messages fill the screen, and wrapping Text in memo or a HOC usually loses the generic. I keep as for a handful of typography and layout primitives; for interactive components I choose asChild or a render prop.

When not to build your own#

Writing Tabs like this takes an afternoon. Writing a Combobox, a Menu with submenus, a DatePicker or nested Dialogs that hold up on every screen reader takes months, and most of that time goes into edge cases you don't know exist yet: typeahead, VoiceOver on iOS, IME input, focus when an element is removed from the DOM. As of mid-2026, this is how I choose:

LibraryGood fit whenNotes
Radix PrimitivesYou want familiar primitives with lots of docsUses asChild, now ships a unified radix-ui package, no official Combobox yet
Base UIStarting a new design systemStable since v1.0 (Dec 2025), uses a render prop, has a Combobox; shadcn/ui made it the default in July 2026
React AriaStrict accessibility and i18n requirementsShips both hooks (react-aria) and components, strong on dates, RTL and locales; more concepts to learn
DownshiftYou only need a custom combobox or selectWhere prop getters became popular

My rule: use a library for behavior, own the rendering yourself. That's also the shadcn/ui model, which now supports Radix, Base UI and React Aria as its foundation. I only build my own when the component is simple enough that its keyboard table fits in ten lines (tabs, disclosure, toggle group), or when the behavior is genuinely specific enough that no library models it.

Key takeaways#

  • Separate the behavior contract (state, focus, keyboard, ARIA) from the rendering contract; accessibility belongs to the behavior layer.
  • Use prop getters to merge consumer props: chain handlers, respect event.defaultPrevented, never overwrite ref.
  • Support controlled and uncontrolled modes with one shared useControllableState; use null instead of undefined for "nothing selected".
  • Write the keyboard table from the WAI-ARIA APG before the code, use roving tabindex, and test with roles and key presses.
  • Compound components over context give tidy JSX; throw clear errors on misuse and split context for large lists.
  • Prefer asChild or a render prop for interactive components; keep the as prop for a few simple primitives.
  • Use Radix, Base UI or React Aria for comboboxes, menus and date pickers; only build your own when the behavior is small enough to fully control.