Skip to main content
cngx-src documentation

CngxTabNav

ComponentPrimaryOnPushNo encapsulationv0.1.0WCAG AA

projects/ui/tabs/tab-nav.component.ts

Import#

import { CngxTabNav } from '@cngx/ui/tabs'

Description#

CNGX tab-nav organism.
A role="navigation" landmark that wraps consumer-authored <a cngxTabLink routerLink> anchors into a tab bar. Native parallel of [cngxMatTabNav] in @cngx/ui/mat-tabs, and the link-driven sibling of the programmatic <cngx-tab-group> tablist: a navigation of natively focusable links is a different WAI-ARIA pattern than an automatic- activation tablist, so it is a separate organism, not a skin flag.

Thin shell over CngxTabGroupPresenter via hostDirectives - the registry, activeIndex, and activeId come from the same brain the tablist organism and the Material bridge compose. There is no keyboard model and no commit gating: links are natural Tab stops, Enter activates the routerLink, and the router runs CanDeactivate natively. Add [cngxTabsRouteSync] to drive the active link from the route - it reflects NavigationEnd onto activeIndex; its commit-action stays dormant because nothing here calls presenter.select().

CngxLiveRegion is mounted as a child <span> rather than a host directive: its role="status" would clobber the host's role="navigation" landmark. Its content is the active link's label (Pillar 2 - the active change is announced).

<cngx-tab-nav cngxTabsRouteSync aria-label="Sections">
  <a cngxTabLink id="overview" routerLink="overview">Overview</a>
  <a cngxTabLink id="profile" routerLink="profile">Profile</a>
</cngx-tab-nav>
<router-outlet></router-outlet>

See CngxTabGroup for the programmatic tablist organism.

Metadata#

Host#

Dependencies#

CNGX_TAB_GROUP_HOSTinject()presenter

Relationships

Index#

Inputs#

ariaLabel#string | undefined
input()
default undefined, { alias: 'aria-label' }
ariaLabelledBy#string | undefined
input()
default undefined, { alias: 'aria-labelledby' }
input()

Visual skin. Cascade input ?? config ?? 'line', reflected onto [data-skin]. Resolved inline (single axis) rather than via createTabsHostAttrs: that helper resolves five tablist axes the nav does not use, so pulling it here would fabricate dead inputs - inline resolution is Komposition statt Konfiguration (Pillar 3).

HostBindings#

BindingExpression
[attr.data-skin]resolvedSkin()
[attr.data-orientation]presenter.orientation()
[attr.aria-orientation]presenter.orientation()
[attr.aria-label]ariaLabel()
[attr.aria-labelledby]ariaLabelledBy()

Structural CSS shared by every tab-group skin.
Layout-only - no colors, sizes, borders, or fonts beyond what is controlled via --cngx-tab-* custom properties.
CngxTabGroup (in @cngx/ui/tabs)references this file via styleUrls; ng-packagr inlines it at publish time so consumers of @cngx/ui/tabs do not need @cngx/common/tabs at runtime.
Headless directive use ([cngxTabGroup] on consumer-authored DOM) deliberately gets no styling here - consumers bring their own visual opinion. Selectors are class-prefixed (.cngx-tabs__*) and apply globally under the component's encapsulation: None.

State modifiers

Driven by ARIA attributes set by the organism:

  • [aria-selected="true"] - active tab, paints the underline indicator via an inset box-shadow so layout does not shift between resting and active
  • [aria-disabled="true"] - cursor: not-allowed, opacity dim via --cngx-tab-disabled-opacity
  • [aria-busy="true"] - cursor: progress while a commit is pending - :focus-visible - outline ring via --cngx-tab-focus-ring
  • .cngx-tab--rejected - persistent failed-target signal: outline ring + one-shot background pulse

Orientation

  • [data-orientation="vertical"] on the host - flips the strip into a left-column grid and changes scroll-snap to the Y axis

Slots

  • .cngx-tabs__strip-wrapper - outer row container
  • .cngx-tabs__strip - the clip box (programmatic scroll only; the <cngx-tab-overflow> molecule is the user access path to tabs that do not fit)
  • .cngx-tabs__tab - one tab button
  • .cngx-tabs__label - shrinkable label inside the button
  • .cngx-tabs__badge - inline error badge; ships its own real-error-tone background so it does not collapse to currentColor (mirrors the stepper precedent)
  • .cngx-tabs__busy-spinner - pending-commit spinner
  • .cngx-tabs__rejection-icon - persistent ! glyph in a circle
  • .cngx-tabs__panels / __panel - panel container + body
  • .cngx-sr-only - locally-duplicated screen-reader utility

Inheritance

  • --cngx-tab-active-indicator-color -> --cngx-color-primary (also pinned via :root delegation so the brand accent paints the underline in both light and dark modes without a 4-block)
  • Strip / tab geometry tokens (--cngx-tab-gap, --cngx-tab-padding, --cngx-tab-strip-padding) - leaf
  • State-tone tokens (--cngx-tab-error-badge-color,
    --cngx-tab-rejection-icon-color, --cngx-tab-rejected-outline, --cngx-tab-rejected-pulse-bg) - leaf, all pinned at the foundation's danger tone so they read against either surface

Reduced motion

Two hooks: under prefers-reduced-motion: reduce the busy spinner is force-stopped; the rejection pulse is gated behind prefers-reduced-motion: no-preference so it never runs when the user has opted out.

Structural + line-skin styling for <cngx-tab-nav>. Pairs with the shared tabs-base.css token vocabulary, loaded first via styleUrls (see tab-nav.component.ts), where the --cngx-tab-* tokens are @property-registered. A registered property is never unset, so a use-site var(--token, fallback) fallback is dead - the registered initial wins. The nav therefore SETs its base spacing tokens (--cngx-tab-gap / --cngx-tab-padding / --cngx-tab-affordance-gap) on :scope, anchored to the --cngx-space-* density scale, exactly as <cngx-tab-group> does - they are inherits:true (or unregistered), so the host SET cascades to the .cngx-tab-nav__link anchors and a root [data-density] swap re-scales them. The inherits:false --cngx-tab-focus-offset is instead SET on the consuming .cngx-tab-nav__link element (a :scope SET never reaches it). The per-skin @scope blocks below still read unregistered skin-local tokens whose use-site fallbacks do fire.

The links are consumer-authored <a cngxTabLink> anchors; this file styles the .cngx-tab-nav__link class the atom puts on each one. --active mirrors aria-current="page", --error mirrors aria-invalid. All five CngxTabsSkin values ship (line default, plus pill, pill-outline, segmented, contained, each horizontal and vertical), reflected onto [data-skin] and painted in the @scope blocks below - matching <cngx-tab-group>. The nav has no inner strip and no owned panel, so each skin is re-expressed on the host :scope and the .cngx-tab-nav__link anchors rather than the group's strip/tab/panel structure.

Index#

Layout

--cngx-tab-gap#*
Default value 0.5rem

Gap between adjacent tab buttons and between a tab's icon / label / badge slots.

--cngx-tab-strip-padding#*
Default value 0.5rem 0

Padding shorthand of the tab strip (outer container).

--cngx-tab-padding#*
Default value 0.5rem 0.75rem

Padding shorthand of an individual tab button.

--cngx-tab-label-line-height#*
Default value 1.4

Line height of the primary label line. Also drives the leading height the row-layout icon aligns to, so a stacked sub-label grows downward while the icon stays beside the primary label.

--cngx-tab-sublabel-font-size#*
Default value 0.8125rem

Font size of a tab's optional secondary label line. inherits: true so a consumer override set on the cngx-tab-group host cascades down to the .cngx-tabs__sublabel descendant - with inherits: false the host-level override is silently dropped.

Surface

--cngx-tab-strip-bg#<color>
Default value transparent

Background of the tab strip.

--cngx-tab-bg#<color>
Default value transparent

Background of an individual tab button.

--cngx-tab-color#*
Default value currentColor

Text color of a resting tab button.

--cngx-tab-sublabel-color#*
Default value color-mix(in oklab, currentColor 65%, transparent)

Text color of a tab's optional secondary label line. Defaults to a translucent tint of the tab's own text colour so it reads as a muted second line on any surface and in either colour scheme.

State / Active

--cngx-tab-active-color#*
Default value currentColor

Text color of the active (selected) tab.

--cngx-tab-active-indicator-color#<color>
Default value oklch(0.66 0.19 50)

Underline color of the active tab - a 2px box-shadow inset stripe along the inline-end edge of the tab button.
Falls back to --cngx-color-primary so the brand accent paints the selection indicator without competing with text contrast.

See: [[--cngx-color-primary]]

--cngx-tab-active-indicator-width#<length>
Default value 2px

Thickness of the active-tab underline indicator.

State / Disabled

--cngx-tab-disabled-opacity#<number>
Default value 0.55

Opacity multiplier applied to disabled tabs.

State / Focus

--cngx-tab-focus-ring#*
Default value 2px solid currentColor

Focus-ring outline shorthand.

--cngx-tab-focus-offset#<length>
Default value 2px

Outline offset of the focus ring.

State / Error

--cngx-tab-error-badge-color#<color>
Default value oklch(0.45 0.15 25)

Background color of the inline error badge. inherits: true so a themed value set on the cngx-tab-group / cngx-tab-nav host (the Material bridge sets it there) reaches the descendant badge - with inherits: false the host value never cascades down and the badge keeps its initial tone. The default theme is unchanged: the initial value inherits identically when nothing sets the token above.

State / Busy

--cngx-tab-busy-spinner-size#*
Default value 1rem

Diameter of the busy spinner shown while a tab's commit is pending.

--cngx-tab-busy-spinner-color#*
Default value currentColor

Stroke color of the busy spinner.

State / Rejected

--cngx-tab-rejection-icon-size#*
Default value 1.25rem

Diameter of the persistent rejection icon (! glyph in a circle).

--cngx-tab-rejection-icon-color#<color>
Default value oklch(0.45 0.15 25)

Background color of the rejection icon disc. inherits: true so the themed disc tone set on the host (the Material bridge) reaches the descendant .cngx-tabs__rejection-icon - mirrors --cngx-tab-error-badge-color. Default theme unchanged.

--cngx-tab-rejection-icon-text#<color>
Default value oklch(1 0 0)

Text color of the rejection icon glyph.

--cngx-tab-rejection-icon-font-size#*
Default value 0.875rem

Font-size of the rejection icon glyph.

--cngx-tab-rejected-bg#<color>
Default value oklch(0.45 0.15 25 / 0.1)

Background wash of a rejected tab - a soft danger tint that settles in after the pulse, so the failed target stays legible without a harsh box. Static by default so it reads against either surface; the Material bridge remaps it onto the error role.

--cngx-tab-rejected-radius#<length>
Default value 0.375rem

Corner radius of the rejected-tab wash, so the tint reads as a soft highlight rather than a hard block.

--cngx-tab-rejected-accent-width#<length>
Default value 2px

Thickness of the rejected tab's bottom accent bar - echoes the active indicator's language in the danger tone instead of a full outline ring.

--cngx-tab-rejected-outline#*
Default value none

Outline shorthand of the rejected tab. none by default - the wash plus the bottom accent carry the signal; set this to restore a full ring.

--cngx-tab-rejected-outline-offset#<length>
Default value 1px

Outline offset of the rejected-tab outline.

--cngx-tab-rejected-pulse-duration#<time>
Default value 600ms

Duration of the rejected-tab pulse animation.

--cngx-tab-rejected-pulse-bg#<color>
Default value oklch(0.45 0.15 25 / 0.18)

Background tint used at the peak of the pulse animation.

State / Dismissable

--cngx-tab-affordance-size#*
Default value 1.25rem

Hit-target size of a tab's close button and the add-tab button.

--cngx-tab-affordance-radius#*
Default value 50%

Corner radius of the close / add affordance buttons.

--cngx-tab-affordance-hover-bg#*
Default value color-mix(in oklch, currentColor 14%, transparent)

Hover/focus fill of the close / add affordance buttons. Defaults to a translucent tint of the current text colour, so it adapts to any surface and either colour scheme without a 4-block.

Native router tabs with a CanDeactivate guard

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';

import { CngxTabLink, CngxTabsRouteSync } from '@cngx/common/tabs';
import { CngxTabNav } from '@cngx/ui/tabs';

import { DemoFormState } from './demo-routes';

// Re-export forces compodocx to ship app.config.ts (and, transitively,
// demo-routes.ts) in the StackBlitz manifest - the router lives there.
export { appConfig } from './app.config';

/**
 * Native router tabs - a `<cngx-tab-nav>` of `<a cngxTabLink routerLink>`
 * anchors over a `<router-outlet>`.
 *
 * Unlike `<cngx-tab-group cngxTabsRouteSync>` (which navigates
 * programmatically through the presenter's commit lifecycle), the links
 * here are real anchors: the router runs `CanDeactivate` natively, and
 * middle-click / open-in-new-tab / hover-URL all work. `[cngxTabLink]`
 * registers each `<a>` as a tab handle (its `id` set to the route
 * segment); `[cngxTabsRouteSync]` reflects the route-active link onto the
 * active index - Ableitung statt Verwaltung, the route is the single
 * source.
 *
 * - **Native gating.** Each link is a `routerLink`, so the router runs
 *   `CanDeactivate` directly - no commit-action. Check "I have unsaved
 *   changes" on Profile, then click another link: the guard refuses, the
 *   navigation is cancelled, and `aria-current` stays on Profile.
 * - **Error marker.** The Profile link binds `[error]` to the shared
 *   unsaved signal, so `aria-invalid` + the error glyph light up while
 *   changes are pending - the communication channel on the native path,
 *   where no commit lifecycle fires.
 * - **External navigation.** Back/forward and direct URL edits move the
 *   active link through route-sync.
 */
@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [CngxTabNav, CngxTabLink, CngxTabsRouteSync, RouterLink, RouterOutlet],
  styleUrls: ['./routed-nav.component.css'],
  template: `
    <p style="opacity: 0.8; font-size: 0.875rem">
      A <code>&lt;cngx-tab-nav cngxTabsRouteSync&gt;</code> of
      <code>routerLink</code> anchors over a
      <code>&lt;router-outlet&gt;</code>. The Profile link guards its exit
      natively while "unsaved changes" is on.
    </p>

    <cngx-tab-nav cngxTabsRouteSync aria-label="Routed account navigation">
      <a cngxTabLink id="overview" label="Overview" routerLink="/overview">Overview</a>
      <a
        cngxTabLink
        id="profile"
        label="Profile"
        routerLink="/profile"
        [error]="state.unsaved() ? 'Unsaved changes - resolve before leaving' : false"
      >
        Profile
      </a>
      <a cngxTabLink id="settings" label="Settings" routerLink="/settings">Settings</a>
    </cngx-tab-nav>

    <div class="routed-nav-demo__outlet">
      <router-outlet></router-outlet>
    </div>
  `,
})
export class RoutedNavExample {
  protected readonly state = inject(DemoFormState);
}
export { RoutedNavExample as AppComponent };