Navigation

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

The install command below needs a licence to run; without one it returns 401.

How to install Pro components →See the plans →

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add "https://webberui.com/r/dynamic-island-nav.en.json?t=<install token>"

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

Active edited usageDefault props
<DynamicIslandNav/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add "https://webberui.com/r/dynamic-island-nav.en.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

PropTypeDefaultDescription
childrenReact.ReactNode—A set of <DynamicIslandNavItem> children
valuestring—The controlled selected value
defaultValuestringfirst itemInitial 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
labelstring'Navigation'Accessible name for the tablist
classNamestring—Appended to the pill body's className

DynamicIslandNavItem

PropTypeDefaultDescription
valuestring—Unique identifier for the item
iconReact.ReactNode—Icon on the left, constrained to 16px
labelstring—Item name, used as the aria-label and expanded into view when selected
disabledbooleanfalseDisable this item so it cannot be selected or focused
childrenReact.ReactNode—Panel content that expands downward when selected; when provided the item is expandable, otherwise it just switches
classNamestring—Appended to this item's button className

DynamicIslandNavItem is a declarative configuration component and renders no node of its own; the buttons and the expanding panel are all rendered by DynamicIslandNav, which reads its props — so always use it as a direct child of DynamicIslandNav.

How it works

  • The selected pill slides: the selected background is a motion.span carrying a shared layoutId; when you switch items, the old one unmounts and the new one mounts with the same layoutId, so Motion springs it between buttons while morphing its width in sync.
  • Name expansion: only the selected item renders its name, animating width from 0 to auto (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 from 0 to auto, 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, the key bound 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 is role="tab" with aria-selected; expandable items carry aria-expanded and aria-controls, and the expanded panel is role="region" pointing back at its item with aria-labelledby.
  • It uses a roving tabindex: only the selected item has tabIndex=0, ← / → move focus between items and select them, Home / End jump 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 always aria-hidden, and a separate role="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.