# Accessible Animated Tabs in React and Tailwind CSS Without a Library

> Use role="tablist" with tab buttons, aria-selected, and panels linked by aria-controls and aria-labelledby. Only the active tab is in the tab order; arrow keys, Home and End move between tabs. Measure the active tab in useLayoutEffect to slide the indicator, and turn the transition on after the first measure. For filters like Week/Month, use buttons with aria-pressed instead.

- URL: https://qodesign.dev/blog/accessible-animated-tabs-react-tailwind-no-library
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

Accessible tabs in React need four things: a `tablist` with `tab` buttons inside it, `aria-selected` on the active tab, a `tabpanel` linked to each tab with `aria-controls` and `aria-labelledby`, and arrow keys that move between tabs while the Tab key jumps straight to the panel. The sliding indicator under the active tab is the easy part. This post builds both in one client component of about 130 lines, with no library, plus a smaller segmented control for filters like Week and Month.

I make [qodesign](/), a set of animated React + TypeScript + Tailwind components. Our [Analytics Dashboard](/components/analytics-dashboard-v1) has a Week/Month switch where a lime pill slides under the chosen option and every number on the dashboard animates to the new range, so this post comes out of building that. The code here is a simplified version written for this post, not the component's source.

## Tabs or a segmented control?

Both are a row of options with a pill under the chosen one. They do different jobs.

1. Tabs swap one panel of content for another. Profile, Billing and Team are tabs: each one shows a different section. That's the `tablist` pattern below.
2. A segmented control changes the view you're already looking at. Week and Month on a dashboard don't open a new panel, they filter the same charts. That's a group of toggle buttons with `aria-pressed`, covered further down.

If you give a filter the tab roles, screen readers announce "tab, 1 of 2" and users expect a new panel that never appears. So ask what the click does: shows a different section, or changes the same one.

## What the ARIA tabs pattern asks for

The [ARIA Authoring Practices tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) is the reference here, and [MDN's page on the tab role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/tab_role) covers the same ground. The parts that matter:

- The container has `role="tablist"` and a label, either `aria-label` or `aria-labelledby` pointing at a visible heading.
- Each tab has `role="tab"`, `aria-selected` set to true or false, and `aria-controls` pointing at its panel.
- Each panel has `role="tabpanel"` and `aria-labelledby` pointing back at its tab. If the panel has nothing focusable at the start, it gets `tabindex="0"` so keyboard users can land on it.
- Left and Right arrows move between tabs and wrap around at the ends. Home and End jump to the first and last tab.
- Tab moves focus into the tab list onto the active tab, and the next Tab press leaves the list. Only the active tab is in the tab order, which is what `tabIndex={selected ? 0 : -1}` does.

The pattern allows two kinds of activation. With automatic activation, a tab shows its panel as soon as it gets focus from the arrow keys. With manual activation, the arrows only move focus, and Enter or Space shows the panel. The APG recommends automatic activation when panels show without noticeable delay, which is the case when the content is already rendered, so that's what the component uses.

## The Tabs component

```tsx
// Tabs.tsx
"use client";

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";

export type TabItem = { id: string; label: string; content: ReactNode };

type TabsProps = {
  items: TabItem[];
  label: string;
  defaultId?: string;
};

export function Tabs({ items, label, defaultId }: TabsProps) {
  const base = useId();
  const [active, setActive] = useState(defaultId ?? items[0]?.id);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const listRef = useRef<HTMLDivElement>(null);
  const [bar, setBar] = useState({ x: 0, w: 0, ready: false });
  const [animate, setAnimate] = useState(false);

  const activeIndex = Math.max(0, items.findIndex((t) => t.id === active));

  // Measure the active tab and move the indicator under it.
  useLayoutEffect(() => {
    const list = listRef.current;
    const measure = () => {
      const el = tabRefs.current[activeIndex];
      if (el) setBar({ x: el.offsetLeft, w: el.offsetWidth, ready: true });
    };
    measure();
    if (!list) return;
    const ro = new ResizeObserver(measure);
    ro.observe(list);
    tabRefs.current.forEach((el) => el && ro.observe(el));
    return () => ro.disconnect();
  }, [activeIndex]);

  // Turn the transition on one frame after the first measure,
  // so the indicator doesn't slide in from the left on load.
  useEffect(() => {
    if (!bar.ready) return;
    const id = requestAnimationFrame(() => setAnimate(true));
    return () => cancelAnimationFrame(id);
  }, [bar.ready]);

  function select(index: number) {
    const next = (index + items.length) % items.length;
    setActive(items[next].id);
    tabRefs.current[next]?.focus();
  }

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (e.key === "ArrowRight") select(activeIndex + 1);
    else if (e.key === "ArrowLeft") select(activeIndex - 1);
    else if (e.key === "Home") select(0);
    else if (e.key === "End") select(items.length - 1);
    else return;
    e.preventDefault();
  }

  return (
    <div>
      <div
        ref={listRef}
        role="tablist"
        aria-label={label}
        onKeyDown={onKeyDown}
        className="relative flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1"
      >
        <span
          aria-hidden="true"
          className={`absolute inset-y-1 left-0 rounded-lg bg-lime-300 ${
            animate
              ? "transition-[transform,width] duration-[450ms] ease-[cubic-bezier(.6,-.2,.3,1.3)] motion-reduce:transition-none"
              : ""
          } ${bar.ready ? "opacity-100" : "opacity-0"}`}
          style={{ width: bar.w, transform: `translateX(${bar.x}px)` }}
        />
        {items.map((t, i) => {
          const selected = i === activeIndex;
          return (
            <button
              key={t.id}
              ref={(el) => {
                tabRefs.current[i] = el;
              }}
              id={`${base}-tab-${t.id}`}
              type="button"
              role="tab"
              aria-selected={selected}
              aria-controls={`${base}-panel-${t.id}`}
              tabIndex={selected ? 0 : -1}
              onClick={() => setActive(t.id)}
              className={`relative z-10 rounded-lg px-4 py-2 text-sm font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lime-300 ${
                selected ? "text-zinc-950" : "text-zinc-400 hover:text-zinc-100"
              }`}
            >
              {t.label}
            </button>
          );
        })}
      </div>

      {items.map((t, i) => (
        <div
          key={t.id}
          id={`${base}-panel-${t.id}`}
          role="tabpanel"
          aria-labelledby={`${base}-tab-${t.id}`}
          tabIndex={0}
          hidden={i !== activeIndex}
          className="mt-4 rounded-xl focus-visible:outline-2 focus-visible:outline-lime-300"
        >
          {t.content}
        </div>
      ))}
    </div>
  );
}
```

How the indicator works: the pill is one absolutely positioned `span` behind the buttons. After render, `useLayoutEffect` reads the active button's `offsetLeft` and `offsetWidth` and moves the pill there with `transform` and `width`. Our dashboard switch measures its buttons the same way. A `ResizeObserver` measures again when the list or any tab changes size, which covers web fonts loading late, a window resize, or a label that changes language.

Three details that are easy to miss:

1. The pill starts invisible. The server doesn't know how wide the buttons are, so the first HTML renders the pill at width 0 with `opacity-0`. It shows once the first measurement lands.
2. The transition only turns on one frame after that first measurement. Without the `animate` flag, the pill would slide in from the left edge on every page load.
3. The easing `cubic-bezier(.6,-.2,.3,1.3)` goes slightly below 0 and above 1, so the pill pulls back a little before it moves and overshoots a little at the end. That's the feel of our dashboard's pill, at 0.45 s. If you want it calmer, use `ease-out` and 250 ms.

The panels all stay in the DOM and the inactive ones get the `hidden` attribute. That keeps automatic activation instant, and the state inside a panel, like a half-filled form, survives switching tabs. If a panel is expensive to render, render only the active one and switch to manual activation.

## Using it from a Server Component

```tsx
// app/settings/page.tsx
import { Tabs } from "./Tabs";

export default function SettingsPage() {
  return (
    <main className="mx-auto max-w-xl p-6">
      <Tabs
        label="Account settings"
        items={[
          { id: "profile", label: "Profile", content: <p>Your name and avatar.</p> },
          { id: "billing", label: "Billing", content: <p>Plan, invoices and card.</p> },
          { id: "team", label: "Team", content: <p>Invite people to your workspace.</p> },
        ]}
      />
    </main>
  );
}
```

The page itself stays a Server Component. Only `Tabs.tsx` has `"use client"`, and the panel content you pass in can be server-rendered JSX. If you see errors about client-only code in a page like this, our post on [use client errors with component libraries in Next.js](/blog/use-client-errors-component-libraries-nextjs) explains where the line goes.

## A segmented control for filters

For Week and Month, where the click changes the data and not the panel, a group of toggle buttons is the right pattern:

```tsx
// RangeSwitch.tsx
"use client";

type Range = "week" | "month";

type RangeSwitchProps = {
  value: Range;
  onChange: (next: Range) => void;
};

const OPTIONS: { value: Range; label: string }[] = [
  { value: "week", label: "Week" },
  { value: "month", label: "Month" },
];

export function RangeSwitch({ value, onChange }: RangeSwitchProps) {
  const index = OPTIONS.findIndex((o) => o.value === value);

  return (
    <div
      role="group"
      aria-label="Date range"
      className="relative grid grid-cols-2 rounded-xl border border-white/10 bg-white/5 p-1"
    >
      <span
        aria-hidden="true"
        className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-lg bg-lime-300 transition-transform duration-[450ms] ease-[cubic-bezier(.6,-.2,.3,1.3)] motion-reduce:transition-none"
        style={{ transform: `translateX(${index * 100}%)` }}
      />
      {OPTIONS.map((o) => (
        <button
          key={o.value}
          type="button"
          aria-pressed={o.value === value}
          onClick={() => onChange(o.value)}
          className={`relative z-10 rounded-lg px-5 py-2 text-sm font-semibold transition-colors ${
            o.value === value ? "text-zinc-950" : "text-zinc-400"
          }`}
        >
          {o.label}
        </button>
      ))}
    </div>
  );
}
```

This one needs no measuring at all. Both options are the same width because the container is a two-column grid, so the pill is half the width and moves by `translateX(100%)` for the second option. The position is known on the server, so there's no invisible first frame and nothing to hydrate. Use this version whenever your options can be equal width. It works for three or four options too: change `grid-cols-2`, the pill's width and the index math.

`aria-pressed` tells screen readers which option is on, and the `role="group"` with a label tells them the buttons belong together. The arrow keys aren't wired here on purpose. These are ordinary buttons, so Tab moves between them like any other buttons on the page.

## Reduced motion

Both components use `motion-reduce:transition-none`, so people who turn on reduced motion in their system settings see the pill jump into place instead of sliding. The colour change on the labels is short and stays. There's more on doing this across a whole app in [prefers-reduced-motion in React](/blog/prefers-reduced-motion-react).

## Four bugs that show up in AI-generated tabs

1. Every tab is in the tab order. Pressing Tab walks through all five tabs before reaching the content. Fix: `tabIndex={selected ? 0 : -1}` and arrow keys for the rest.
2. Divs instead of buttons. A `div` with `onClick` can't take focus and doesn't answer to Enter or Space. Use `button type="button"`.
3. The pill is measured in `useEffect` and flickers. `useEffect` runs after the browser paints, so the pill shows in the wrong place for a frame. `useLayoutEffect` runs before paint.
4. Ids that change between server and client. Building ids with `Math.random()` gives a hydration warning. `useId` gives the same id on both sides; we covered the general case in [fixing hydration errors in Next.js](/blog/fix-hydration-error-dates-random-nextjs).

If you're prompting Cursor, v0 or Lovable for tabs, name the parts AI tools leave out: `role="tablist"` with a label, `aria-selected`, `aria-controls` and `aria-labelledby`, a roving `tabIndex`, Left, Right, Home and End keys, and `useLayoutEffect` for the indicator. Most first drafts give you a row of styled buttons and a pill, and nothing else.

## FAQ

### Do I need a library like Radix or Headless UI for tabs?

Not for the tabs above. A library helps when you need vertical tabs, tabs inside tabs, or tabs that can be closed and reordered, because the keyboard rules get longer. If you already use shadcn/ui, its Tabs component is built on Radix and covers those cases.

### Should the URL change when the tab changes?

If people will want to share or bookmark a tab, yes. Keep the active tab in a search param like `?tab=billing`, read it on the server, and pass it as `defaultId`. For small UI choices inside a card, local state is enough.

### Does this work with Tailwind v3?

Yes. Every class here exists in Tailwind v3.4, including the arbitrary values for duration, easing and width. One difference: in v3, add `focus-visible:outline` next to `focus-visible:outline-2` so the focus ring is solid. In v4, `outline-2` sets the style for you.

### Where can I see the sliding pill in a real dashboard?

The [Analytics Dashboard](/components/analytics-dashboard-v1) has the Week/Month switch from this post, with the KPI numbers counting up, the bars springing to the new values and the goal ring refilling when you switch. To build that count-up yourself, [Animated Number Counter in React Without a Library](/blog/animated-number-counter-react-no-library) eases each number from its old value to the new one, so switching the range never makes the figures jump. There's a walkthrough in our [animated analytics dashboard guide](/react/animated-analytics-dashboard). For a mobile bottom bar with a moving indicator, see [the animated tab bar in the Next.js App Router](/blog/animated-tab-bar-nextjs-app-router), and for the chart side, [an animated SVG line chart without a library](/blog/animated-svg-line-chart-react-no-library).
