Tabs
Switch between sections of one page — underline, pills and segmented styles.
'use client';
import { Tabs } from '@akhatmirzo/ui';
export default function TabsVariants() {
return (
<div className="flex w-full flex-col gap-8">
{(['underline', 'pills', 'segmented'] as const).map((variant) => (
<Tabs key={variant} defaultValue="info" variant={variant}>
<Tabs.List aria-label={`Mijoz (${variant})`}>
<Tabs.Trigger value="info">Ma'lumot</Tabs.Trigger>
<Tabs.Trigger value="orders" badge={12}>
Buyurtmalar
</Tabs.Trigger>
<Tabs.Trigger value="reviews">Sharhlar</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="info" className="text-text-muted">
Ism, telefon va yetkazib berish manzili.
</Tabs.Content>
<Tabs.Content value="orders" className="text-text-muted">
Oxirgi 12 ta buyurtma ro'yxati.
</Tabs.Content>
<Tabs.Content value="reviews" className="text-text-muted">
Mijoz hali sharh qoldirmagan.
</Tabs.Content>
</Tabs>
))}
</div>
);
}
Import
import { Tabs } from '@akhatmirzo/ui';When to use
- Several views of one subject — "Details", "Orders" and "Reviews" on a customer card. Users see one at a time.
- Switching a period or a filter —
segmented("Day / Week / Month") is compact and clear. - Not for navigating between pages. If each section needs its own URL, build link-based navigation instead.
- When users need to compare sections, tabs get in the way: show everything at once or use an
Accordion. - Aim for 2–7 tabs. More still works (the row scrolls horizontally), but hidden tabs are harder to discover.
Anatomy
| Part | What it does |
|---|---|
Tabs | State (value, defaultValue), variant, size, orientation |
Tabs.List | The row of tabs and the sliding indicator |
Tabs.Trigger | A single tab: value, optional badge, disabled |
Tabs.Content | The panel for a tab (matched by value) |
<Tabs defaultValue="info">
<Tabs.List aria-label="Customer">
<Tabs.Trigger value="info">Details</Tabs.Trigger>
<Tabs.Trigger value="orders" badge={12}>
Orders
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="info">...</Tabs.Content>
<Tabs.Content value="orders">...</Tabs.Content>
</Tabs>Variants
| Variant | Looks like |
|---|---|
underline | A 2px line under the active tab that slides between tabs (default) |
pills | The active tab sits on a rounded primary-soft background |
segmented | The active tab is a raised "card" inside a gray container |
The indicator slides from one tab to the next over 200ms.
Sizes
size is sm (32px) or md (40px, default). In segmented, the inner tab is 28 / 32px tall.
'use client';
import { Tabs } from '@akhatmirzo/ui';
export default function TabsSizes() {
return (
<div className="flex flex-col items-start gap-4">
{(['underline', 'pills', 'segmented'] as const).map((variant) => (
<Tabs key={variant} defaultValue="all" variant={variant} size="sm">
<Tabs.List aria-label="Holat bo'yicha">
<Tabs.Trigger value="all">Hammasi</Tabs.Trigger>
<Tabs.Trigger value="active">Faol</Tabs.Trigger>
<Tabs.Trigger value="archive">Arxiv</Tabs.Trigger>
</Tabs.List>
</Tabs>
))}
</div>
);
}
Vertical
orientation="vertical" stacks the tabs in a column on the left with the content on the right —
a good fit for settings pages. Use ↑ ↓ on the keyboard.
'use client';
import { Tabs } from '@akhatmirzo/ui';
export default function TabsVertical() {
return (
<Tabs defaultValue="profile" orientation="vertical" variant="pills" className="w-full">
<Tabs.List aria-label="Sozlamalar" className="w-44">
<Tabs.Trigger value="profile">Profil</Tabs.Trigger>
<Tabs.Trigger value="security">Xavfsizlik</Tabs.Trigger>
<Tabs.Trigger value="notifications" badge={3}>
Bildirishnomalar
</Tabs.Trigger>
<Tabs.Trigger value="billing" disabled>
To'lovlar
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="profile" className="text-text-muted">
Ism, familiya va profil rasmi.
</Tabs.Content>
<Tabs.Content value="security" className="text-text-muted">
Parol va ikki bosqichli tasdiqlash.
</Tabs.Content>
<Tabs.Content value="notifications" className="text-text-muted">
SMS, email va Telegram orqali bildirishnomalar.
</Tabs.Content>
</Tabs>
);
}
Many tabs
When tabs don't fit, the row scrolls horizontally and soft fades appear at the edges to hint that
there's more. Pass scrollable={false} to Tabs.List to turn this off.
'use client';
import { Tabs } from '@akhatmirzo/ui';
const months = [
'Yanvar',
'Fevral',
'Mart',
'Aprel',
'May',
'Iyun',
'Iyul',
'Avgust',
'Sentabr',
'Oktabr',
'Noyabr',
'Dekabr',
];
export default function TabsScrollable() {
return (
<div className="w-full max-w-md">
<Tabs defaultValue="Mart">
<Tabs.List aria-label="Oylar">
{months.map((m) => (
<Tabs.Trigger key={m} value={m}>
{m}
</Tabs.Trigger>
))}
</Tabs.List>
{months.map((m) => (
<Tabs.Content key={m} value={m} className="text-text-muted">
{m} oyi bo'yicha savdo hisoboti.
</Tabs.Content>
))}
</Tabs>
</div>
);
}
Counters and disabled tabs
badge adds a counter next to the label: a number or any ReactNode (a Badge,
for example). A disabled tab can't be clicked and keyboard navigation skips it.
'use client';
import { Badge, Tabs } from '@akhatmirzo/ui';
export default function TabsBadges() {
return (
<Tabs defaultValue="new">
<Tabs.List aria-label="Buyurtmalar holati">
<Tabs.Trigger value="new" badge={4}>
Yangi
</Tabs.Trigger>
<Tabs.Trigger
value="paid"
badge={
<Badge color="success" size="sm">
+2
</Badge>
}
>
To'langan
</Tabs.Trigger>
<Tabs.Trigger value="canceled" disabled>
Bekor qilingan
</Tabs.Trigger>
</Tabs.List>
</Tabs>
);
}
Controlled
With value + onValueChange, you own the active tab. Tabs also work without any
Tabs.Content — as a plain switcher.
Savdo: 8 740 000 so'm
'use client';
import { Tabs } from '@akhatmirzo/ui';
import { useState } from 'react';
const totals: Record<string, string> = {
day: "1 250 000 so'm",
week: "8 740 000 so'm",
month: "36 900 000 so'm",
year: "412 500 000 so'm",
};
export default function TabsControlled() {
const [period, setPeriod] = useState('week');
return (
<div className="flex flex-col items-start gap-3">
<Tabs value={period} onValueChange={setPeriod} variant="segmented">
<Tabs.List aria-label="Davr">
<Tabs.Trigger value="day">Kun</Tabs.Trigger>
<Tabs.Trigger value="week">Hafta</Tabs.Trigger>
<Tabs.Trigger value="month">Oy</Tabs.Trigger>
<Tabs.Trigger value="year">Yil</Tabs.Trigger>
</Tabs.List>
</Tabs>
<p className="m-0 text-sm text-text-muted">
Savdo: <span className="font-semibold text-text">{totals[period]}</span>
</p>
</div>
);
}
Props
Tabs
Also accepts <div> attributes and the Radix Root props (activationMode, dir).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Active tab (controlled) |
defaultValue | string | — | Initially active tab |
onValueChange | (value: string) => void | — | Called when the active tab changes |
variant | 'underline' | 'pills' | 'segmented' | 'underline' | Visual style |
size | 'sm' | 'md' | 'md' | Height: 32 / 40px |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Direction of the tab row |
Tabs.List
| Prop | Type | Default | Description |
|---|---|---|---|
scrollable | boolean | true | Horizontal scroll with edge fades when tabs don't fit |
aria-label | string | — | Name of the tab group (recommended) |
loop | boolean | true | Arrow keys wrap from the last tab to the first |
Tabs.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | Tab value |
badge | ReactNode | — | A counter next to the label, e.g. 12 |
disabled | boolean | — | Disables the tab |
Tabs.Content
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | The tab it belongs to |
forceMount | true | — | Keeps it in the DOM while hidden |
Data attributes
| Attribute | On | Value |
|---|---|---|
data-variant | root, list | underline · pills · segmented |
data-size | root, list | sm · md |
data-orientation | all parts | horizontal · vertical |
data-state | tab, panel | active · inactive |
data-disabled | tab | present when disabled |
data-tabs-indicator | indicator | the sliding line or background |
Keyboard
| Key | Action |
|---|---|
← / → (↑ / ↓ when vertical) | Moves between tabs; the focused tab activates at once |
Home / End | First / last tab |
Tab | Moves from the tab row into the active panel |
Accessibility
- Roles. The list is
role="tablist"(witharia-orientation), each tab isrole="tab"(aria-selected,aria-controls), and each panel isrole="tabpanel"(aria-labelledby). It is built on Radix Tabs. - Name. Give
Tabs.Listanaria-label("Customer", "Period") so screen readers announce the group by name. - Roving focus. The tab row is a single
Tabstop; arrow keys move inside it. - The focus ring is drawn inside the tab so the scroll container doesn't clip it.
- The indicator is decorative (
aria-hidden). Withprefers-reduced-motion, it jumps instead of sliding.