Dynamic Island Nav
A Dynamic Island-style pill navigation — the selected pill slides and morphs between items, the selected item's name expands from 0, and one more click morphs the island downward into a panel.
This is a WebberUI Pro component
Free during the launch campaign: sign up or sign in, then hit “Copy install command” in the preview above and it installs straight away — no payment, no credit card. The command below returns 401 while you are signed out.
npx shadcn@latest add "https://webberui.com/r/dynamic-island-nav.json?t=<install token>"Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<DynamicIslandNav />
Installation
npx shadcn@latest add "https://webberui.com/r/dynamic-island-nav.json?t=<install token>"Or, once registries are configured in components.json, install it as @webberui/dynamic-island-nav.
Usage
Wrap a set of <DynamicIslandNavItem> elements in <DynamicIslandNav>. Each item only needs value, icon, and label; if you provide children, that item can be clicked once more after being selected to expand the island panel downward.
import { Bell, Home, Search } from "lucide-react";
import {
DynamicIslandNav,
DynamicIslandNavItem,
} from "@/components/ui/dynamic-island-nav";
<DynamicIslandNav defaultValue="home">
<DynamicIslandNavItem value="home" icon={<Home aria-hidden />} label="Home" />
<DynamicIslandNavItem
value="search"
icon={<Search aria-hidden />}
label="Search"
/>
<DynamicIslandNavItem value="alerts" icon={<Bell aria-hidden />} label="Alerts">
<p>You have 3 new notifications</p>
</DynamicIslandNavItem>
</DynamicIslandNav>controlled mode
Passing value and onValueChange puts it in controlled mode, so the selection state is managed from the outside:
const [value, setValue] = React.useState("home");
<DynamicIslandNav value={value} onValueChange={setValue}>
{/* …items… */}
</DynamicIslandNav>Fixed to the bottom
position="fixed-bottom" fixes the whole island centered at the bottom of the window (z-50), with the outer corridor not taking pointer events — a good fit for app-level floating navigation:
<DynamicIslandNav position="fixed-bottom" defaultValue="home">
{/* …items… */}
</DynamicIslandNav>Props
DynamicIslandNav
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | A set of <DynamicIslandNavItem> children |
value | string | — | The controlled selected value |
defaultValue | string | first item | Initial selected value in uncontrolled mode |
onValueChange | (value: string) => void | — | Callback fired when the selected value changes |
position | 'inline' | 'fixed-bottom' | 'inline' | inline follows the document flow; fixed-bottom is fixed centered at the bottom of the window |
label | string | 'Navigation' | Accessible name for the tablist |
className | string | — | Appended to the pill body's className |
DynamicIslandNavItem
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Unique identifier for the item |
icon | React.ReactNode | — | Icon on the left, constrained to 16px |
label | string | — | Item name, used as the aria-label and expanded into view when selected |
disabled | boolean | false | Disable this item so it cannot be selected or focused |
children | React.ReactNode | — | Panel content that expands downward when selected; when provided the item is expandable, otherwise it just switches |
className | string | — | Appended to this item's button className |
DynamicIslandNavItemis a declarative configuration component and renders no node of its own; the buttons and the expanding panel are all rendered byDynamicIslandNav, which reads its props — so always use it as a direct child ofDynamicIslandNav.
How it works
- The selected pill slides: the selected background is a
motion.spancarrying a sharedlayoutId; when you switch items, the old one unmounts and the new one mounts with the samelayoutId, so Motion springs it between buttons while morphing its width in sync. - Name expansion: only the selected item renders its name, animating width from
0toauto(AnimatePresence), which morphs the whole pill left and right; unselected items keep just their icon. - Island expansion: click again once an item "with
children" is selected and the panel height morphs from0toauto, the parent pill grows naturally with the content, and the arrow on the right rotates 180° to signal the expanded state; when you switch to a different item, thekeybound to the selected value replays the panel's reveal. - Expansion rules: switching to a new item expands it automatically if it has a panel and collapses if it does not; clicking the current item again toggles its expanded state.
Accessibility
- The pill row is
role="tablist"and each item isrole="tab"witharia-selected; expandable items carryaria-expandedandaria-controls, and the expanded panel isrole="region"pointing back at its item witharia-labelledby. - It uses a roving tabindex: only the selected item has
tabIndex=0,←/→move focus between items and select them,Home/Endjump to the first and last, and disabled items are skipped automatically. - Every item carries an
aria-label(even when unselected and its name is not expanded), so screen readers can still read the full name; animated nodes are alwaysaria-hidden, and a separaterole="status"plain-text region announces the selected and expanded state. - When the user has "reduce motion" enabled at the system level, the pill slide, name expansion, and panel morph all go to zero, degrading to instant switching with a brief fade in and out and no sense of displacement.