Skip to content

NavOverflow

Navigation

A single-line navigation that moves items which do not fit into More.

Responsive workspace navigationCompact navigationCurrent hidden sectionDisabled overflow entry
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

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
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.

NavOverflow props
PropType / DefaultDescription
labelstring

Default: required

Accessible navigation name.
itemsOverflowNavItem[]

Default: required

Unique id, label, href, and optional disabled flag.
currentIdstring

Default: —

Current section id.
onNavigate(id: string) => void

Default: —

Observe a chosen available link.
classNamestring

Default: —

Extend the container with semantic theme utilities.

Build with these too