Skip to content
Nizivo UI

MobileTabBar

Five destinations at the bottom of the screen below 640 px, in place of the Sidebar. They are links in a navigation landmark, and More opens the rest in a bottom sheet.

Server component with client leaf MobileTabBarMore

Import

import { MobileTabBar, MobileTabBarMore } from '@nizivo/react/mobile-tab-bar';

// Once per app (Getting started), after
// @nizivo/tokens/tokens.css and @nizivo/react/css/base.css and @nizivo/react/css/drawer.css and @nizivo/react/css/icon-button.css
import '@nizivo/react/css/mobile-tab-bar.css';

Static or interactive

Split. MobileTabBar.Root and MobileTabBar.Item render on the server. Two client parts come with them: a small island in the root, which writes the bar's height to the page (below), and MobileTabBarMore, which brings the Drawer for its sheet. How to choose.

In a client app (a Vite or React Router single-page app, or a 'use client' file) it is an ordinary React component: event handlers, refs and state work as usual. In a client app.

Destinations, not tabs

Each destination is a page, so the bar is a <nav> named "Primary" around a list of links, not a tablist. The page says which one is current (aria-current="page", the filled pill). Four destinations and More fit a 390 px screen. A count shows on the icon's corner and is part of the link's text, so write it to read well after the label ("12 open").

More

MobileTabBarMore is the fifth cell: a button that opens a bottom sheet, titled "More" (labels.more, or heading), holding the destinations without a tab, usually NavItems. The sheet is modal: focus moves in and stays there, Escape or a press outside closes it, and focus returns to More. Following a link in it closes it, as a route change does. When the page shown is one of the sheet's, pass current to More: the pill fills, and the sheet's own link carries aria-current.

The bottom inset

While the bar is laid out, its island writes the bar's height to --nz-layout-bottom-inset on <html> and sets data-nz-bottom-bar there, in a layout effect, so the first paint already has them. StickyBar sticks above the bar, toasts rise above it, and AppShell's <main> leaves room for it. When the bar is hidden (from 640 px in the shell) or unmounted, both are removed. The bar's bottom padding includes the device's safe area (env(safe-area-inset-bottom), which needs viewport-fit=cover in the viewport meta).

The bar is not fixed on its own: AppShell pins it to the bottom below 640 px.

Hooks: --nz-mobile-tab-bar-bg, --nz-mobile-tab-bar-border.

Examples

Basic

Figma's Mobile Tab Bar: four destinations and More, links in a <nav> (each is a page, so not a tablist). More opens the other destinations in a bottom sheet; following one closes it. Below 640 px AppShell pins the bar to the bottom of the screen.

import type { JSX, ReactNode } from 'react';
import { NavItem } from '@nizivo/react/nav-item';
import { MobileTabBar, MobileTabBarMore } from '@nizivo/react/mobile-tab-bar';

/** Stands in for an `@nizivo/icons` glyph (20 px Lucide strokes, sized by the slot). */
function Glyph({ children }: { children: ReactNode }): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {children}
    </svg>
  );
}

const ICONS = {
  home: (
    <Glyph>
      <path d="M3 10.5 12 3l9 7.5V21H3z" />
      <path d="M9 21v-7h6v7" />
    </Glyph>
  ),
  orders: (
    <Glyph>
      <path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
      <path d="M3 6h18M16 10a4 4 0 0 1-8 0" />
    </Glyph>
  ),
  products: (
    <Glyph>
      <path d="m21 8-9-5-9 5v8l9 5 9-5z" />
      <path d="m3 8 9 5 9-5M12 13v8" />
    </Glyph>
  ),
  customers: (
    <Glyph>
      <circle cx="9" cy="8" r="4" />
      <path d="M2 21a7 7 0 0 1 14 0M16 4a4 4 0 0 1 0 8M22 21a7 7 0 0 0-4-6.3" />
    </Glyph>
  ),
  analytics: (
    <Glyph>
      <path d="M3 3v18h18M8 17v-5M13 17V8M18 17v-9" />
    </Glyph>
  ),
  settings: (
    <Glyph>
      <circle cx="12" cy="12" r="3" />
      <path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9 7 7M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" />
    </Glyph>
  ),
};

export function MobileTabBarExample(): JSX.Element {
  return (
    <MobileTabBar.Root style={{ inlineSize: 390 }}>
      <MobileTabBar.Item href="/" icon={ICONS.home} current>
        Home
      </MobileTabBar.Item>
      <MobileTabBar.Item href="/orders" icon={ICONS.orders} count={12}>
        Orders
      </MobileTabBar.Item>
      <MobileTabBar.Item href="/products" icon={ICONS.products}>
        Products
      </MobileTabBar.Item>
      <MobileTabBar.Item href="/customers" icon={ICONS.customers}>
        Customers
      </MobileTabBar.Item>
      <MobileTabBarMore>
        <NavItem href="/analytics" icon={ICONS.analytics}>
          Analytics
        </NavItem>
        <NavItem href="/settings" icon={ICONS.settings}>
          Settings
        </NavItem>
      </MobileTabBarMore>
    </MobileTabBar.Root>
  );
}

More Current

A page from the sheet is shown: More draws the filled indicator, the sheet's link is current.

import type { JSX, ReactNode } from 'react';
import { NavItem } from '@nizivo/react/nav-item';
import { MobileTabBar, MobileTabBarMore } from '@nizivo/react/mobile-tab-bar';

/** Stands in for an `@nizivo/icons` glyph (20 px Lucide strokes, sized by the slot). */
function Glyph({ children }: { children: ReactNode }): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {children}
    </svg>
  );
}

const ICONS = {
  home: (
    <Glyph>
      <path d="M3 10.5 12 3l9 7.5V21H3z" />
      <path d="M9 21v-7h6v7" />
    </Glyph>
  ),
  orders: (
    <Glyph>
      <path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
      <path d="M3 6h18M16 10a4 4 0 0 1-8 0" />
    </Glyph>
  ),
  products: (
    <Glyph>
      <path d="m21 8-9-5-9 5v8l9 5 9-5z" />
      <path d="m3 8 9 5 9-5M12 13v8" />
    </Glyph>
  ),
  customers: (
    <Glyph>
      <circle cx="9" cy="8" r="4" />
      <path d="M2 21a7 7 0 0 1 14 0M16 4a4 4 0 0 1 0 8M22 21a7 7 0 0 0-4-6.3" />
    </Glyph>
  ),
  analytics: (
    <Glyph>
      <path d="M3 3v18h18M8 17v-5M13 17V8M18 17v-9" />
    </Glyph>
  ),
  settings: (
    <Glyph>
      <circle cx="12" cy="12" r="3" />
      <path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9 7 7M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" />
    </Glyph>
  ),
};

export function MobileTabBarMoreCurrentExample(): JSX.Element {
  return (
    <MobileTabBar.Root style={{ inlineSize: 390 }}>
      <MobileTabBar.Item href="/" icon={ICONS.home}>
        Home
      </MobileTabBar.Item>
      <MobileTabBar.Item href="/orders" icon={ICONS.orders}>
        Orders
      </MobileTabBar.Item>
      <MobileTabBar.Item href="/products" icon={ICONS.products}>
        Products
      </MobileTabBar.Item>
      <MobileTabBar.Item href="/customers" icon={ICONS.customers}>
        Customers
      </MobileTabBar.Item>
      <MobileTabBarMore current>
        <NavItem href="/analytics" icon={ICONS.analytics} current>
          Analytics
        </NavItem>
        <NavItem href="/settings" icon={ICONS.settings}>
          Settings
        </NavItem>
      </MobileTabBarMore>
    </MobileTabBar.Root>
  );
}

Props

Client component Renders data-slot="mobile-tab-bar-more" with the class nz-mobile-tab-bar-more. Props: MobileTabBarMoreProps. Source: src/mobile-tab-bar/mobile-tab-bar-more.client.tsx.

NameTypeDefaultDescription
childrenrequiredReactNode–

The sheet's content: the other destinations, usually NavItems.

currentbooleanfalse

The page being shown is one of the sheet's: draws the bar's filled indicator on More. It is a button, so nothing is announced; the sheet's link carries aria-current.

headingReactNode–

The sheet's title, when it differs from the button's label.

iconReactNode–

The glyph in the bar. Defaults to the internal ellipsis (Lucide's).

labelsPartial<MobileTabBarMoreLabels>–
  • Also takes every attribute of <button> except children. (Omit<ComponentPropsWithRef<"button">, "children">)

Parts

Use the parts through the namespace (MobileTabBar.Root) or by their flat names (MobileTabBarRoot); both work from Server Components.

MobileTabBar.Root

The <nav> and its list, with the bottom-inset island.

Server component Renders data-slot="mobile-tab-bar" with the class nz-mobile-tab-bar. Props: MobileTabBarRootProps. Source: src/mobile-tab-bar/mobile-tab-bar.tsx.

NameTypeDefaultDescription
childrenrequiredReactNode–

The destinations: MobileTabBar.Items, and MobileTabBarMore last. Five at most fit.

labelsPartial<MobileTabBarLabels>–
  • Also takes every attribute of <nav>; children is redeclared above. (ComponentPropsWithRef<"nav">)

MobileTabBar.Item

One destination: a list item around a link, with its icon in Figma's indicator pill and its label under it. Props other than icon, count and current go to the link.

Server component Renders data-slot="mobile-tab-bar-item" with the class nz-mobile-tab-bar-item. Props: MobileTabBarItemProps. Source: src/mobile-tab-bar/mobile-tab-bar.tsx.

NameTypeDefaultDescription
childrenrequiredReactNode–

The label under the icon.

countReactNode–

A count on the icon's corner, such as open orders. It is part of the link's text, so it is announced with the label: write it so it reads well there ("12 open").

currentbooleanfalse

The page being shown: aria-current="page" and the filled indicator.

iconrequiredReactNode–

A decorative 20 px glyph in the indicator; it is aria-hidden.

renderRenderProp<MobileTabBarItemState>–

The router's link, for example render={<Link href="/orders" />}. It must render an <a>.

  • Also takes every attribute of <a> except children. (Omit<ComponentPropsWithRef<"a">, "children">)

render takes an element, or, in client code, a function of the props and this state (MobileTabBarItemState):

NameTypeDefaultDescription
currentrequiredboolean–

Other exports

MobileTabBarItemProps type

import type { MobileTabBarItemProps } from '@nizivo/react/mobile-tab-bar'

The props of MobileTabBar.Item, listed above.

MobileTabBarItemState type

import type { MobileTabBarItemState } from '@nizivo/react/mobile-tab-bar'

The render state of MobileTabBar.Item, listed above.

MobileTabBarLabels type

import type { MobileTabBarLabels } from '@nizivo/react/mobile-tab-bar'

Strings this part renders (§16.4).

NameTypeDefaultDescription
navrequiredstring'Primary'

The navigation landmark's name.

MobileTabBarMoreLabels type

import type { MobileTabBarMoreLabels } from '@nizivo/react/mobile-tab-bar'

Strings this part renders (§16.4).

NameTypeDefaultDescription
closerequiredstring'Close'

The sheet's close button.

morerequiredstring'More'

The button's label in the bar, and the sheet's title.

MobileTabBarMoreProps type

import type { MobileTabBarMoreProps } from '@nizivo/react/mobile-tab-bar'

The props of MobileTabBarMore, listed above.

MobileTabBarRootProps type

import type { MobileTabBarRootProps } from '@nizivo/react/mobile-tab-bar'

The props of MobileTabBar.Root, listed above.

Data attributes and slots

Style parts by class (.nz-<slot>) and state by data attribute. Attribute meanings come from contracts/vocabulary.json.

SlotRendered byData attributes
mobile-tab-bar
.nz-mobile-tab-bar
MobileTabBar.Root
root (no render)
none
mobile-tab-bar-count
.nz-mobile-tab-bar-count
MobileTabBar.Item
inner element (CSS only)
none
mobile-tab-bar-indicator
.nz-mobile-tab-bar-indicator
MobileTabBar.Item
inner element (CSS only)
none
mobile-tab-bar-item
.nz-mobile-tab-bar-item
MobileTabBar.Item
root (takes render)
none
mobile-tab-bar-label
.nz-mobile-tab-bar-label
MobileTabBar.Item
inner element (CSS only)
none
mobile-tab-bar-link
.nz-mobile-tab-bar-link
MobileTabBar.Item
inner element (CSS only); interactive, focus: self
none
mobile-tab-bar-list
.nz-mobile-tab-bar-list
MobileTabBar.Root
inner element (CSS only)
none
mobile-tab-bar-more
.nz-mobile-tab-bar-more
MobileTabBarMore
root (no render)
none
mobile-tab-bar-more-indicator
.nz-mobile-tab-bar-more-indicator
MobileTabBarMore
inner element (CSS only)
none
mobile-tab-bar-more-label
.nz-mobile-tab-bar-more-label
MobileTabBarMore
inner element (CSS only)
none
mobile-tab-bar-more-trigger
.nz-mobile-tab-bar-more-trigger
MobileTabBarMore
inner element (CSS only); interactive, focus: self
  • data-current (present or absent) ← current
    The page shown is one of the More sheet's

CSS hooks

Set a hook on the component's class or any ancestor to restyle it without specificity games; leave it unset for the default shown.

HookDefaultRead as
--nz-mobile-tab-bar-bg--nz-bg-surface
  • background on .nz-mobile-tab-bar
  • border on .nz-mobile-tab-bar-count
--nz-mobile-tab-bar-border--nz-border-subtle
  • border-block-start on .nz-mobile-tab-bar

Layers: nz.components. Classes: .nz-mobile-tab-bar, .nz-mobile-tab-bar-count, .nz-mobile-tab-bar-icon, .nz-mobile-tab-bar-indicator, .nz-mobile-tab-bar-item, .nz-mobile-tab-bar-label, .nz-mobile-tab-bar-link, .nz-mobile-tab-bar-list, .nz-mobile-tab-bar-more, .nz-mobile-tab-bar-more-indicator, .nz-mobile-tab-bar-more-label, .nz-mobile-tab-bar-more-sheet, .nz-mobile-tab-bar-more-trigger.

Tokens this stylesheet reads (29)

--nz-bg-brand, --nz-bg-brand-subtle, --nz-bg-muted, --nz-bg-pressed, --nz-bg-surface, --nz-border-subtle, --nz-border-width-default, --nz-duration-fast, --nz-easing-standard, --nz-font-label-m, --nz-font-label-m-strong-font-weight, --nz-font-label-s, --nz-icon-brand, --nz-icon-secondary, --nz-radius-full, --nz-radius-md, --nz-size-icon-md, --nz-space-1, --nz-space-1-5, --nz-space-16, --nz-space-2, --nz-space-4, --nz-space-5, --nz-text-brand, --nz-text-on-brand, --nz-text-secondary, --nz-tracking-label-m, --nz-tracking-label-m-strong, --nz-tracking-label-s

Accessibility

These parts are interactive. Each gets the focus outline and touch-action rule from @nizivo/react/css/base.css (the nz.base layer), so the floor survives a restyle:

  • mobile-tab-bar-link: draws the focus outline itself on keyboard focus.
  • mobile-tab-bar-more-trigger: draws the focus outline itself on keyboard focus.

Pattern: WAI-ARIA Authoring Practices.

Keyboard

KeysAction
Tab, Shift+TabMoves between the destinations and More, as on any link or button.
EnterOn a destination, follows its link. On More, opens the sheet and moves focus into it.
SpaceOn More, opens the sheet.
EscapeCloses the sheet and returns focus to More.

Source: native <a> elements for the destinations; More is the Drawer's trigger and its sheet a bottom Drawer (react-aria-components 1.22.1 ModalOverlay and Modal), which contains focus while open. The browser lane (test/browser/app-shell.spec.tsx) checks opening, Escape with focus back on More, and a followed link closing the sheet, in Chromium, Firefox and WebKit.

Figma

Node ids from design/component-map.json, which nz gen writes from component.json. The links open the Nizivo Figma library, which may ask you to sign in.

On this page