Navigation
Navigation Bar
MD3 navigation bar — the bottom bar on a phone, 3–5 destinations.
import { NavigationBar } from 'omaris' Examples
Basic
Three to five destinations, on a phone. More than five wants a drawer.
<script lang="ts"> import { NavigationBar, NavigationBarItem } from 'omaris'; let tab = $state('home');</script>{#snippet house()} ...{/snippet}{#snippet bag()} ...{/snippet}{#snippet person()} ...{/snippet}<div class="w-full max-w-sm overflow-hidden rounded-shape-lg border border-border"> <NavigationBar label="Sections"> <NavigationBarItem label="Home" selected={tab === 'home'} onclick={() => (tab = 'home')}> {#snippet icon()}{@render house()}{/snippet} </NavigationBarItem> <NavigationBarItem label="Orders" badge={3} selected={tab === 'orders'} onclick={() => (tab = 'orders')} > {#snippet icon()}{@render bag()}{/snippet} </NavigationBarItem> <NavigationBarItem label="Profile" selected={tab === 'profile'} onclick={() => (tab = 'profile')} > {#snippet icon()}{@render person()}{/snippet} </NavigationBarItem> </NavigationBar></div> Label modes
selected shows the label only under the current item, which MD3 allows — for a bar of five where the labels would crowd each other.
<script lang="ts"> import { NavigationBar, NavigationBarItem } from 'omaris'; let tab = $state('a');</script>{#snippet dot()} ...{/snippet}<div class="flex w-full max-w-sm flex-col gap-4"> <div class="overflow-hidden rounded-shape-lg border border-border"> <NavigationBar labels="always" label="labels = always"> <NavigationBarItem label="Home" selected={tab === 'a'} onclick={() => (tab = 'a')}> {#snippet icon()}{@render dot()}{/snippet} </NavigationBarItem> <NavigationBarItem label="Search" selected={tab === 'b'} onclick={() => (tab = 'b')}> {#snippet icon()}{@render dot()}{/snippet} </NavigationBarItem> <NavigationBarItem label="Saved" selected={tab === 'c'} onclick={() => (tab = 'c')}> {#snippet icon()}{@render dot()}{/snippet} </NavigationBarItem> </NavigationBar> </div> <div class="overflow-hidden rounded-shape-lg border border-border"> <NavigationBar labels="selected" label="labels = selected"> <NavigationBarItem label="Home" selected={tab === 'a'} onclick={() => (tab = 'a')}> {#snippet icon()}{@render dot()}{/snippet} </NavigationBarItem> <NavigationBarItem label="Search" selected={tab === 'b'} onclick={() => (tab = 'b')}> {#snippet icon()}{@render dot()}{/snippet} </NavigationBarItem> <NavigationBarItem label="Saved" selected={tab === 'c'} onclick={() => (tab = 'c')}> {#snippet icon()}{@render dot()}{/snippet} </NavigationBarItem> </NavigationBar> </div></div> Swipeable screens
Wrap the bar and its screens in Navigation, set swipeable, and the destination is one bindable value: drag a screen sideways and the next one follows the finger while the active pill slides along at the same fraction. A flick moves a screen however short it was, and the ends push back. Try it with a finger — mouse is only on here so a desktop can feel it too.
Home
<script lang="ts"> import { Navigation, NavigationBar, NavigationBarItem, NavigationView, NavigationViews, Text } from 'omaris'; let screen = $state('home');</script>{#snippet house()} ...{/snippet}{#snippet bag()} ...{/snippet}{#snippet person()} ...{/snippet}{#snippet screenBody(title: string, lines: number)} <div class="flex flex-col gap-3 p-4"> <Text variant="title-md" as="h3">{title}</Text> {#each { length: lines }, index (index)} <div class="h-12 rounded-shape-md bg-surface-container-high"></div> {/each} </div>{/snippet}<div class="w-full max-w-sm overflow-hidden rounded-shape-lg border border-border"> <Navigation swipeable bind:value={screen} class="h-96"> <NavigationViews mouse> <NavigationView value="home">{@render screenBody('Home', 4)}</NavigationView> <NavigationView value="orders">{@render screenBody('Orders', 6)}</NavigationView> <NavigationView value="profile">{@render screenBody('Profile', 3)}</NavigationView> </NavigationViews> <NavigationBar label="Main" elevated> <NavigationBarItem value="home" label="Home" icon={house} /> <NavigationBarItem value="orders" label="Orders" badge={3} icon={bag} /> <NavigationBarItem value="profile" label="Profile" icon={person} /> </NavigationBar> </Navigation></div> Right to left
The same screens under dir="rtl". Everything mirrors together: the destinations run right to left, the screens are stacked the same way, and the finger drags them the way the writing runs — a drag to the right moves forward, and the pill slides along under it. Nothing in the markup changes; the direction is read off the layout.
الرئيسية
<script lang="ts"> import { Navigation, NavigationBar, NavigationBarItem, NavigationView, NavigationViews, Text } from 'omaris'; let screen = $state('home');</script>{#snippet house()} ...{/snippet}{#snippet bag()} ...{/snippet}{#snippet person()} ...{/snippet}{#snippet screenBody(title: string, lines: number)} <div class="flex flex-col gap-3 p-4"> <Text variant="title-md" as="h3">{title}</Text> {#each { length: lines }, index (index)} <div class="h-12 rounded-shape-md bg-surface-container-high"></div> {/each} </div>{/snippet}<div dir="rtl" class="w-full max-w-sm overflow-hidden rounded-shape-lg border border-border"> <Navigation swipeable bind:value={screen} class="h-96"> <NavigationViews mouse> <NavigationView value="home">{@render screenBody('الرئيسية', 4)}</NavigationView> <NavigationView value="orders">{@render screenBody('الطلبات', 6)}</NavigationView> <NavigationView value="profile">{@render screenBody('الحساب', 3)}</NavigationView> </NavigationViews> <NavigationBar label="التنقل" elevated> <NavigationBarItem value="home" label="الرئيسية" icon={house} /> <NavigationBarItem value="orders" label="الطلبات" badge={3} icon={bag} /> <NavigationBarItem value="profile" label="الحساب" icon={person} /> </NavigationBar> </Navigation></div> Overridden
The same set with the defaults taken apart: labels="selected" so the label rides the swipe in and out, lazy so a screen is built the first time it is reached, a tighter threshold so a shorter drag counts, and classes on the pill and the label. fill={false} sizes the viewport by its content instead of filling the column.
<script lang="ts"> import { Navigation, NavigationBar, NavigationBarItem, NavigationView, NavigationViews, Text } from 'omaris'; let screen = $state('two');</script>{#snippet dot()} ...{/snippet}<div class="w-full max-w-sm overflow-hidden rounded-shape-lg border border-border"> <Navigation swipeable bind:value={screen}> <NavigationViews mouse lazy fill={false} threshold={0.15} class="h-40"> <NavigationView value="one" class="p-4"> <Text variant="body-md">First. Drag left.</Text> </NavigationView> <NavigationView value="two" class="p-4"> <Text variant="body-md">Second — built on the way past.</Text> </NavigationView> <NavigationView value="three" class="p-4"> <Text variant="body-md">Third, and the end pushes back.</Text> </NavigationView> </NavigationViews> <NavigationBar label="Sections" labels="selected" size="sm"> <NavigationBarItem value="one" label="One" icon={dot} classes={{ indicator: 'rounded-shape-sm', label: 'uppercase tracking-wide' }} /> <NavigationBarItem value="two" label="Two" icon={dot} classes={{ indicator: 'rounded-shape-sm', label: 'uppercase tracking-wide' }} /> <NavigationBarItem value="three" label="Three" icon={dot} classes={{ indicator: 'rounded-shape-sm', label: 'uppercase tracking-wide' }} /> </NavigationBar> </Navigation></div> When to use it
Use it for
- Primary navigation on a phone: three to five top-level destinations along the bottom,
fixed, on every screen. - The layout below
md, whereAppShellSidebarhas dropped out and the drawer is off screen. - A count on a destination, like unread messages or open orders.
badgesits on the icon without touching the pill. - Screens a thumb can swipe between. Wrap the bar and its content in
Navigationwithswipeable, give each destination avalueand each screen aNavigationView.
Not for
- More than five destinations → Navigation Drawer, modal on a phone or
responsive. - A laptop or desktop → Navigation Drawer. There is no rail component;
collapsedon the drawer is the rail. - Views within one screen → Tabs, which swipe the same way.
- An action like compose, search or add → FAB or the
actionsof a Top App Bar. - Two destinations → Tabs or a Segmented Button. A bar of two is mostly empty.
Do
- Show the same destinations, in the same order, on every top-level screen, and mark the current one
selected. - Give
selectedIconthe filled twin oficon, and setelevatedwhen content scrolls underneath it. - Keep
labels="always". Drop toselectedonly when five labels squeeze and the icons are unmistakable. - Use
Navigationinstead ofselected={x === 'home'}and anonclickon every item. One bindablevalueowns the destination and enables the swipe and the sliding indicator. - Keep the
NavigationVieworder the same as the items' order. A drag follows the writing direction; underdir="rtl"the bar mirrors. Pairswipeablewithlazywhen a screen is expensive, or every view is mounted.
Don't
- Change which destinations appear depending on the screen, or put a transient or destructive action in it.
- Use
labels="never". An icon alone is a guess for a first-time visitor. - Show it alongside the drawer on a wide screen. One primary navigation at a time.
- Mark a plain sideways scroller
data-swipe-ignore. It hands over the swipe once it runs out of room. Save the attribute for something with its own pointer handling: a carousel, a slider, a canvas. - Turn
mouseon for a production app. A drag is a touch gesture; on a desktop it fights text selection.
Quick reference
layout bar(default)railrow
API
NavigationBar
MD3 navigation bar — the bottom bar on a phone, 3–5 destinations.
Holds NavigationBarItems and shares the label policy with them, so "labels only on the active item" is one prop here rather than a decision repeated on every destination.
Inside a Navigation the bar stops being a row of independent buttons: items take a value, the container says which one is current, and the active indicator becomes a single pill that slides between them — under the finger, at the same fraction, when the screens are swiped.
import { NavigationBar } from 'omaris' Props
size Defaults to 'md'
NavigationBarSize elevated Defaults to false
boolean fixed Defaults to false
boolean labels Defaults to 'always'
NavigationLabelMode Applies to every item that doesn't override it.
label string Accessible name — "Main".
class string children Snippet Navigation
A bottom bar and the screens it moves between, holding the one piece of state they share.
On its own a NavigationBar is a row of buttons and the app decides what they do. Wrapped in a Navigation the destination becomes one bindable value: items take a value instead of selected and an onclick, and NavigationViews lays the screens out beside each other so a finger can drag between them — the way a phone's own apps behave, with the active indicator sliding under the drag rather than jumping when it ends.
import { Navigation } from 'omaris' <Navigation swipeable bind:value class="h-dvh"> <NavigationViews> <NavigationView value="home">…</NavigationView> <NavigationView value="profile">…</NavigationView> </NavigationViews> <NavigationBar label="Main"> <NavigationBarItem value="home" label="Home" icon={home} /> <NavigationBarItem value="profile" label="Profile" icon={person} /> </NavigationBar></Navigation> Props
value bindableDefaults to undefined
string The current destination. Bindable.
swipeable Defaults to false
boolean Let the views be dragged sideways. Needs them wrapped in NavigationViews, and mounts them all at once so there is something to drag to — pair it with lazy when a screen is expensive.
class string children Snippet NavigationBarItem
A destination in a NavigationBar.
The MD3 signature is the active indicator: a pill that sits behind the icon only, not the label, and grows into place when the destination is selected. The icon and the pill are separate elements for exactly that reason — the ripple and state layer belong to the pill, while the label underneath stays untouched.
In an expanded rail the same destination becomes a row — icon beside label — and the pill stretches to cover the whole row, the way MD3's expanded rail and drawer draw it.
import { NavigationBarItem } from 'omaris' Parts
Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.
root- No description in the source yet.
indicator- The pill behind the icon — MD3's active indicator.
label- No description in the source yet.
Props
label string Label under the icon.
value string Names the destination, and the NavigationView it opens. Inside a Navigation this replaces selected and the onclick that went with it — the container owns which one is current.
selected Defaults to false
boolean labels NavigationLabelMode Override the container's label policy for this destination.
badge number | boolean A count on the icon — rendered as an anchored badge.
badgeMax Defaults to 99
number Cap for a numeric badge.
ripple Defaults to true
boolean class string classes NavigationBarItemClasses Per-part Tailwind overrides. class still covers the root.
icon Snippet The icon. Swap it for a filled version when selected if you like.
selectedIcon Snippet Icon shown only while selected — MD3's filled/outlined pair.
children Snippet variant ButtonVariant Fill style. MD3's five, plus link for inline navigation.
tone ButtonTone Color role the fill style paints with.
size ButtonSize MD3 Expressive heights: 32 / 40 / 56 / 96 / 136dp.
shape ButtonShape Pill (round) or MD3 rounded rectangle (square).
confetti boolean | ConfettiOptions Confetti on press. true for the default burst, or any ConfettiOptions — confetti={{ preset: 'fireworks' }}.
loading boolean Grows a spinner in at the start of the button and blocks interaction. The label stays put; only an icon button swaps its icon out.
toggle boolean Turns the button into a two-state toggle driven by pressed. Ignored when href is set — a link has no pressed state.
pressed bindableboolean Selected state of a toggle button. Bindable.
NavigationView
One screen behind one destination. Its value must match the item's.
Views scroll on their own — a phone remembers where you were on the other tab, and a shared scroller cannot. Inside a swipeable NavigationViews every view stays mounted regardless of keepMounted; there has to be something beside the current one to drag towards. lazy on the viewport is the way out of paying for all of them at once: a view is then built the first time it comes within a swipe of the screen, and kept afterwards.
import { NavigationView } from 'omaris' Props
value required string Must match the value of the destination that opens it.
keepMounted Defaults to false
boolean Keep the view mounted while hidden, in a set that isn't swipeable. Costs memory but preserves scroll position, form state and any in-flight work.
class string children Snippet NavigationViews
The viewport the screens live in, and the thing a swipe moves.
Without swipeable on the Navigation it shows one view at a time and costs nothing. With it, the views are laid out in a row and this drags them: the track follows the finger one-to-one, resists past the first and last destination, and settles on the next one from either the distance travelled or the speed it was thrown at — a flick moves a screen even when it barely moved at all.
touch-action: pan-y hands the browser the vertical axis and keeps the horizontal one, so a swipe never fights a scroll. It is not enough on its own: Chromium hands a touch to its own scrolling anyway and fires pointercancel a frame later, which is why the horizontal touchmove is also cancelled once the drag has been claimed. Without that the finger gets one frame of movement and the gesture dies.
import { NavigationViews } from 'omaris' 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.
mouse Defaults to false
boolean Drag with a mouse too. Off by default — a swipe is a touch gesture.
lazy Defaults to false
boolean Build a screen the first time it is reached, then keep it mounted.
fill Defaults to true
boolean Grow to fill the Navigation column. Off sizes it by its content.
class string children Snippet