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
- Implement tab navigation
All triggers
- Add horizontal navigation within a page/screen
- Build a filter row with switchable views
- Replace dropdown with visible tab options
Specification body
Spec — Tabs
Facet Visual do Koder Design. Material parity: https://m3.material.io/components/tabs.
2 tab levels
| Level | Position | Use |
|---|---|---|
| Primary | Top of content area, beneath app bar | Main view switcher (Profile / Activity / Settings) |
| Secondary | Within a primary tab's content | Filter 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,
accentcolor - Padding per tab: 16 px horizontal, 12 px vertical
- Height: 48 px
R1 — Tab content options
| Mode | Content |
|---|---|
| Label only | Just text |
| Icon + Label | 18 px icon above OR before label |
| Icon only | Single 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
| State | Label color | Indicator |
|---|---|---|
| Inactive (rest) | text-muted | — |
| Inactive + hover | text | — |
| Inactive + focused | text + focus ring | — |
| Active (current) | accent | accent line (2 px) |
| Active + focused | accent + focus ring | accent line |
| Disabled | 38% 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-muted→accent - Content area cross-fades to new content (motion-fast)
- Reduced motion: indicator snaps; no fade
R4 — Fixed vs scrollable tabs
| Mode | When | Behavior |
|---|---|---|
| Fixed | Total tabs fit within container width | Equal-width tabs OR centered with auto sizing |
| Scrollable | Tabs overflow container | Horizontal 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:
| Aspect | Primary | Secondary |
|---|---|---|
| Container | Same as toolbar | Same as surrounding content |
| Indicator | 2 px solid below | 2 px or chip-style border |
| Padding | 16 px horizontal | 12 px horizontal |
| Label weight | 600 | 500 |
Secondary tabs render WITHIN a primary tab's content area, not in the app bar.
R8 — Accessibility
role="tablist"on containerrole="tab"on each tab;aria-selected="true"on activearia-controlspointing to the panel id- Panel:
role="tabpanel"+aria-labelledbypointing 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
| Preset | Indicator |
|---|---|
material3 | 2 px solid line at bottom |
material2 | 3 px solid line at bottom |
ios_cupertino | Active tab has accent-tint bg (no line) |
gnome | Active tab background tint + 1 px line |
windows_11 | Mica backdrop on row, accent line |
brutalist | 4 px thick line + sharp corners |
terminal_classic | ASCII 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)
Cross-link
interaction/states.kmd— focus/hover visualsthemes/color-roles.kmd— accent indicatorthemes/motion.kmd— indicator slide animationfoundations/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
accentindicator 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 hasrole="tab" - AND the active tab has
aria-selected="true"andaria-controlsreferencing the panel id - AND the panel has
role="tabpanel"andaria-labelledbyreferencing 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
specs/foundations/elements.kmdspecs/interaction/states.kmdspecs/themes/color-roles.kmd