forty-cdk
llms.txt

Primitives

Tabs

A tablist that switches between panels of content.

forty-cdk/tabs WAI-ARIA APG

Move between triggers with the arrow keys and activate with Space. The active trigger and its panel share data-state="active".

Your public name, avatar and bio. Anyone visiting your page can see these.

Headless, with a selectable activation mode (automatic vs manual), configurable orientation, and roving tabindex.

Anatomy

<div forTabs [(value)]="active">
  <div forTabsList aria-label="Settings sections">
    <button type="button" forTabsTrigger value="profile">Profile</button>
    <button type="button" forTabsTrigger value="security">Security</button>
  </div>
  <div forTabsContent value="profile">…profile…</div>
  <div forTabsContent value="security">…security…</div>
</div>

Examples

Focus without selecting

activationMode='manual' lets the arrow keys move focus without selecting; the user presses Space or Enter to activate. Manual activation is the better choice when panel content is expensive.

Visits, unique users and bounce rate over the selected period.

Vertical

orientation='vertical' stacks the tablist beside the panel and switches roving navigation to ArrowUp / ArrowDown. It is reflected as data-orientation for styling.

Workspace name, URL slug and default timezone.

Manual activation

<div forTabs activationMode="manual" [(value)]="active">
  <!-- Arrow keys move focus only. Space / Enter activates. -->
</div>

Known limitations

The panel's focusable-content detection does not re-measure across a shadow boundary, nor on a CSS-only visibility flip. The measurement runs on the panel's first render and again on mutations of its own subtree, filtered to the attributes that change whether an element is focusable (disabled, hidden, inert, tabindex, type, contenteditable). Two changes are therefore invisible to it and leave the previous answer standing:

  • Focusable content appearing (or disappearing) inside a shadow root: a web component in the panel that renders its controls on a later tick, or swaps them. The shadow root's own subtree is not observable, so a panel that gains its first focusable control that way keeps its redundant tabindex="0", and one that loses its last keeps none, leaving the panel unreachable by keyboard for a screen-reader user reading it. Nothing in the DOM looks wrong.
  • A visibility flip driven purely by a stylesheet: the measurement excludes CSS-hidden elements, but class and style are not watched, so toggling a class that hides or reveals the panel's only control does not re-measure.

Workaround. Bind [interactiveContent]. An explicit true / false wins over the detection in either direction, so the stale measurement stops driving the tabindex. It is the right channel whenever you know the answer for a panel, which is the usual case for a panel whose content is a web component. Remounting the panel with @if also re-measures, since a fresh directive instance measures again.

The library-wide shadow-DOM statement, covering the two limits that affect overlays rather than panels, is Shadow DOM in forty-cdk/shared.

API

ForTabs

PropertyTypeDescription
value
model
Two-way bindable. The selected tab's value, or null when nothing is selected. null is the unset state and is distinct from a tab whose value is ''.
Default: —
activationMode
input
Use 'manual' when panel content is expensive. In manual mode the user must press Space / Enter.
Default: 'automatic' (selection follows arrow focus)
orientation
input
Drives keyboard navigation and aria-orientation.
Default: 'horizontal'
dir
input
Swaps ArrowLeft / ArrowRight. Inherits the ambient direction when unset.
Default: null
disabled
input
When true, blocks all selection and keyboard nav.
Default: —
loop
input
When true (default), arrow nav wraps around past the first / last trigger. Set to false for a non-wrapping tablist.
Default: true
Data attributeValues
data-orientationhorizontal | vertical
data-disabledpresent | absent

ForTabsList

Data attributeValues
data-orientationhorizontal | vertical

ForTabsTrigger

PropertyTypeDescription
value
input.required
The tab's identifier. Must match the value of its ForTabsContent.
Default: —
disabled
input
Disables this trigger; arrow nav still reaches it.
Default: —
Data attributeValues
data-stateactive | inactive
data-disabledpresent | absent
data-orientationhorizontal | vertical

Reflects on its host: id, aria-selected, aria-controls (looked up from the matching content), aria-disabled, tabindex. A disabled trigger keeps aria-disabled="true" + data-disabled="" (no native disabled, per APG). It leaves the Tab sequence but arrow nav and Home / End still reach it, so it is announced; activating it does nothing.

ForTabsContent

PropertyTypeDescription
value
input.required
Pairs the panel with the trigger of the same value.
Default: —
interactiveContent
input
Overrides the automatic focusable-content detection that drives tabindex. true forces no tab stop (the panel always holds its own focusable content); false forces a tab stop regardless.
Default: null (auto-detect)
Data attributeValues
data-stateactive | inactive
data-orientationhorizontal | vertical

Reflects: id, role="tabpanel", aria-labelledby (the matching trigger's id), tabindex="0" only when the panel has no focusable descendants (APG), aria-hidden (when inactive), inert (when inactive).

The directive does not apply [hidden]. Two patterns work:

  • Leave all panels mounted (idiomatic): this preserves scroll/input state across activations. While inactive, the directive sets aria-hidden="true" and inert so each non-selected panel is out of the accessibility tree and focus order. Hide the inactive ones visually with CSS keyed on [data-state="inactive"] (e.g. display: none).
  • Mount/unmount with @if (active() === 'tab'): the panel is absent while inactive; useful for heavy panels or when you want animate.enter / animate.leave.

Keyboard

  • Tab moves focus into / out of the tablist; lands on the user-focused trigger (or the selected one, if none focused yet).
  • ArrowRight / ArrowLeft in horizontal, ArrowDown / ArrowUp in vertical: move focus between triggers, wrap-around. RTL swaps Left/Right.
  • Home / End jump to the first / last trigger.
  • Space / Enter activate the focused trigger (no-op in automatic mode since arrow nav already activated it).
  • Disabled triggers are reached but not activated: automatic mode does not select one that arrow nav lands on.

Accessibility

Implements the WAI-ARIA Tabs pattern.

  • Label the tablist via aria-label on ForTabsList, or aria-labelledby pointing to a heading.
  • Choose activationMode='automatic' when panels render quickly; 'manual' when activation has noticeable cost (network, heavy computation).
  • Panel tabindex follows APG: a panel with no focusable descendants is itself a tab stop (tabindex="0") so screen-reader users can focus and read it, while a panel that already contains focusable content (a form, links, buttons) is not a tab stop. The directive detects this automatically and reacts to subtree changes. Use [interactiveContent] to override the detection in either direction. Known limitations covers the two kinds of change the detection cannot observe.
  • aria-controls and aria-labelledby are wired automatically when triggers and contents share the same value. aria-controls is emitted on every trigger whose panel is registered, so a panel kept mounted while inactive is referenced from its trigger, and a panel unmounted with @if is not referenced at all.

Styling

forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under API, not off the for* selectors (Styling forty-cdk explains why).

.tb-trigger[data-state='active'] {
  border-bottom: 2px solid currentColor;
}

.tb-trigger[data-disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}

.tb-content[data-state='inactive'] {
  display: none;
}

Wrapping in a design system

Subclass the root and re-provide FOR_TABS_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.