Primitives
Tabs
A tablist that switches between panels of content.
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
classandstyleare 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
| Property | Type | Description |
|---|---|---|
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 attribute | Values | |
|---|---|---|
data-orientation | horizontal | vertical | |
data-disabled | present | absent |
ForTabsList
| Data attribute | Values | |
|---|---|---|
data-orientation | horizontal | vertical |
ForTabsTrigger
| Property | Type | Description |
|---|---|---|
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 attribute | Values | |
|---|---|---|
data-state | active | inactive | |
data-disabled | present | absent | |
data-orientation | horizontal | 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
| Property | Type | Description |
|---|---|---|
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 attribute | Values | |
|---|---|---|
data-state | active | inactive | |
data-orientation | horizontal | 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"andinertso 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 wantanimate.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-labelonForTabsList, oraria-labelledbypointing to a heading. - Choose
activationMode='automatic'when panels render quickly;'manual'when activation has noticeable cost (network, heavy computation). - Panel
tabindexfollows 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-controlsandaria-labelledbyare wired automatically when triggers and contents share the samevalue.aria-controlsis 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@ifis 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.