Tabs

Switch a view in place, or move between pages.

Overview

Tabs switch a panel on the same page. A row of links to other pages is NavTabs.

Switch view

Do
Showing overview.
import { useState } from "react";
import { Body, Stack, Tabs } from "@hackcove/ui";

function Example() {
  const [value, setValue] = useState("overview");
  return (
    <Stack>
      <Tabs
        label="Project views"
        value={value}
        onChange={setValue}
        items={[
          { value: "overview", label: "Overview", controls: "panel" },
          { value: "activity", label: "Activity", controls: "panel" },
          { value: "settings", label: "Settings", controls: "panel" },
        ]}
      />
      <Body id="panel" role="tabpanel">
        Showing {value}.
      </Body>
    </Stack>
  );
}
Tabs switches a view in place; the panel it controls sits right under the row.

Switch page

Do
import { NavTabs } from "@hackcove/ui";

<NavTabs
  label="Sections"
  items={[
    { label: "Quotes", href: "#quotes", active: true },
    { label: "Invoices", href: "#invoices" },
  ]}
/>
Going to another page is not a tab: NavTabs are links, so the URL and history move.
Don't
import { useState } from "react";
import { Tabs } from "@hackcove/ui";

function Example() {
  const [value, setValue] = useState("quotes");
  return (
    <Tabs
      label="Sections"
      value={value}
      onChange={(next) => {
        setValue(next);
        location.assign(`#${next}`);
      }}
      items={[
        { value: "quotes", label: "Quotes" },
        { value: "invoices", label: "Invoices" },
      ]}
    />
  );
}
A Tabs row that only navigates leaves the URL, the back button and a new tab behind.

Count

Do
import { useState } from "react";
import { Tabs } from "@hackcove/ui";

function Example() {
  const [value, setValue] = useState("shots");
  return (
    <Tabs
      label="Assets"
      value={value}
      onChange={setValue}
      items={[
        { value: "shots", label: "Shots", count: 12 },
        { value: "clips", label: "Clips", count: 3 },
      ]}
    />
  );
}
A count sits muted after the label and is read as part of the tab's name.
Don't
import { useState } from "react";
import { Tabs } from "@hackcove/ui";

function Example() {
  const [value, setValue] = useState("shots");
  return (
    <Tabs
      label="Assets"
      value={value}
      onChange={setValue}
      items={[
        { value: "shots", label: "Shots (12)" },
        { value: "clips", label: "Clips (3)" },
      ]}
    />
  );
}
A count typed into the label is plain text: it does not mute, and it cannot be styled the same.

Rail

Do
import { NavTabs } from "@hackcove/ui";

<NavTabs
  orientation="vertical"
  label="Sections"
  items={[
    { label: "Overview", href: "#overview" },
    { label: "Button", href: "#button", active: true },
    { label: "Card", href: "#card" },
  ]}
/>
A rail of links is NavTabs with orientation="vertical": one nav landmark, the current page marked.
Don't
import { ButtonLink, Stack } from "@hackcove/ui";

<Stack gap="xs">
  <ButtonLink href="#overview" variant="ghost">
    Overview
  </ButtonLink>
  <ButtonLink href="#button" variant="ghost">
    Button
  </ButtonLink>
  <ButtonLink href="#card" variant="ghost">
    Card
  </ButtonLink>
</Stack>
A stack of ghost ButtonLinks is not a nav landmark, and nothing marks the current page.

Usage

  • Tabs switches a view in place; the caller owns value and onChange, and the panel sits right under the row.
  • Going to another page is NavTabs: links, so the URL, the back button and a new tab all work.
  • A count sits muted after the label and is read as part of the tab's name.
  • variant="pill" fills the current tab, for under a PageHeader.
  • A disabled tab keeps a title as the reason.
  • NavTabs with orientation="vertical" is the rail of a SidebarLayout: one link per line.

Accessibility

  • The row is a tab list named by label; the arrow keys, Home and End move between the tabs, and a disabled tab is skipped.
  • Give the panel role="tabpanel" and a tab controls that points at its id.
  • A tab is a button with role="tab" and aria-selected; only the current tab is in the tab order, and the arrow keys move and choose.
  • NavTabs is a <nav> named by label; the current link gets aria-current="page".

API

Tabs

Underline tabs for switching a view in place: a row over a bottom rule, the current tab underlined in the accent. items are { value, label } buttons (value and onChange are the caller's state; the arrow keys, Home and End move between tabs) or links (href, or render to draw the app's own router <Link>; value marks the current one, which gets aria-current). variant="pill" fills the current tab instead, with no rule, for under a PageHeader. An item may carry a count, and a button one disabled (with a title for the reason); disabled on the row disables every tab. label names the tab list. The panel is the caller's: give it role="tabpanel", and a tab controls to point at it.


disabled

Every option is disabled, e.g. until the page has hydrated.

Type
boolean

items required
Type
SwitchButton[] | SwitchLink[]

label required

The accessible name of the group.

Type
string

onChange

Called with the option's value when one is chosen, by click or arrow key.

Type
((value: string) => void)

value required

The value of the current option.

Type
string

variant

underline (default) is a row over a bottom rule; pill fills the current tab, as NavTabs does.

Type
"underline" | "pill"
Default
underline

NavTabs

A row of pill links where the active one is filled. Plain <a> by default; a tab's render draws a router's Link. orientation="vertical" stacks the same links in one column at the rail's width, for a SidebarLayout rail: the same pill, the same aria-current, one link per line.


items required
Type
NavTab[]

label required

The accessible name of the nav landmark.

Type
string

orientation
Type
"horizontal" | "vertical"
Default
horizontal

Functions

  • navTabClass The classes of one NavTabs pill (current or not, disabled drops the hover fill), for a router link that has to look like a tab.