Skip to main content

BottomNav

Info:Navigation
21

A thumb-reachable bottom tab bar: a real tablist with roving focus and an accent indicator.

bottom nav tab bar mobile phone touch tabs sections

Live demo

live · @xtyle/astro

BottomNav

Sections

A real tablist: click a tab, or focus one and use the arrow keys (Home / End jump to the ends). The whole bar is a single tab stop, so a keyboard user doesn't tab past every section to get out of it. The selected tab takes an accent bar as well as the accent color, so which section you're in never rests on hue alone.

Labels only

The icon is optional; a tab is happy as a label alone. The bar is an equal-column grid, so the sections always divide the width evenly. Keep it to three to five: it's a thumb surface, and past five the targets get too narrow to hit.

BottomNav is the tab bar that pairs with MobileShell the way Statusbar pairs with AppShell: the app's top-level sections, sitting where a thumb actually reaches.

It renders the tablist from its tabs through its own fragment, like Tabs and Segmented, so a mod can reshape a tab the same way it can reshape any other control.

The runtime adds what markup can't: a roving tabindex, so the whole bar is a single tab stop in the page with the arrow keys (plus Home / End) moving between sections inside it, rather than every tab being its own stop to tab past.

Each tab takes an optional icon and a badge. The selected one is marked by an accent bar as well as by color, so which section you're in never rests on hue alone.

When to use

How this component composes with the rest of the set.

Drop it in a MobileShell's nav slot; the two are designed as a pair.
Keep it to three to five sections. A bottom bar is a thumb surface, not a menu, and past five the targets get too narrow to hit.
It carries top-level sections, not actions. A destructive or one-off action belongs in the app bar or a sheet, where an accidental thumb-brush can't fire it.

Props

3 props, straight from the manifest.

PropTypeDefaultBindingsDescription
value string
html svelte astro
The selected tab's `data-value`. Reflected, and updated on select; listen for `change` to read it back.
label string Sections
html svelte astro
The accessible name of the tablist, e.g. "Sections".
tabs BottomNavTab[]
html svelte astro
The tabs, in the bindings: `{ value, label, icon?, badge? }`. Pass an array from the bindings, or JSON on the attribute for a static page.

Appearance

States

selected

.xtyle-bottom-nav__item[aria-selected="true"]

The current section: accent-colored, and marked by the accent bar above it.

Anatomy

The named parts that make up the component, with their selectors.

bar

.xtyle-bottom-nav

The tab bar itself: an equal-column grid, so any number of sections divide the width evenly. Absorbs the bottom safe-area inset so nothing sits under a home indicator.

--bg-1 --line --border-thin

item

.xtyle-bottom-nav__item

A tab: an icon over a label, with the whole cell as the touch target rather than just the glyph.

--fg-3 --accent-text --text-xs --space-8

indicator

.xtyle-bottom-nav__item[aria-selected="true"]::before

The accent bar over the selected tab: the non-color cue that says which section is current.

--accent --border-thick --radius-sm

badge

.xtyle-bottom-nav__badge

The optional count on a tab, for an unread or pending total.

--accent --accent-fg --radius-full

Tokens & coverage

What the component consumes, checked live against what the algorithm produces.

Success:fully covered 21/21 consumed tokens produced default register: 299 tokens

Live coverage check against the xtyle-default register (derive(xtyleDefault, { anchors })coverComponent(manifest, register)). Every token this component consumes must be a key the algorithm produces.

--accent --accent-fg --accent-text --bg-1 --border-thick --border-thin --duration-fast --ease-standard --fg-3 --line --radius-full --radius-sm --ring --space-1 --space-2 --space-4 --space-6 --space-8 --text-xs --weight-medium --weight-semibold

Accessibility

It is a real role="tablist" of role="tab" buttons carrying aria-selected, so a screen reader announces the section count and which one is current.
A roving tabindex makes the whole bar one tab stop: the arrow keys (and Home / End) move between sections, so a keyboard user doesn't have to tab past every tab to leave the bar.
The selected tab is marked by an accent bar as well as by color, so the current section reads for a color-deficient user (WCAG 1.4.1).
The tabs are real <button>s carrying role="tab", rendered through the same SSR path as every other component, so the bar paints and every label is reachable before the runtime hydrates it; the runtime adds the roving tabindex and the key handling.
The bar absorbs the bottom safe-area inset, so no tab sits under a home indicator where it can't be tapped.

Code

Sections

A tab bar over the app's top-level sections. Arrow keys move between them; change reports the new value.

<xtyle-bottom-nav
	value="inbox"
	label="Sections"
	tabs='[{"value":"inbox","label":"Inbox","icon":"folder","badge":3},
	       {"value":"threads","label":"Threads","icon":"menu"},
	       {"value":"you","label":"You","icon":"gear"}]'
></xtyle-bottom-nav>

<script>
	document.querySelector("xtyle-bottom-nav")
		.addEventListener("change", (e) => show(e.target.value));
</script>
<script lang="ts">
	import { BottomNav } from "@xtyle/svelte";

	let section = $state("inbox");
	const tabs = [
		{ value: "inbox", label: "Inbox", icon: "folder", badge: 3 },
		{ value: "threads", label: "Threads", icon: "menu" },
		{ value: "you", label: "You", icon: "gear" },
	];
</script>

<BottomNav {tabs} bind:value={section} label="Sections" />
---
import BottomNav from "@xtyle/astro/BottomNav.astro";
---

<BottomNav
	value="inbox"
	label="Sections"
	tabs={[
		{ value: "inbox", label: "Inbox", icon: "folder", badge: 3 },
		{ value: "threads", label: "Threads", icon: "menu" },
		{ value: "you", label: "You", icon: "gear" },
	]}
/>