Improving Accessibility - Tabs Component

Published: (February 3, 2026 at 09:42 PM EST)
6 min read
Source: Dev.to

Source: Dev.to

Cover image for Improving Accessibility – Tabs Component

Why Tabs?

  • Tabs look simple, but they force you to think about:
    • Keyboard navigation (Arrow keys vs Tab key)
    • Focus management
    • ARIA roles and relationships
    • Disabled‑state handling
    • Component composition and APIs

In interviews and real apps, tabs are a very common a11y failure point.

Key Concepts Used in This Implementation

1. Compound Component Pattern

Instead of one giant component, we expose a small, expressive API:


  
    
  

  
    
  
  • Keeps the API expressive
  • Avoids prop drilling
  • Mirrors how headless‑UI libraries are designed

2. Children.map + cloneElement

We need to:

  • Inject an index into each child
  • Attach refs for programmatic focus
  • Preserve any user‑defined JSX

Children.map and cloneElement make this straightforward.

3. forwardRef

Keyboard navigation requires programmatic focus movement.
The parent must be able to call .focus() on each tab, and forwardRef enables that cleanly.

4. Accessibility First

The component follows the WAI‑ARIA Tabs Authoring Practices, including:

  • role="tablist", role="tab", role="tabpanel"
  • aria-selected, aria-controls, aria-labelledby
  • Roving tabIndex
  • Skipping disabled tabs
  • Automatic activation model

Accessibility Behavior (What This Component Supports)

  • ArrowLeft / ArrowRight navigate between tabs.
  • Only one tab is tabbable at a time.
  • Disabled tabs are skipped during keyboard navigation.
  • Panels are hidden from screen readers when inactive.
  • Focus never jumps unexpectedly.

Full Source Code

import type React from "react";
import {
  createContext,
  useState,
  type ReactNode,
  Children,
  cloneElement,
  useContext,
  useRef,
  forwardRef,
  isValidElement,
  useEffect,
} from "react";
import styles from "./style.module.css";

/**
 * TabsContext holds shared state for the compound components
 */
const TabsContext = createContext<{
  activeIndex: number;
  setActiveIndex: (i: number) => void;
  onChange?: (index: number) => void;
  disableMap: Array<boolean>;
  setDisableMap: React.Dispatch<React.SetStateAction<Array<boolean>>>;
} | null>(null);

/**
 * Safe context hook
 */
// eslint-disable-next-line react-refresh/only-export-components
export const useTabsContext = () => {
  const context = useContext(TabsContext);
  if (context === null) {
    throw new Error(
      "useTabsContext must be used within a TabsContext.Provider"
    );
  }
  return context;
};

interface TabsProps {
  children: ReactNode;
  onChange?: (index: number) => void;
}

interface TabListProps {
  children: ReactNode;
}

interface PanelListProps {
  children: ReactNode;
}

type TabProps = React.ComponentPropsWithRef<"button"> & {
  index: number;
  children: ReactNode;
};

interface TabPanelProps {
  index: number;
  children: ReactNode;
}

export function TabList({ children }: TabListProps) {
  const { activeIndex, setActiveIndex, onChange, disableMap } =
    useTabsContext();
  const tabsRef = useRef<Array<HTMLButtonElement | null>>([]);
  const childrenLength = Children.count(children);

  function handleKeyDown(e: React.KeyboardEvent) {
    const { key } = e;

    const getNextIndex = () => {
      for (let i = activeIndex + 1; i < childrenLength; i++) {
        if (!disableMap[i]) return i;
      }
      for (let i = 0; i <= activeIndex; i++) {
        if (!disableMap[i]) return i;
      }
      return activeIndex;
    };

    const getPrevIndex = () => {
      for (let i = activeIndex - 1; i >= 0; i--) {
        if (!disableMap[i]) return i;
      }
      for (let i = childrenLength - 1; i >= activeIndex; i--) {
        if (!disableMap[i]) return i;
      }
      return activeIndex;
    };

    if (key === "ArrowRight") {
      e.preventDefault();
      const nextIndex = getNextIndex();
      tabsRef.current[nextIndex]?.focus();
      setActiveIndex(nextIndex);
      if (typeof onChange === "function") onChange(nextIndex);
    } else if (key === "ArrowLeft") {
      e.preventDefault();
      const prevIndex = getPrevIndex();
      tabsRef.current[prevIndex]?.focus();
      setActiveIndex(prevIndex);
      if (typeof onChange === "function") onChange(prevIndex);
    }
  }

  return (
    <div role="tablist" onKeyDown={handleKeyDown}>
      {Children.map(children, (child, index) =>
        cloneElement(child as React.ReactElement, {
          index,
          ref: (el: HTMLButtonElement | null) => {
            tabsRef.current[index] = el;
          },
        })
      )}
    </div>
  );
}

export function PanelList({ children }: PanelListProps) {
  return (
    <div>
      {Children.map(children, (child, index) => {
        if (!isValidElement(child)) return null;
        return cloneElement(child, { index });
      })}
    </div>
  );
}

export const Tab = forwardRef<HTMLButtonElement, TabProps>((props, ref) => {
  const { index, children, disabled, ...restProps } = props;
  const { activeIndex, setActiveIndex, onChange, setDisableMap } =
    useTabsContext();

  function clickHandler() {
    setActiveIndex(index);
    if (typeof onChange === "function") onChange(index);
  }

  useEffect(() => {
    setDisableMap((prev) => {
      const next = [...prev];
      next[index] = !!disabled;
      return next;
    });
  }, [disabled, index, setDisableMap]);

  return (
    <button
      role="tab"
      aria-selected={activeIndex === index}
      aria-controls={`panel-${index}`}
      tabIndex={activeIndex === index ? 0 : -1}
      disabled={disabled}
      onClick={clickHandler}
      ref={ref}
      {...restProps}
    >
      {children}
    </button>
  );
});

export const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
  ({ index, children, ...rest }, ref) => {
    const { activeIndex } = useTabsContext();
    return (
      <div
        role="tabpanel"
        id={`panel-${index}`}
        aria-labelledby={`tab-${index}`}
        hidden={activeIndex !== index}
        ref={ref}
        {...rest}
      >
        {children}
      </div>
    );
  }
);

export function Tabs({ children, onChange }: TabsProps) {
  const [activeIndex, setActiveIndex] = useState(0);
  const [disableMap, setDisableMap] = useState<Array<boolean>>([]);

  return (
    <TabsContext.Provider
      value={{
        activeIndex,
        setActiveIndex,
        onChange,
        disableMap,
        setDisableMap,
      }}
    >
      {children}
    </TabsContext.Provider>
  );
}

The code above demonstrates a fully accessible, compound‑component tabs implementation that follows the WAI‑ARIA Authoring Practices.

Tabs Component (React)

import {
  createContext,
  useContext,
  useEffect,
  useState,
  useCallback,
  Children,
  forwardRef,
  Ref,
} from "react";
import styles from "./Tabs.module.css";

type TabsContextProps = {
  activeIndex: number;
  setActiveIndex: (index: number) => void;
  onChange?: (index: number) => void;
  disableMap: boolean[];
  setDisableMap: (map: boolean[]) => void;
};

const TabsContext = createContext<TabsContextProps | undefined>(undefined);

function useTabsContext() {
  const ctx = useContext(TabsContext);
  if (!ctx) {
    throw new Error("Tabs components must be used within a provider");
  }
  return ctx;
}

/* -------------------------------------------------
   Tab Button
--------------------------------------------------- */
type TabButtonProps = {
  children: React.ReactNode;
  index: number;
  disabled?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

export const TabButton = forwardRef(
  (
    { children, index, disabled = false, ...restProps }: TabButtonProps,
    ref: Ref<HTMLButtonElement>
  ) => {
    const { activeIndex, setActiveIndex, onChange, disableMap, setDisableMap } =
      useTabsContext();

    const clickHandler = useCallback(() => {
      if (disabled) return;
      setActiveIndex(index);
      onChange?.(index);
    }, [disabled, index, setActiveIndex, onChange]);

    useEffect(() => {
      setDisableMap((prev) => {
        const temp = [...prev];
        temp[index] = !!disabled;
        return temp;
      });
    }, [disabled, index, setDisableMap]);

    return (
      <button
        role="tab"
        aria-selected={activeIndex === index}
        aria-controls={`panel-${index}`}
        tabIndex={activeIndex === index ? 0 : -1}
        disabled={disabled}
        onClick={clickHandler}
        ref={ref}
        {...restProps}
      >
        {children}
      </button>
    );
  }
);

/* -------------------------------------------------
   Tab Panel
--------------------------------------------------- */
type TabPanelProps = {
  children: React.ReactNode;
  index: number;
};

export function TabPanel({ children, index }: TabPanelProps) {
  const { activeIndex } = useTabsContext();
  return (
    <div
      role="tabpanel"
      id={`panel-${index}`}
      aria-labelledby={`tab-${index}`}
      hidden={activeIndex !== index}
    >
      {children}
    </div>
  );
}

/* -------------------------------------------------
   Root Tabs component
--------------------------------------------------- */
type RootTabsProps = {
  children: React.ReactNode;
  onChange?: (index: number) => void;
};

function Tabs({ children, onChange }: RootTabsProps) {
  const [activeIndex, setActiveIndex] = useState(0);
  const [disableMap, setDisableMap] = useState(() =>
    Array(Children.count(children)).fill(false)
  );

  return (
    <TabsContext.Provider
      value={{
        activeIndex,
        setActiveIndex,
        onChange,
        disableMap,
        setDisableMap,
      }}
    >
      {children}
    </TabsContext.Provider>
  );
}

export default Tabs;

Styles (Tabs.module.css)

.tabBtn:focus {
  outline: 2px solid orangered;
  outline-offset: 4px;
}

.activeBtn {
  background-color: green;
  color: #fff;
  font-weight: 700;
}

.hidden {
  display: none;
}

Personal Note

I learned and revised more concepts than I expected before I started working on this and it was really fun.
Any suggestions and improvements are always welcome.

Back to Blog

Related posts

Read more »

Build an Accessible Audio Controller

Overview After two days of ARIA theory lessons on freeCodeCamp, the next workshop focused on building an accessible audio controller. The session began with a...

From Prop Drilling to Context API😏

Introduction – Why I Decided to Deep‑Dive Into Code Flow For the last few days, I wasn’t just coding — I was trying to understand the soul of a React project....