Skip to content
omaris

Navigation

Navigation Bar

MD3 navigation bar — the bottom bar on a phone, 3–5 destinations.

import { NavigationBar } from 'omaris'
Learn

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, where AppShellSidebar has dropped out and the drawer is off screen.
  • A count on a destination, like unread messages or open orders. badge sits on the icon without touching the pill.
  • Screens a thumb can swipe between. Wrap the bar and its content in Navigation with swipeable, give each destination a value and each screen a NavigationView.

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; collapsed on 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 actions of 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 selectedIcon the filled twin of icon, and set elevated when content scrolls underneath it.
  • Keep labels="always". Drop to selected only when five labels squeeze and the icons are unmistakable.
  • Use Navigation instead of selected={x === 'home'} and an onclick on every item. One bindable value owns the destination and enables the swipe and the sliding indicator.
  • Keep the NavigationView order the same as the items' order. A drag follows the writing direction; under dir="rtl" the bar mirrors. Pair swipeable with lazy when 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 mouse on for a production app. A drag is a touch gesture; on a desktop it fights text selection.

Quick reference

layout
  • bar (default)
  • rail
  • row

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 bindable

Defaults 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 bindable
boolean

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