BottomNavigation
A bar of an app's main destinations, held against the bottom edge of the window. One glyph with a name under it is one destination, and the one the reader is on carries aria-current.
import { BottomNavigation, BottomNavigationItem } from 'neba';
<BottomNavigation label="Main" value={section} onValueChange={setSection}>
<BottomNavigationItem value="home" icon={<HomeIcon />}>
Home
</BottomNavigationItem>
<BottomNavigationItem value="search" icon={<SearchIcon />}>
Search
</BottomNavigationItem>
</BottomNavigation>;Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'outline' | 'text' | 'outline' | Weight of the bar, said the way a container says it: the sheet is never dyed. What carries the colour family is the one item that is current |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The row’s floor and the scale of the glyph and the name. md is 56px |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | Padding only: never the height, never the type scale |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. 0 by default: the bar is attached to the edge of the window rather than floating over it, and divider is what separates it from the content |
| value | string | number | null | — | The destination the reader is on. Use with onValueChange for a controlled bar |
| defaultValue | string | number | null | — | Which starts current, for an uncontrolled bar |
| onValueChange | (value: string | number) => void | — | Called with the destination that was pressed |
| positionshared | 'static' | 'sticky' | 'fixed' | 'fixed' | How the bar sits in the page’s scroll. fixed by default, against the static everything else defaults to: a bottom navigation is held against the bottom edge of the window |
| labels | 'all' | 'selected' | 'none' | 'all' | Which names are drawn. An undrawn name is still in the document for a screen reader |
| divider | boolean | true | Draws a hairline along the top edge, against the content. On by default, the other way round from Toolbar |
| safeArea | boolean | true | Keeps the bar clear of the home indicator by adding env(safe-area-inset-bottom) under it. The sheet still reaches the bottom of the screen |
| disabled | boolean | false | Every destination stops answering |
| label | string | — | The name the bar is announced by: "Main", "Sections" |
| render | useRender.RenderProp | — | Renders something other than a nav (render={<footer />}). Base UI’s own escape hatch, and rarely what you want here: a row of destinations is navigation |
| children | ReactNode | — | The BottomNavigationItems |
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | string | number | — | Identifies the destination. What onValueChange reports |
| icon | ReactNode | — | The glyph above the name |
| href | string | — | Renders the item as a link rather than as a button |
| target | string | — | Where an href opens. Anything but this tab gets noopener noreferrer merged into rel |
| rel | string | — | The link’s rel. Merged with the two tokens above rather than replaced |
| disabled | boolean | false | Unavailable, but still part of the set |
| children | ReactNode | — | The destination’s name. Read out even when labels keeps it undrawn |
Every other <nav> attribute passes through to the root and every other <button> attribute to each destination, except onChange: the change worth listening for is onValueChange.
The shared axes (variant size color density elevation position) are defined in prop conventions.
Examples
position
The default is fixed, against the static everything else in the library defaults to, and that is what this component is: it is held against the bottom edge of the window whatever the page does. The page then needs bottom padding of its own, or its last line sits behind the bar.
sticky keeps the bar in the flow but stops it at the bottom edge of the scrolling region. static is an ordinary sheet in the flow.
import { BottomNavigation, BottomNavigationItem, Typography } from 'neba';
function DotIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="4.25" stroke="currentColor" strokeWidth="1.5" />
</svg>
);
}
export default function BottomNavigationPinned() {
return (
<div className="h-64 w-full max-w-xs overflow-y-auto rounded-(--neba-radius-md) border [border-color:var(--neba-border)]">
<div className="flex flex-col gap-4 p-4">
{Array.from({ length: 8 }, (_, index) => (
<Typography key={index}>
Row {index + 1}. Scroll the box: the bar stays against its bottom edge.
</Typography>
))}
</div>
<BottomNavigation position="sticky" defaultValue="feed" safeArea={false}>
<BottomNavigationItem value="feed" icon={<DotIcon />}>
Feed
</BottomNavigationItem>
<BottomNavigationItem value="saved" icon={<DotIcon />}>
Saved
</BottomNavigationItem>
</BottomNavigation>
</div>
);
}labels
all draws every name. selected draws only the current one, and none draws none of them.
An undrawn name is still in the document. A button whose whole label is a glyph has no accessible name at all, so what goes is the pixels and nothing else.
import { BottomNavigation, BottomNavigationItem, Typography } from 'neba';
function DiscIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="5.25" stroke="currentColor" strokeWidth="1.5" />
<circle cx="8" cy="8" r="1.5" fill="currentColor" />
</svg>
);
}
const modes = ['all', 'selected', 'none'] as const;
export default function BottomNavigationLabels() {
return (
<div className="flex w-full max-w-xs flex-col gap-4">
{modes.map((labels) => (
<div key={labels} className="flex flex-col gap-1">
<Typography level="caption" className="text-(--neba-muted-fg)">
labels="{labels}"
</Typography>
<BottomNavigation position="static" labels={labels} defaultValue="library">
<BottomNavigationItem value="listen" icon={<DiscIcon />}>
Listen
</BottomNavigationItem>
<BottomNavigationItem value="library" icon={<DiscIcon />}>
Library
</BottomNavigationItem>
<BottomNavigationItem value="radio" icon={<DiscIcon />}>
Radio
</BottomNavigationItem>
</BottomNavigation>
</div>
))}
</div>
);
}href
A destination with an href is a real <a>. That is what makes a long press offer "open in a new tab" and what puts the address in the status bar, neither of which a <button> calling a router can do.
import { BottomNavigation, BottomNavigationItem } from 'neba';
function DocsIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4 2.75h5L12 6v7.25H4V2.75Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path d="M8.75 2.75V6H12" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
);
}
function GridIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<rect
x="2.75"
y="2.75"
width="4.5"
height="4.5"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
<rect
x="8.75"
y="2.75"
width="4.5"
height="4.5"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
<rect
x="2.75"
y="8.75"
width="4.5"
height="4.5"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
<rect
x="8.75"
y="8.75"
width="4.5"
height="4.5"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
);
}
export default function BottomNavigationLinks() {
return (
<div className="w-full max-w-xs">
<BottomNavigation position="static" label="Documentation" value="components">
<BottomNavigationItem value="guide" href="/guide/getting-started" icon={<DocsIcon />}>
Guide
</BottomNavigationItem>
<BottomNavigationItem value="components" href="/components/" icon={<GridIcon />}>
Components
</BottomNavigationItem>
<BottomNavigationItem value="design" href="/design/design-language" icon={<DocsIcon />}>
Design
</BottomNavigationItem>
</BottomNavigation>
</div>
);
}variant, divider, safeArea
variant says what it says on every other container: the sheet is never dyed, and what carries the colour family is the one destination that is current. divider is the hairline along the top edge, facing the content, and it is on by default. safeArea adds env(safe-area-inset-bottom) under the row to clear a phone's home indicator, while the sheet itself still reaches the bottom of the screen.
import type { ComponentProps } from 'react';
import { BottomNavigation, BottomNavigationItem, Typography } from 'neba';
function DotIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="4.25" stroke="currentColor" strokeWidth="1.5" />
</svg>
);
}
function Bar({ children, ...props }: ComponentProps<typeof BottomNavigation>) {
return (
<BottomNavigation position="static" defaultValue="two" {...props}>
<BottomNavigationItem value="one" icon={<DotIcon />}>
One
</BottomNavigationItem>
<BottomNavigationItem value="two" icon={<DotIcon />}>
Two
</BottomNavigationItem>
<BottomNavigationItem value="three" icon={<DotIcon />}>
Three
</BottomNavigationItem>
{children}
</BottomNavigation>
);
}
export default function BottomNavigationAppearance() {
return (
<div className="flex w-full max-w-xs flex-col gap-4">
<div className="flex flex-col gap-1">
<Typography level="caption" className="text-(--neba-muted-fg)">
variant="solid" color="success"
</Typography>
<Bar variant="solid" color="success" />
</div>
<div className="flex flex-col gap-1">
<Typography level="caption" className="text-(--neba-muted-fg)">
size="sm" density="compact"
</Typography>
<Bar size="sm" density="compact" />
</div>
<div className="flex flex-col gap-1">
<Typography level="caption" className="text-(--neba-muted-fg)">
variant="text" divider={'{false}'}
</Typography>
<Bar variant="text" divider={false} />
</div>
</div>
);
}Controlling it
Pass value and the bar keeps no state of its own, which is the shape to use when the router already knows where the reader is.
<BottomNavigation value={pathname} onValueChange={navigate}>
<BottomNavigationItem value="/home" icon={<HomeIcon />}>
Home
</BottomNavigationItem>
</BottomNavigation>Accessibility
- The root is a
<nav>andlabelnames it. It is not arole="tablist": a tab list promises one tab stop for the set and arrow keys within it, and a bottom navigation changes the page rather than which panel of one is showing. - The current destination carries
aria-current="page". - Each destination is a real
<button>, or a real<a>when it is given anhref. - A name that
labelskeeps undrawn stays in the document, where it is the destination's accessible name. - With
position="fixed", pad the bottom of the page by the bar's height, or its last line is covered.