Navigation
Bottom nav
A mobile tab bar of three to five destinations, each an icon over a label, with a badge and safe-area padding.
- Built on
<nav>aria-currentenv(safe-area-inset-bottom)- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.1 kB
- Built from
- Icon
Code
import { BottomNav } from "@/components/equip/bottom-nav";
export default function Demo() {
return (
<BottomNav
style={{ width: 216, borderRadius: "var(--eq-radius-l)", border: "1px solid var(--eq-line)" }}
items={[
{ label: "Home", href: "#home", icon: "home", current: true },
{ label: "Search", href: "#search", icon: "search" },
{ label: "Inbox", href: "#inbox", icon: "inbox", badge: 3, badgeLabel: "3 unread" },
{ label: "Profile", href: "#profile", icon: "user" },
]}
/>
);
}HTMLworks in any framework, with the part's CSS
<nav style="width:216px;border-radius:var(--eq-radius-l);border:1px solid var(--eq-line)" aria-label="Main" class="eq-bottom-nav">
<ul class="eq-bottom-nav-list">
<li class="eq-bottom-nav-item">
<a href="#home" class="eq-bottom-nav-link" aria-current="page"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 11 12 4l8.5 7" /><path d="M5.5 9.5V20h13V9.5" /><path d="M10 20v-5.5h4V20" /></svg></span><span class="eq-bottom-nav-label">Home</span></a>
</li>
<li class="eq-bottom-nav-item">
<a href="#search" class="eq-bottom-nav-link"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5a6.5 6.5 0 1 0 13 0a6.5 6.5 0 1 0 -13 0" /><path d="M15.5 15.5l4.5 4.5" /></svg></span><span class="eq-bottom-nav-label">Search</span></a>
</li>
<li class="eq-bottom-nav-item">
<a href="#inbox" class="eq-bottom-nav-link"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M5.5 4.5h13a2 2 0 0 1 2 2v11a2 2 0 0 1 -2 2h-13a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2z" /><path d="M3.5 13h4.5l1.5 2.5h5l1.5-2.5h4.5" /></svg><span class="eq-bottom-nav-badge" aria-hidden="true">3</span></span><span class="eq-bottom-nav-label">Inbox</span><span class="eq-bottom-nav-hidden">, 3 unread</span></a>
</li>
<li class="eq-bottom-nav-item">
<a href="#profile" class="eq-bottom-nav-link"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M8.75 7.25a3.25 3.25 0 1 0 6.5 0a3.25 3.25 0 1 0 -6.5 0" /><path d="M4.5 20.5V20a6 6 0 0 1 6-6h3a6 6 0 0 1 6 6v.5" /></svg></span><span class="eq-bottom-nav-label">Profile</span></a>
</li>
</ul>
</nav>Examples
Five destinations
Five is the most a phone can hold with readable labels; each column still gets a target 56px tall.
Code
import { BottomNav } from "@/components/equip/bottom-nav";
export default function Example() {
return (
<BottomNav
aria-label="Store"
style={{ width: "100%", maxWidth: 400 }}
items={[
{ label: "Shop", href: "#shop", icon: "store" },
{ label: "Saved", href: "#saved", icon: "heart" },
{ label: "Cart", href: "#cart", icon: "cart", current: true, badge: 2, badgeLabel: "2 items" },
{ label: "Orders", href: "#orders", icon: "truck", badge: true, badgeLabel: "an order has shipped" },
{ label: "Account", href: "#account", icon: "user" },
]}
/>
);
}HTMLworks in any framework, with the part's CSS
<nav style="width:100%;max-width:400px" aria-label="Store" class="eq-bottom-nav">
<ul class="eq-bottom-nav-list">
<li class="eq-bottom-nav-item">
<a href="#shop" class="eq-bottom-nav-link"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 9.5 5 4.5h14l1.5 5a2.83 2.83 0 0 1-5.67 0 2.83 2.83 0 0 1-5.66 0 2.83 2.83 0 0 1-5.67 0z" /><path d="M5 12v6.5a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V12" /><path d="M10 20.5V16h4v4.5" /></svg></span><span class="eq-bottom-nav-label">Shop</span></a>
</li>
<li class="eq-bottom-nav-item">
<a href="#saved" class="eq-bottom-nav-link"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7.15A4.6 4.6 0 1 0 4.18 11.54L12 20.15L19.82 11.54A4.6 4.6 0 1 0 12 7.15z" /></svg></span><span class="eq-bottom-nav-label">Saved</span></a>
</li>
<li class="eq-bottom-nav-item">
<a href="#cart" class="eq-bottom-nav-link" aria-current="page"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4.5h2.25L6.75 15h11.5l2-7.5H5.4" /><path d="M7.25 19.25a1.25 1.25 0 1 0 2.5 0a1.25 1.25 0 1 0 -2.5 0M15.25 19.25a1.25 1.25 0 1 0 2.5 0a1.25 1.25 0 1 0 -2.5 0" /></svg><span class="eq-bottom-nav-badge" aria-hidden="true">2</span></span><span class="eq-bottom-nav-label">Cart</span><span class="eq-bottom-nav-hidden">, 2 items</span></a>
</li>
<li class="eq-bottom-nav-item">
<a href="#orders" class="eq-bottom-nav-link"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M5 17H3.5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1H13a1 1 0 0 1 1 1v11" /><path d="M14 9h3.5l4 4v3a1 1 0 0 1-1 1H19M9 17h6" /><path d="M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /></svg><span class="eq-bottom-nav-badge" data-dot="true" aria-hidden="true"></span></span><span class="eq-bottom-nav-label">Orders</span><span class="eq-bottom-nav-hidden">, an order has shipped</span></a>
</li>
<li class="eq-bottom-nav-item">
<a href="#account" class="eq-bottom-nav-link"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M8.75 7.25a3.25 3.25 0 1 0 6.5 0a3.25 3.25 0 1 0 -6.5 0" /><path d="M4.5 20.5V20a6 6 0 0 1 6-6h3a6 6 0 0 1 6 6v.5" /></svg></span><span class="eq-bottom-nav-label">Account</span></a>
</li>
</ul>
</nav>In a phone layout
The bar sits at the foot of the screen with the content scrolling above it; pass fixed to pin it to the viewport instead.
Code
import { BottomNav } from "@/components/equip/bottom-nav";
export default function Example() {
return (
<div
style={{
display: "grid",
gridTemplateRows: "1fr auto",
width: "100%",
maxWidth: 280,
height: 300,
overflow: "hidden",
border: "1px solid var(--eq-line-strong)",
borderRadius: 28,
background: "var(--eq-bg)",
}}
>
<div style={{ overflow: "auto", padding: 16, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>
<p style={{ margin: "0 0 4px", fontWeight: "var(--eq-weight-strong)", color: "var(--eq-ink)" }}>Today</p>
<p style={{ margin: "0 0 12px" }}>3 runs logged, 14.2 km in total.</p>
<p style={{ margin: "0 0 4px", fontWeight: "var(--eq-weight-strong)", color: "var(--eq-ink)" }}>This week</p>
<p style={{ margin: 0 }}>Longest run 8.4 km on Sunday. Rest day planned for Thursday.</p>
</div>
<BottomNav
aria-label="Training"
items={[
{ label: "Today", href: "#today", icon: "activity", current: true },
{ label: "Plan", href: "#plan", icon: "calendar" },
{ label: "Routes", href: "#routes", icon: "map" },
]}
/>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;grid-template-rows:1fr auto;width:100%;max-width:280px;height:300px;overflow:hidden;border:1px solid var(--eq-line-strong);border-radius:28px;background:var(--eq-bg)">
<div style="overflow:auto;padding:16px;font-size:var(--eq-text-s);color:var(--eq-ink-2)">
<p style="margin:0 0 4px;font-weight:var(--eq-weight-strong);color:var(--eq-ink)">Today</p>
<p style="margin:0 0 12px">3 runs logged, 14.2 km in total.</p>
<p style="margin:0 0 4px;font-weight:var(--eq-weight-strong);color:var(--eq-ink)">This week</p>
<p style="margin:0">Longest run 8.4 km on Sunday. Rest day planned for Thursday.</p>
</div>
<nav aria-label="Training" class="eq-bottom-nav">
<ul class="eq-bottom-nav-list">
<li class="eq-bottom-nav-item">
<a href="#today" class="eq-bottom-nav-link" aria-current="page"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 12h4l3-7.5 5 15 3-7.5h4" /></svg></span><span class="eq-bottom-nav-label">Today</span></a>
</li>
<li class="eq-bottom-nav-item">
<a href="#plan" class="eq-bottom-nav-link"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M5.5 5h13a2 2 0 0 1 2 2v11.5a2 2 0 0 1 -2 2h-13a2 2 0 0 1 -2 -2v-11.5a2 2 0 0 1 2 -2z" /><path d="M3.5 10h17" /><path d="M8 3v4M16 3v4" /></svg></span><span class="eq-bottom-nav-label">Plan</span></a>
</li>
<li class="eq-bottom-nav-item">
<a href="#routes" class="eq-bottom-nav-link"><span class="eq-bottom-nav-icon"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="l" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5 9 4l6 2.5L20.5 4v13.5L15 20l-6-2.5L3.5 20z" /><path d="M9 4v13.5M15 6.5V20" /></svg></span><span class="eq-bottom-nav-label">Routes</span></a>
</li>
</ul>
</nav>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
items | { label: string; href: string; icon: IconName | ReactElement; current?: boolean; badge?: number | string | true; badgeLabel?: string }[] | none | Three to five destinations. Any other anchor attribute on an item, such as onClick, goes to its link. |
fixed | boolean | false | Pins the bar to the bottom of the viewport. Leave it off to place it in your own layout. |
aria-label | string | "Main" | Names the landmark. |
...rest | ComponentProps<"nav"> | none | Every native nav attribute. |
Anatomy
| root | .eq-bottom-nav | A nav landmark. Padded by the safe-area insets. |
|---|---|---|
| list | .eq-bottom-nav-list | A grid of equal columns. |
| item | .eq-bottom-nav-item | |
| link | .eq-bottom-nav-link | The full column, at least 56px tall. |
| icon | .eq-bottom-nav-icon | Its ::before is the indicator that grows in behind the current icon. |
| label | .eq-bottom-nav-label | |
| badge | .eq-bottom-nav-badge | A count, or a dot with data-dot. Hidden from assistive tech. |
| hidden text | .eq-bottom-nav-hidden | Reads the badge after the label, from badgeLabel or the count. |
Keyboard
| Tab | Moves through the destinations in order. |
|---|---|
| Enter | Follows the focused link. |
Notes
- These are links in a nav landmark, not tabs: each destination is its own page, so the current one gets aria-current="page" and Tab moves between them.
- Labels always show; an icon alone is rarely clear enough for a whole destination.
- The badge is drawn for the eye and repeated in hidden text after the label, so the link reads as "Inbox, 3 unread".
- The bar pads itself with env(safe-area-inset-bottom); add viewport-fit=cover to the page's viewport meta for the inset to apply.
- With fixed, add bottom padding to the page so the last content is not hidden behind the bar.
- Renders on the server with no client JavaScript.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/bottom-nav.tsx62 lines
import type { ComponentProps, ReactElement } from "react";
import { Icon, type IconName } from "./icon";
import "./bottom-nav.css";
export type BottomNavItem = Omit<ComponentProps<"a">, "children"> & {
/** Short, one word if you can. Always shown under the icon. */
label: string;
href: string;
/** An Icon name, or your own 24px element. */
icon: IconName | ReactElement;
/** Marks the destination the person is on with aria-current="page". */
current?: boolean;
/** A count, or true for a plain dot. */
badge?: number | string | true;
/** What a screen reader hears for the badge. Defaults to the count, or "new" for a dot. */
badgeLabel?: string;
};
export type BottomNavProps = ComponentProps<"nav"> & {
/** Three to five destinations. */
items: BottomNavItem[];
/** Pins it to the bottom of the viewport. Leave it off to place it yourself. */
fixed?: boolean;
};
export function BottomNav({ items, fixed = false, className, "aria-label": label = "Main", ...rest }: BottomNavProps) {
return (
<nav
{...rest}
aria-label={label}
className={["eq-bottom-nav", className].filter(Boolean).join(" ")}
data-fixed={fixed || undefined}
>
<ul className="eq-bottom-nav-list">
{items.map(({ label: text, href, icon, current = false, badge, badgeLabel, className: linkClass, ...link }) => {
const heard = badgeLabel ?? (badge === true ? "new" : badge != null ? String(badge) : undefined);
return (
<li key={href} className="eq-bottom-nav-item">
<a
{...link}
href={href}
className={["eq-bottom-nav-link", linkClass].filter(Boolean).join(" ")}
aria-current={current ? "page" : undefined}
>
<span className="eq-bottom-nav-icon">
{typeof icon === "string" ? <Icon name={icon} size="l" /> : icon}
{badge != null && (
<span className="eq-bottom-nav-badge" data-dot={badge === true || undefined} aria-hidden="true">
{badge === true ? null : badge}
</span>
)}
</span>
<span className="eq-bottom-nav-label">{text}</span>
{heard && <span className="eq-bottom-nav-hidden">, {heard}</span>}
</a>
</li>
);
})}
</ul>
</nav>
);
}components/equip/bottom-nav.css169 lines
@layer equip {
.eq-bottom-nav {
box-sizing: border-box;
min-width: 0;
/* The home indicator and rounded corners of a phone sit under this padding. */
padding: var(--eq-space-1) max(var(--eq-space-1), env(safe-area-inset-right)) max(var(--eq-space-1), env(safe-area-inset-bottom))
max(var(--eq-space-1), env(safe-area-inset-left));
border-top: 1px solid var(--eq-line);
background: var(--eq-surface);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
font: var(--eq-weight) var(--eq-text-xs) / 1.2 var(--eq-font);
}
.eq-bottom-nav[data-fixed] {
position: fixed;
inset: auto 0 0;
z-index: 50;
}
.eq-bottom-nav-list {
display: grid;
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
margin: 0;
padding: 0;
list-style: none;
}
.eq-bottom-nav-item {
display: flex;
min-width: 0;
}
/* The whole column is the target: at least 56px tall and the full width of its share. */
.eq-bottom-nav-link {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 0;
min-height: 56px;
border-radius: var(--eq-radius-m);
color: var(--eq-ink-2);
text-decoration: none;
-webkit-tap-highlight-color: transparent;
transition:
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
/* The indicator behind the icon. It grows out from the centre when the item becomes current. */
.eq-bottom-nav-icon {
position: relative;
display: grid;
place-items: center;
width: 52px;
height: 30px;
isolation: isolate;
transition: scale var(--eq-dur) var(--eq-ease);
}
.eq-bottom-nav-icon::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: var(--eq-radius-full);
background: var(--eq-solid);
opacity: 0;
scale: 0.5 1;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring);
}
.eq-bottom-nav-icon > svg {
width: 24px;
height: 24px;
transition: color var(--eq-dur) var(--eq-ease);
}
.eq-bottom-nav-label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-bottom-nav-link[aria-current="page"] {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
.eq-bottom-nav-link[aria-current="page"] .eq-bottom-nav-icon {
color: var(--eq-solid-ink);
}
.eq-bottom-nav-link[aria-current="page"] .eq-bottom-nav-icon::before {
opacity: 1;
scale: 1;
}
.eq-bottom-nav-badge {
box-sizing: border-box;
position: absolute;
top: -4px;
inset-inline-start: calc(50% + 4px);
display: grid;
place-items: center;
min-width: 20px;
height: 20px;
padding-inline: var(--eq-space-1);
border-radius: var(--eq-radius-s);
background: var(--eq-danger);
box-shadow: 0 0 0 2px var(--eq-surface);
color: var(--eq-solid-ink);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.eq-bottom-nav-badge[data-dot] {
top: 2px;
inset-inline-start: calc(50% + 7px);
min-width: 0;
width: 8px;
height: 8px;
padding: 0;
border-radius: var(--eq-radius-full);
}
.eq-bottom-nav-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (hover: hover) {
.eq-bottom-nav-link:hover {
color: var(--eq-ink);
}
.eq-bottom-nav-link:hover:not([aria-current="page"]) .eq-bottom-nav-icon::before {
background: var(--eq-tint-strong);
opacity: 1;
scale: 1;
}
}
.eq-bottom-nav-link:active .eq-bottom-nav-icon {
scale: var(--eq-press);
}
.eq-bottom-nav-link:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
}