Navigation counters
components specs/components/nav-counters.kmd
Async-loaded count badges on navigation/tab items (e.g. "Issues 42", "Pull requests 7") that load in parallel and render together so the nav never reflows item-by-item. Defines the loading contract and the no-layout-thrash rule. Modeled after GitHub Primer UnderlineNav counters.
When this spec applies
Primary triggers
- Implement async counter badges on tabs/nav
All triggers
- Add live counts to tabs or a nav bar (Issues/PRs/Notifications)
- Load tab counters asynchronously without the nav jumping around
Specification body
Component — Navigation counters
Status: v0.1.0 — Draft. Promoted from the GitHub Primer parity scan (meta/docs/stack #092). Source: https://primer.style/components/nav-list (UnderlineNav counters).
R1 — What it is
A counter is a small numeric badge attached to a nav/tab item reflecting a
live count for that destination. It reuses the visual of
specs/components/badges.kmd (numeric variant) but adds an async load
contract (R2–R3) specific to navigation.
R2 — Load in parallel, render together
- All visible counters load concurrently (one batched request where the API allows), not sequentially per tab.
- They render together in a single layout pass once resolved (or on a short ceiling timeout), so the nav reflows at most once — never N times as each count trickles in. This is the core anti-layout-thrash rule (avoids cumulative layout shift).
R3 — Placeholder while loading
- During load, reserve the counter's space with a
specs/components/skeleton.kmdplaceholder (or a fixed min-width slot) so item widths do not jump when numbers arrive. - On error, the counter is simply omitted (the tab still works); failure of one counter never blocks the others or the nav.
R4 — Zero and large values
- A count of zero renders no badge (absence, not "0") unless the surface explicitly wants to show zero.
- Large counts are abbreviated per
specs/components/badges.kmd(e.g.5k+), with the exact value in the accessible name + title.
R5 — Freshness
- Counters are a hint, not a transaction: they may be cached/eventually consistent. Re-fetch on tab focus / surface revisit, not on a tight poll.
R6 — Accessibility & i18n
- The count is part of the tab's accessible name ("Issues, 42 items"), not
an orphan number; the badge itself is
aria-hiddenwhen the name already includes it. - The loading placeholder is not announced as content.
- Number formatting + abbreviations localize per
specs/i18n/contract.kmd(locale digit grouping; "k"/"mil" etc.).
Não-escopo
- The badge visual itself (
specs/components/badges.kmd). - The tab/nav structure & selection (
specs/components/tabs.kmd,specs/components/navigation.kmd). - Real-time push updates of counts (separate; this is load-time + on-focus).
Requirements (testable)
Requirement: Parallel load, single render pass {#req-nav-counters-render-together}
All visible counters SHALL load concurrently and render together in a single layout pass once resolved (or on a short ceiling timeout), so the nav reflows at most once and never once per counter as values trickle in.
Scenario: Counters resolve without item-by-item reflow
- GIVEN a nav with three tabs (Issues, Pull requests, Notifications) each declaring an async counter
- WHEN the surface mounts and all three counter requests resolve
- THEN the counters are fetched concurrently (one batched request or three in-flight simultaneously, not sequentially)
- AND the nav performs exactly one layout pass that reveals all three counts together
- AND no intermediate layout shift is observed between the first and last counter arriving
Scenario: Slow counter hits the ceiling timeout
- GIVEN two counters resolve quickly and a third exceeds the ceiling timeout
- WHEN the ceiling timeout elapses
- THEN the resolved counters render in a single pass at the timeout boundary
- AND the nav does not wait indefinitely for the slow counter
Requirement: Space reserved during load {#req-nav-counters-placeholder}
While a counter is loading, the component SHALL reserve the counter's space with a skeleton placeholder or a fixed min-width slot, so tab item widths do not jump when numbers arrive.
Scenario: Item width is stable across the load transition
- GIVEN a tab whose counter is still loading and showing a placeholder
- WHEN the counter value resolves and replaces the placeholder
- THEN the tab item's rendered width is unchanged between the placeholder state and the resolved state
Requirement: Counter failure is isolated {#req-nav-counters-error-isolation}
On error a counter SHALL be omitted rather than shown, and the failure of one counter SHALL NOT block the other counters or the nav's usability.
Scenario: One counter errors while others succeed
- GIVEN three tabs with async counters where one counter request fails
- WHEN all three requests settle
- THEN the failing tab renders with no counter badge
- AND the other two tabs render their resolved counts
- AND all three tabs remain selectable and functional
Requirement: Zero and large value rendering {#req-nav-counters-zero-and-large}
A count of zero SHALL render no badge (absence, not "0") unless the surface explicitly opts to show zero, and large counts SHALL be abbreviated while the exact value is preserved in the accessible name and title.
Scenario: Zero renders as absence
- GIVEN a tab whose counter resolves to 0 with no explicit show-zero opt-in
- WHEN the counter renders
- THEN no counter badge is displayed on the tab
Scenario: Large count is abbreviated with exact value retained
- GIVEN a tab whose counter resolves to 5300
- WHEN the counter renders
- THEN the visible badge shows an abbreviated form (e.g. "5k+")
- AND the tab's accessible name and title contain the exact value 5300
Requirement: Accessible name and i18n formatting {#req-nav-counters-a11y-i18n}
The count SHALL be part of the tab's accessible name (e.g. "Issues, 42 items") rather than an orphan number, the badge SHALL be aria-hidden when the accessible name already includes the count, the loading placeholder SHALL NOT be announced as content, and number formatting/abbreviations SHALL localize per the i18n contract.
Scenario: Count is embedded in the tab accessible name
- GIVEN a tab labelled "Issues" whose counter resolves to 42
- WHEN a screen reader computes the tab's accessible name
- THEN the accessible name includes both the label and the count (e.g. "Issues, 42 items")
- AND the counter badge element is marked aria-hidden
Scenario: Loading placeholder is not announced
- GIVEN a tab whose counter is still loading and showing a placeholder
- WHEN a screen reader traverses the tab
- THEN the placeholder contributes no announced content to the tab's accessible name
Scenario: Digit grouping follows locale
- GIVEN the active locale uses "mil" style grouping and a counter resolves to a large value
- WHEN the counter renders
- THEN the visible number and abbreviation are formatted per the locale's digit grouping and abbreviation rules
References
specs/components/tabs.kmdspecs/components/navigation.kmdspecs/components/badges.kmdspecs/components/skeleton.kmdspecs/i18n/contract.kmd