NavOverflow
NavigationA single-line navigation that moves items which do not fit into More.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { NavOverflow } from "@/components/ui/nav-overflow";
import { useState } from "react";
const items = [
{ id: "overview", label: "Overview", href: "#overview" },
{ id: "activity", label: "Activity", href: "#activity" },
{ id: "people", label: "People", href: "#people" },
{ id: "billing", label: "Billing", href: "#billing" },
{ id: "integrations", label: "Integrations", href: "#integrations" },
{ id: "settings", label: "Settings", href: "#settings" },
{
id: "enterprise",
label: "Enterprise",
href: "#enterprise",
disabled: true,
},
];
export default function NavOverflowExample() {
const [current, setCurrent] = useState("overview");
return (
<div className="w-full max-w-xl space-y-7">
<div>
<h3 className="font-semibold">Room for every section</h3>
<p className="mt-1 text-sm text-muted-foreground">
Items that do not fit move into More. Try the mobile preview.
</p>
</div>
<div className="rounded-xl border bg-card p-3">
<NavOverflow
label="Workspace sections"
items={items}
currentId={current}
onNavigate={setCurrent}
/>
</div>
<div className="max-w-64 rounded-xl border bg-card p-3">
<NavOverflow
label="Compact workspace sections"
items={items}
currentId={current}
onNavigate={setCurrent}
/>
</div>
<p role="status" className="text-xs text-muted-foreground">
Current section: {current}
</p>
</div>
);
}Usage
Provide stable item ids, readable labels, and navigation hrefs. ResizeObserver measures available width and real label widths; excess entries move into a Radix dropdown, including the current section when needed. onNavigate is an optional observer and does not prevent ordinary link navigation. Disabled items stay visible but unavailable. Use a container wide enough for the More trigger. A separate hidden inert measurement row never becomes a second navigation surface.
import { NavOverflow } from "@/components/ui/nav-overflow";Shipped as editable source in src/components/ui/nav-overflow.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/nav-overflow.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { DropdownMenu } from "radix-ui";
import { ChevronDown, Check } from "lucide-react";
import { cn } from "@dashboardpack/core/lib/utils";
import { useDocumentDirection } from "@/lib/use-document-direction";
export interface OverflowNavItem {
id: string;
label: string;
href: string;
disabled?: boolean;
}
export interface NavOverflowProps {
label: string;
items: OverflowNavItem[];
currentId?: string;
onNavigate?: (id: string) => void;
className?: string;
}
export function NavOverflow({
label,
items,
currentId,
onNavigate,
className,
}: NavOverflowProps) {
const direction = useDocumentDirection();
const root = useRef<HTMLElement>(null);
const measure = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(items.length);
useEffect(() => {
const container = root.current;
const sizing = measure.current;
if (!container || !sizing) return;
const update = () => {
const widths = Array.from(
sizing.querySelectorAll<HTMLElement>("[data-nav-measure]"),
).map((element) => element.getBoundingClientRect().width);
const available = container.clientWidth;
const total =
widths.reduce((sum, width) => sum + width, 0) +
Math.max(0, widths.length - 1) * 4;
if (total <= available) {
setVisible(items.length);
return;
}
const more =
sizing
.querySelector<HTMLElement>("[data-more]")
?.getBoundingClientRect().width ?? 88;
let used = more;
let count = 0;
for (const width of widths) {
if (used + 4 + width > available) break;
used += 4 + width;
count++;
}
setVisible(count);
};
const observer = new ResizeObserver(update);
observer.observe(container);
document.fonts.ready.then(update);
return () => observer.disconnect();
}, [items]);
const count = Math.min(visible, items.length);
const style =
"rounded-md px-3 py-2 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring";
return (
<nav
ref={root}
aria-label={label}
className={cn("relative min-w-0", className)}
>
<div
ref={measure}
aria-hidden
inert
className="pointer-events-none invisible absolute h-0 overflow-hidden whitespace-nowrap"
>
<div className="flex gap-1">
{items.map((item) => (
<span key={item.id} data-nav-measure className={style}>
{item.label}
</span>
))}
<span
data-more
className={cn(style, "inline-flex items-center gap-1 border")}
>
More
<ChevronDown className="size-3.5" />
</span>
</div>
</div>
<ul className="flex h-10 items-center gap-1 overflow-hidden whitespace-nowrap">
{items.slice(0, count).map((item) => (
<li key={item.id}>
{item.disabled ? (
<span aria-disabled className={cn(style, "opacity-50")}>
{item.label}
</span>
) : (
<a
href={item.href}
aria-current={item.id === currentId ? "page" : undefined}
onClick={() => onNavigate?.(item.id)}
className={cn(
style,
item.id === currentId
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{item.label}
</a>
)}
</li>
))}
{count < items.length && (
<li>
<DropdownMenu.Root dir={direction}>
<DropdownMenu.Trigger
className={cn(
style,
"inline-flex items-center gap-1 border bg-background",
)}
aria-label="More sections"
>
More
<ChevronDown className="size-3.5" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
sideOffset={6}
className="z-50 min-w-44 max-w-[calc(100vw-2rem)] rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg"
>
{items.slice(count).map((item) =>
item.disabled ? (
<DropdownMenu.Item
key={item.id}
disabled
className="rounded px-3 py-2 text-sm opacity-50"
>
{item.label}
</DropdownMenu.Item>
) : (
<DropdownMenu.Item key={item.id} asChild>
<a
href={item.href}
aria-current={
item.id === currentId ? "page" : undefined
}
onClick={() => onNavigate?.(item.id)}
className="flex items-center justify-between gap-4 rounded px-3 py-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground"
>
{item.label}
{item.id === currentId && (
<Check aria-hidden className="size-3.5" />
)}
</a>
</DropdownMenu.Item>
),
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</li>
)}
</ul>
{!items.length && (
<p className="text-sm text-muted-foreground">No navigation items.</p>
)}
</nav>
);
}Required helper: src/lib/use-document-direction.ts
Copy this helper alongside the component. It keeps keyboard navigation aligned with the page direction and supports live RTL changes.
"use client";
import { useSyncExternalStore } from "react";
function subscribe(notify: () => void) {
const observer = new MutationObserver(notify);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["dir"],
});
return () => observer.disconnect();
}
function snapshot(): "ltr" | "rtl" {
return document.documentElement.dir === "rtl" ? "rtl" : "ltr";
}
/** Keep headless controls aligned with the document, including live RTL changes. */
export function useDocumentDirection() {
return useSyncExternalStore(subscribe, snapshot, () => "ltr" as const);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Accessible navigation name. |
| items | OverflowNavItem[]Default: required | Unique id, label, href, and optional disabled flag. |
| currentId | stringDefault: — | Current section id. |
| onNavigate | (id: string) => voidDefault: — | Observe a chosen available link. |
| className | stringDefault: — | Extend the container with semantic theme utilities. |