FloatingBottomNavigation
A bar of an app's main destinations, floating clear of the bottom edge rather than attached to it. It is only as wide as its destinations, it is cut as a stadium, and the page keeps going underneath it.
import { BottomNavigationItem, FloatingBottomNavigation } from 'neba';
<FloatingBottomNavigation label="Main" value={section} onValueChange={setSection}>
<BottomNavigationItem value="home" icon={<HomeIcon />}>
Home
</BottomNavigationItem>
<BottomNavigationItem value="search" icon={<SearchIcon />}>
Search
</BottomNavigationItem>
</FloatingBottomNavigation>;Its destinations are BottomNavigationItem, the same item BottomNavigation takes. Everything about an item (value, icon, href, disabled) is documented there.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'outline' | 'text' | 'outline' | Weight of the sheet, said the way a container says it: never dyed. outline is the default here because the bar is over the page rather than against its edge |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The row’s floor and the scale of the glyph and the name. The same ladder BottomNavigation is on: 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 | 2 | Drop shadow depth. 2 for the reason Pill’s is: this bar is not part of the page, it hovers over it |
| 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' | 'absolute' | 'fixed' | How the bar sits in the page’s scroll. absolute holds it against the nearest positioned ancestor rather than the window: the same addition FloatingActionButton makes |
| offset | number | string | 16 | How far the bar floats above the bottom edge. This is the whole difference from BottomNavigation: the page keeps going underneath |
| labels | 'all' | 'selected' | 'none' | 'selected' | Which names are drawn. selected here, against the all a full-width bar defaults to: this bar is only as wide as what is in it. An undrawn name is still in the document |
| safeArea | boolean | true | Adds env(safe-area-inset-bottom) to offset. The whole sheet moves up: there is nothing under it to keep covered |
| 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. Rarely what you want here |
| children | ReactNode | — | The BottomNavigationItems. The same item both bars take |
Every other <nav> attribute passes through to the root, except onChange: the change worth listening for is onValueChange. The shared axes (variant size color density elevation position) are defined in prop conventions.
Examples
offset
How far the bar floats above the bottom edge, as a number of pixels or any CSS length. This is the whole difference between this component and BottomNavigation: because the page keeps going underneath, the sheet is a stadium rather than a bar with two corners, it carries a shadow, and it is sized by its contents.
safeArea adds env(safe-area-inset-bottom) to that gap, so the bar clears a phone's home indicator. Unlike on a full-width bar it moves the whole sheet, because there is nothing under it to keep covered.
<FloatingBottomNavigation offset={24} safeArea={false} />position
fixed (the default) holds the bar against the bottom of the window. absolute holds it against the bottom of the nearest positioned ancestor, which is what a bar inside a screen of its own wants, and is what the preview above uses. sticky holds it against the bottom of whatever is scrolling, and static puts it back in the flow, centred.
import { BottomNavigationItem, FloatingBottomNavigation, Typography } from 'neba';
import { HomeIcon, ProfileIcon, SearchIcon } from './icons';
export default function FloatingBottomNavigationPinned() {
return (
<div className="h-64 w-full max-w-sm 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: 10 }, (_, index) => (
<Typography key={index}>
Row {index + 1}. Scroll the box: the bar keeps its gap from the bottom edge.
</Typography>
))}
</div>
<FloatingBottomNavigation
position="sticky"
offset={12}
safeArea={false}
size="sm"
defaultValue="home"
>
<BottomNavigationItem value="home" icon={<HomeIcon />}>
Home
</BottomNavigationItem>
<BottomNavigationItem value="search" icon={<SearchIcon />}>
Search
</BottomNavigationItem>
<BottomNavigationItem value="you" icon={<ProfileIcon />}>
You
</BottomNavigationItem>
</FloatingBottomNavigation>
</div>
);
}labels
selected (the default here) draws only the name of the destination the reader is on. A floating bar is as wide as what is in it, so five drawn names would stretch it across the screen and it would stop being a lozenge.
all draws every name and none draws none of them. An undrawn name is still in the document, where it is what gives the glyph beside it an accessible name.
import { BottomNavigationItem, FloatingBottomNavigation, Typography } from 'neba';
import { HomeIcon, ProfileIcon, SearchIcon } from './icons';
const options = ['selected', 'all', 'none'] as const;
export default function FloatingBottomNavigationLabels() {
return (
<div className="flex w-full flex-col items-center gap-6">
{options.map((labels) => (
<div key={labels} className="flex flex-col items-center gap-2">
<Typography level="caption" color="secondary">
labels="{labels}"
</Typography>
<FloatingBottomNavigation position="static" labels={labels} defaultValue="home">
<BottomNavigationItem value="home" icon={<HomeIcon />}>
Home
</BottomNavigationItem>
<BottomNavigationItem value="search" icon={<SearchIcon />}>
Search
</BottomNavigationItem>
<BottomNavigationItem value="you" icon={<ProfileIcon />}>
You
</BottomNavigationItem>
</FloatingBottomNavigation>
</div>
))}
</div>
);
}The highlight
The highlight belongs to the bar rather than to the destination that is current, which is why it can travel: it is measured off whichever item carries aria-current and animates its left, top, width and height to the next one. Nothing is transformed, so the name riding over it is never resampled.
A name that labels is not drawing is collapsed rather than clipped (the box it sits in travels between nothing and the width of the words), so pressing a destination re-shapes the bar around it instead of jumping to the new arrangement: the name grows, its neighbours move over, and the highlight slides under it on the same clock. A reader who has asked for reduced motion gets the new arrangement without the journey.
variant, color, size
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. outline is the default here rather than the sheet with no edge: the hairline is what separates a floating lozenge from whatever is passing underneath it.
import { BottomNavigationItem, FloatingBottomNavigation, Typography } from 'neba';
import { HomeIcon, ProfileIcon, SearchIcon } from './icons';
const variants = ['outline', 'solid', 'text'] as const;
export default function FloatingBottomNavigationAppearance() {
return (
<div className="flex w-full flex-col items-center gap-6">
{variants.map((variant) => (
<div key={variant} className="flex flex-col items-center gap-2">
<Typography level="caption" color="secondary">
variant="{variant}"
</Typography>
<FloatingBottomNavigation
position="static"
variant={variant}
color="info"
size="sm"
defaultValue="home"
>
<BottomNavigationItem value="home" icon={<HomeIcon />}>
Home
</BottomNavigationItem>
<BottomNavigationItem value="search" icon={<SearchIcon />}>
Search
</BottomNavigationItem>
<BottomNavigationItem value="you" icon={<ProfileIcon />}>
You
</BottomNavigationItem>
</FloatingBottomNavigation>
</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.
<FloatingBottomNavigation value={pathname} onValueChange={navigate}>
<BottomNavigationItem value="/home" icon={<HomeIcon />}>
Home
</BottomNavigationItem>
</FloatingBottomNavigation>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: which is the whole accessible name of an item that is only a glyph. - With
position="fixed", pad the bottom of the page by the bar's height plus itsoffset, or its last line is covered.