WebberUI

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.

How to install Pro components →See the plans →

Loading preview…
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

PropTypeDefaultDescription
childrenReact.ReactNodeA set of <DynamicIslandNavItem> children
valuestringThe controlled selected value
defaultValuestringfirst itemInitial selected value in uncontrolled mode
onValueChange(value: string) => voidCallback 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
classNamestringAppended to the pill body's className

DynamicIslandNavItem

PropTypeDefaultDescription
valuestringUnique identifier for the item
iconReact.ReactNodeIcon on the left, constrained to 16px
labelstringItem name, used as the aria-label and expanded into view when selected
disabledbooleanfalseDisable this item so it cannot be selected or focused
childrenReact.ReactNodePanel content that expands downward when selected; when provided the item is expandable, otherwise it just switches
classNamestringAppended 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.

On this page