Navigation
Tabs
MD3 tabs — a TabList of Tabs over one TabPanel per tab.
import { Tabs } from 'omaris' Examples
Basic
A TabList of Tabs over one TabPanel per tab, matched by value — views of one thing on one page, not a way between pages.
Three services, all green.
<script lang="ts"> import { Tabs, TabList, Tab, TabPanel } from 'omaris'; let active = $state('overview');</script><Tabs bind:value={active} class="w-full"> <TabList label="Project sections"> <Tab value="overview" label="Overview" /> <Tab value="activity" label="Activity" /> <Tab value="settings" label="Settings" /> </TabList> <TabPanel value="overview" class="pt-4"> <p class="text-body-md text-muted-foreground">Three services, all green.</p> </TabPanel> <TabPanel value="activity" class="pt-4"> <p class="text-body-md text-muted-foreground">Amina deployed storefront 3 minutes ago.</p> </TabPanel> <TabPanel value="settings" class="pt-4"> <p class="text-body-md text-muted-foreground">Region, domains, environment variables.</p> </TabPanel></Tabs> Icons and badges
stacked puts the icon above the label, MD3's two-line primary tab — for a top-level set where the icon is worth the extra height.
12 unread.
<script lang="ts"> import { Badge, Tabs, TabList, Tab, TabPanel } from 'omaris'; let active = $state('inbox');</script><Tabs bind:value={active} class="w-full"> <TabList label="Mail"> <Tab value="inbox" label="Inbox" stacked> {#snippet icon()} ... {/snippet} <!-- A count, not a pill: `value` gives the badge MD3's circular count shape and `size="sm"` keeps it under the label it rides beside. A default-size `<Badge>` with children is a status pill — twice the height of the tab's own text, and it drags the label off centre under the icon. --> {#snippet badge()} <Badge tone="destructive" size="sm" value={12} label="12 unread" /> {/snippet} </Tab> <Tab value="sent" label="Sent" stacked> {#snippet icon()} ... {/snippet} </Tab> </TabList> <TabPanel value="inbox" class="pt-4"> <p class="text-body-md text-muted-foreground">12 unread.</p> </TabPanel> <TabPanel value="sent" class="pt-4"> <p class="text-body-md text-muted-foreground">Nothing sent today.</p> </TabPanel></Tabs> Secondary and fitted
secondary is for a set nested inside a primary tab's content. fitted shares the width evenly instead of hugging the labels.
Showing the last day.
<script lang="ts"> import { Tabs, TabList, Tab, TabPanel } from 'omaris'; let active = $state('day');</script><Tabs bind:value={active} variant="secondary" fitted class="w-full max-w-md"> <TabList label="Range"> <Tab value="day" label="Day" /> <Tab value="week" label="Week" /> <Tab value="month" label="Month" /> </TabList> <TabPanel value={active} class="pt-4"> <p class="text-body-md text-muted-foreground">Showing the last {active}.</p> </TabPanel></Tabs> Scrollable
scrollable is the answer to a set too wide for the screen: one line that swipes, rather than tabs squeezed until their labels truncate.
Deliveries in Baghdad.
<script lang="ts"> import { Tabs, TabList, Tab, TabPanel } from 'omaris'; let active = $state('Baghdad');</script><Tabs bind:value={active} class="w-full max-w-lg"> <TabList label="Cities" scrollable> <Tab value="Baghdad" label="Baghdad" /> <Tab value="Erbil" label="Erbil" /> <Tab value="Basra" label="Basra" /> <Tab value="Mosul" label="Mosul" /> <Tab value="Duhok" label="Duhok" /> <Tab value="Najaf" label="Najaf" /> <Tab value="Karbala" label="Karbala" /> <Tab value="Kirkuk" label="Kirkuk" /> </TabList> <TabPanel value={active} class="pt-4"> <p class="text-body-md text-muted-foreground">Deliveries in {active}.</p> </TabPanel></Tabs> Swipeable
Swipeable panels, for a phone. Wrap them in TabPanels and set swipeable: the track follows the finger, a flick moves a panel however short it was, and the indicator slides along at the same fraction instead of catching up afterwards. Vertical scrolling is untouched.
Twelve unread. Drag this panel sideways.
<script lang="ts"> import { Tabs, TabList, Tab, TabPanel, TabPanels } from 'omaris'; let value = $state('inbox');</script><div class="w-80 max-w-full rounded-shape-md border border-border p-2"> <Tabs swipeable fitted bind:value> <TabList label="Mail"> <Tab value="inbox" label="Inbox" /> <Tab value="sent" label="Sent" /> <Tab value="drafts" label="Drafts" /> </TabList> <TabPanels> <TabPanel value="inbox"> <p class="text-sm">Twelve unread. Drag this panel sideways.</p> </TabPanel> <TabPanel value="sent"> <p class="text-sm">Nothing sent today.</p> </TabPanel> <TabPanel value="drafts"> <p class="text-sm">Two drafts waiting.</p> </TabPanel> </TabPanels> </Tabs></div> Vertical
orientation="vertical" stacks the tabs down the start edge, beside the panels: a settings screen's sections. ↑ and ↓ move between them.
Name, photo and bio.
<script lang="ts"> import { Tabs, TabList, Tab, TabPanel, Text } from 'omaris'; let section = $state('profile');</script><Tabs bind:value={section} orientation="vertical" class="w-full max-w-lg"> <TabList label="Settings"> <Tab value="profile" label="Profile" /> <Tab value="notifications" label="Notifications" /> <Tab value="security" label="Security" /> </TabList> <TabPanel value="profile"><Text>Name, photo and bio.</Text></TabPanel> <TabPanel value="notifications"><Text>What reaches your phone, and when.</Text></TabPanel> <TabPanel value="security"><Text>Passkeys and signed-in devices.</Text></TabPanel></Tabs> When to use it
Use it for
- Views of one thing within a page, like a customer's orders, invoices and notes, where the URL stays the same.
Tabsholds the value,TabListthe row, oneTabPanelperTab. - A second level inside a primary tab's content:
variant="secondary". Its indicator runs the width of the tab. - More tabs than the width:
scrollable. A few equal ones on a phone:fitted. - Forms across tabs.
keepMountedon the panel keeps typed values, scroll and in-flight work alive while it is hidden. - A phone.
swipeableonTabs, with the panels wrapped inTabPanels, adds the sideways drag a touch screen expects. The indicator follows the finger.
Not for
- Moving between the app's screens → Navigation Drawer or Navigation Bar.
- Two or three filters on the same list → Segmented Button or filter Chips.
- Steps that must happen in order → a Button for next and Progress for how far.
- Sections people read top to bottom → headings and a Toc.
Do
- Keep labels to a word or two, sentence case, about the same length. The indicator hugs the label on a primary tab, so a long one stands out.
- Use
stackedicons on primary tabs only. Secondary tabs are text. - Put a live count in
badge, like open tickets. Nothing that never changes. - Sync
valuewith the URL (?tab=) when a person might share or refresh the page. - Turn
swipeableon where the tabs are a phone's main surface, pair it withfittedso the whole row is visible, and mark a panel's own horizontal scroller withdata-swipe-ignoreso its gesture wins over the tab swipe.
Don't
- Nest primary inside primary. The second level is
secondary; there is no third. - Mix icon tabs and text-only tabs in one list.
- Put an action like "+ New" in the row. A tab opens a panel; an action is a Button beside it.
- Squeeze six tabs into a phone width.
scrollableswipes on one line;fittedat six truncates every label. - Turn
swipeableon for heavy panels; every panel is mounted at once. Never rely on the swipe alone either. A mouse and a keyboard never see it.
Quick reference
variant primary(default)secondary
size smmd(default)
API
Tabs
MD3 tabs — a TabList of Tabs over one TabPanel per tab.
primary is the top-level set, bigger, with the indicator hugging the label. secondary is for a set nested inside a primary tab's content.
swipeable adds the gesture a phone expects: wrap the panels in TabPanels and they can be dragged sideways, following the finger, with the indicator sliding along at the same fraction rather than jumping when the drag ends.
import { Tabs } from 'omaris' Props
value bindableDefaults to undefined
string The selected tab's value. Bindable.
variant Defaults to 'primary'
TabsVariant MD3's two kinds. primary is the top-level one — bigger, with the indicator hugging the label. secondary sits inside a primary tab's content and runs its indicator the full width of the tab.
size Defaults to 'md'
TabsSize fitted Defaults to false
boolean Share the width evenly rather than hugging the labels.
swipeable Defaults to false
boolean Let the panels be dragged sideways. Needs the panels wrapped in TabPanels, and mounts them all at once so there is something to drag to — so leave it off for a set whose panels are expensive.
orientation Defaults to 'horizontal'
TabsOrientation vertical stacks the tabs down the start edge beside the panels — a settings screen's sections. ↑ and ↓ move between them.
activation Defaults to 'automatic'
TabsActivation automatic shows a tab's panel as soon as the arrow keys reach it. manual only moves focus, and Enter or Space opens it — for panels that are expensive to show, or that fetch when they open.
class string children Snippet Tab
One tab in a TabList. Its value names the TabPanel it opens.
import { Tab } from 'omaris' Props
value required string Identifies the tab, and the panel it controls.
label string Label text. Use children for rich content.
stacked Defaults to false
boolean Stack the icon above the label — MD3's two-line primary tab.
badge Snippet A count or dot rendered after the label.
icon Snippet Leading icon.
class string children Snippet TabList
The row of tabs, and the indicator that slides between them.
scrollable is the answer to a set too wide for the screen: one line that swipes, rather than tabs squeezed until their labels truncate.
import { TabList } from 'omaris' Props
label string Accessible name for the tab set.
divider Defaults to true
boolean Draw the hairline the indicator rides on.
scrollable Defaults to false
boolean One line that scrolls sideways, for a set too wide to fit.
class string children Snippet TabPanel
The content behind one tab. Its value must match that tab's.
Panels unmount when hidden, which is what you want until it isn't — keepMounted keeps scroll position, form state and in-flight work alive. Inside a swipeable TabPanels every panel stays mounted regardless; there has to be something beside the current one to drag towards.
import { TabPanel } from 'omaris' Props
value required string Must match the value of the tab that opens it.
keepMounted Defaults to false
boolean Keep the panel mounted while hidden. Costs memory but preserves scroll position, form state and any in-flight work in the panel.
class string children Snippet TabPanels
The viewport the panels live in, and the thing a swipe moves.
Without swipeable on the Tabs it is a plain wrapper and costs nothing. With it, the panels are laid out in a row and this drags them: the track follows the finger one-to-one, resists past the first and last panel, and settles on the next one from either the distance travelled or the speed it was thrown at — a flick moves a panel even when it barely moved at all.
touch-action: pan-y is what keeps the page scrolling normally: the browser keeps the vertical axis and this takes the horizontal one, so a swipe never fights a scroll.
import { TabPanels } from 'omaris' <Tabs swipeable bind:value> <TabList>…</TabList> <TabPanels> <TabPanel value="one">…</TabPanel> <TabPanel value="two">…</TabPanel> </TabPanels></Tabs> Props
threshold Defaults to 0.25
number Fraction of the width a drag has to cover before it counts as a move. A fast flick moves regardless.
disabled Defaults to false
boolean Turns the gesture off for this viewport, leaving the layout alone.
class string children Snippet