BottomNav

A phone app's primary navigation: three to five destinations docked to the bottom of the screen, where a thumb can reach them, with the home indicator's safe area kept clear.

Navigation BottomNav.jsx BottomNav.d.ts BottomNav.md

Playground

Change a prop and watch it. The code below follows along.

BottomNav — The primary navigation of a phone app, docked or floating, with badges.

Install and import

BottomNav is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { BottomNav } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

A phone app's primary navigation: three to five destinations docked to the bottom of the screen, where a thumb can reach them, with the home indicator's safe area kept clear.

Use it when

  • A phone app has a handful of top-level destinations a person moves between often.
  • Inside an AppShell, passed as bottomNav.

Don't use it when

  • There are more than five destinations. Put the rest behind a "More" item or in a Drawer.
  • It is a website rather than an app. Use Navbar, which collapses to a menu on a phone.
  • The choices are views of one screen. Those are Tabs.

Variants

  • bar: a full-width docked bar, glass by default, with an indicator pill behind the active icon.
  • floating: an inset glass pill that sits over a fade of the page surface, with room for an action beside it, such as an add or record button. Good over imagery and dark, immersive screens.

Example

<BottomNav
  current={tab}
  onNavigate={setTab}
  items={[
    { id: "today", label: "Today", icon: <HomeIcon /> },
    { id: "trends", label: "Trends", icon: <ChartIcon />, badge: 2 },
    { id: "me", label: "Me", icon: <UserIcon /> },
  ]}
/>

Every item is at least --dt-size-touch-target tall. The current item carries aria-current="page", and a badge is announced with its label ("Trends, 2 new"), so it is not colour alone.

Tokens

--dt-bottomnav-* (Tier 3): the bar surface and border, the item colours at rest and active, the indicator for each variant, and the badge. The bar reads --dt-surface-glass and --dt-backdrop-glass; the floating variant sits on --dt-scrim-fade-bottom.

Props

The typed contract, from BottomNav.d.ts.

import * as React from "react";

export interface BottomNavItem {
  id: string;
  label: React.ReactNode;
  /** A 24px glyph. It inherits the item's colour. */
  icon: React.ReactNode;
  /** Renders the item as a link. Without it the item is a button. */
  href?: string;
  /** A count, or true for a dot. Announced with the label. */
  badge?: number | boolean;
}

/**
 * A phone app's primary navigation, docked to the bottom of the screen with
 * the home indicator's safe area kept clear. Three to five destinations.
 */
export interface BottomNavProps extends React.HTMLAttributes<HTMLElement> {
  items: BottomNavItem[];
  /** Id of the current destination. */
  current?: string;
  /** Called with an item's id. When set, link items are intercepted and routed through it. */
  onNavigate?: (id: string) => void;
  /**
   * bar is a full-width docked bar with an indicator pill behind the active
   * icon. floating is an inset glass pill over a fade of the page surface,
   * with room for an action button beside it. @default "bar"
   */
  variant?: "bar" | "floating";
  /** Glass that content scrolls beneath. Off gives a solid overlay surface. @default true */
  translucent?: boolean;
  /** A primary action beside the destinations, usually a round IconButton: add, compose, record. */
  action?: React.ReactNode;
  /** @default "Main" */
  label?: string;
}

export declare function BottomNav(props: BottomNavProps): React.JSX.Element;

Tokens it reads

Every custom property BottomNav.jsx resolves, read from the source: 8 component, 27 semantic, 7 primitive. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
  • primitive Raw values. A component reading one directly is a deliberate exception.
TokenTierDeclared as
--dt-bottomnav-badge-bgcomponentvar(--dt-surface-danger)
--dt-bottomnav-badge-fgcomponentvar(--dt-text-on-danger)
--dt-bottomnav-bgcomponentvar(--dt-surface-glass)
--dt-bottomnav-bordercomponentvar(--dt-border-glass)
--dt-bottomnav-fgcomponentvar(--dt-text-secondary)
--dt-bottomnav-fg-activecomponentvar(--dt-text-primary)
--dt-bottomnav-indicatorcomponentvar(--dt-surface-selected)
--dt-bottomnav-indicator-floatingcomponentvar(--dt-surface-glass-tint)
--dt-backdrop-glasssemanticsaturate(1.6) blur(var(--dt-blur-glass))
--dt-border-glasssemanticcolor-mix(in oklab, var(--dt-text-primary) 10%, transparent)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-motion-microsemanticvar(--dt-duration-100) var(--dt-easing-standard)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-radius-pillsemanticvar(--dt-radius-raw-full)
--dt-scrim-fade-bottomsemanticlinear-gradient(to top, var(--dt-surface-base) 40%, transparent)
--dt-size-icon-lgsemanticvar(--dt-dim-6)
--dt-size-touch-targetsemanticvar(--dt-dim-11)
--dt-space-inline-smsemanticvar(--dt-dim-3)
--dt-space-inset-2xssemanticvar(--dt-dim-1)
--dt-space-inset-mdsemanticvar(--dt-dim-4)
--dt-space-inset-smsemanticvar(--dt-dim-3)
--dt-space-inset-xssemanticvar(--dt-dim-2)
--dt-space-stack-2xssemanticvar(--dt-dim-1)
--dt-surface-dangersemanticvar(--dt-color-red-600)
--dt-surface-glasssemanticcolor-mix(in oklab, var(--dt-surface-overlay) 72%, transparent)
--dt-surface-glass-tintsemanticcolor-mix(in oklab, var(--dt-text-primary) 8%, transparent)
--dt-surface-overlaysemanticvar(--dt-color-white)
--dt-surface-selectedsemanticvar(--dt-color-neutral-100)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-label-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-on-dangersemanticvar(--dt-color-white)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-dim-1primitive4px
--dt-dim-14primitive56px
--dt-dim-2primitive8px
--dt-dim-4primitive16px
--dt-dim-7primitive28px
--dt-font-size-2xsprimitive11px
--dt-font-weight-semiboldprimitive600

Source

React only, no dependencies, every value a token.

import React from "react";

const GLASS = "var(--dt-backdrop-glass, saturate(1.4) blur(16px))";
const SAFE_BOTTOM = "env(safe-area-inset-bottom, 0px)";

function NavBadge({ value }) {
  if (!value) return null;
  const dot = value === true;
  return (
    <span
      aria-hidden="true"
      style={{
        position: "absolute", top: dot ? 0 : -4, right: dot ? 0 : -8,
        minWidth: dot ? "var(--dt-dim-2)" : "var(--dt-dim-4)", height: dot ? "var(--dt-dim-2)" : "var(--dt-dim-4)",
        padding: dot ? 0 : "0 var(--dt-dim-1)", boxSizing: "border-box",
        borderRadius: "var(--dt-radius-pill)",
        background: "var(--dt-bottomnav-badge-bg, var(--dt-surface-danger))",
        color: "var(--dt-bottomnav-badge-fg, var(--dt-text-on-danger))",
        fontFamily: "var(--dt-text-label-sm-family)", fontSize: "var(--dt-font-size-2xs)", lineHeight: "var(--dt-dim-4)",
        fontWeight: "var(--dt-font-weight-semibold)", textAlign: "center",
      }}
    >
      {dot ? null : value > 99 ? "99+" : value}
    </span>
  );
}

export function BottomNav({ items = [], current, onNavigate, variant = "bar", translucent = true, action, label = "Main", style, ...rest }) {
  const floating = variant === "floating";
  const surface = translucent
    ? { background: "var(--dt-bottomnav-bg, var(--dt-surface-glass))", backdropFilter: GLASS, WebkitBackdropFilter: GLASS }
    : { background: "var(--dt-surface-overlay)" };

  const list = (
    <ul
      style={{
        listStyle: "none", margin: 0, flex: 1, minWidth: 0,
        display: "grid", gridTemplateColumns: `repeat(${Math.max(1, items.length)}, minmax(0, 1fr))`,
        padding: floating ? "var(--dt-space-inset-2xs)" : "var(--dt-space-inset-2xs) var(--dt-space-inset-xs)",
        ...(floating
          ? { ...surface, borderRadius: "var(--dt-radius-pill)", border: "var(--dt-border-width-default) solid var(--dt-bottomnav-border, var(--dt-border-glass))" }
          : null),
      }}
    >
      {items.map((item) => {
        const on = item.id === current;
        const Tag = item.href ? "a" : "button";
        const badgeText = item.badge === true ? ", new" : item.badge ? ", " + item.badge + " new" : "";
        return (
          <li key={item.id} style={{ display: "flex", minWidth: 0 }}>
            <Tag
              {...(item.href ? { href: item.href } : { type: "button" })}
              aria-current={on ? "page" : undefined}
              aria-label={typeof item.label === "string" ? item.label + badgeText : undefined}
              onClick={(e) => {
                if (!onNavigate) return;
                if (item.href) e.preventDefault();
                onNavigate(item.id);
              }}
              style={{
                flex: 1, minWidth: 0, minHeight: "var(--dt-size-touch-target, 44px)",
                display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
                gap: "var(--dt-space-stack-2xs)", padding: "var(--dt-space-inset-2xs) 0",
                border: 0, borderRadius: floating ? "var(--dt-radius-pill)" : "var(--dt-radius-control)",
                background: floating && on ? "var(--dt-bottomnav-indicator-floating, var(--dt-surface-glass-tint))" : "transparent",
                color: on ? "var(--dt-bottomnav-fg-active, var(--dt-text-primary))" : "var(--dt-bottomnav-fg, var(--dt-text-secondary))",
                textDecoration: "none", cursor: "pointer", font: "inherit",
                transition: "color var(--dt-motion-micro), background var(--dt-motion-micro)",
              }}
            >
              <span
                style={{
                  position: "relative", display: "grid", placeItems: "center",
                  width: floating ? "var(--dt-size-icon-lg)" : "var(--dt-dim-14)", height: "var(--dt-dim-7)",
                  borderRadius: "var(--dt-radius-pill)",
                  background: !floating && on ? "var(--dt-bottomnav-indicator, var(--dt-surface-selected))" : "transparent",
                  transition: "background var(--dt-motion-micro)",
                }}
              >
                <span style={{ display: "grid", placeItems: "center", width: "var(--dt-size-icon-lg)", height: "var(--dt-size-icon-lg)" }}>{item.icon}</span>
                <NavBadge value={item.badge} />
              </span>
              <span
                style={{
                  maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
                  fontFamily: "var(--dt-text-label-sm-family)", fontSize: "var(--dt-text-label-sm-size)",
                  lineHeight: "var(--dt-text-label-sm-line)", fontWeight: on ? "var(--dt-font-weight-semibold)" : "var(--dt-text-label-sm-weight)",
                }}
              >
                {item.label}
              </span>
            </Tag>
          </li>
        );
      })}
    </ul>
  );

  if (floating) {
    return (
      <nav
        aria-label={label}
        style={{
          display: "flex", alignItems: "center", gap: "var(--dt-space-inline-sm)",
          padding: `var(--dt-space-inset-md) var(--dt-space-inset-md) calc(var(--dt-space-inset-md) + ${SAFE_BOTTOM})`,
          background: "var(--dt-scrim-fade-bottom, transparent)",
          ...style,
        }}
        {...rest}
      >
        {list}
        {action && <span style={{ flex: "none", display: "flex" }}>{action}</span>}
      </nav>
    );
  }

  return (
    <nav
      aria-label={label}
      style={{
        display: "flex", alignItems: "center", gap: "var(--dt-space-inline-sm)",
        paddingBottom: SAFE_BOTTOM,
        borderTop: "var(--dt-border-width-default) solid var(--dt-bottomnav-border, var(--dt-border-glass))",
        ...surface,
        ...style,
      }}
      {...rest}
    >
      {list}
      {action && <span style={{ flex: "none", display: "flex", paddingRight: "var(--dt-space-inset-sm)" }}>{action}</span>}
    </nav>
  );
}

In context

BottomNav also appears on the navigation reference cards, alongside the rest of its family.