Selected: Products
Overview
Tabs swap one view for another inside the same surface. They do not navigate to another page; they do not act on selection; they do not configure anything. Reach for them when the user needs to see the same object through different lenses — a customer's orders vs. their settlements, an invoice's timeline vs. its line items. If pressing the tab changes the URL, the page, or the object the user is looking at, you want a link, not a tab.
Tabs are for sibling views, not for steps
A row of tabs implies the views are equivalent — same object, different angle. If the user has to complete tab 1 before tab 2 makes sense, you have a wizard, not tabs. Use a stepper.
Anatomy
A label, a baseline, and a 2 px stroke that thickens under the active tab. The simplest indicator pattern in the system.
Label
Inter Tight Medium 16 / 20 in
text.on-fill.secondary-on-fill(#212121). Sentence case for product surfaces, ALL CAPS only on the marketing site. No icons in the canonical — labels do the work.Selected indicator
2 px bottom stroke in
border.selected(#0f0f0f). Sits flush with the row baseline so the indicator and the baseline read as one continuous line — no gap, no inset.Baseline
1 px hairline in
border.tertiary(#f0f0f0) running under every inactive tab. It anchors the row visually and provides the surface the selected stroke rises out of.Optional badge
Either a numeric count pill (
background.accent.red.subtlewithtext.on-fill.critical-on-fill) or a “New” pill (blue.50surface, primary text). 6 px gap between label and badge.
The row uses a 28 px gap between items and a fixed 40 px height. Tab items pad 16 px top and bottom internally; the stroke and baseline live inside that padding so the visual rhythm of the surrounding page never shifts when the user changes tabs.
States
Five states cover every interaction. Hover and active are deliberately quiet — the tab is a navigation surface, and over- decorated hover states pull focus away from the content the user is actually trying to read.
default
Resting state. 1 px baseline in border.tertiary. Label at full opacity.
hover
Pointer over the item. Label drops to 85% opacity — no underline, no surface fill.
active
Pressed state, while the user is mid-click. Label dims to 70%; the baseline doesn't move.
focus
Keyboard focus. 2 px outline in border.selected wrapping the tab — visible on tab navigation, suppressed on click.
selected
Current tab. The baseline thickens to 2 px in border.selected; the label inherits the same ink so the indicator and the word read as one shape.
Badges
Two badge slots — never both at once. The count is a hard number the user should act on; the “New” pill is a single- shot announcement of an unseen tab.
Count badge
A small numeric pill in critical tone. Use for actionable counts — unread items, pending approvals, tasks waiting. Drop the badge when the count is zero; never render “0”.
New pill
A blue-tinted “New” pill announces a tab the user hasn't seen yet. Remove the pill once the tab has been visited — persistence past the first session turns it into noise.
Density
Two is the floor, six is the ceiling. The 28 px gap is fixed, which means the row needs to fit on the smallest surface it ever renders on — typically a 1024 px column inside the portal.
Two tabs
Two tabs is the floor. Below two, use a heading. The 28 px gap stays the same regardless of how many items the row carries.
Five tabs
The practical ceiling. Five fits a 1024 px column comfortably; six starts to crowd. Past six, switch to a Select — tabs are for ranges the user wants to compare, not for a long list of options.
In product
Tabs at the top of a panel, with content below. The tab row sits inside the same card as its panel; the bottom baseline visually attaches to the panel surface so the two read as one object.
Daily settlement summary, refund totals, and the headline approval rate. Updates every five minutes.
Overflow
Tabs do not scroll, do not stack, and do not collapse to a “More” menu. If the tabs don't fit, the composition is wrong, not the component.
Single row, no overflow
Tabs do not scroll horizontally and do not collapse into an overflow menu. If they don't fit, the row was the wrong control — switch to a Select.
Behavior
- Click swaps the panel only. The page URL, scroll position, and surrounding controls stay put. If the tab needs to be deep-linkable, mirror it into the query string (
?tab=transactions) — but the click itself never triggers a navigation. - Default to the first tab. On first load, the leftmost tab is selected. Persist the user's last choice across sessions only when the tabs represent a workflow the user returns to (Inbox vs. Drafts) — never for views (Overview vs. Forecasts) where stale state is confusing.
- Selection is instant. The panel swaps in the same frame as the click. If the new panel needs data, render the panel skeleton inside the panel area, not inside the tab — the tab indicator should never sit in a loading state.
- Disabled tabs are forbidden. If a view doesn't apply to the user, hide the tab. A row of items with one greyed out forces every user to read the tooltip explaining why; better to render a smaller row.
- Counts update live. When a tab's count changes (a new dispute arrives), the badge updates in place — no animation, no flash. The user learns to glance at the row to check for new work.
Accessibility
- Roles: the row is
role="tablist"; each tab isrole="tab"witharia-selected; the panel below isrole="tabpanel"labelled by the active tab. - Keyboard:
←/→moves between tabs;Home/Endjumps to the first / last;EnterandSpaceactivate.Tabmoves focus into the panel — never between tabs. - Focus visibility: the focus state shows a 2 px ring in
border.selectedwrapping the tab. The ring is suppressed on click (use:focus-visible) so the indicator only appears for keyboard users. - Badge announcements: a count badge is read as part of the tab name —
"Disputes, 3"— usingaria-labelon the tab. Don't leave the badge as visual-only; counts are often the reason the user is here. - Reduced motion: there is no entrance or indicator animation by default. If the project adds a sliding indicator, gate it behind
prefers-reduced-motion: no-preference.
Code
The component composes from a Tabs wrapper and a single Tab primitive. The wrapper carries the row state; the tab is a button that renders its label and optional badge.
tsx
import { Tabs, TabPanel } from "@flatpay-dk/ui";
const items = [
{ label: "Overview" },
{ label: "Transactions" },
{ label: "Forecasts", newBadge: true },
{ label: "Disputes", count: 3 },
] as const;
const [tab, setTab] = useState(0);
return (
<section className="rounded-2xl border bg-card">
<div className="px-6 pt-6">
<Tabs
items={items}
value={tab}
onChange={setTab}
ariaLabel="Reports"
/>
</div>
<TabPanel label={items[tab].label} className="px-6 py-6">
{/* Panel contents — render based on items[tab].label */}
</TabPanel>
</section>
);
// Mirror to the URL when the tabs need to be deep-linkable
const router = useRouter();
const search = useSearchParams();
const active = items.findIndex((i) => i.label === search.get("tab"));
<Tabs
items={items}
value={active === -1 ? 0 : active}
onChange={(i) => router.replace(`?tab=${items[i].label}`)}
ariaLabel="Reports"
/>Best practices
Tabs are easy to over-use. The questions below catch the most common misfires before they reach the screen.
Do
Use tabs when the user needs to look at the same object through different lenses. Sibling views, equivalent weight.
Don't
Don't use tabs for steps in a flow. If tab 2 needs tab 1 finished first, you want a stepper, not tabs.
Do
Cap the row at six tabs. Five fits a 1024 px column with breathing room.
Don't
Don't squeeze ten tabs into a row. Past six, the row stops being scannable — switch to a Select.
Do
Drop the count badge to nothing when the value is zero. A row of '0' badges trains the user to ignore them.
Don't
Don't render a 0 count badge. Empty mailboxes don't need a red pill saying '0'.
Do
Show one tab at a time as the active one. The 2 px stroke is the only visual claim a tab can make.
Don't
Don't tint the inactive tabs to add 'flavour'. Color earns its place by carrying state — and the active stroke already does that work.
Props
Tabs
| Prop | Type | Default | Description |
|---|---|---|---|
| items | Array<{ label: string; count?: number; newBadge?: boolean }> | — | The tabs to render in row order. Each item provides its label and optional badge. Cap at 6 items. |
| value | number | 0 | Index of the active tab. Controlled — pair with onChange. The row defaults to the first tab on first render. |
| onChange | (index: number) => void | — | Called when the user activates a different tab. Mirror to the URL only when the tabs need to be deep-linkable. |
| ariaLabel | string | — | Accessible name for the tablist. Names the object the tabs belong to — 'Reports', 'Mailbox', 'Customer 1142'. |
Tab
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Tab label. Inter Tight Medium 16/20. Sentence case in product surfaces. |
| selected | boolean | false | Marks the tab as the active one — thickens the bottom stroke to 2 px and inks the label in border.selected. |
| count | number | — | Numeric badge in critical tone, rendered after the label. Drop the badge for zero — never render '0'. |
| newBadge | boolean | false | Renders a 'New' pill in info tone. Use once, then drop after the user has visited the tab. |
| onClick | () => void | — | Click handler. The Tabs wrapper provides this when used inside a row; expose for standalone use. |