Skip to contentAsheeUIAsheeUI

Tabs

One region, several views, and a bar that says which one is showing.

Tabs keep several views of one subject in a single region and let the reader say which one is showing. Nothing navigates: the panel is swapped in place, so the page around it, its scroll position and its state stay where they were.

The bar is a real tablist rather than a row of styled links, which is what makes the choice answerable from a keyboard, and only the visible panel is in the document, so a hidden view cannot be reached by a tab press or read out by a screen reader.

Usage

import { Tabs } from "@asheeui/web";

<Tabs
  tabs={[
    { id: "summary", label: "Summary", content: <InvoiceSummary invoice={invoice} /> },
    { id: "lines", label: "Lines", badge: 3, content: <InvoiceLines invoice={invoice} /> },
  ]}
/>

A tab is an id, a label and the content it reveals, plus three optional parts: an icon before the label, a badge after it for a count or a status, and disabled for a view that exists but cannot be opened yet. name is read when label is absent, which is the older spelling of the same thing.

Treatments

underline is the default, and it is what a section of a page wants: the bar is the rule under it and the selected trigger is the part of the rule that is marked. bordered puts every trigger inside one outlined container, which reads as a panel's own switch. ghost drops the container and keeps the triggers, which is what a toolbar beside a heading wants.

Two different variant vocabularies meet on this component. The variant prop is the bar's, and takes underline, bordered or ghost; an individual trigger's treatment is a Variant, the same five Button has, and is stated through options.active and options.inactive.

In underline the selected trigger is drawn as an underlined control whatever options.active.variant says. A bar whose rule is underlined and whose selected trigger were a filled button would be two treatments at once; the colour still applies, so an underlined bar can still be accented.

Selection

defaultActiveId is the uncontrolled form: it names the tab that starts selected and is read once, at mount, so changing it afterwards does nothing. Without it the first tab is selected.

activeId makes the bar controlled, and onChange reports the id the reader asked for. A controlled bar draws what the prop says rather than what was pressed, so a page that receives the id and does not store it leaves the bar exactly where it was. That is the contract, not a failure: the selection is the page's state, and the bar is one of the ways to change it. An activeId that names no tab falls back to the first, so the bar always has one reachable trigger and one visible panel.

Both props take whatever a TabItem.id takes, a string or a number. The panel follows whichever tab the resolved id names, and its contents are remounted on each change, which is why the panel can animate in without a stale subtree underneath it.

Props

The defaults read as a cascade rather than a list. size, variant, radius and options.active.radius are registered by the component as md, underline, md and md, so a page with no configuration gets an underlined bar of medium triggers, rounded at md.

The trigger states are deliberately not registered. options.active.variant and options.active.color resolve from the application's defaultVariant and defaultColor, so a product states its selected tab once, and options.inactive.variant falls back to ghost with no colour, so an unselected trigger stays quiet whatever the application states. The inactive radius resolves from the bar's own radius rather than from defaultRadius, so rounding the bar rounds the triggers with it unless they say otherwise.

components.tabs is read by both renderers, so one section states the bar for web and native at once. What each platform does with it is its own: the native bar scrolls when it does not fit, and its triggers are never shorter than a thumb can reach.

Accessibility

  • The bar is role="tablist" with aria-orientation="horizontal". Each trigger is a real button with role="tab" and aria-selected, and the panel is role="tabpanel" labelled by its own trigger. Only the selected trigger carries aria-controls: the panel it names is the only one mounted, so an unselected trigger pointing at a panel that is not in the document would promise a destination that does not exist.
  • The bar is one stop in the page's tab order, not one per trigger: the selected trigger has tabindex="0" and the rest have -1. The arrow keys move to the next and previous trigger and wrap at the ends, Home and End jump to the first and last, and a disabled tab is skipped rather than focused and refused.
  • Moving with an arrow key also selects: the panel changes with the focus, which is what a tablist calls automatic activation. There is no second press to confirm, so the arrow keys are the whole gesture.
  • Panels are removed rather than hidden, so the contents of a view that is not showing are neither in the accessibility tree nor in the tab order.