From b79b445dcdde637ee1c10ca0a86484e6b1f5c4ce Mon Sep 17 00:00:00 2001
From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com>
Date: Tue, 3 Nov 2026 19:05:42 +0530
Subject: [PATCH] feat: add a floating variant to NavigationBar
---
CHANGELOG.md | 27 +++
api-surface.json | 1 +
docs/docs/components/navigation-bar.mdx | 65 ++++++-
docs/static/llms-full.txt | 17 +-
example/app/navigation-bar.tsx | 58 ++++++
packages/components/llms.txt | 17 +-
.../src/__tests__/NavigationBar.test.tsx | 169 ++++++++++++++++++
.../src/__tests__/elevation.test.tsx | 13 ++
.../__tests__/rn-prop-passthrough.test.tsx | 12 ++
.../src/__tests__/web/elevation.web.test.tsx | 13 ++
packages/components/src/index.ts | 1 +
.../src/navigation-bar/NavigationBar.tsx | 59 +++++-
.../src/navigation-bar/NavigationItem.tsx | 3 +-
.../components/src/navigation-bar/index.ts | 1 +
.../components/src/navigation-bar/styles.ts | 114 ++++++++++--
.../components/src/navigation-bar/types.ts | 80 +++++++--
scripts/build-llms.ts | 37 +++-
17 files changed, 632 insertions(+), 55 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dec78142..f9b25b5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,33 @@ and read the whole release rather than scanning for a label.
Prior history: these packages were published as `@onlynative/*` through
`0.0.0-alpha.8`. The `@rootnative` line below starts over at `0.0.0-alpha.0`.
+## Unreleased
+
+**No prop is removed.** One new variant in `@rootnative/components`. One
+type change can stop a build: an `interface` that extends
+`NavigationBarProps`.
+
+- **New: `NavigationBar` `variant="floating"`.** A capsule that floats over
+ the content: 64dp high, at most 480dp wide, with a 16dp gap to each side
+ and to the bottom of the window. Each destination shows its icon only,
+ and its label is the screen-reader name. The active indicator fills the
+ destination. The bar places itself at the bottom of its parent and reads
+ the safe-area insets from a `SafeAreaProvider`. It floats over a bottom
+ inset of 40dp or less, which holds only a gesture handle, and it stays
+ 16dp above a taller inset. MD3 has no floating navigation bar. The
+ capsule takes the MD3 colors, and the height, the edge gap and the item
+ gap of the MD3 floating toolbar. Its elevation, level 3, is not an MD3
+ value for this surface. The default `variant` is `'docked'`, the bar
+ as before. `labelVisibility`, `labelStyle` and `insetBottom` apply to the
+ docked bar only, and the type rejects them on the floating bar. The new
+ type `NavigationBarVariant` names the two values.
+
+- **`NavigationBarProps` is now a type alias**, so that the type can reject
+ the docked props on the floating bar. An `interface` cannot extend a
+ union, so an `interface` that extends `NavigationBarProps` does not
+ compile. Use an intersection in its place:
+ `type MyBarProps = NavigationBarProps & { … }`.
+
## 0.0.0-alpha.34 — 2026-11-03
**No breaking change.** One new component, `Carousel`, in
diff --git a/api-surface.json b/api-surface.json
index 26a51fe4..4a766d3c 100644
--- a/api-surface.json
+++ b/api-surface.json
@@ -233,6 +233,7 @@
"NavigationBarItem",
"NavigationBarLabelVisibility",
"NavigationBarProps",
+ "NavigationBarVariant",
"NavigationDrawer",
"NavigationDrawerDivider",
"NavigationDrawerDividerProps",
diff --git a/docs/docs/components/navigation-bar.mdx b/docs/docs/components/navigation-bar.mdx
index b445f4fd..0a856e23 100644
--- a/docs/docs/components/navigation-bar.mdx
+++ b/docs/docs/components/navigation-bar.mdx
@@ -10,7 +10,9 @@ Follows the [Material Design 3 Navigation bar](https://m3.material.io/components
specification.
Each destination is an icon over a label, with the active one marked by a `secondaryContainer`
-pill behind its icon. The bar is 80dp tall on a `surfaceContainer` background.
+pill behind its icon. The bar is 80dp tall on a `surfaceContainer` background. The
+[floating](#floating) variant is a 64dp capsule over the content, with an icon for each
+destination.
:::info NavigationBar is a bar, not a navigator
`NavigationBar` renders the destinations and tells you which one was pressed. It does not own
@@ -140,7 +142,49 @@ inside the same pill shape, on active and inactive destinations alike.
A bottom-anchored bar must clear the home indicator. Pass `insetBottom` to wrap the row in a
`SafeAreaView` that adds the bottom inset below the bar's 80dp — leave it off (the default) when
-an ancestor already applies the bottom edge, or the inset would be added twice.
+an ancestor already applies the bottom edge, or the inset would be added twice. The floating bar
+reads the insets by itself: see [Floating](#floating).
+
+## Floating
+
+`variant="floating"` draws the bar as a capsule that floats over the content. MD3 has no
+floating navigation bar. This form takes the MD3 colors, and the height, the gap to the window
+edges and the item gap of the MD3 floating toolbar: 64dp, 16dp and 4dp. Its level 3 elevation is
+not an MD3 value for this surface.
+
+- The capsule is 64dp high and at most 480dp wide, with a 16dp gap to each side and to the
+ bottom of the window.
+- Each destination shows its icon only. The label is the screen-reader name of the destination.
+- The active indicator fills the destination.
+
+The bar places itself at the bottom of its parent, over the content. Make it the last child of a
+view that reaches the bottom edge of the window. Pad the end of the content, so that the last
+item can scroll clear of the bar: 80dp (the 64dp capsule and the 16dp gap), plus the bottom
+inset when the inset is more than 40dp.
+
+```tsx
+
+ {/* … */}
+
+
+```
+
+The bar reads the safe-area insets from a `SafeAreaProvider`. Expo Router adds one.
+
+- A bottom inset of 40dp or less holds only a gesture handle: the iOS home indicator or the
+ Android gesture bar. The capsule floats over it, 16dp from the bottom edge.
+- A taller bottom inset holds the Android navigation buttons. The capsule stays 16dp above it.
+- The side insets are added to the 16dp gap.
+- Without a provider, the capsule keeps the 16dp gap.
+
+`labelVisibility`, `labelStyle` and `insetBottom` apply to the docked bar only. The type rejects
+them with `variant="floating"`.
## Overrides
@@ -151,7 +195,7 @@ an ancestor already applies the bottom edge, or the inset would be added twice.
| `selectedContentColor` | Icon and label color of the active destination; the state layers re-derive from it |
| `indicatorColor` | The active indicator pill |
| `labelStyle` | Every destination's label `Text` — does not affect the icons |
-| `style` | The bar container |
+| `style` | The bar container; on the floating bar, the capsule |
Disabled destinations always use the MD3 disabled treatment (38% content); no override changes
that.
@@ -175,6 +219,18 @@ These are the Expressive-updated androidx tokens: the indicator is 56dp wide (no
pre-Expressive 64dp) and the active label is `secondary` (not `onSurface`). The 64dp-tall
Expressive short bar is a possible 1.x variant; this bar uses compose's `TallContainerHeight`.
+The floating bar changes these:
+
+| Token | Value |
+|-------|-------|
+| Container | `surfaceContainer`, elevation level 3, full corners |
+| Height | 64dp |
+| Width | The parent less a 16dp gap on each side, at most 480dp |
+| Bottom gap | 16dp from the bottom edge, or 16dp above a bottom inset of more than 40dp |
+| Padding | 6dp around the destinations, 4dp between them |
+| Active indicator | Fills the destination, `secondaryContainer`, full corners |
+| Label | None; the label is the screen-reader name |
+
## Accessibility
- The bar reports `role="tablist"` and each destination `role="tab"` with
@@ -184,7 +240,8 @@ Expressive short bar is a possible 1.x variant; this bar uses compose's `TallCon
hides the visible label; set `accessibilityLabel` on the item to override it.
- Disabled destinations expose `accessibilityState.disabled` and stop responding.
- On web, destinations show a keyboard focus ring (`secondary`, 3dp) around the indicator pill,
- driven by the same focus-visible state as the rest of the library.
+ driven by the same focus-visible state as the rest of the library. On the floating bar, the
+ ring goes around the destination.
## Props
diff --git a/docs/static/llms-full.txt b/docs/static/llms-full.txt
index 0d356ef6..daa8232d 100644
--- a/docs/static/llms-full.txt
+++ b/docs/static/llms-full.txt
@@ -2463,6 +2463,14 @@ import { NavigationBar } from '@rootnative/components/navigation-bar'
// (active icon+label), indicatorColor (pill), labelStyle. Disabled items stay
// at the MD3 38%.
+
+// Floating: a 64dp capsule over the content, icons only, 16dp from the window
+// edges. It places itself at the bottom of its parent and reads the safe-area
+// insets: a bottom inset of 40 or less (a gesture handle) goes under it.
+
+ {/* content: pad its end by 80 */}
+
+
```
Props:
@@ -2470,16 +2478,17 @@ Props:
- `value?: string` — Value of the active destination (controlled).
- `defaultValue?: string` — Value of the initially active destination (uncontrolled). Defaults to the first item — a navigation bar with nothing active reads as broken.
- `onValueChange?: (value: string) => void` — Called with the value of the destination that was pressed.
-- `labelVisibility?: 'always' | 'selected' | 'never'` — Default: `'always'`. When destination labels are shown.
-- `insetBottom?: boolean` — Default: `false`. When `true`, wraps the row in a SafeAreaView that adds the bottom inset below the bar's 80dp, so it clears the home indicator. Leave it off when an ancestor (e.g. `Layout` with a `bottom` edge) already handles it.
- `containerColor?: string` — Default: `surfaceContainer`. Override the bar background.
- `contentColor?: string` — Default: `onSurfaceVariant`. Override the icon and label color of inactive destinations.
- `selectedContentColor?: string` — Default: `onSecondaryContainer (icon) / secondary (label)`. Override the icon and label color of the active destination. State-layer colors are derived from it automatically.
- `indicatorColor?: string` — Default: `secondaryContainer`. Override the active indicator pill color.
-- `labelStyle?: StyleProp` — Style applied to every destination's label `Text` — does not affect the icons.
-- `style?: StyleProp` — Style applied to the bar container.
+- `style?: StyleProp` — Style applied to the bar container. On the floating bar, that is the capsule.
- `accessibilityLabel?: string` — Screen-reader label for the bar itself.
- `testID?: string` — Test id applied to the bar container. Each destination carries `-item-` and its indicator `-item--indicator`.
+- `variant?: 'docked' | 'floating'` — Default: `'docked'`. `'docked'` is the MD3 navigation bar, an 80dp band along the bottom edge. `'floating'` is a 64dp capsule over the content, at most 480 wide, 16dp from the window edges, with an icon for each destination and no visible label; the indicator fills the destination. It places itself at the bottom of its parent and reads the insets from a `SafeAreaProvider`: it floats over a bottom inset of 40 or less and stays 16dp above a taller one.
+- `labelVisibility?: 'always' | 'selected' | 'never'` — Default: `'always'`. When destination labels are shown. Docked only.
+- `insetBottom?: boolean` — Default: `false`. Adds the bottom safe-area inset below the bar's 80dp. Leave it off when an ancestor already handles the edge. Docked only.
+- `labelStyle?: StyleProp` — Style for every label `Text`. Docked only.
- Inherits `ViewProps` (except `children`)
#### NavigationBarItem
diff --git a/example/app/navigation-bar.tsx b/example/app/navigation-bar.tsx
index e036f96e..bb541a6f 100644
--- a/example/app/navigation-bar.tsx
+++ b/example/app/navigation-bar.tsx
@@ -9,6 +9,7 @@ import {
import { useTheme } from '@rootnative/core'
import { useMemo, useState } from 'react'
import { ScrollView, StyleSheet } from 'react-native'
+import { SafeAreaProvider } from 'react-native-safe-area-context'
import { catalogMetadata } from '../src/metadata'
import { ScreenIntro } from '../src/ScreenIntro'
import { ScreenNavFooter } from '../src/ScreenNavFooter'
@@ -36,6 +37,35 @@ const PANELS: Record = {
profile: 'Your account and settings.',
}
+// A nested `SafeAreaProvider` measures the insets of its own frame, so the
+// capsule keeps its gap to the frame and not to the window.
+function FloatingDemo() {
+ const [destination, setDestination] = useState('home')
+
+ return (
+
+
+ {PANELS[destination]}
+ {Array.from({ length: 12 }, (_, index) => (
+
+ Scroll content row {index + 1}
+
+ ))}
+
+
+
+ )
+}
+
function NavigationBarScreenContent() {
const theme = useTheme()
const [destination, setDestination] = useState('home')
@@ -108,6 +138,22 @@ function NavigationBarScreenContent() {
+
+ Floating
+
+
+
+
+ variant="floating" is a capsule over the content,
+ 16dp from the edges, with an icon for each destination. The
+ label is the screen-reader name.
+
+
+
+
+
+
+
Disabled and overrides
@@ -151,4 +197,16 @@ const styles = StyleSheet.create({
scroll: {
flexGrow: 1,
},
+ floatingFrame: {
+ flex: 0,
+ height: 320,
+ overflow: 'hidden',
+ },
+ // 16 of padding plus the 64dp capsule and its 16dp gap, so the last row
+ // scrolls clear of it.
+ floatingScrollContent: {
+ padding: 16,
+ paddingBottom: 112,
+ rowGap: 16,
+ },
})
diff --git a/packages/components/llms.txt b/packages/components/llms.txt
index 615bf249..053ccd0d 100644
--- a/packages/components/llms.txt
+++ b/packages/components/llms.txt
@@ -1803,6 +1803,14 @@ import { NavigationBar } from '@rootnative/components/navigation-bar'
// (active icon+label), indicatorColor (pill), labelStyle. Disabled items stay
// at the MD3 38%.
+
+// Floating: a 64dp capsule over the content, icons only, 16dp from the window
+// edges. It places itself at the bottom of its parent and reads the safe-area
+// insets: a bottom inset of 40 or less (a gesture handle) goes under it.
+
+ {/* content: pad its end by 80 */}
+
+
```
Props:
@@ -1810,16 +1818,17 @@ Props:
- `value?: string` — Value of the active destination (controlled).
- `defaultValue?: string` — Value of the initially active destination (uncontrolled). Defaults to the first item — a navigation bar with nothing active reads as broken.
- `onValueChange?: (value: string) => void` — Called with the value of the destination that was pressed.
-- `labelVisibility?: 'always' | 'selected' | 'never'` — Default: `'always'`. When destination labels are shown.
-- `insetBottom?: boolean` — Default: `false`. When `true`, wraps the row in a SafeAreaView that adds the bottom inset below the bar's 80dp, so it clears the home indicator. Leave it off when an ancestor (e.g. `Layout` with a `bottom` edge) already handles it.
- `containerColor?: string` — Default: `surfaceContainer`. Override the bar background.
- `contentColor?: string` — Default: `onSurfaceVariant`. Override the icon and label color of inactive destinations.
- `selectedContentColor?: string` — Default: `onSecondaryContainer (icon) / secondary (label)`. Override the icon and label color of the active destination. State-layer colors are derived from it automatically.
- `indicatorColor?: string` — Default: `secondaryContainer`. Override the active indicator pill color.
-- `labelStyle?: StyleProp` — Style applied to every destination's label `Text` — does not affect the icons.
-- `style?: StyleProp` — Style applied to the bar container.
+- `style?: StyleProp` — Style applied to the bar container. On the floating bar, that is the capsule.
- `accessibilityLabel?: string` — Screen-reader label for the bar itself.
- `testID?: string` — Test id applied to the bar container. Each destination carries `-item-` and its indicator `-item--indicator`.
+- `variant?: 'docked' | 'floating'` — Default: `'docked'`. `'docked'` is the MD3 navigation bar, an 80dp band along the bottom edge. `'floating'` is a 64dp capsule over the content, at most 480 wide, 16dp from the window edges, with an icon for each destination and no visible label; the indicator fills the destination. It places itself at the bottom of its parent and reads the insets from a `SafeAreaProvider`: it floats over a bottom inset of 40 or less and stays 16dp above a taller one.
+- `labelVisibility?: 'always' | 'selected' | 'never'` — Default: `'always'`. When destination labels are shown. Docked only.
+- `insetBottom?: boolean` — Default: `false`. Adds the bottom safe-area inset below the bar's 80dp. Leave it off when an ancestor already handles the edge. Docked only.
+- `labelStyle?: StyleProp` — Style for every label `Text`. Docked only.
- Inherits `ViewProps` (except `children`)
#### NavigationBarItem
diff --git a/packages/components/src/__tests__/NavigationBar.test.tsx b/packages/components/src/__tests__/NavigationBar.test.tsx
index 64e46a47..daca590d 100644
--- a/packages/components/src/__tests__/NavigationBar.test.tsx
+++ b/packages/components/src/__tests__/NavigationBar.test.tsx
@@ -2,6 +2,7 @@ import { lightTheme } from '@rootnative/core'
import { renderSettled, renderWithTheme } from '@rootnative/utils/test'
import { fireEvent, screen } from '@testing-library/react-native'
import { StyleSheet } from 'react-native'
+import { SafeAreaProvider } from 'react-native-safe-area-context'
import { NavigationBar } from '../navigation-bar'
import type { NavigationBarItem } from '../navigation-bar'
@@ -224,3 +225,171 @@ describe('NavigationBar — overrides', () => {
).toBe('700')
})
})
+
+type Style = Record
+
+/** Every node's flattened style, in tree order. */
+function styles(node: unknown): Style[] {
+ if (!node || typeof node !== 'object') return []
+ if (Array.isArray(node)) return node.flatMap(styles)
+ const { props, children } = node as {
+ props?: { style?: unknown }
+ children?: unknown[]
+ }
+ const own = props?.style ? [StyleSheet.flatten(props.style) as Style] : []
+ return [...own, ...(children ?? []).flatMap(styles)]
+}
+
+/** The view that places the floating capsule in its parent. */
+function track(): Style {
+ const found = styles(screen.toJSON()).find(
+ (style) => style.position === 'absolute' && style.left === 0,
+ )
+ if (!found) throw new Error('no track')
+ return found
+}
+
+function insetsOf(bottom: number, left = 0, right = 0) {
+ return {
+ frame: { x: 0, y: 0, width: 390, height: 844 },
+ insets: { top: 0, bottom, left, right },
+ }
+}
+
+describe('NavigationBar — floating', () => {
+ it('shows an icon for each destination and names it with the label', () => {
+ renderBar({ variant: 'floating' })
+
+ expect(screen.queryByText('Home')).toBeNull()
+ expect(screen.queryByText('Search')).toBeNull()
+ expect(
+ screen.getByRole('tab', { name: 'Home' }).props.accessibilityState,
+ ).toMatchObject({ selected: true })
+ expect(screen.getByRole('tab', { name: 'Library' })).toBeTruthy()
+ })
+
+ it('is a navigation landmark around a named tablist', () => {
+ renderBar({ variant: 'floating', accessibilityLabel: 'Main navigation' })
+ expect(screen.getByTestId('nav').props.role).toBe('navigation')
+ const tablist = screen.getByTestId('nav-tablist')
+ expect(tablist.props.role).toBe('tablist')
+ expect(tablist.props.accessibilityLabel).toBe('Main navigation')
+ })
+
+ it('moves the active destination on press', () => {
+ const onValueChange = jest.fn()
+ renderBar({ variant: 'floating', onValueChange })
+
+ fireEvent.press(screen.getByRole('tab', { name: 'Search' }))
+ expect(onValueChange).toHaveBeenCalledWith('search')
+ expect(
+ screen.getByRole('tab', { name: 'Search' }).props.accessibilityState,
+ ).toMatchObject({ selected: true })
+ })
+
+ it('is a 64dp capsule, at most 480 wide, on surfaceContainer', () => {
+ renderBar({ variant: 'floating' })
+
+ const capsule = StyleSheet.flatten(screen.getByTestId('nav').props.style)
+ expect(capsule).toMatchObject({
+ width: '100%',
+ maxWidth: 480,
+ borderRadius: lightTheme.shape.cornerFull,
+ backgroundColor: lightTheme.colors.surfaceContainer,
+ })
+ expect(
+ StyleSheet.flatten(screen.getByTestId('nav-tablist').props.style),
+ ).toMatchObject({ height: 64, padding: 6, gap: 4 })
+ })
+
+ it('fills each destination with its indicator', () => {
+ renderBar({ variant: 'floating' })
+
+ // The docked bar lays the indicator out in a 56×32 pill. The floating
+ // pill grows to fill the destination instead.
+ const all = styles(screen.toJSON())
+ expect(all.some((style) => style.width === 56)).toBe(false)
+ const item = StyleSheet.flatten(
+ screen.getByRole('tab', { name: 'Home' }).props.style,
+ )
+ expect(item).toMatchObject({ flexGrow: 1, flexBasis: 0 })
+ expect(item.paddingHorizontal).toBeUndefined()
+ expect(
+ StyleSheet.flatten(
+ screen.getByTestId('nav-item-home-indicator').props.style,
+ ).opacity,
+ ).toBe(1)
+ })
+
+ it('keeps the color overrides', () => {
+ renderBar({
+ variant: 'floating',
+ containerColor: '#101010',
+ indicatorColor: '#404040',
+ })
+
+ expect(
+ StyleSheet.flatten(screen.getByTestId('nav').props.style).backgroundColor,
+ ).toBe('#101010')
+ expect(
+ StyleSheet.flatten(
+ screen.getByTestId('nav-item-home-indicator').props.style,
+ ).backgroundColor,
+ ).toBe('#404040')
+ })
+
+ it('sits 16 from the edges outside a SafeAreaProvider', () => {
+ renderBar({ variant: 'floating' })
+ expect(track()).toMatchObject({
+ bottom: 16,
+ paddingLeft: 16,
+ paddingRight: 16,
+ pointerEvents: 'box-none',
+ })
+ })
+
+ it('floats over a bottom inset that holds only a gesture handle', () => {
+ renderWithTheme(
+
+
+ ,
+ )
+ expect(track().bottom).toBe(16)
+ })
+
+ it('stays above a taller bottom inset', () => {
+ renderWithTheme(
+
+
+ ,
+ )
+ expect(track().bottom).toBe(64)
+ })
+
+ it('keeps the gap inside the side insets', () => {
+ renderWithTheme(
+
+
+ ,
+ )
+ expect(track()).toMatchObject({ paddingLeft: 75, paddingRight: 63 })
+ })
+
+ // The floating bar shows no label and reads the bottom inset by itself.
+ // The directive is the assertion: it fails the build if the union ever
+ // stops rejecting the docked props. The type error is on the attribute,
+ // because `never` rejects its value, so the directive sits above it.
+ it('rejects the docked props at the type level', () => {
+ renderWithTheme(
+ ,
+ )
+ // A consumer on plain JS gets no type error, so the label stays hidden.
+ expect(screen.queryByText('Home')).toBeNull()
+ })
+})
diff --git a/packages/components/src/__tests__/elevation.test.tsx b/packages/components/src/__tests__/elevation.test.tsx
index 3005107c..c7156515 100644
--- a/packages/components/src/__tests__/elevation.test.tsx
+++ b/packages/components/src/__tests__/elevation.test.tsx
@@ -286,6 +286,19 @@ const STATIC_CASES = [
),
rest: lightTheme.elevation.level2,
},
+ {
+ name: 'NavigationBar — floating',
+ ui: (
+
+ ),
+ rest: lightTheme.elevation.level3,
+ },
{
// The FAB inside is flat, so the bar is the one shadow node.
name: 'BottomAppBar',
diff --git a/packages/components/src/__tests__/rn-prop-passthrough.test.tsx b/packages/components/src/__tests__/rn-prop-passthrough.test.tsx
index 16ab5c86..f9026a4f 100644
--- a/packages/components/src/__tests__/rn-prop-passthrough.test.tsx
+++ b/packages/components/src/__tests__/rn-prop-passthrough.test.tsx
@@ -75,6 +75,18 @@ describe('RN prop passthrough reaches the root node', () => {
expect(screen.getByTestId('nav').props.nativeID).toBe('probe')
})
+ it('NavigationBar — floating', () => {
+ renderWithTheme(
+ ,
+ )
+ expect(screen.getByTestId('nav').props.nativeID).toBe('probe')
+ })
+
it('NavigationRail', () => {
renderWithTheme(
,
diff --git a/packages/components/src/__tests__/web/elevation.web.test.tsx b/packages/components/src/__tests__/web/elevation.web.test.tsx
index 2c0ae011..5ce6a532 100644
--- a/packages/components/src/__tests__/web/elevation.web.test.tsx
+++ b/packages/components/src/__tests__/web/elevation.web.test.tsx
@@ -229,6 +229,19 @@ it.each([
),
level: lightTheme.elevation.level2,
},
+ {
+ name: 'NavigationBar — floating',
+ ui: (
+
+ ),
+ level: lightTheme.elevation.level3,
+ },
{
// The FAB inside is flat, so the bar is the one box-shadow.
name: 'BottomAppBar',
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
index fba02097..79344b69 100644
--- a/packages/components/src/index.ts
+++ b/packages/components/src/index.ts
@@ -158,6 +158,7 @@ export type {
NavigationBarItem,
NavigationBarLabelVisibility,
NavigationBarProps,
+ NavigationBarVariant,
} from './navigation-bar'
export { NavigationRail } from './navigation-rail'
diff --git a/packages/components/src/navigation-bar/NavigationBar.tsx b/packages/components/src/navigation-bar/NavigationBar.tsx
index 41c2dafa..0a9b4623 100644
--- a/packages/components/src/navigation-bar/NavigationBar.tsx
+++ b/packages/components/src/navigation-bar/NavigationBar.tsx
@@ -1,10 +1,18 @@
import { useTheme } from '@rootnative/core'
-import { useCallback, useMemo, useState } from 'react'
+import { useCallback, useContext, useMemo, useState } from 'react'
import { View } from 'react-native'
+import { SafeAreaInsetsContext } from 'react-native-safe-area-context'
+import { pointerEvents } from '../internal/pointerEvents'
import { SafeAreaView } from '../safe-area'
import type { Edge } from '../safe-area'
import { NavigationItem } from './NavigationItem'
-import { createNavigationBarStyles, getNavigationBarColors } from './styles'
+import {
+ FLOATING_BAR_GAP,
+ createFloatingNavigationBarStyles,
+ createNavigationBarStyles,
+ floatingBarBottom,
+ getNavigationBarColors,
+} from './styles'
import type { NavigationBarProps } from './types'
const BOTTOM_EDGE: Edge[] = ['bottom']
@@ -14,6 +22,7 @@ export function NavigationBar({
value,
defaultValue,
onValueChange,
+ variant = 'docked',
labelVisibility = 'always',
insetBottom = false,
containerColor,
@@ -27,6 +36,7 @@ export function NavigationBar({
...rest
}: NavigationBarProps) {
const theme = useTheme()
+ const floating = variant === 'floating'
const colors = useMemo(
() =>
getNavigationBarColors(
@@ -41,6 +51,29 @@ export function NavigationBar({
() => createNavigationBarStyles(theme, containerColor),
[theme, containerColor],
)
+ const floatingStyles = useMemo(
+ () => createFloatingNavigationBarStyles(theme, containerColor),
+ [theme, containerColor],
+ )
+
+ // Outside a `SafeAreaProvider` the floating bar has no insets to read, and
+ // it keeps the plain gap to the edges.
+ const insets = useContext(SafeAreaInsetsContext)
+ const insetBottomValue = insets?.bottom ?? 0
+ const insetLeft = insets?.left ?? 0
+ const insetRight = insets?.right ?? 0
+ const trackStyle = useMemo(
+ () => [
+ floatingStyles.track,
+ {
+ bottom: floatingBarBottom(insetBottomValue),
+ paddingLeft: FLOATING_BAR_GAP + insetLeft,
+ paddingRight: FLOATING_BAR_GAP + insetRight,
+ },
+ pointerEvents.boxNone,
+ ],
+ [floatingStyles.track, insetBottomValue, insetLeft, insetRight],
+ )
const isControlled = value !== undefined
const [selfValue, setSelfValue] = useState(
@@ -61,7 +94,7 @@ export function NavigationBar({
// read on web.
const row = (
)
+ if (floating) {
+ return (
+
+
+ {row}
+
+
+ )
+ }
+
return (
{
+/**
+ * The form of the bar. `'docked'` is the MD3 navigation bar: an 80dp band
+ * along the bottom edge, with a label under each icon. `'floating'` is a
+ * capsule that floats over the content, clear of the window edges, with an
+ * icon for each destination and no visible label.
+ */
+export type NavigationBarVariant = 'docked' | 'floating'
+
+interface NavigationBarCommonProps extends Omit {
/** The destinations to render. MD3 recommends 3–5. */
items: NavigationBarItem[]
/** Value of the active destination (controlled). */
@@ -53,18 +61,6 @@ export interface NavigationBarProps extends Omit {
defaultValue?: string
/** Called with the value of the destination that was pressed. */
onValueChange?: (value: string) => void
- /**
- * When destination labels are shown.
- * @default 'always'
- */
- labelVisibility?: NavigationBarLabelVisibility
- /**
- * When `true`, wraps the row in a SafeAreaView that adds the bottom inset
- * below the bar's 80dp, so it clears the home indicator. Leave it off when
- * an ancestor (e.g. `Layout` with a `bottom` edge) already handles it.
- * @default false
- */
- insetBottom?: boolean
/**
* Override the bar background.
* @default surfaceContainer
@@ -87,11 +83,9 @@ export interface NavigationBarProps extends Omit {
*/
indicatorColor?: string
/**
- * Style applied to every destination's label `Text` — does not affect the
- * icons.
+ * Style applied to the bar container. On the floating bar, that is the
+ * capsule.
*/
- labelStyle?: StyleProp
- /** Style applied to the bar container. */
style?: StyleProp
/** Screen-reader label for the bar itself. */
accessibilityLabel?: string
@@ -101,3 +95,55 @@ export interface NavigationBarProps extends Omit {
*/
testID?: string
}
+
+/**
+ * The props that only one variant takes. The floating bar shows no label,
+ * and it reads the bottom inset by itself, so the type rejects
+ * `labelVisibility`, `labelStyle` and `insetBottom` on it.
+ */
+type NavigationBarVariantProps =
+ | {
+ /**
+ * The form of the bar.
+ * @default 'docked'
+ */
+ variant?: 'docked'
+ /**
+ * When destination labels are shown.
+ * @default 'always'
+ */
+ labelVisibility?: NavigationBarLabelVisibility
+ /**
+ * When `true`, wraps the row in a SafeAreaView that adds the bottom
+ * inset below the bar's 80dp, so it clears the home indicator. Leave
+ * it off when an ancestor (e.g. `Layout` with a `bottom` edge) already
+ * handles it.
+ * @default false
+ */
+ insetBottom?: boolean
+ /**
+ * Style applied to every destination's label `Text` — does not affect
+ * the icons.
+ */
+ labelStyle?: StyleProp
+ }
+ | {
+ /**
+ * A capsule that floats over the content. It places itself at the
+ * bottom of its parent, so make it the last child of a view that
+ * reaches the bottom edge of the window. It reads the safe-area
+ * insets from a `SafeAreaProvider`.
+ */
+ variant: 'floating'
+ labelVisibility?: never
+ insetBottom?: never
+ labelStyle?: never
+ }
+
+/**
+ * Props of the NavigationBar. `labelVisibility`, `insetBottom` and
+ * `labelStyle` apply to the docked bar only — see
+ * `NavigationBarVariantProps`.
+ */
+export type NavigationBarProps = NavigationBarCommonProps &
+ NavigationBarVariantProps
diff --git a/scripts/build-llms.ts b/scripts/build-llms.ts
index 75690d06..fa417cfa 100644
--- a/scripts/build-llms.ts
+++ b/scripts/build-llms.ts
@@ -1291,6 +1291,14 @@ import { NavigationBar } from '@rootnative/components/navigation-bar'
// (active icon+label), indicatorColor (pill), labelStyle. Disabled items stay
// at the MD3 38%.
+
+// Floating: a 64dp capsule over the content, icons only, 16dp from the window
+// edges. It places itself at the bottom of its parent and reads the safe-area
+// insets: a bottom inset of 40 or less (a gesture handle) goes under it.
+
+ {/* content: pad its end by 80 */}
+
+
\`\`\``,
'navigation-rail': `\`\`\`tsx
@@ -2003,9 +2011,34 @@ import { Grid } from '@rootnative/components/layout'
if (dirName === 'navigation-bar') {
let output = `### ${displayName}\n\n${example}\n\n`
- const propsIface = interfaces.find((i) => i.name === 'NavigationBarProps')
+ // `NavigationBarProps` is a type alias: the shared props come from
+ // `NavigationBarCommonProps`, and the props of one variant are described
+ // here, because a union arm's props are invisible to the parser.
+ const propsIface = interfaces.find(
+ (i) => i.name === 'NavigationBarCommonProps',
+ )
if (propsIface) {
- output += formatPropsSection(propsIface, typeAliases)
+ const section = formatPropsSection(propsIface, typeAliases)
+ const inheritsLine = '- Inherits `ViewProps` (except `children`)\n'
+ const variantProps =
+ "- `variant?: 'docked' | 'floating'` — Default: `'docked'`. " +
+ "`'docked'` is the MD3 navigation bar, an 80dp band along the bottom " +
+ "edge. `'floating'` is a 64dp capsule over the content, at most 480 " +
+ 'wide, 16dp from the window edges, with an icon for each destination ' +
+ 'and no visible label; the indicator fills the destination. It ' +
+ 'places itself at the bottom of its parent and reads the insets from ' +
+ 'a `SafeAreaProvider`: it floats over a bottom inset of 40 or less ' +
+ 'and stays 16dp above a taller one.\n' +
+ "- `labelVisibility?: 'always' | 'selected' | 'never'` — Default: " +
+ "`'always'`. When destination labels are shown. Docked only.\n" +
+ '- `insetBottom?: boolean` — Default: `false`. Adds the bottom ' +
+ "safe-area inset below the bar's 80dp. Leave it off when an ancestor " +
+ 'already handles the edge. Docked only.\n' +
+ '- `labelStyle?: StyleProp` — Style for every label `Text`. ' +
+ 'Docked only.\n'
+ output += section.includes(inheritsLine)
+ ? section.replace(inheritsLine, variantProps + inheritsLine)
+ : section + variantProps
output += '\n'
}