Skip to content
omaris

Navigation

Tabs

MD3 tabs — a TabList of Tabs over one TabPanel per tab.

import { Tabs } from 'omaris'
Learn

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. Tabs holds the value, TabList the row, one TabPanel per Tab.
  • 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. keepMounted on the panel keeps typed values, scroll and in-flight work alive while it is hidden.
  • A phone. swipeable on Tabs, with the panels wrapped in TabPanels, adds the sideways drag a touch screen expects. The indicator follows the finger.

Not for

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 stacked icons on primary tabs only. Secondary tabs are text.
  • Put a live count in badge, like open tickets. Nothing that never changes.
  • Sync value with the URL (?tab=) when a person might share or refresh the page.
  • Turn swipeable on where the tabs are a phone's main surface, pair it with fitted so the whole row is visible, and mark a panel's own horizontal scroller with data-swipe-ignore so 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. scrollable swipes on one line; fitted at six truncates every label.
  • Turn swipeable on 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
  • sm
  • md (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 bindable

Defaults 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