ComponentsNavDock
NavDock
Show navigation links in a compact icon dock.
On this page
Use this component from the local workspace package. Follow the setup guide first. A public npm package and registry are not available yet.
View registry filesImport the component into your page or component file.
import {
CollapseDock,
NavDock,
NavDockButton,
NavDockItem,
NavDockLink,
NavDockList,
NavDockSeparator,
NavDockSubmenu,
NavDockSubmenuContent,
NavDockSubmenuTrigger,
} from "@dethink/components";NavDock keeps destinations role-specific: anchors navigate, buttons switch local panels, and submenu triggers disclose related destinations.
Interactive playground
Live controls for placement, size, variant, motion preset, and title mode. Sweep the pointer across the dock to feel the continuous magnification and compare spring characters between presets.
Sweep the pointer along the dock to feel the continuous magnification, open the Team submenu, and switch motion presets to compare spring characters. The Support item shows the disabled state.
Show sourceexamples/navdock/playground.tsx
"use client";
import { useState } from "react";
import {
NavDock,
RadioGroup,
RadioGroupItem,
type NavDockItemData,
type NavDockMotion,
type NavDockPlacement,
type NavDockShowTitle,
type NavDockSize,
type NavDockVariant,
} from "@dethink/components";
import {
CalendarDays,
FolderKanban,
Inbox,
LayoutDashboard,
LifeBuoy,
Users,
} from "lucide-react";
const iconProps = {
"aria-hidden": true,
absoluteStrokeWidth: true,
strokeWidth: 2.1,
};
function ControlGroup<T extends string>({
label,
name,
onChange,
options,
value,
}: {
label: string;
name: string;
onChange: (value: T) => void;
options: readonly T[];
value: T;
}) {
return (
<fieldset className="min-w-0">
<legend className="text-muted-foreground mb-2 text-xs font-semibold tracking-wide uppercase">
{label}
</legend>
<RadioGroup
aria-label={label}
controlSize="sm"
name={name}
orientation="horizontal"
value={value}
onValueChange={(nextValue) => onChange(nextValue as T)}
>
{options.map((option) => (
<label
key={option}
className="text-muted-foreground has-checked:text-foreground inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium capitalize"
>
<RadioGroupItem value={option} />
{option}
</label>
))}
</RadioGroup>
</fieldset>
);
}
export function NavDockPlayground() {
const [placement, setPlacement] = useState<NavDockPlacement>("bottom");
const [size, setSize] = useState<NavDockSize>("md");
const [variant, setVariant] = useState<NavDockVariant>("glass");
const [motion, setMotion] = useState<NavDockMotion>("standard");
const [showTitle, setShowTitle] = useState<NavDockShowTitle>("hover");
const [currentPanel, setCurrentPanel] = useState("dashboard");
const items: NavDockItemData[] = [
{
icon: <LayoutDashboard {...iconProps} />,
onAction: () => setCurrentPanel("dashboard"),
title: "Dashboard",
value: "dashboard",
},
{
badge: 4,
icon: <Inbox {...iconProps} />,
onAction: () => setCurrentPanel("inbox"),
title: "Inbox",
value: "inbox",
},
{
icon: <FolderKanban {...iconProps} />,
onAction: () => setCurrentPanel("projects"),
title: "Projects",
value: "projects",
},
{
icon: <CalendarDays {...iconProps} />,
onAction: () => setCurrentPanel("calendar"),
title: "Calendar",
value: "calendar",
},
{
icon: <Users {...iconProps} />,
submenu: [
{ kind: "label", title: "Team", value: "team-label" },
{
description: "Invite and manage members.",
href: "#members",
title: "Members",
value: "members",
},
{
badge: 2,
description: "Pending role requests.",
href: "#requests",
title: "Requests",
value: "requests",
},
{ kind: "separator", value: "team-separator" },
{
external: true,
href: "https://example.com/directory",
title: "Company directory",
value: "directory",
},
],
title: "Team",
value: "team",
},
{
disabled: true,
disabledReason: "Support is offline right now.",
icon: <LifeBuoy {...iconProps} />,
onAction: () => setCurrentPanel("support"),
title: "Support",
value: "support",
},
];
const vertical = placement === "left" || placement === "right";
return (
<div className="grid gap-6">
<div className="flex flex-wrap gap-x-6 gap-y-4">
<ControlGroup
label="Placement"
name="navdock-playground-placement"
options={["bottom", "top", "left", "right"] as const}
value={placement}
onChange={setPlacement}
/>
<ControlGroup
label="Size"
name="navdock-playground-size"
options={["sm", "md", "lg"] as const}
value={size}
onChange={setSize}
/>
<ControlGroup
label="Variant"
name="navdock-playground-variant"
options={["default", "glass", "solid"] as const}
value={variant}
onChange={setVariant}
/>
<ControlGroup
label="Motion"
name="navdock-playground-motion"
options={["none", "subtle", "standard", "expressive"] as const}
value={motion}
onChange={setMotion}
/>
<ControlGroup
label="Titles"
name="navdock-playground-titles"
options={["never", "hover", "always"] as const}
value={showTitle}
onChange={setShowTitle}
/>
</div>
<div
className={`border-border bg-muted/25 flex min-h-[22rem] items-center rounded-lg border p-8 ${
vertical
? placement === "left"
? "justify-start"
: "justify-end"
: "justify-center"
} ${placement === "top" ? "items-start" : ""} ${
placement === "bottom" ? "items-end" : ""
}`}
>
<NavDock
aria-label="Playground navigation dock"
collapseMode="none"
currentValue={currentPanel}
items={items}
motion={motion}
placement={placement}
showTitle={showTitle}
size={size}
variant={variant}
/>
</div>
<p className="text-muted-foreground text-sm leading-6">
Sweep the pointer along the dock to feel the continuous magnification,
open the Team submenu, and switch motion presets to compare spring
characters. The Support item shows the disabled state.
</p>
</div>
);
}Product-area navigation
Route links, current matching from a pathname, a lightweight submenu, and external status access in a title-visible dock.
Route-derived current state can match exact pages or child routes without changing the dock interaction state.
Local workspace sections
In-page action items update local content with onAction while currentValue keeps the active panel visible.
Workspace health, activity, and recent owner changes.
Show sourceexamples/navdock/workspace-switcher.tsx
"use client";
import { useMemo, useState } from "react";
import { NavDock, type NavDockItemData } from "@dethink/components";
import {
Activity,
Gauge,
PanelsTopLeft,
Settings2,
Sparkles,
} from "lucide-react";
const iconProps = {
"aria-hidden": true,
absoluteStrokeWidth: true,
strokeWidth: 2.1,
};
const sections = [
{
value: "overview",
title: "Overview",
icon: <PanelsTopLeft {...iconProps} />,
metric: "42 active workspaces",
description: "Workspace health, activity, and recent owner changes.",
},
{
value: "runs",
title: "Runs",
icon: <Sparkles {...iconProps} />,
metric: "18 queued runs",
description: "In-page action items switch local panels without navigation.",
},
{
value: "activity",
title: "Activity",
icon: <Activity {...iconProps} />,
metric: "7 alerts today",
description: "The current panel is still announced with aria-current.",
},
{
value: "usage",
title: "Usage",
icon: <Gauge {...iconProps} />,
metric: "81% quota used",
description: "Badges and descriptions belong in the content, not the dock.",
},
{
value: "settings",
title: "Settings",
icon: <Settings2 {...iconProps} />,
metric: "Team managed",
description: "Local sections can keep the dock icon-first and compact.",
},
];
export function NavDockWorkspaceSwitcher() {
const [currentPanel, setCurrentPanel] = useState("overview");
const currentSection =
sections.find((section) => section.value === currentPanel) ?? sections[0];
const items = useMemo<NavDockItemData[]>(
() =>
sections.map((section) => ({
icon: section.icon,
onAction: () => setCurrentPanel(section.value),
title: section.title,
value: section.value,
})),
[],
);
return (
<div className="border-border bg-background grid gap-6 rounded-md border p-5">
<div className="flex justify-center">
<NavDock
aria-label="Workspace section navigation"
currentValue={currentPanel}
items={items}
showTitle="hover"
variant="default"
/>
</div>
<div className="border-border bg-muted/30 rounded-md border p-4">
<div className="text-foreground text-sm font-semibold">
{currentSection.metric}
</div>
<p className="text-muted-foreground mt-2 text-sm leading-6">
{currentSection.description}
</p>
</div>
</div>
);
}CardStack panel switcher
onAction handles the local action of flipping the CardStack to a panel, and currentValue marks that panel in primary โ the same current contract link items use when navigating between pages. Cycling the stack with its own controls keeps the dock in sync.
Show sourceexamples/navdock/card-stack-switcher.tsx
"use client";
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardStack,
CardTitle,
NavDock,
type NavDockItemData,
} from "@dethink/components";
import { BarChart3, CreditCard, Map, Users } from "lucide-react";
const iconProps = {
"aria-hidden": true,
absoluteStrokeWidth: true,
strokeWidth: 2.1,
};
const panels = [
{
value: "metrics",
title: "Metrics",
icon: <BarChart3 {...iconProps} />,
heading: "Weekly active teams",
detail: "Up 12% week over week",
body: "Activation is driven by the new onboarding checklist. Retention holds at 86% for teams that finish it.",
},
{
value: "roadmap",
title: "Roadmap",
icon: <Map {...iconProps} />,
heading: "Q3 delivery plan",
detail: "3 of 5 epics on track",
body: "Realtime collaboration ships next sprint. The audit-log epic is waiting on the retention policy decision.",
},
{
value: "team",
title: "Team",
icon: <Users {...iconProps} />,
heading: "Hiring pipeline",
detail: "2 offers out",
body: "Platform engineer and product designer offers are pending. Support rotation is fully staffed through August.",
},
{
value: "billing",
title: "Billing",
icon: <CreditCard {...iconProps} />,
heading: "Subscription health",
detail: "Net revenue retention 108%",
body: "Annual plan upgrades outpace churn. Two enterprise renewals close this month.",
},
];
export function NavDockCardStackSwitcher() {
const [activeIndex, setActiveIndex] = useState(0);
const items: NavDockItemData[] = panels.map((panel, index) => ({
icon: panel.icon,
onAction: () => setActiveIndex(index),
title: panel.title,
value: panel.value,
}));
return (
<div className="grid gap-2">
<CardStack
aria-label="Workspace panels"
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
showControls={false}
>
{panels.map((panel) => (
<Card key={panel.value}>
<CardHeader>
<CardTitle>{panel.heading}</CardTitle>
<CardDescription>{panel.detail}</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground text-sm leading-6">
{panel.body}
</CardContent>
</Card>
))}
</CardStack>
<div className="flex justify-center">
<NavDock
aria-label="Workspace panel navigation"
currentValue={panels[activeIndex].value}
items={items}
showTitle="hover"
variant="glass"
/>
</div>
</div>
);
}AI workspace quick access
Compound composition with icon-only access, a separator, local actions, route links, and a disclosure submenu.
Agent run queue
The dock stays compact while the main panel changes. Submenu items remain buttons and links rather than ARIA menu items.
12 runs waiting for review.
94% across the latest prompt set.
Show sourceexamples/navdock/ai-workspace.tsx
"use client";
import { useState } from "react";
import {
NavDock,
NavDockButton,
NavDockItem,
NavDockLink,
NavDockList,
NavDockSeparator,
NavDockSubmenu,
NavDockSubmenuContent,
NavDockSubmenuTrigger,
} from "@dethink/components";
import {
Bot,
BrainCircuit,
DatabaseZap,
FileSearch,
MessagesSquare,
SlidersHorizontal,
SquareTerminal,
} from "lucide-react";
const iconProps = {
"aria-hidden": true,
absoluteStrokeWidth: true,
strokeWidth: 2.1,
};
export function NavDockAiWorkspace() {
const [currentTool, setCurrentTool] = useState("agent");
return (
<div className="border-border bg-muted/20 flex min-h-[24rem] overflow-hidden rounded-md border">
<div className="border-border bg-background flex w-24 items-center justify-center border-r">
<NavDock
aria-label="AI workspace quick access"
currentValue={currentTool}
placement="left"
showTitle="never"
size="sm"
variant="glass"
>
<NavDockList>
<NavDockItem
icon={<Bot {...iconProps} />}
title="Agent runs"
value="agent"
>
<NavDockButton onAction={() => setCurrentTool("agent")} />
</NavDockItem>
<NavDockItem
icon={<MessagesSquare {...iconProps} />}
title="Prompts"
value="prompts"
>
<NavDockButton onAction={() => setCurrentTool("prompts")} />
</NavDockItem>
<NavDockItem
icon={<BrainCircuit {...iconProps} />}
title="Evaluations"
value="evals"
>
<NavDockButton onAction={() => setCurrentTool("evals")} />
</NavDockItem>
<NavDockSeparator />
<NavDockItem
icon={<SlidersHorizontal {...iconProps} />}
title="Controls"
value="controls"
>
<NavDockSubmenu>
<NavDockSubmenuTrigger />
<NavDockSubmenuContent>
<button
className="text-foreground hover:bg-muted focus-visible:ring-ring rounded-sm px-3 py-2 text-start text-sm outline-none focus-visible:ring-2"
type="button"
>
Model routing
</button>
<button
className="text-foreground hover:bg-muted focus-visible:ring-ring rounded-sm px-3 py-2 text-start text-sm outline-none focus-visible:ring-2"
type="button"
>
Cost limits
</button>
</NavDockSubmenuContent>
</NavDockSubmenu>
</NavDockItem>
<NavDockItem
icon={<SquareTerminal {...iconProps} />}
title="Console"
value="console"
>
<NavDockLink href="/ai/console" />
</NavDockItem>
</NavDockList>
</NavDock>
</div>
<div className="grid flex-1 content-center gap-4 p-6">
<div className="border-border bg-background text-muted-foreground inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium">
<DatabaseZap {...iconProps} className="size-4" />
AI workspace
</div>
<div>
<h3 className="font-heading text-foreground text-xl font-semibold">
{currentTool === "agent" ? "Agent run queue" : "Workspace panel"}
</h3>
<p className="text-muted-foreground mt-2 max-w-lg text-sm leading-6">
The dock stays compact while the main panel changes. Submenu items
remain buttons and links rather than ARIA menu items.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="border-border bg-background rounded-md border p-4">
<FileSearch {...iconProps} className="text-primary mb-3 size-5" />
<div className="text-foreground text-sm font-semibold">
Review queue
</div>
<p className="text-muted-foreground mt-1 text-sm leading-6">
12 runs waiting for review.
</p>
</div>
<div className="border-border bg-background rounded-md border p-4">
<BrainCircuit {...iconProps} className="text-primary mb-3 size-5" />
<div className="text-foreground text-sm font-semibold">
Eval pass rate
</div>
<p className="text-muted-foreground mt-1 text-sm leading-6">
94% across the latest prompt set.
</p>
</div>
</div>
</div>
</div>
);
}Mobile collapsed dock
CollapseDock makes the compact trigger explicit and expands into an absolute vertical rail with spring motion.
home
Show sourceexamples/navdock/mobile-collapsed.tsx
"use client";
import { useMemo, useState } from "react";
import {
CollapseDock,
NavDock,
type NavDockItemData,
} from "@dethink/components";
import {
Bell,
LayoutGrid,
Menu,
MessageCircle,
Search,
Settings2,
Sparkles,
} from "lucide-react";
const iconProps = {
"aria-hidden": true,
absoluteStrokeWidth: true,
strokeWidth: 2.1,
};
const mobileSections = [
{
value: "home",
title: "Home",
icon: <LayoutGrid {...iconProps} />,
},
{
value: "search",
title: "Search",
icon: <Search {...iconProps} />,
},
{
value: "assist",
title: "Assist",
icon: <Sparkles {...iconProps} />,
},
{
value: "messages",
title: "Messages",
icon: <MessageCircle {...iconProps} />,
},
{
value: "alerts",
title: "Alerts",
icon: <Bell {...iconProps} />,
},
{
value: "settings",
title: "Settings",
icon: <Settings2 {...iconProps} />,
},
];
export function NavDockMobileCollapsed() {
const [currentPanel, setCurrentPanel] = useState("home");
const items = useMemo<NavDockItemData[]>(
() =>
mobileSections.map((section) => ({
icon: section.icon,
onAction: () => setCurrentPanel(section.value),
title: section.title,
value: section.value,
})),
[],
);
return (
<div className="border-border bg-background mx-auto grid min-h-[32rem] max-w-sm overflow-hidden rounded-[2rem] border p-5 shadow-sm">
<div className="border-border bg-muted/25 relative overflow-hidden rounded-[1.5rem] border p-5">
<NavDock
aria-label="Mobile workspace dock"
currentValue={currentPanel}
motion="expressive"
placement="bottom"
position="absolute"
showTitle="never"
size="sm"
variant="glass"
>
<CollapseDock
collapseLabel="Close workspace dock"
defaultCollapsed
items={items}
triggerIcon={<Menu {...iconProps} />}
triggerLabel="Open workspace dock"
/>
</NavDock>
<div className="grid min-h-[24rem] content-start">
<div className="border-border bg-background/90 rounded-lg border p-4">
<div className="text-foreground text-sm font-semibold">
Current panel
</div>
<p className="text-muted-foreground mt-1 text-sm capitalize">
{currentPanel}
</p>
</div>
</div>
</div>
</div>
);
}| Prop | What it does | Default |
|---|---|---|
placement / position"bottom" | "top" | "left" | "right" / "static" | "absolute" | "fixed" | Physical edge and positioning mode. Static preserves layout; absolute and fixed opt into overlay-style placement with edge offsets. | "bottom" / "static" |
variant / size"default" | "glass" | "solid" / "sm" | "md" | "lg" | Token-backed dock surface and item scale. Provider density still controls the surrounding spacing rhythm. | "default" / "md" |
showTitle"never" | "hover" | "always" | Visual title behavior. Hidden titles remain accessible names for icon-only docks and collapsed rails. | "hover" |
motion"none" | "subtle" | "standard" | "expressive" | Motion/react preset for magnification, title reveal, submenu presence, and collapsed rail open/close. | "standard" |
itemsNavDockItemData[] | Recommended data API. Root items are role-exclusive links, in-page actions, or submenu disclosures. | Not set |
value / defaultValue / onValueChangestring | null / string | null / (value) => void | Controlled or uncontrolled active interaction value used for hover/focus magnification and title reveal. | Not set |
openValue / defaultOpenValue / onOpenValueChangestring | null / string | null / (value) => void | Controlled or uncontrolled submenu disclosure state, separate from active hover/focus state. | Not set |
currentValue / isItemCurrentstring / (item, context) => boolean | 'page' | 'location' | undefined | Route-derived current matching that maps to aria-current without replacing interaction or submenu state. | Not set |
CollapseDockcompound child | Explicit collapsed-mode slot. It renders icon-only content, opens as a vertical rail, and keeps the trigger blended into the dock. | Not set |
NavDockSeparatorcompound child | Decorative dock-axis divider for grouping compound NavDock items. NavDockDivider is kept as an alias. | Not set |