ComponentsActions & navigationTabs
On this page
Actions & navigation

Tabs released

One section of a page showing at a time, in two faces and two layouts.

  • Import@pacit/components/tabs
  • Selectorspct-tabs, pct-tab

Implements the W3C ARIA APG Tabs pattern ↗

Name, avatar, language.

tabs.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctTabs, PctTab } from '@pacit/components/tabs';
ExportedWhat it is
PctTabApiinterfaceWhat a strip needs to know about one of its panels, and nothing more. Declared HERE, beside the strip, so that the panel can provide it while importing the strip — the other direction (contentChildren(PctTab)) would close the import cycle. It is pct-menu's channel one component over, and for the same reason.
PCT_TABtokenThe channel through which a panel announces itself to the strip it stands in.
PctTabsApiinterfaceWhat a panel needs to know about the strip it stands in: which value is showing, and how to ask for its own. The second half exists for one caller — a panel the browser has just found text in (0045).
PCT_TABStokenThe channel through which a strip offers itself to the panels below it.
PctTabscomponentpct-tabsTabs: one section of a page showing at a time, chosen from a strip of labels.
PctTabcomponentpct-tabOne section of a <pct-tabs>: role="tabpanel", holding its content where the consumer wrote it, and handing the strip above only the label to draw.
PctTabsOrientationtype'horizontal' | 'vertical'Which axis the strip runs along. It is aria-orientation on the tablist and the axis the arrow keys walk — one input for both, because a strip drawn down the side and walked with ArrowRight is the defect the attribute exists to describe.
PctTabsActivationtype'automatic' | 'manual'Whether moving the keyboard over the strip also chooses.
PctTabsVarianttype'underline' | 'segmented'Which face the strip wears.
<pct-tabs [(value)]="section" ariaLabel="Account settings">
  <pct-tab value="general" label="General">Name, avatar, language.</pct-tab>
  <pct-tab value="network" label="Network">Proxies and timeouts.</pct-tab>
</pct-tabs>

<!-- a segmented switch, and a strip beside its panel -->
<pct-tabs variant="segmented" [(value)]="period" ariaLabel="Period">
  <pct-tab value="day" label="Day">Today, hour by hour.</pct-tab>
  <pct-tab value="week" label="Week">Seven days side by side.</pct-tab>
</pct-tabs>

<pct-tabs orientation="vertical" [(value)]="section" ariaLabel="Project">
  <pct-tab value="overview" label="Overview">What it is, in one paragraph.</pct-tab>
  <pct-tab value="members" label="Members">Who can read and who can write.</pct-tab>
</pct-tabs>
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

A strip down the side

orientation="vertical" puts the strip beside its panel rather than above it: a column of labels on one side, the chosen section on the other. The arrows that walk it become Up and Down, and the chosen tab's edge moves to the inline side of the strip. Every distance is logical, so in a right-to-left document the strip moves to the right and the panel to the left with nothing to set.

What the project is, in one paragraph.

Manual activation

By default the arrows choose as they move. With activation="manual" they only move the focus, and Enter or Space makes the choice — the APG's own second mode, for panels that are expensive to show.

Yesterday, hour by hour.

A segmented switch

variant="segmented" swaps the rail for a recessed track and raises the chosen tab out of it. This page's own Preview / Code control is this one input — before the variant existed it took a dozen token overrides to undo the rail plus two rules reaching through the parts, because the track and the chosen fill had no token to set.

Seven days side by side.

API

What it reads, and what it writes back

Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.

Tabs: one section of a page showing at a time, chosen from a strip of labels.

Inputs

NameTypeWhat it does
valuetwo-waydefault ''stringWhich panel is showing, by the value of a <pct-tab>.
orientationdefault 'horizontal'PctTabsOrientationWhich axis the strip runs along; the arrows follow it.
activationdefault 'automatic'PctTabsActivationWhether a walk over the strip also chooses. See PctTabsActivation.
variantdefault 'underline'PctTabsVariantWhich face the strip wears; the keyboard and the semantics are the same either way. See PctTabsVariant.
ariaLabeldefault ''stringThe strip's accessible name. An INPUT rather than an aria-label on the tag: the role sits on a <div> inside this template, the host carries no role at all, and an ARIA name on a roleless element is ignored (req-a11y-built-in).
ariaLabelledbydefault ''stringAs ariaLabel, for a name that already stands somewhere on the page.

Outputs

None of its own. The native events — (click), (focus), the form's submit — are the platform's, and two-way models above carry the rest.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
classpct-tabs
data-pct-orientationorientation()
data-pct-variantvariant()
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

In a test, PctTabsHarness, PctTabHarness from @pacit/components/testing read a part by the name below and a state by its attribute — the harness gate holds the names to the package.

PartSelectorWhat it is
panel[data-pct-part="panel"]a tab's section
list[data-pct-part="list"]the strip of tabs
tab[data-pct-part="tab"]one tab button

Tokens

Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.

TokenStyles
--pct-tabs-list-bg{pct.surface-100}#f1f5f9 · #1e293bGround of the strip, painted by the segmented face.
--pct-tabs-list-border{pct.border}#e2e8f0 · #334155Colour of the strip's rail.
--pct-tabs-list-gap{pct.space.2}4pxGap between tabs.
--pct-tabs-list-inset{pct.space.2}4pxInset of the track around the segments.
--pct-tabs-panel-gap{pct.space.5}16pxDistance between a side strip and its panel.
--pct-tabs-panel-padding-y{pct.space.4}12pxVertical padding of the panel.
--pct-tabs-tab-bg-hover{pct.surface-100}#f1f5f9 · #1e293bBackground of a tab on hover.
--pct-tabs-tab-bg-selected{pct.surface}#ffffff · #0f172aBackground of the chosen tab in the segmented face.
--pct-tabs-tab-border-selected{pct.primary}#2563eb · #60a5faColour of the chosen tab's edge.
--pct-tabs-tab-fg{pct.text-muted}#334155 · #e2e8f0Text colour of a tab.
--pct-tabs-tab-fg-disabled{pct.text-disabled}#64748bText colour of a tab while disabled.
--pct-tabs-tab-fg-hover{pct.text}#0f172a · #ffffffText colour of a tab on hover.
--pct-tabs-tab-fg-selected{pct.text}#0f172a · #ffffffText colour of a tab when selected.
--pct-tabs-tab-font-weight{pct.font.weight.medium}500Font weight of a tab.
--pct-tabs-tab-padding-x{pct.space.4}12pxHorizontal padding of a tab.
--pct-tabs-tab-padding-y{pct.space.3}8pxVertical padding of a tab.
--pct-tabs-tab-radius{pct.radius.md}8pxCorner radius of a tab.
--pct-tabs-tab-target-min{pct.target.min}24pxTouch-target floor of a tab.

Theming

A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.

[data-theme='brand'] {
  --pct-tabs-tab-fg-selected: #0f766e;
  --pct-tabs-tab-border-selected: #0f766e;
  --pct-tabs-list-border: #99f6e4;

  /* the segmented face: the track and the segment raised out of it */
  --pct-tabs-list-bg: #f0fdfa;
  --pct-tabs-tab-bg-selected: #ffffff;
}

Name, avatar, language.

Accessibility

Measured, not assumed

Pattern: Tabs — named in the class JSDoc

Keyboard

keyeffecttest
ArrowRight / ArrowLefthorizontal strip: the next / previous reachable tab, swapped under dir="rtl"apps/sandbox-e2e/src/tabs.spec.ts
ArrowDown / ArrowUpvertical strip: the same, along the other axisapps/sandbox-e2e/src/tabs.spec.ts
Home / Endthe first / last tab that can be reachedapps/sandbox-e2e/src/tabs.spec.ts
Enter / Spacechooses the focused tab — the platform's, on a real <button>apps/sandbox-e2e/src/tabs.spec.ts
Tabone stop for the whole strip, and the panel is the next oneapps/sandbox-e2e/src/tabs.spec.ts

The arrows come round at both ends, which is the APG's own example. There is no typeahead: the shared walk in core switches it off when the source names no label, and a strip of a handful of visible words is not a list somebody types at. In activation="automatic" a movement also chooses; in "manual" it only moves, and Enter or Space — which this component does not handle, because a <button> already turns them into a click — does the choosing.

What is measured, and what is not yet

Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.

MeasuredARIA APG pattern in the JSDoclibs/components/tabs/src/tabs.ts, libs/components/tabs/src/tab.ts
MeasuredKeyboard map testedapps/sandbox-e2e/src/tabs.spec.ts (every row above, in three engines) and libs/components/tabs/src/tabs.spec.ts (the walk's edges, the disabled skip, the keys it leaves alone)
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts — the /tabs view, LTR and RTL, with all four strips of the sandbox on the page at once (horizontal, manual, vertical, overflowing); and a stage of its own in the derived list of panels — a strip's panel is a panel part the sandbox holds open, so the stage holds the claim that one stands open in the walk rather than an audit of its own (lesson-196)
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts — tabs-strip, tabs-vertical, tabs-vertical-rtl and tabs-segmented
Measuredforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — the chosen tab's edge in Highlight against Canvas on the ones not chosen: the mark survives because it is drawn on every tab and only its colour differs, so no palette can flatten a line into a line that is not there
Measuredprefers-reduced-motionlibs/components/tabs/src/tabs.scss — the only transition is color over --pct-motion-transition-duration, and the preference is answered by the token build (req-a11y-motion)
MeasuredTouch target ≥ 24×24 pxapps/sandbox-e2e/src/tabs.spec.ts — every tab's box measured outright; the floor is --pct-tabs-tab-target-min on the tab itself, so what a finger gets is what an eye sees
By designSize axisnone — deliberately: a tab is not a control with a height. req-api-size is about --pct-control-height-*, and a strip takes its height from the type and the padding, which a skin moves through the tokens
GapDensity axisnone — gap. The same one every component here has (req-token-density)
MeasuredRTLapps/sandbox-e2e/src/tabs.spec.ts (the arrows swapped, with the direction inherited from the document rather than written on the strip) plus the tabs-vertical-rtl screenshot, which is where the rail and the mark change sides with no rule of their own
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts — the /tabs view renders and hydrates with no NG05xx. Every panel is in the server's HTML, hidden by an attribute rather than by a branch
By designFormsnone — deliberately: a tab strip is not a form control. It holds no value a form owns and implements no FormValueControl
MeasuredThe hidden panel is findableapps/sandbox-e2e/src/tabs.spec.ts — hidden="until-found" computes content-visibility: hidden with display: block, and the text inside answers checkVisibility() === false; libs/components/tabs/src/tabs.spec.ts for the three states of the attribute
MeasuredThe reveal is answeredlibs/components/tabs/src/tabs.spec.ts — beforematch on a panel chooses its tab, and does not when the tab is disabled
MeasuredA tab stands in a tablisttools/check-aria.mjs point 9 — every role="tab" read off the template's tree with nothing but its tablist above it, axe's own requiredContext table being the rule (lesson-65)
MeasuredThe strip is one Tab stopapps/sandbox-e2e/src/tabs.spec.ts — the roving 0, the next Tab landing on the panel, and the scroll container's tabIndex === -1 in all three engines (lesson-126)
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json — nine entries. The rail and the raised segment are warn and say why: neither is what identifies a control or its state, and the things that do — both label pairs and the chosen tab's mark — are errors
By designStrings through PCT_TEXTSnone — deliberately: the component prints no string of its own, so there is no key to route. tools/check-texts.mjs reads its templates all the same
MeasuredSize budgetlibs/components/size.snapshot.md — the ./tabs row
MeasuredScreen-reader logRead 2026-09-28 from docs/acr/at/ — three readers, whole sandbox, and the act — ArrowRight on the selected tab. The revealed tab is announced by the two readers whose arrow reached it — NVDA with its position, Network, tab, selected, 2 of 3, Orca by name and role, Network · page tab. — and neither reads the panel it revealed, which is the pattern's promise — an arrow announces the tab and the panel waits to be entered. VoiceOver's arrow moved nothing in this pass — focus stayed on the first tab and the reader said nothing — after a walk that read one stop (General selected tab, 1 of 3 Account settings tab group) and moved no further; where its cursor stood, the record does not say. On arrival the tab and its panel are both announced: Orca General · page tab. then General · scroll pane clickable., NVDA General, property page — the panel's content, which the record of 2026-09-16 heard in full (543eacde), stayed unread this time
MeasuredThe segmented faceapps/sandbox-e2e/src/tabs.spec.ts — the track, the raised segment and the four rounded corners read computed; libs/components/tabs/src/tabs.spec.ts for the attribute the face is keyed on and its default
MeasuredA side strip stands beside itapps/sandbox-e2e/src/tabs.spec.ts — the panel's box measured against the strip's in LTR and RTL, so the rail, the inset and the order all change sides together
Measureddocs page/components/tabs on the published site — prerendered, the demo's own source is the code tab
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

93.5%mutation score115 of 123 mutants killed
16 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
9colour pairs measuredlight and dark, at every token build
4visual baselinesChromium, the sandbox's own pictures
12elements in the preview12 listeners · 1 render to settle · 3804 µs in jsdom, held exactly but for the clock

Why it is built this way

Two things separate it from every other component here. The panels are the consumer's markup in the place they wrote it — a <pct-tab> is the panel, and the strip above is drawn from the labels the panels hand up, so nothing is projected twice and the DOM order the pattern asks for is the order anybody would write. And a panel nobody chose is still text in the document: it is hidden="until-found", so the browser's own find-in-page searches it and can reveal it, and the reveal is answered rather than undone (0045) — and because that hiding keeps the element's box rather than taking it away, the panel drops its padding while it is hidden, or every panel nobody chose would leave its own empty space under the chosen one (lesson-150). Every rule that keys on one of the host's attributes reaches its target with child combinators for the same family of reason: a panel may hold another strip, both are the same component and so carry the same encapsulation attribute, and a descendant selector from the outer host would dress the inner one's tabs (lesson-151).

It wears two faces and two layouts, and all four are paint on the same machinery — the same roles, the same roving tabindex, the same walk. variant="underline" is the default: labels on a rail, the chosen one marked by an edge. variant="segmented" makes the strip a recessed track and raises the chosen tab out of it, which is the control a settings screen usually wants. orientation="vertical" puts the strip beside its panel rather than above it, and every distance in that layout is logical, so RTL moves the strip and the rail to the other side with nothing to set (0064).

Decisions behind it

  • 0064A face the consumer cannot reach without undoing ours is a variant
  • 0045A panel nobody chose is still text in the document
  • 0026One channel per politeness, and a message with a home announces from it
  • 0032A menu moves focus, a listbox points at it
  • 0013No headless core / skin split
  • 0039A state the platform publishes is not ours to write
  • lesson-150content-visibility: hidden hides the contents; the box, and its padding, stay
  • lesson-151Encapsulation scopes a selector to a COMPONENT, not to an instance: a component that can hold itself must reach its parts by structure
  • lesson-124A required input read from a sibling's host binding throws, and @for is where that happens
  • lesson-196A list that decides what a gate looks at is a denominator nobody measures
  • lesson-65An audit of the rendered page measures the pages you wrote, not the component you shipped
  • lesson-126overflow: auto makes an element focusable in two engines of three
  • lesson-94An effect that reads what it writes: one consumer pays a pass, two never finish
  • lesson-249A centred stage that shrink-wraps its demo moves the control the reader just pressed

Known limitations

  • Every panel's content is rendered, always. That is the price of the panels being text in the document rather than a branch, and it is the whole of what 0045 buys. A consumer whose panel is expensive wraps their own content in an @defer or an @if — which this component cannot do for them without taking the text back out.
  • A tab is a string, not a slot. No icon, no count badge, no two lines. A slot would be a second public directive and a second thing to type, and the first component to want one cannot tell a shared property from an accident of the only case — the same reasoning the toast's tone has.
  • The strip scrolls and nothing scrolls it for you. More labels than room means a scroll container, and what carries the keyboard is the platform bringing a focused element into view. There are no scroll buttons, and a pointer user on a device with no horizontal wheel gesture has only the scrollbar. It also has a measured side effect: overflow: auto makes the strip focusable in blink and gecko, though never a stop in the tab order (lesson-126).
  • A disabled panel is not findable. Deliberate, and the reason is in 0045: find-in-page promises a way in and a disabled tab has none. It does mean a consumer who disables a tab takes its text out of the page's search, which is a thing to know rather than a thing to fix.
  • The panel is always a Tab stop. The APG asks for it only when the content holds nothing focusable, and whether it does is the consumer's business and not knowable here — so the answer that is right in both cases is taken, and it costs one extra stop after the strip.
  • The segmented face carries its state in a fill, and a fill is the weakest channel. The track and the raised segment differ by 1.10:1 in the light theme and 1.22:1 in the dark one — measured, and a warn in the policy on purpose. What identifies the chosen tab is the label pair above it, an error-level entry in both themes, plus an outline the forced-colors block restores when the platform drops the fill. A skin that flattens surface-100 into surface gets a segmented strip with no visible track, and is told so by the build rather than blocked (0064).
  • A side strip is a flex row, so a very narrow box is the consumer's problem. The strip takes its width from its longest label and the panel takes the rest; there is no breakpoint at which the layout falls back to the stacked one, because a component here answers its container and not the viewport, and the container query that could do it belongs to whoever owns the box. A consumer with a narrow column switches orientation themselves.
  • No lazy value write-back. A strip whose value names nothing shows the first reachable panel and does not write that value into the consumer's signal. The alternative is a component that repairs the consumer's state during a render, which finishes in one pass for one consumer and never for two (lesson-94).
  • A panel nobody chose contributes no width. It is skipped for layout, not only for paint — that is how until-found hides it — so in a box that shrinks to fit its content (a flex item, an inline-block, a dialog sized by what it holds) the host is exactly as wide as the chosen panel and changes width with every choice. In normal flow the host is a block and takes its container's width; a shrink-wrapped one needs an inline-size of its own, which is what the site's demos give it (lesson-249).