TL;DR 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.
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 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.
Both are a row of options with a pill under the chosen one. They do different jobs.
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.
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.
Analytics Dashboard V1 running live. A dark analytics dashboard where every number counts up live.
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.
// 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:
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.
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.
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.
// app/settings/page.tsximport { 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 explains where the line goes.
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.
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.
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.
Divs instead of buttons. A div with onClick can't take focus and doesn't answer to Enter or Space. Use button type="button".
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.
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.
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.
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.
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.
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.