Skip to content

Tabs

components specs/components/tabs.kmd

Horizontal navigation within a single context — primary tabs (top of content) and secondary tabs (filter within tab). Material parity (`/components/tabs`). Includes scrollable tabs, swipe gesture, and accessibility contract.

When this spec applies

Primary triggers

All triggers

Specification body

Spec — Tabs

Facet Visual do Koder Design. Material parity: https://m3.material.io/components/tabs.

2 tab levels

LevelPositionUse
PrimaryTop of content area, beneath app barMain view switcher (Profile / Activity / Settings)
SecondaryWithin a primary tab's contentFilter within view (All / Mine / Shared)

Maximum 2 levels — never nest a 3rd. If needed, use sub-navigation (drawer, menu) at the deeper level.

Anatomy (primary tab row)

┌────────────────────────────────────────────┐
│   Profile     Activity    Settings         │   ← labels
│   ━━━━━━━                                  │   ← indicator (active)
└────────────────────────────────────────────┘
  • Container: full width, surface bg
  • Label: title-small (14/20, weight 600)
  • Indicator: 2 px line below active tab, accent color
  • Padding per tab: 16 px horizontal, 12 px vertical
  • Height: 48 px

R1 — Tab content options

ModeContent
Label onlyJust text
Icon + Label18 px icon above OR before label
Icon onlySingle 24 px icon (with aria-label)
Label + count"Inbox (12)" — count as badge or inline

Don't mix modes within a single tab row. All tabs in a row use the same mode.

R2 — States

StateLabel colorIndicator
Inactive (rest)text-muted
Inactive + hovertext
Inactive + focusedtext + focus ring
Active (current)accentaccent line (2 px)
Active + focusedaccent + focus ringaccent line
Disabled38% opacity

R3 — Indicator animation

When user clicks a different tab:

  • Indicator slides from old position to new (motion-medium, ~250ms)
  • Color of active label transitions from text-mutedaccent
  • Content area cross-fades to new content (motion-fast)
  • Reduced motion: indicator snaps; no fade

R4 — Fixed vs scrollable tabs

ModeWhenBehavior
FixedTotal tabs fit within container widthEqual-width tabs OR centered with auto sizing
ScrollableTabs overflow containerHorizontal scroll, no fixed width, indicator scrolls with tabs

Switch automatically per layout: at Compact class scroll if more than 4 tabs; at Expanded class fixed up to 6 tabs.

R5 — Swipe gesture

On touch surfaces:

  • Swipe left/right within content area switches to adjacent tab
  • Swipe matches indicator motion (1:1 with finger)
  • Release with > 50% threshold: switches; < 50%: snaps back
  • Indicator animates smoothly with swipe

Disabled when content has its own horizontal scroll (e.g., carousel).

R6 — Tab content area

  • Content area renders below tab row
  • Switching tabs: cross-fade (motion-fast) OR slide-in (motion-medium) — pick one style per app; don't mix
  • Preserve scroll position per tab (going back to "Profile" tab remembers its scroll)
  • Lazy-render content on first visit; keep mounted after

R7 — Secondary tabs

Look slightly different from primary to indicate hierarchy:

AspectPrimarySecondary
ContainerSame as toolbarSame as surrounding content
Indicator2 px solid below2 px or chip-style border
Padding16 px horizontal12 px horizontal
Label weight600500

Secondary tabs render WITHIN a primary tab's content area, not in the app bar.

R8 — Accessibility

  • role="tablist" on container
  • role="tab" on each tab; aria-selected="true" on active
  • aria-controls pointing to the panel id
  • Panel: role="tabpanel" + aria-labelledby pointing to tab id
  • Keyboard:
    • Arrow Left/Right cycles tabs (wrap around at ends)
    • Home/End jumps to first/last
    • Tab moves OUT of tab row into the panel
  • Screen reader announces: "Tab, Activity, 2 of 3, selected"

R9 — Per-preset variation

PresetIndicator
material32 px solid line at bottom
material23 px solid line at bottom
ios_cupertinoActive tab has accent-tint bg (no line)
gnomeActive tab background tint + 1 px line
windows_11Mica backdrop on row, accent line
brutalist4 px thick line + sharp corners
terminal_classicASCII underline [==Active==]

R10 — Forbidden patterns

  • ❌ More than 2 levels of tabs (use drawer/menu for deeper nav)
  • ❌ Tab labels longer than 20 chars (truncate or use shorter)
  • ❌ Hiding inactive tabs (defeats the "all options visible" purpose)
  • ❌ Confirmation dialog on tab switch (defeats quick navigation)
  • ❌ Tabs that navigate to different URLs (use links, not tabs — tabs are within ONE context)
  • interaction/states.kmd — focus/hover visuals
  • themes/color-roles.kmd — accent indicator
  • themes/motion.kmd — indicator slide animation
  • foundations/elements.kmd — Control family

Requirements (testable)

Requirement: Two-level maximum hierarchy {#req-tabs-two-level-max}

Tabs SHALL support at most two levels — a primary row at the top of the content area and an optional secondary row within a primary tab's content — and SHALL NOT nest a third level of tabs; deeper navigation must use a drawer or menu instead.

Scenario: Primary and secondary levels render correctly

  • GIVEN a screen with a primary tab row and a secondary tab row inside the active primary tab's content
  • WHEN the component tree is rendered
  • THEN the primary tab row is positioned at the top of the content area beneath the app bar
  • AND the secondary tab row is rendered within the active primary tab's content area, not in the app bar

Scenario: A third tab level is a forbidden nesting

  • GIVEN a primary tab containing a secondary tab
  • WHEN a third tab row is placed inside a secondary tab's content
  • THEN the layout is flagged as a forbidden three-level nesting
  • AND at most two tab levels are rendered

Requirement: Active tab indicator and state colors {#req-tabs-active-indicator}

The active tab SHALL display a 2 px indicator line in the accent color and render its label in accent, while inactive tabs SHALL render their label in text-muted with no indicator, so exactly one tab is visibly current at a time.

Scenario: Selecting a tab moves the indicator and recolors labels

  • GIVEN a primary tab row where the first tab is active
  • WHEN the user selects a different tab
  • THEN a single 2 px accent indicator line appears beneath the newly selected tab and no other
  • AND the newly selected tab's label color is accent
  • AND the previously active tab's label color returns to text-muted

Scenario: Reduced motion snaps the indicator

  • GIVEN the user has reduced-motion enabled
  • WHEN the user switches to an adjacent tab
  • THEN the indicator moves to the new position without a sliding animation
  • AND the content area changes without a cross-fade

Requirement: Fixed versus scrollable layout {#req-tabs-fixed-vs-scrollable}

The tab row SHALL switch between fixed and scrollable layout by available width and count: at the Compact window class it SHALL become scrollable when more than 4 tabs are present, and at the Expanded class it SHALL remain fixed for up to 6 tabs.

Scenario: Compact class with many tabs scrolls

  • GIVEN a Compact window class
  • WHEN a tab row contains 5 tabs
  • THEN the tab row is scrollable horizontally with no fixed per-tab width
  • AND the active indicator scrolls together with its tab

Scenario: Expanded class with six tabs stays fixed

  • GIVEN an Expanded window class
  • WHEN a tab row contains 6 tabs
  • THEN the tab row uses fixed layout without horizontal scrolling

Requirement: Swipe gesture switches adjacent tabs {#req-tabs-swipe-gesture}

On touch surfaces the tab content area SHALL switch to an adjacent tab on horizontal swipe, tracking the finger 1:1 with the indicator, committing the switch when the swipe passes 50% of the width and snapping back otherwise; it SHALL be disabled when the content has its own horizontal scroll.

Scenario: Swipe past threshold commits the switch

  • GIVEN a touch surface showing the second of three tabs
  • WHEN the user swipes left across more than 50% of the content width and releases
  • THEN the third tab becomes active
  • AND the indicator ends aligned under the third tab

Scenario: Swipe below threshold snaps back

  • GIVEN a touch surface showing the second of three tabs
  • WHEN the user swipes left across less than 50% of the content width and releases
  • THEN the second tab remains active
  • AND the indicator returns to its original position under the second tab

Scenario: Swipe disabled over horizontally scrolling content

  • GIVEN a tab whose content is a horizontally scrolling carousel
  • WHEN the user swipes horizontally within that content
  • THEN the carousel scrolls and no tab switch occurs

Requirement: Accessibility roles and keyboard navigation {#req-tabs-accessibility}

The tab row SHALL expose role="tablist" with each tab as role="tab", marking the active tab aria-selected="true" and pointing aria-controls at its role="tabpanel", and SHALL support keyboard navigation: Arrow Left/Right cycles tabs with wrap-around, Home/End jumps to first/last, and Tab moves focus out of the row into the panel.

Scenario: ARIA roles and relationships are wired

  • GIVEN a rendered tab row with an active tab and its panel
  • WHEN the accessibility tree is inspected
  • THEN the container has role="tablist" and each tab has role="tab"
  • AND the active tab has aria-selected="true" and aria-controls referencing the panel id
  • AND the panel has role="tabpanel" and aria-labelledby referencing the active tab id

Scenario: Arrow keys cycle with wrap-around

  • GIVEN keyboard focus on the last tab of the row
  • WHEN the user presses Arrow Right
  • THEN focus and selection move to the first tab of the row

Scenario: Home and End jump to ends

  • GIVEN keyboard focus on a middle tab of the row
  • WHEN the user presses End and then Home
  • THEN End moves focus to the last tab
  • AND Home moves focus to the first tab

References