NavMenu
Preview
Code
ts
import NavMenu from '@/components/layout/NavMenu';src/components/layout/NavMenu.tsx
AI prompt
text
Build a documentation-style navigation menu component in React + TypeScript + Tailwind CSS.
Plain text links under section headings — deliberately NO icon per row. Once a menu is long enough to need grouping (a component index, an API reference, a settings tree) the icons all end up the same generic glyph and the eye reads the text anyway; text-only rows scan faster and let a section hold twenty items. Two orientations and an optional filter.
## Look — vertical (default)
- `<nav>` as a flex column, `gap-6` between sections. Section heading: `mb-1.5 px-2 text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500`. Items in a `<ul>`.
- Row: a link, `flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-[13px]`; the label truncates; an optional badge sits at the right in `text-[10px] text-slate-400`.
- Inactive: `text-slate-600 dark:text-slate-400`, hover `bg-slate-100 text-slate-900` (dark `bg-slate-800 text-slate-100`).
- Active: a filled pill — `bg-indigo-50 font-semibold text-indigo-700`, dark `bg-indigo-500/10 text-indigo-300`.
## Look — horizontal (for a top or bottom bar)
Not the vertical menu turned sideways:
- One row, `flex items-center gap-4`. Each section is inline: its heading shrinks to an inline group label (same 10px uppercase style) followed by its links at `gap-3`. Groups are separated by a 1px × 16px vertical rule (`bg-slate-200 dark:bg-slate-700`) instead of vertical space.
- Links: `whitespace-nowrap border-b-2 px-1 py-1`, transparent border, hover only darkens the text. Active is an UNDERLINE — `border-indigo-600 font-semibold text-indigo-700`, dark `border-indigo-400 text-indigo-300`. A pill in a one-row bar reads as a button, not as "you are here".
- `showSectionLabels={false}` drops the group labels (a flat bar); it works in vertical mode too.
## Filter
- Opt-in: below ~20 items it is chrome to skip past. `filterable` makes the menu render its own field at the top (full width; `w-44` at the start of a horizontal bar): the standard text input with a 14px lucide `Search` icon inset at the left (`pl-8`), placeholder "Filter…", which is also its aria-label.
- `filter` (a string) is the other arrangement: the query comes from outside (e.g. a header search) and the menu renders NO input. If both are passed the external query wins.
- Match: trimmed, case-insensitive substring on the item label. Sections left with no items drop out entirely — a heading with nothing under it is noise. With nothing left, show one `text-[11px] text-slate-400` line (`emptyLabel`, "No matches").
## API
- `sections: { label: string; items: { label: string; href: string; badge?: ReactNode }[] }[]`
- `activeHref?: string` — matched EXACTLY, not by prefix (a menu is a list of destinations).
- `orientation?: 'vertical' | 'horizontal'` (vertical), `filterable?` (false), `filter?: string`, `filterPlaceholder` ('Filter…'), `emptyLabel` ('No matches'), `showSectionLabels` (true), `className`.
- Links use the app's client-side link component (e.g. Next.js `Link`); the active one gets `aria-current="page"`.
## Demo
Sections "Workspace" (Overview, Members with badge "8", Projects) and "Settings" (Billing, Roles, Integrations). Two 224px bordered boxes side by side — one with `activeHref="/members"`, one `filterable` with `activeHref="/roles"` — and the same sections as a horizontal bar in an `overflow-x-auto` strip.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { Search } from 'lucide-react';
import { cn } from '@/lib/cn';
export type NavItem = { label: string; href: string; badge?: React.ReactNode };
export type NavSection = { label: string; items: NavItem[] };
export type NavOrientation = 'vertical' | 'horizontal';
/**
* A documentation-style menu: plain text links under section headings.
*
* Deliberately not the icon-rail `Sidebar`. Once a menu is long enough to need
* grouping — a component index, an API reference, a settings tree — an icon per
* row stops helping, because they all end up being the same generic glyph and
* the eye reads the text anyway. Text-only rows scan faster and let a section
* hold twenty items without becoming a wall of squares.
*
* `horizontal` is for a top or bottom bar.
* It is not just the vertical menu turned sideways: headings shrink to inline
* group labels, groups get a divider between them instead of vertical space, and
* the active item is marked with an underline rather than a filled pill —
* a pill in a one-row bar reads as a button, not as "you are here".
*
* The filter is opt-in. Below roughly twenty items it is chrome you have to skip
* past; above it, scrolling to find a name is the slow part.
*/
export default function NavMenu({
sections,
activeHref,
orientation = 'vertical',
filter,
filterable = false,
filterPlaceholder = 'Filter…',
emptyLabel = 'No matches',
showSectionLabels = true,
className,
}: {
sections: NavSection[];
/** Current route. Matched exactly — a menu is a list of destinations, not prefixes. */
activeHref?: string;
orientation?: NavOrientation;
/**
* Externally-driven filter query.
*
* When set, the menu filters on it and renders NO input of its own — the
* field lives wherever the caller put it, typically the header. `filterable`
* is the other arrangement: the menu owns both the field and the state.
* Passing both is not an error; the external query wins, because a component
* filtering on a value it cannot see the field for is the more confusing half.
*/
filter?: string;
filterable?: boolean;
filterPlaceholder?: string;
emptyLabel?: string;
/** Turn off to render a flat bar with no group labels at all. */
showSectionLabels?: boolean;
className?: string;
}) {
const [internalQuery, setInternalQuery] = useState('');
const controlled = filter !== undefined;
const query = controlled ? filter : internalQuery;
const horizontal = orientation === 'horizontal';
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return sections;
return sections
.map((s) => ({ ...s, items: s.items.filter((i) => i.label.toLowerCase().includes(q)) }))
// A heading with nothing under it is noise, so empty sections drop out.
.filter((s) => s.items.length > 0);
}, [sections, query]);
const link = (item: NavItem) => {
const active = item.href === activeHref;
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex items-center gap-2 text-[13px] transition-colors',
horizontal ? 'whitespace-nowrap border-b-2 px-1 py-1' : 'justify-between rounded-md px-2 py-1.5',
horizontal && active && 'border-indigo-600 font-semibold text-indigo-700 dark:border-indigo-400 dark:text-indigo-300',
horizontal && !active && 'border-transparent text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100',
!horizontal && active && 'bg-indigo-50 font-semibold text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300',
!horizontal && !active && 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100',
)}
>
<span className={horizontal ? undefined : 'truncate'}>{item.label}</span>
{item.badge != null && (
<span className="shrink-0 text-[10px] text-slate-400">{item.badge}</span>
)}
</Link>
</li>
);
};
const filterField = !controlled && filterable && (
<div className={cn('relative', horizontal ? 'w-44 shrink-0' : '')}>
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
value={internalQuery}
onChange={(e) => setInternalQuery(e.target.value)}
placeholder={filterPlaceholder}
aria-label={filterPlaceholder}
className="field-input pl-8"
/>
</div>
);
if (horizontal) {
return (
<nav className={cn('flex items-center gap-4', className)}>
{filterField}
{visible.length === 0 ? (
<p className="text-[11px] text-slate-400">{emptyLabel}</p>
) : (
visible.map((section, i) => (
<div key={section.label} className="flex items-center gap-3">
{i > 0 && <span className="h-4 w-px shrink-0 bg-slate-200 dark:bg-slate-700" aria-hidden />}
{showSectionLabels && (
<span className="shrink-0 text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{section.label}
</span>
)}
<ul className="flex items-center gap-3">{section.items.map(link)}</ul>
</div>
))
)}
</nav>
);
}
return (
<nav className={cn('flex flex-col gap-6', className)}>
{filterField}
{visible.length === 0 ? (
<p className="px-2 text-[11px] text-slate-400">{emptyLabel}</p>
) : (
visible.map((section) => (
<div key={section.label}>
{showSectionLabels && (
<p className="mb-1.5 px-2 text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{section.label}
</p>
)}
<ul>{section.items.map(link)}</ul>
</div>
))
)}
</nav>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
sections* | NavSection[] | — | |
activeHref | string | — | Current route. Matched exactly — a menu is a list of destinations, not prefixes. |
orientation | NavOrientation | 'vertical' | |
filter | string | — | Externally-driven filter query. When set, the menu filters on it and renders NO input of its own — the field lives wherever the caller put it, typically the header. `filterable` is the other arrangement: the menu owns both the field and the state. Passing both is not an error; the external query wins, because a component filtering on a value it cannot see the field for is the more confusing half. |
filterable | boolean | false | |
filterPlaceholder | string | 'Filter…' | |
emptyLabel | string | 'No matches' | |
showSectionLabels | boolean | true | Turn off to render a flat bar with no group labels at all. |
className | string | — |