Skip to the code
cf.

Tabs with keyboard support

The tea shop shows brewing notes for each kind of tea in tabs. Build App: it takes tabs, a list of { id, label, content }, and label, the name of the tab list. When they aren't given, as in the Preview, show a few tabs of your own and the label Brewing guide.

Render a <div role="tablist"> with aria-label set to label, holding one <button role="tab"> per item with its label as text. The selected tab has aria-selected="true", the others "false". The first tab is selected at first.

Each tab has a panel: a <div role="tabpanel"> with the item's content, tabIndex={0}, and aria-labelledby set to its tab's id. Each tab's aria-controls is its panel's id. Every panel is always in the page; all but the selected one have the hidden attribute. Make the ids unique on the page.

Clicking a tab selects it. Only the selected tab is in the Tab order (tabIndex={0}; the others get -1), so Tab from the selected tab goes straight to its panel.

With the focus on a tab, ArrowRight and ArrowLeft move the focus to the next and previous tab and select it, wrapping around at the ends. Home and End move to the first and last tab.

<App label="Brewing guide" tabs={[
  { id: "green", label: "Green", content: "Steep at 75 degrees for two minutes." },
  { id: "black", label: "Black", content: "Steep at 95 degrees for four minutes." },
]} />
 
// renders:
<div role="tablist" aria-label="Brewing guide">
  <button role="tab" id="t-green" aria-selected="true" aria-controls="p-green" tabindex="0">Green</button>
  <button role="tab" id="t-black" aria-selected="false" aria-controls="p-black" tabindex="-1">Black</button>
</div>
<div role="tabpanel" id="p-green" aria-labelledby="t-green" tabindex="0">Steep at 75 degrees for two minutes.</div>
<div role="tabpanel" id="p-black" aria-labelledby="t-black" tabindex="0" hidden>Steep at 95 degrees for four minutes.</div>

What the tests check

  1. renders a labelled tablist with one tab per item
    const tabs = [
      { id: "green", label: "Green", content: "Steep at 75 degrees for two minutes." },
      { id: "black", label: "Black", content: "Steep at 95 degrees for four minutes." },
      { id: "oolong", label: "Oolong", content: "Steep at 85 degrees for three minutes." },
    ];
    render(<App tabs={tabs} label="Brewing guide" />);
    const tablist = screen.getByRole("tablist", { name: "Brewing guide" });
    expect(within(tablist).getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["Green", "Black", "Oolong"]);
    
  2. selects the first tab at first and shows only its panel
    const tabs = [
      { id: "green", label: "Green", content: "Steep at 75 degrees for two minutes." },
      { id: "black", label: "Black", content: "Steep at 95 degrees for four minutes." },
      { id: "oolong", label: "Oolong", content: "Steep at 85 degrees for three minutes." },
    ];
    render(<App tabs={tabs} label="Brewing guide" />);
    expect(screen.getByRole("tab", { name: "Green" })).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("tab", { name: "Black" })).toHaveAttribute("aria-selected", "false");
    expect(screen.getByRole("tabpanel")).toHaveTextContent("Steep at 75 degrees for two minutes.");
    expect(screen.getByText("Steep at 95 degrees for four minutes.")).not.toBeVisible();
    
  3. clicking a tab selects it and shows its panel
    const tabs = [
      { id: "green", label: "Green", content: "Steep at 75 degrees for two minutes." },
      { id: "black", label: "Black", content: "Steep at 95 degrees for four minutes." },
      { id: "oolong", label: "Oolong", content: "Steep at 85 degrees for three minutes." },
    ];
    render(<App tabs={tabs} label="Brewing guide" />);
    await user.click(screen.getByRole("tab", { name: "Oolong" }));
    expect(screen.getByRole("tab", { name: "Oolong" })).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("tab", { name: "Green" })).toHaveAttribute("aria-selected", "false");
    expect(screen.getByRole("tabpanel", { name: "Oolong" })).toHaveTextContent("Steep at 85 degrees for three minutes.");
    
  4. each tab controls its panel, and the panel is labelled by the tab
    const tabs = [
      { id: "green", label: "Green", content: "Steep at 75 degrees for two minutes." },
      { id: "black", label: "Black", content: "Steep at 95 degrees for four minutes." },
      { id: "oolong", label: "Oolong", content: "Steep at 85 degrees for three minutes." },
    ];
    render(<App tabs={tabs} label="Brewing guide" />);
    for (const item of tabs) {
      const tab = screen.getByRole("tab", { name: item.label });
      const panel = document.getElementById(tab.getAttribute("aria-controls"));
      expect(panel).toHaveAttribute("role", "tabpanel");
      expect(panel).toHaveTextContent(item.content);
      expect(tab.id).toBeTruthy();
      expect(panel).toHaveAttribute("aria-labelledby", tab.id);
    }
    
  5. only the selected tab is in the Tab order, and Tab goes on to the panel
    const tabs = [
      { id: "green", label: "Green", content: "Steep at 75 degrees for two minutes." },
      { id: "black", label: "Black", content: "Steep at 95 degrees for four minutes." },
      { id: "oolong", label: "Oolong", content: "Steep at 85 degrees for three minutes." },
    ];
    render(<App tabs={tabs} label="Brewing guide" />);
    await user.click(screen.getByRole("tab", { name: "Black" }));
    expect(screen.getByRole("tab", { name: "Black" })).toHaveAttribute("tabindex", "0");
    expect(screen.getByRole("tab", { name: "Green" })).toHaveAttribute("tabindex", "-1");
    expect(screen.getByRole("tab", { name: "Oolong" })).toHaveAttribute("tabindex", "-1");
    await user.tab();
    expect(screen.getByRole("tabpanel", { name: "Black" })).toHaveFocus();
    
  6. ArrowRight moves to the next tab and selects it, wrapping at the end
    const tabs = [
      { id: "green", label: "Green", content: "Steep at 75 degrees for two minutes." },
      { id: "black", label: "Black", content: "Steep at 95 degrees for four minutes." },
      { id: "oolong", label: "Oolong", content: "Steep at 85 degrees for three minutes." },
    ];
    render(<App tabs={tabs} label="Brewing guide" />);
    await user.click(screen.getByRole("tab", { name: "Green" }));
    await user.keyboard("{ArrowRight}");
    expect(screen.getByRole("tab", { name: "Black" })).toHaveFocus();
    expect(screen.getByRole("tab", { name: "Black" })).toHaveAttribute("aria-selected", "true");
    await user.keyboard("{ArrowRight}{ArrowRight}");
    expect(screen.getByRole("tab", { name: "Green" })).toHaveFocus();
    expect(screen.getByRole("tab", { name: "Green" })).toHaveAttribute("aria-selected", "true");
    
  7. ArrowLeft on the first tab wraps to the last
    const tabs = [
      { id: "green", label: "Green", content: "Steep at 75 degrees for two minutes." },
      { id: "black", label: "Black", content: "Steep at 95 degrees for four minutes." },
      { id: "oolong", label: "Oolong", content: "Steep at 85 degrees for three minutes." },
    ];
    render(<App tabs={tabs} label="Brewing guide" />);
    await user.click(screen.getByRole("tab", { name: "Green" }));
    await user.keyboard("{ArrowLeft}");
    expect(screen.getByRole("tab", { name: "Oolong" })).toHaveFocus();
    expect(screen.getByRole("tab", { name: "Oolong" })).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("tabpanel")).toHaveTextContent("Steep at 85 degrees for three minutes.");
    
  8. Home and End jump to the first and last tab
    const tabs = [
      { id: "green", label: "Green", content: "Steep at 75 degrees for two minutes." },
      { id: "black", label: "Black", content: "Steep at 95 degrees for four minutes." },
      { id: "oolong", label: "Oolong", content: "Steep at 85 degrees for three minutes." },
    ];
    render(<App tabs={tabs} label="Brewing guide" />);
    await user.click(screen.getByRole("tab", { name: "Black" }));
    await user.keyboard("{End}");
    expect(screen.getByRole("tab", { name: "Oolong" })).toHaveFocus();
    expect(screen.getByRole("tab", { name: "Oolong" })).toHaveAttribute("aria-selected", "true");
    await user.keyboard("{Home}");
    expect(screen.getByRole("tab", { name: "Green" })).toHaveFocus();
    expect(screen.getByRole("tab", { name: "Green" })).toHaveAttribute("aria-selected", "true");
    
  9. two tab lists on one page get different ids
    const tabs = [
      { id: "green", label: "Green", content: "Steep at 75 degrees for two minutes." },
      { id: "black", label: "Black", content: "Steep at 95 degrees for four minutes." },
      { id: "oolong", label: "Oolong", content: "Steep at 85 degrees for three minutes." },
    ];
    render(
      <>
        <App tabs={tabs} label="First" />
        <App tabs={tabs} label="Second" />
      </>,
    );
    const ids = screen.getAllByRole("tab").map((tab) => tab.id);
    expect(new Set(ids).size).toBe(6);
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).