Component
Navbar with User Menu
A navbar containing a user/account menu built from the same dropdown architecture as every other variant: profile and settings links, a separator, a destructive-adjacent sign-out action, full keyboard support, Escape, outside-pointer close, and focus restoration.
- Component
- React
- TSX
- Tailwind CSS
- MIT
What it does
Capabilities
- Semantic nav landmark
- Real anchors
- Aria-current active item
- Mobile disclosure
- Account dropdown
- Avatar trigger
- Sign-out action
- Separator groups
- Aria-current='page' active item
- Aria-expanded + aria-controls toggle
- Aria-haspopup dropdown triggers
- Focus restoration on close
Preview
Live component
9:41 100%
devsnips.site/library/react/components/navbar/navbar-with-user-menu/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Navbar/navbar-with-user-menu React/Components/Navbar/navbar-with-user-menu import {
createContext,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import type {
AnchorHTMLAttributes,
ButtonHTMLAttributes,
HTMLAttributes,
KeyboardEvent as ReactKeyboardEvent,
ReactNode,
RefObject,
} from "react";
/**
* DevSnips React Navbar — with user menu.
*
* A navbar containing a user/account menu built from the shared dropdown
* architecture: profile and settings links (real anchors), a separator, a
* sign-out action (real button), full keyboard support, Escape /
* outside-pointer close, and focus restoration. Built entirely from the
* shared Navbar primitives; see the `navbar` reference for the full system
* documentation.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export type NavbarBreakpoint = "sm" | "md" | "lg";
export type NavbarVariant = "default" | "transparent";
export type NavbarSectionAlign = "start" | "center" | "end";
export type NavbarActionVariant = "primary" | "outline" | "ghost";
export type NavbarMobilePlacement = "panel" | "side";
export type NavbarDropdownPlacement = "bottom-start" | "bottom-end";
/* ------------------------------------------------------------------------ */
/* Shared class constants (single visual system) */
/* ------------------------------------------------------------------------ */
// Navigation height 48–56px (DESIGN_TOKENS §22): the bar is h-14 (56px).
const NAV_CLASSES =
"border-b border-[var(--ds-color-border)] bg-[var(--ds-color-surface)]";
const NAV_TRANSPARENT_CLASSES = "border-b border-transparent bg-transparent";
const BAR_CLASSES = "relative mx-auto flex h-14 max-w-6xl items-center gap-2 px-4 sm:px-6";
const BRAND_CLASSES =
"inline-flex shrink-0 items-center gap-2 rounded-[var(--ds-radius-sm)] text-sm font-semibold leading-5 tracking-tight text-[var(--ds-color-foreground)] transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const SECTION_ALIGN_CLASSES: Record<NavbarSectionAlign, string> = {
start: "flex min-w-0 items-center gap-1",
center: "flex min-w-0 flex-1 items-center justify-center gap-1",
end: "ml-auto flex min-w-0 items-center justify-end gap-2",
};
const LINK_LAYOUT =
"inline-flex items-center gap-1.5 rounded-[var(--ds-radius-sm)] px-2.5 py-1.5 text-sm font-medium leading-5 transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const LINK_MOBILE_LAYOUT =
"flex w-full items-center gap-1.5 rounded-[var(--ds-radius-sm)] px-3 py-2 text-sm font-medium leading-5 transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const LINK_IDLE =
"text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)]";
const LINK_ACTIVE = "bg-[var(--ds-color-surface-active)] text-[var(--ds-color-foreground)]";
const LINK_DISABLED = "pointer-events-none text-[var(--ds-color-muted-foreground)] opacity-50";
const ACTION_LAYOUT =
"inline-flex h-9 items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] px-3 text-sm font-medium leading-5 transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const ACTION_VARIANT_CLASSES: Record<NavbarActionVariant, string> = {
primary:
"bg-[var(--ds-color-primary)] text-[var(--ds-color-primary-foreground)] shadow-[var(--ds-shadow-xs)] hover:opacity-90",
outline:
"border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] hover:bg-[var(--ds-color-surface-hover)]",
ghost:
"text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)]",
};
const TOGGLE_CLASSES =
"ml-auto inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const MOBILE_PANEL_CLASSES =
"absolute inset-x-0 top-full z-40 max-h-[calc(100dvh-4rem)] overflow-y-auto border-b border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] shadow-[var(--ds-shadow-md)]";
const MOBILE_SIDE_CLASSES =
"fixed inset-y-0 left-0 z-50 flex w-72 max-w-[calc(100vw-4rem)] flex-col border-r border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] shadow-[var(--ds-shadow-lg)]";
const MOBILE_OVERLAY_CLASSES =
"fixed inset-0 z-40 bg-[var(--ds-color-overlay)]";
const MOBILE_CONTENT_CLASSES = "flex flex-col gap-1 px-4 py-4";
const DROPDOWN_PANEL_CLASSES =
"absolute z-40 mt-1.5 max-h-[min(24rem,calc(100dvh-6rem))] min-w-[13rem] max-w-[calc(100vw-1.5rem)] overflow-y-auto rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] p-1 shadow-[var(--ds-shadow-md)]";
const DROPDOWN_ITEM_LAYOUT =
"flex w-full items-center gap-2 rounded-[var(--ds-radius-sm)] px-2 py-1.5 text-left text-[13px] leading-5 transition-colors duration-150 ease-out focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const DIVIDER_CLASSES = "mx-1 my-1 h-px bg-[var(--ds-color-border)]";
const CONTENT_VISIBLE_CLASSES: Record<NavbarBreakpoint, string> = {
sm: "hidden sm:flex",
md: "hidden md:flex",
lg: "hidden lg:flex",
};
const BELOW_BREAKPOINT_CLASSES: Record<NavbarBreakpoint, string> = {
sm: "sm:hidden",
md: "md:hidden",
lg: "lg:hidden",
};
const DROPDOWN_PLACEMENT_CLASSES: Record<NavbarDropdownPlacement, string> = {
"bottom-start": "left-0 top-full",
"bottom-end": "right-0 top-full",
};
function MenuIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
</svg>
);
}
function CloseIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
function ChevronDown({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function ExternalLinkIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
);
}
/* ------------------------------------------------------------------------ */
/* Navbar (root) context */
/* ------------------------------------------------------------------------ */
interface NavbarContextValue {
mobileOpen: boolean;
toggleMobile(): void;
closeMobile(): void;
toggleRef: RefObject<HTMLButtonElement>;
mobileRegionId: string;
breakpoint: NavbarBreakpoint;
}
const NavbarContext = createContext<NavbarContextValue | null>(null);
function useNavbar(component: string): NavbarContextValue {
const context = useContext(NavbarContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Navbar>.`);
}
return context;
}
/** True when rendered inside `<NavbarMobileContent>` — links stack full-width. */
const NavbarMobileAreaContext = createContext(false);
export interface NavbarProps {
/** Mobile-menu open state (controlled). */
open?: boolean;
/** Initial mobile-menu open state (uncontrolled). */
defaultOpen?: boolean;
/** Called whenever the mobile menu requests to open or close. */
onOpenChange?: (open: boolean) => void;
/** Accessible name of the navigation landmark. */
label?: string;
/** Responsive breakpoint below which the desktop content collapses. */
breakpoint?: NavbarBreakpoint;
/** `transparent` removes the surface + bottom border for use over a page header. */
variant?: NavbarVariant;
className?: string;
children?: ReactNode;
}
export function Navbar({
open,
defaultOpen = false,
onOpenChange,
label = "Main",
breakpoint = "md",
variant = "default",
className,
children,
}: NavbarProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const rootRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const reactId = useId();
const mobileRegionId = `ds-navbar-mobile${reactId}`;
function requestOpen(next: boolean) {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
}
const context: NavbarContextValue = {
mobileOpen: actualOpen,
toggleMobile() {
requestOpen(!actualOpen);
},
closeMobile() {
if (actualOpen) requestOpen(false);
},
toggleRef,
mobileRegionId,
breakpoint,
};
// Escape closes the mobile navigation from anywhere in the document.
useEffect(() => {
if (!actualOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
requestOpen(false);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpen]);
// A pointer down outside the navbar closes an open mobile navigation.
useEffect(() => {
if (!actualOpen) return;
function onPointerDown(event: PointerEvent) {
const root = rootRef.current;
if (root && !root.contains(event.target as Node)) requestOpen(false);
}
document.addEventListener("pointerdown", onPointerDown, true);
return () => document.removeEventListener("pointerdown", onPointerDown, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpen]);
// Focus restoration: when the mobile region unmounts, focus would be
// stranded on <body> — return it to the toggle. Closing via the toggle
// itself keeps focus on the toggle (no-op), and Tab moves on naturally.
const wasOpenRef = useRef(false);
useEffect(() => {
if (wasOpenRef.current && !actualOpen) {
const active = document.activeElement;
const toggle = toggleRef.current;
if (toggle && toggle.isConnected && (active === null || active === document.body)) {
toggle.focus();
}
}
wasOpenRef.current = actualOpen;
}, [actualOpen]);
return (
<NavbarContext.Provider value={context}>
<nav
ref={rootRef}
aria-label={label}
className={cx(variant === "transparent" ? NAV_TRANSPARENT_CLASSES : NAV_CLASSES, className)}
>
<div className={BAR_CLASSES}>{children}</div>
</nav>
</NavbarContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* Brand / content regions */
/* ------------------------------------------------------------------------ */
export interface NavbarBrandProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
/** Home URL the brand points at. */
href?: string;
children?: ReactNode;
}
export function NavbarBrand({ href = "/", className, children, ...rest }: NavbarBrandProps) {
return (
<a href={href} className={cx(BRAND_CLASSES, className)} {...rest}>
{children}
</a>
);
}
export interface NavbarContentProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function NavbarContent({ className, children, ...rest }: NavbarContentProps) {
const context = useNavbar("NavbarContent");
return (
<div
className={cx("min-w-0 flex-1 items-center gap-4", CONTENT_VISIBLE_CLASSES[context.breakpoint], className)}
{...rest}
>
{children}
</div>
);
}
export interface NavbarSectionProps extends HTMLAttributes<HTMLUListElement> {
/** Region of the bar: `start` (after the brand), `center`, or `end` (trailing). */
align?: NavbarSectionAlign;
children?: ReactNode;
}
export function NavbarSection({ align = "start", className, children, ...rest }: NavbarSectionProps) {
return (
<ul role="list" className={cx(SECTION_ALIGN_CLASSES[align], className)} {...rest}>
{children}
</ul>
);
}
export interface NavbarItemProps extends HTMLAttributes<HTMLLIElement> {
children?: ReactNode;
}
export function NavbarItem({ className, children, ...rest }: NavbarItemProps) {
return (
<li className={cx("flex min-w-0", className)} {...rest}>
{children}
</li>
);
}
/* ------------------------------------------------------------------------ */
/* Links and actions */
/* ------------------------------------------------------------------------ */
export interface NavbarLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
href?: string;
/** Marks the current page: `aria-current="page"` + the active surface. */
active?: boolean;
/** Opens in a new tab (`target="_blank" rel="noreferrer"`) with an indicator. */
external?: boolean;
/** Renders a non-interactive `aria-disabled` span — never a dead anchor. */
disabled?: boolean;
children?: ReactNode;
}
export function NavbarLink({
href = "#",
active = false,
external = false,
disabled = false,
onClick,
className,
children,
...rest
}: NavbarLinkProps) {
const context = useNavbar("NavbarLink");
const inMobileArea = useContext(NavbarMobileAreaContext);
const classes = cx(
inMobileArea ? LINK_MOBILE_LAYOUT : LINK_LAYOUT,
disabled ? LINK_DISABLED : active ? LINK_ACTIVE : LINK_IDLE,
className,
);
if (disabled) {
return (
<span aria-disabled="true" className={classes}>
{children}
</span>
);
}
return (
<a
href={href}
aria-current={active ? "page" : undefined}
target={external ? "_blank" : undefined}
rel={external ? "noreferrer" : undefined}
onClick={(event) => {
onClick?.(event);
context.closeMobile();
}}
className={classes}
{...rest}
>
<span className="min-w-0 truncate">{children}</span>
{external ? (
<>
<ExternalLinkIcon className="size-3.5 shrink-0 text-[var(--ds-color-muted-foreground)]" />
<span className="sr-only">(opens in a new tab)</span>
</>
) : null}
</a>
);
}
interface NavbarActionBaseProps {
/** Visual weight of the action. */
variant?: NavbarActionVariant;
className?: string;
children?: ReactNode;
}
export type NavbarActionProps =
| (NavbarActionBaseProps & { href: string } & Omit<
AnchorHTMLAttributes<HTMLAnchorElement>,
"href"
>)
| (NavbarActionBaseProps & { href?: undefined } & ButtonHTMLAttributes<HTMLButtonElement>);
export function NavbarAction(props: NavbarActionProps) {
if (props.href !== undefined) {
const { variant = "primary", href, className, children, ...rest } = props;
return (
<a href={href} className={cx(ACTION_LAYOUT, ACTION_VARIANT_CLASSES[variant], className)} {...rest}>
{children}
</a>
);
}
const { variant = "primary", className, children, type = "button", ...rest } = props;
return (
<button type={type} className={cx(ACTION_LAYOUT, ACTION_VARIANT_CLASSES[variant], className)} {...rest}>
{children}
</button>
);
}
/* ------------------------------------------------------------------------ */
/* Mobile navigation */
/* ------------------------------------------------------------------------ */
export interface NavbarToggleProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Accessible name override (defaults to "Open/Close navigation menu"). */
label?: string;
}
export function NavbarToggle({ label, className, onClick, ...rest }: NavbarToggleProps) {
const context = useNavbar("NavbarToggle");
// The first mounted toggle (the bar's) permanently owns the shared ref, so
// a second toggle (e.g. a close button inside a side panel) never leaves
// the focus-restore target pointing at an unmounted element.
function claimRef(node: HTMLButtonElement | null) {
if (node && !context.toggleRef.current) {
(context.toggleRef as { current: HTMLButtonElement | null }).current = node;
}
}
return (
<button
ref={claimRef}
type="button"
aria-expanded={context.mobileOpen}
aria-controls={context.mobileRegionId}
aria-label={label ?? (context.mobileOpen ? "Close navigation menu" : "Open navigation menu")}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
context.toggleMobile();
}}
className={cx(TOGGLE_CLASSES, BELOW_BREAKPOINT_CLASSES[context.breakpoint], className)}
{...rest}
>
{context.mobileOpen ? <CloseIcon className="size-5" /> : <MenuIcon className="size-5" />}
</button>
);
}
export interface NavbarMobileProps {
/**
* `panel` (default): a full-width region disclosed directly under the bar.
* `side`: a compact side panel with an overlay — Escape and overlay
* interaction close it, body scroll is locked while open, and focus moves
* into the panel (focus is NOT trapped: this is a navigation disclosure,
* not a modal dialog).
*/
placement?: NavbarMobilePlacement;
className?: string;
children?: ReactNode;
}
export function NavbarMobile({ placement = "panel", className, children }: NavbarMobileProps) {
const context = useNavbar("NavbarMobile");
const panelRef = useRef<HTMLDivElement>(null);
const open = context.mobileOpen;
// Side panel only: move focus into the panel on open and lock body scroll
// (with scrollbar-width compensation so the page does not shift).
useEffect(() => {
if (!open || placement !== "side") return;
const panel = panelRef.current;
const first = panel?.querySelector<HTMLElement>("a[href], button:not([disabled])");
first?.focus();
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
const previousOverflow = document.body.style.overflow;
const previousPaddingRight = document.body.style.paddingRight;
document.body.style.overflow = "hidden";
if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
return () => {
document.body.style.overflow = previousOverflow;
document.body.style.paddingRight = previousPaddingRight;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, placement]);
if (!open) return null;
if (placement === "side") {
return (
<>
<div
aria-hidden="true"
data-ds-navbar-overlay=""
className={cx(MOBILE_OVERLAY_CLASSES, BELOW_BREAKPOINT_CLASSES[context.breakpoint])}
onPointerDown={() => context.closeMobile()}
/>
<div
ref={panelRef}
id={context.mobileRegionId}
className={cx(MOBILE_SIDE_CLASSES, BELOW_BREAKPOINT_CLASSES[context.breakpoint], className)}
>
{children}
</div>
</>
);
}
return (
<div
ref={panelRef}
id={context.mobileRegionId}
className={cx(MOBILE_PANEL_CLASSES, BELOW_BREAKPOINT_CLASSES[context.breakpoint], className)}
>
{children}
</div>
);
}
export interface NavbarMobileContentProps extends HTMLAttributes<HTMLUListElement> {
children?: ReactNode;
}
export function NavbarMobileContent({ className, children, ...rest }: NavbarMobileContentProps) {
return (
<NavbarMobileAreaContext.Provider value={true}>
<ul role="list" className={cx(MOBILE_CONTENT_CLASSES, className)} {...rest}>
{children}
</ul>
</NavbarMobileAreaContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* Navigation dropdown (disclosure pattern — links stay real anchors) */
/* ------------------------------------------------------------------------ */
interface NavbarDropdownContextValue {
open: boolean;
initialFocus: "first" | "last";
requestOpen(next: boolean, focusTarget?: "first" | "last"): void;
closeMenu(options?: { refocus?: boolean }): void;
triggerRef: RefObject<HTMLButtonElement>;
rootRef: RefObject<HTMLDivElement>;
triggerId: string;
contentId: string;
placement: NavbarDropdownPlacement;
}
const NavbarDropdownContext = createContext<NavbarDropdownContextValue | null>(null);
function useNavbarDropdown(component: string): NavbarDropdownContextValue {
const context = useContext(NavbarDropdownContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <NavbarDropdown>.`);
}
return context;
}
/** Focusable dropdown items owned directly by `content`. */
function dropdownItems(content: HTMLElement): HTMLElement[] {
return Array.from(
content.querySelectorAll<HTMLElement>("a[href], button"),
).filter(
(el) =>
el.closest("[data-ds-navbar-dropdown-content]") === content &&
!el.hasAttribute("disabled") &&
el.getAttribute("aria-disabled") !== "true",
);
}
function focusDropdownItem(items: HTMLElement[], index: number): void {
if (items.length === 0) return;
const wrapped = ((index % items.length) + items.length) % items.length;
items[wrapped].focus();
}
export interface NavbarDropdownProps {
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
/** Alignment of the panel relative to the trigger; flips to stay in the viewport. */
placement?: NavbarDropdownPlacement;
className?: string;
children?: ReactNode;
}
export function NavbarDropdown({
defaultOpen = false,
placement = "bottom-start",
className,
children,
}: NavbarDropdownProps) {
const [open, setOpen] = useState(defaultOpen);
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
const triggerRef = useRef<HTMLButtonElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const reactId = useId();
const triggerId = `ds-navbar-dd-trigger${reactId}`;
const contentId = `ds-navbar-dd-content${reactId}`;
function requestOpen(next: boolean, focusTarget: "first" | "last" = "first") {
setInitialFocus(focusTarget);
setOpen(next);
}
function closeMenu(options?: { refocus?: boolean }) {
const refocus = options?.refocus ?? true;
setOpen(false);
if (refocus) triggerRef.current?.focus();
}
// A pointer down outside the dropdown closes it without stealing focus.
useEffect(() => {
if (!open) return;
function onPointerDown(event: PointerEvent) {
const root = rootRef.current;
if (root && !root.contains(event.target as Node)) closeMenu({ refocus: false });
}
document.addEventListener("pointerdown", onPointerDown, true);
return () => document.removeEventListener("pointerdown", onPointerDown, true);
}, [open]);
const context: NavbarDropdownContextValue = {
open,
initialFocus,
requestOpen,
closeMenu,
triggerRef,
rootRef,
triggerId,
contentId,
placement,
};
return (
<NavbarDropdownContext.Provider value={context}>
<div ref={rootRef} className={cx("relative flex min-w-0", className)}>
{children}
</div>
</NavbarDropdownContext.Provider>
);
}
export interface NavbarDropdownTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Meaningful leading icon (rendered aria-hidden). */
icon?: ReactNode;
children?: ReactNode;
}
export function NavbarDropdownTrigger({
icon,
className,
children,
onClick,
onKeyDown,
...rest
}: NavbarDropdownTriggerProps) {
const context = useNavbarDropdown("NavbarDropdownTrigger");
function handleKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "ArrowDown") {
event.preventDefault();
context.requestOpen(true, "first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
context.requestOpen(true, "last");
}
}
return (
<button
ref={context.triggerRef}
type="button"
id={context.triggerId}
aria-haspopup="true"
aria-expanded={context.open}
aria-controls={context.contentId}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
context.requestOpen(!context.open);
}}
onKeyDown={handleKeyDown}
className={cx(
LINK_LAYOUT,
LINK_IDLE,
"aria-expanded:bg-[var(--ds-color-surface-hover)] aria-expanded:text-[var(--ds-color-foreground)]",
className,
)}
{...rest}
>
{icon ? (
<span aria-hidden="true" className="inline-flex shrink-0 [&_svg]:size-3.5">
{icon}
</span>
) : null}
<span className="min-w-0 truncate">{children}</span>
<ChevronDown
className={cx(
"size-3.5 shrink-0 text-[var(--ds-color-muted-foreground)] transition-transform duration-150 ease-out motion-reduce:transition-none",
context.open && "rotate-180",
)}
/>
</button>
);
}
export interface NavbarDropdownContentProps extends HTMLAttributes<HTMLDivElement> {
/** Explicit accessible name; otherwise the panel is labelled by its trigger. */
"aria-label"?: string;
children?: ReactNode;
}
export function NavbarDropdownContent({
onKeyDown,
className,
children,
...rest
}: NavbarDropdownContentProps) {
const context = useNavbarDropdown("NavbarDropdownContent");
const contentRef = useRef<HTMLDivElement>(null);
const [resolved, setResolved] = useState<NavbarDropdownPlacement>(context.placement);
const [measured, setMeasured] = useState(false);
const open = context.open;
// Move focus into the panel when it opens (first item, or last when the
// trigger was invoked with ArrowUp).
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
const items = dropdownItems(node);
focusDropdownItem(items, context.initialFocus === "last" ? items.length - 1 : 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Measure against the viewport and flip the horizontal alignment when the
// preferred side would overflow. Runs before paint; the panel stays
// `invisible` until the first measurement so the flip never flashes.
useLayoutEffect(() => {
if (!open) {
setMeasured(false);
setResolved(context.placement);
return;
}
const node = contentRef.current;
const trigger = context.triggerRef.current;
if (!node || !trigger) return;
const t = trigger.getBoundingClientRect();
const c = node.getBoundingClientRect();
const margin = 8;
let align: "start" | "end" = context.placement === "bottom-end" ? "end" : "start";
if (align === "start" && t.left + c.width > window.innerWidth - margin && t.right - c.width >= margin) {
align = "end";
} else if (align === "end" && t.right - c.width < margin && t.left + c.width <= window.innerWidth - margin) {
align = "start";
}
setResolved(align === "end" ? "bottom-end" : "bottom-start");
setMeasured(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, context.placement]);
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const node = contentRef.current;
if (!node) return;
const items = dropdownItems(node);
const current = items.indexOf(document.activeElement as HTMLElement);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
event.stopPropagation();
focusDropdownItem(items, current + 1);
break;
case "ArrowUp":
event.preventDefault();
event.stopPropagation();
focusDropdownItem(items, current - 1);
break;
case "Home":
event.preventDefault();
event.stopPropagation();
focusDropdownItem(items, 0);
break;
case "End":
event.preventDefault();
event.stopPropagation();
focusDropdownItem(items, items.length - 1);
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
context.closeMenu();
break;
case "Tab":
// Let focus leave naturally; close without stealing it back.
context.closeMenu({ refocus: false });
break;
default:
break;
}
}
if (!open) return null;
return (
<div
ref={contentRef}
id={context.contentId}
data-ds-navbar-dropdown-content=""
aria-labelledby={rest["aria-label"] ? undefined : context.triggerId}
onKeyDown={handleKeyDown}
className={cx(
DROPDOWN_PANEL_CLASSES,
DROPDOWN_PLACEMENT_CLASSES[resolved],
!measured && "invisible",
className,
)}
{...rest}
>
{children}
</div>
);
}
export interface NavbarDropdownItemProps {
/** Navigation target. When omitted, the item renders a `<button>` action. */
href?: string;
/** Marks the current page: `aria-current="page"` + the active surface. */
active?: boolean;
/** Opens in a new tab (`target="_blank" rel="noreferrer"`) with an indicator. */
external?: boolean;
/** Renders a non-interactive `aria-disabled` span — skipped by arrow keys. */
disabled?: boolean;
/** Meaningful leading icon (rendered aria-hidden). */
icon?: ReactNode;
/** Called when the item is activated, before the dropdown closes. */
onSelect?: () => void;
/** Accessible name override (e.g. icon-forward items). */
"aria-label"?: string;
className?: string;
children?: ReactNode;
}
export function NavbarDropdownItem({
href,
active = false,
external = false,
disabled = false,
icon,
onSelect,
className,
children,
...rest
}: NavbarDropdownItemProps) {
const context = useNavbarDropdown("NavbarDropdownItem");
const classes = cx(
DROPDOWN_ITEM_LAYOUT,
disabled ? LINK_DISABLED : active ? LINK_ACTIVE : LINK_IDLE,
className,
);
const iconSlot = icon ? (
<span
aria-hidden="true"
className={cx(
"inline-flex shrink-0 [&_svg]:size-4",
active ? "text-current" : "text-[var(--ds-color-muted-foreground)]",
)}
>
{icon}
</span>
) : null;
const label = <span className="min-w-0 flex-1 truncate">{children}</span>;
const externalMarker = external ? (
<>
<ExternalLinkIcon className="size-3.5 shrink-0 text-[var(--ds-color-muted-foreground)]" />
<span className="sr-only">(opens in a new tab)</span>
</>
) : null;
if (disabled) {
return (
<span aria-disabled="true" className={classes}>
{iconSlot}
{label}
</span>
);
}
if (href !== undefined) {
return (
<a
href={href}
aria-current={active ? "page" : undefined}
target={external ? "_blank" : undefined}
rel={external ? "noreferrer" : undefined}
onClick={() => {
onSelect?.();
context.closeMenu();
}}
className={classes}
{...rest}
>
{iconSlot}
{label}
{externalMarker}
</a>
);
}
return (
<button
type="button"
onClick={() => {
onSelect?.();
context.closeMenu();
}}
className={classes}
{...rest}
>
{iconSlot}
{label}
</button>
);
}
export interface NavbarDividerProps extends HTMLAttributes<HTMLDivElement> {
children?: undefined;
}
export function NavbarDivider({ className, ...rest }: NavbarDividerProps) {
return (
<div
role="separator"
aria-orientation="horizontal"
className={cx(DIVIDER_CLASSES, className)}
{...rest}
/>
);
} /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import {
createContext,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState
} from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const NAV_CLASSES = "border-b border-[var(--ds-color-border)] bg-[var(--ds-color-surface)]";
const NAV_TRANSPARENT_CLASSES = "border-b border-transparent bg-transparent";
const BAR_CLASSES = "relative mx-auto flex h-14 max-w-6xl items-center gap-2 px-4 sm:px-6";
const BRAND_CLASSES = "inline-flex shrink-0 items-center gap-2 rounded-[var(--ds-radius-sm)] text-sm font-semibold leading-5 tracking-tight text-[var(--ds-color-foreground)] transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const SECTION_ALIGN_CLASSES = {
start: "flex min-w-0 items-center gap-1",
center: "flex min-w-0 flex-1 items-center justify-center gap-1",
end: "ml-auto flex min-w-0 items-center justify-end gap-2"
};
const LINK_LAYOUT = "inline-flex items-center gap-1.5 rounded-[var(--ds-radius-sm)] px-2.5 py-1.5 text-sm font-medium leading-5 transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const LINK_MOBILE_LAYOUT = "flex w-full items-center gap-1.5 rounded-[var(--ds-radius-sm)] px-3 py-2 text-sm font-medium leading-5 transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const LINK_IDLE = "text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)]";
const LINK_ACTIVE = "bg-[var(--ds-color-surface-active)] text-[var(--ds-color-foreground)]";
const LINK_DISABLED = "pointer-events-none text-[var(--ds-color-muted-foreground)] opacity-50";
const ACTION_LAYOUT = "inline-flex h-9 items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] px-3 text-sm font-medium leading-5 transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const ACTION_VARIANT_CLASSES = {
primary: "bg-[var(--ds-color-primary)] text-[var(--ds-color-primary-foreground)] shadow-[var(--ds-shadow-xs)] hover:opacity-90",
outline: "border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] hover:bg-[var(--ds-color-surface-hover)]",
ghost: "text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)]"
};
const TOGGLE_CLASSES = "ml-auto inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const MOBILE_PANEL_CLASSES = "absolute inset-x-0 top-full z-40 max-h-[calc(100dvh-4rem)] overflow-y-auto border-b border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] shadow-[var(--ds-shadow-md)]";
const MOBILE_SIDE_CLASSES = "fixed inset-y-0 left-0 z-50 flex w-72 max-w-[calc(100vw-4rem)] flex-col border-r border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] shadow-[var(--ds-shadow-lg)]";
const MOBILE_OVERLAY_CLASSES = "fixed inset-0 z-40 bg-[var(--ds-color-overlay)]";
const MOBILE_CONTENT_CLASSES = "flex flex-col gap-1 px-4 py-4";
const DROPDOWN_PANEL_CLASSES = "absolute z-40 mt-1.5 max-h-[min(24rem,calc(100dvh-6rem))] min-w-[13rem] max-w-[calc(100vw-1.5rem)] overflow-y-auto rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] p-1 shadow-[var(--ds-shadow-md)]";
const DROPDOWN_ITEM_LAYOUT = "flex w-full items-center gap-2 rounded-[var(--ds-radius-sm)] px-2 py-1.5 text-left text-[13px] leading-5 transition-colors duration-150 ease-out focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const DIVIDER_CLASSES = "mx-1 my-1 h-px bg-[var(--ds-color-border)]";
const CONTENT_VISIBLE_CLASSES = {
sm: "hidden sm:flex",
md: "hidden md:flex",
lg: "hidden lg:flex"
};
const BELOW_BREAKPOINT_CLASSES = {
sm: "sm:hidden",
md: "md:hidden",
lg: "lg:hidden"
};
const DROPDOWN_PLACEMENT_CLASSES = {
"bottom-start": "left-0 top-full",
"bottom-end": "right-0 top-full"
};
function MenuIcon({ className }) {
return <svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
</svg>;
}
function CloseIcon({ className }) {
return <svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>;
}
function ChevronDown({ className }) {
return <svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m6 9 6 6 6-6" />
</svg>;
}
function ExternalLinkIcon({ className }) {
return <svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>;
}
const NavbarContext = createContext(null);
function useNavbar(component) {
const context = useContext(NavbarContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Navbar>.`);
}
return context;
}
const NavbarMobileAreaContext = createContext(false);
function Navbar({
open,
defaultOpen = false,
onOpenChange,
label = "Main",
breakpoint = "md",
variant = "default",
className,
children
}) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const rootRef = useRef(null);
const toggleRef = useRef(null);
const reactId = useId();
const mobileRegionId = `ds-navbar-mobile${reactId}`;
function requestOpen(next) {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
}
const context = {
mobileOpen: actualOpen,
toggleMobile() {
requestOpen(!actualOpen);
},
closeMobile() {
if (actualOpen) requestOpen(false);
},
toggleRef,
mobileRegionId,
breakpoint
};
useEffect(() => {
if (!actualOpen) return;
function onKeyDown(event) {
if (event.key === "Escape") {
event.preventDefault();
requestOpen(false);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [actualOpen]);
useEffect(() => {
if (!actualOpen) return;
function onPointerDown(event) {
const root = rootRef.current;
if (root && !root.contains(event.target)) requestOpen(false);
}
document.addEventListener("pointerdown", onPointerDown, true);
return () => document.removeEventListener("pointerdown", onPointerDown, true);
}, [actualOpen]);
const wasOpenRef = useRef(false);
useEffect(() => {
if (wasOpenRef.current && !actualOpen) {
const active = document.activeElement;
const toggle = toggleRef.current;
if (toggle && toggle.isConnected && (active === null || active === document.body)) {
toggle.focus();
}
}
wasOpenRef.current = actualOpen;
}, [actualOpen]);
return <NavbarContext.Provider value={context}>
<nav
ref={rootRef}
aria-label={label}
className={cx(variant === "transparent" ? NAV_TRANSPARENT_CLASSES : NAV_CLASSES, className)}
>
<div className={BAR_CLASSES}>{children}</div>
</nav>
</NavbarContext.Provider>;
}
function NavbarBrand({ href = "/", className, children, ...rest }) {
return <a href={href} className={cx(BRAND_CLASSES, className)} {...rest}>
{children}
</a>;
}
function NavbarContent({ className, children, ...rest }) {
const context = useNavbar("NavbarContent");
return <div
className={cx("min-w-0 flex-1 items-center gap-4", CONTENT_VISIBLE_CLASSES[context.breakpoint], className)}
{...rest}
>
{children}
</div>;
}
function NavbarSection({ align = "start", className, children, ...rest }) {
return <ul role="list" className={cx(SECTION_ALIGN_CLASSES[align], className)} {...rest}>
{children}
</ul>;
}
function NavbarItem({ className, children, ...rest }) {
return <li className={cx("flex min-w-0", className)} {...rest}>
{children}
</li>;
}
function NavbarLink({
href = "#",
active = false,
external = false,
disabled = false,
onClick,
className,
children,
...rest
}) {
const context = useNavbar("NavbarLink");
const inMobileArea = useContext(NavbarMobileAreaContext);
const classes = cx(
inMobileArea ? LINK_MOBILE_LAYOUT : LINK_LAYOUT,
disabled ? LINK_DISABLED : active ? LINK_ACTIVE : LINK_IDLE,
className
);
if (disabled) {
return <span aria-disabled="true" className={classes}>
{children}
</span>;
}
return <a
href={href}
aria-current={active ? "page" : undefined}
target={external ? "_blank" : undefined}
rel={external ? "noreferrer" : undefined}
onClick={(event) => {
onClick?.(event);
context.closeMobile();
}}
className={classes}
{...rest}
>
<span className="min-w-0 truncate">{children}</span>
{external ? <>
<ExternalLinkIcon className="size-3.5 shrink-0 text-[var(--ds-color-muted-foreground)]" />
<span className="sr-only">(opens in a new tab)</span>
</> : null}
</a>;
}
function NavbarAction(props) {
if (props.href !== undefined) {
const { variant: variant2 = "primary", href, className: className2, children: children2, ...rest2 } = props;
return <a href={href} className={cx(ACTION_LAYOUT, ACTION_VARIANT_CLASSES[variant2], className2)} {...rest2}>
{children2}
</a>;
}
const { variant = "primary", className, children, type = "button", ...rest } = props;
return <button type={type} className={cx(ACTION_LAYOUT, ACTION_VARIANT_CLASSES[variant], className)} {...rest}>
{children}
</button>;
}
function NavbarToggle({ label, className, onClick, ...rest }) {
const context = useNavbar("NavbarToggle");
function claimRef(node) {
if (node && !context.toggleRef.current) {
context.toggleRef.current = node;
}
}
return <button
ref={claimRef}
type="button"
aria-expanded={context.mobileOpen}
aria-controls={context.mobileRegionId}
aria-label={label ?? (context.mobileOpen ? "Close navigation menu" : "Open navigation menu")}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
context.toggleMobile();
}}
className={cx(TOGGLE_CLASSES, BELOW_BREAKPOINT_CLASSES[context.breakpoint], className)}
{...rest}
>
{context.mobileOpen ? <CloseIcon className="size-5" /> : <MenuIcon className="size-5" />}
</button>;
}
function NavbarMobile({ placement = "panel", className, children }) {
const context = useNavbar("NavbarMobile");
const panelRef = useRef(null);
const open = context.mobileOpen;
useEffect(() => {
if (!open || placement !== "side") return;
const panel = panelRef.current;
const first = panel?.querySelector("a[href], button:not([disabled])");
first?.focus();
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
const previousOverflow = document.body.style.overflow;
const previousPaddingRight = document.body.style.paddingRight;
document.body.style.overflow = "hidden";
if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
return () => {
document.body.style.overflow = previousOverflow;
document.body.style.paddingRight = previousPaddingRight;
};
}, [open, placement]);
if (!open) return null;
if (placement === "side") {
return <>
<div
aria-hidden="true"
data-ds-navbar-overlay=""
className={cx(MOBILE_OVERLAY_CLASSES, BELOW_BREAKPOINT_CLASSES[context.breakpoint])}
onPointerDown={() => context.closeMobile()}
/>
<div
ref={panelRef}
id={context.mobileRegionId}
className={cx(MOBILE_SIDE_CLASSES, BELOW_BREAKPOINT_CLASSES[context.breakpoint], className)}
>
{children}
</div>
</>;
}
return <div
ref={panelRef}
id={context.mobileRegionId}
className={cx(MOBILE_PANEL_CLASSES, BELOW_BREAKPOINT_CLASSES[context.breakpoint], className)}
>
{children}
</div>;
}
function NavbarMobileContent({ className, children, ...rest }) {
return <NavbarMobileAreaContext.Provider value={true}>
<ul role="list" className={cx(MOBILE_CONTENT_CLASSES, className)} {...rest}>
{children}
</ul>
</NavbarMobileAreaContext.Provider>;
}
const NavbarDropdownContext = createContext(null);
function useNavbarDropdown(component) {
const context = useContext(NavbarDropdownContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <NavbarDropdown>.`);
}
return context;
}
function dropdownItems(content) {
return Array.from(
content.querySelectorAll("a[href], button")
).filter(
(el) => el.closest("[data-ds-navbar-dropdown-content]") === content && !el.hasAttribute("disabled") && el.getAttribute("aria-disabled") !== "true"
);
}
function focusDropdownItem(items, index) {
if (items.length === 0) return;
const wrapped = (index % items.length + items.length) % items.length;
items[wrapped].focus();
}
function NavbarDropdown({
defaultOpen = false,
placement = "bottom-start",
className,
children
}) {
const [open, setOpen] = useState(defaultOpen);
const [initialFocus, setInitialFocus] = useState("first");
const triggerRef = useRef(null);
const rootRef = useRef(null);
const reactId = useId();
const triggerId = `ds-navbar-dd-trigger${reactId}`;
const contentId = `ds-navbar-dd-content${reactId}`;
function requestOpen(next, focusTarget = "first") {
setInitialFocus(focusTarget);
setOpen(next);
}
function closeMenu(options) {
const refocus = options?.refocus ?? true;
setOpen(false);
if (refocus) triggerRef.current?.focus();
}
useEffect(() => {
if (!open) return;
function onPointerDown(event) {
const root = rootRef.current;
if (root && !root.contains(event.target)) closeMenu({ refocus: false });
}
document.addEventListener("pointerdown", onPointerDown, true);
return () => document.removeEventListener("pointerdown", onPointerDown, true);
}, [open]);
const context = {
open,
initialFocus,
requestOpen,
closeMenu,
triggerRef,
rootRef,
triggerId,
contentId,
placement
};
return <NavbarDropdownContext.Provider value={context}>
<div ref={rootRef} className={cx("relative flex min-w-0", className)}>
{children}
</div>
</NavbarDropdownContext.Provider>;
}
function NavbarDropdownTrigger({
icon,
className,
children,
onClick,
onKeyDown,
...rest
}) {
const context = useNavbarDropdown("NavbarDropdownTrigger");
function handleKeyDown(event) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "ArrowDown") {
event.preventDefault();
context.requestOpen(true, "first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
context.requestOpen(true, "last");
}
}
return <button
ref={context.triggerRef}
type="button"
id={context.triggerId}
aria-haspopup="true"
aria-expanded={context.open}
aria-controls={context.contentId}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
context.requestOpen(!context.open);
}}
onKeyDown={handleKeyDown}
className={cx(
LINK_LAYOUT,
LINK_IDLE,
"aria-expanded:bg-[var(--ds-color-surface-hover)] aria-expanded:text-[var(--ds-color-foreground)]",
className
)}
{...rest}
>
{icon ? <span aria-hidden="true" className="inline-flex shrink-0 [&_svg]:size-3.5">
{icon}
</span> : null}
<span className="min-w-0 truncate">{children}</span>
<ChevronDown
className={cx(
"size-3.5 shrink-0 text-[var(--ds-color-muted-foreground)] transition-transform duration-150 ease-out motion-reduce:transition-none",
context.open && "rotate-180"
)}
/>
</button>;
}
function NavbarDropdownContent({
onKeyDown,
className,
children,
...rest
}) {
const context = useNavbarDropdown("NavbarDropdownContent");
const contentRef = useRef(null);
const [resolved, setResolved] = useState(context.placement);
const [measured, setMeasured] = useState(false);
const open = context.open;
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
const items = dropdownItems(node);
focusDropdownItem(items, context.initialFocus === "last" ? items.length - 1 : 0);
}, [open]);
useLayoutEffect(() => {
if (!open) {
setMeasured(false);
setResolved(context.placement);
return;
}
const node = contentRef.current;
const trigger = context.triggerRef.current;
if (!node || !trigger) return;
const t = trigger.getBoundingClientRect();
const c = node.getBoundingClientRect();
const margin = 8;
let align = context.placement === "bottom-end" ? "end" : "start";
if (align === "start" && t.left + c.width > window.innerWidth - margin && t.right - c.width >= margin) {
align = "end";
} else if (align === "end" && t.right - c.width < margin && t.left + c.width <= window.innerWidth - margin) {
align = "start";
}
setResolved(align === "end" ? "bottom-end" : "bottom-start");
setMeasured(true);
}, [open, context.placement]);
function handleKeyDown(event) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const node = contentRef.current;
if (!node) return;
const items = dropdownItems(node);
const current = items.indexOf(document.activeElement);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
event.stopPropagation();
focusDropdownItem(items, current + 1);
break;
case "ArrowUp":
event.preventDefault();
event.stopPropagation();
focusDropdownItem(items, current - 1);
break;
case "Home":
event.preventDefault();
event.stopPropagation();
focusDropdownItem(items, 0);
break;
case "End":
event.preventDefault();
event.stopPropagation();
focusDropdownItem(items, items.length - 1);
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
context.closeMenu();
break;
case "Tab":
context.closeMenu({ refocus: false });
break;
default:
break;
}
}
if (!open) return null;
return <div
ref={contentRef}
id={context.contentId}
data-ds-navbar-dropdown-content=""
aria-labelledby={rest["aria-label"] ? undefined : context.triggerId}
onKeyDown={handleKeyDown}
className={cx(
DROPDOWN_PANEL_CLASSES,
DROPDOWN_PLACEMENT_CLASSES[resolved],
!measured && "invisible",
className
)}
{...rest}
>
{children}
</div>;
}
function NavbarDropdownItem({
href,
active = false,
external = false,
disabled = false,
icon,
onSelect,
className,
children,
...rest
}) {
const context = useNavbarDropdown("NavbarDropdownItem");
const classes = cx(
DROPDOWN_ITEM_LAYOUT,
disabled ? LINK_DISABLED : active ? LINK_ACTIVE : LINK_IDLE,
className
);
const iconSlot = icon ? <span
aria-hidden="true"
className={cx(
"inline-flex shrink-0 [&_svg]:size-4",
active ? "text-current" : "text-[var(--ds-color-muted-foreground)]"
)}
>
{icon}
</span> : null;
const label = <span className="min-w-0 flex-1 truncate">{children}</span>;
const externalMarker = external ? <>
<ExternalLinkIcon className="size-3.5 shrink-0 text-[var(--ds-color-muted-foreground)]" />
<span className="sr-only">(opens in a new tab)</span>
</> : null;
if (disabled) {
return <span aria-disabled="true" className={classes}>
{iconSlot}
{label}
</span>;
}
if (href !== undefined) {
return <a
href={href}
aria-current={active ? "page" : undefined}
target={external ? "_blank" : undefined}
rel={external ? "noreferrer" : undefined}
onClick={() => {
onSelect?.();
context.closeMenu();
}}
className={classes}
{...rest}
>
{iconSlot}
{label}
{externalMarker}
</a>;
}
return <button
type="button"
onClick={() => {
onSelect?.();
context.closeMenu();
}}
className={classes}
{...rest}
>
{iconSlot}
{label}
</button>;
}
function NavbarDivider({ className, ...rest }) {
return <div
role="separator"
aria-orientation="horizontal"
className={cx(DIVIDER_CLASSES, className)}
{...rest}
/>;
}
export { Navbar, NavbarBrand, NavbarContent, NavbarSection, NavbarItem, NavbarLink, NavbarAction, NavbarToggle, NavbarMobile, NavbarMobileContent, NavbarDropdown, NavbarDropdownTrigger, NavbarDropdownContent, NavbarDropdownItem, NavbarDivider }; # Navbar with User Menu
A navbar containing a user/account menu built from the same dropdown architecture as every other variant: profile and settings links, a separator, a destructive-adjacent sign-out action, full keyboard support, Escape, outside-pointer close, and focus restoration.
## Installation
Copy `code.tsx` (TypeScript) or `code.jsx` (plain JavaScript) into your project — it is a single self-contained module with no dependencies beyond React. Make sure your app loads Tailwind CSS and the DevSnips `--ds-*` design tokens (see [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md)); the component consumes the tokens through Tailwind arbitrary values such as `bg-[var(--ds-color-surface)]`. No component-specific CSS file is required.
## Usage
```tsx
import {
Navbar, NavbarBrand, NavbarContent, NavbarSection, NavbarItem,
NavbarLink, NavbarDropdown, NavbarDropdownTrigger, NavbarDropdownContent,
NavbarDropdownItem, NavbarDivider, NavbarToggle, NavbarMobile, NavbarMobileContent,
} from "./navbar";
<NavbarSection align="end">
<NavbarItem>
<NavbarDropdown placement="bottom-end">
<NavbarDropdownTrigger aria-label="Account menu">
<Avatar />Ada Rivers
</NavbarDropdownTrigger>
<NavbarDropdownContent>
<NavbarDropdownItem href="/profile">Profile</NavbarDropdownItem>
<NavbarDropdownItem href="/settings">Settings</NavbarDropdownItem>
<NavbarDropdownItem href="/usage" disabled>Usage (unavailable)</NavbarDropdownItem>
<NavbarDivider />
<NavbarDropdownItem onSelect={signOut}>Sign out</NavbarDropdownItem>
</NavbarDropdownContent>
</NavbarDropdown>
</NavbarItem>
</NavbarSection>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import {
Navbar, NavbarBrand, NavbarContent, NavbarSection, NavbarItem,
NavbarLink, NavbarDropdown, NavbarDropdownTrigger, NavbarDropdownContent,
NavbarDropdownItem, NavbarDivider, NavbarToggle, NavbarMobile, NavbarMobileContent,
} from "./navbar";
<NavbarSection align="end">
<NavbarItem>
<NavbarDropdown placement="bottom-end">
<NavbarDropdownTrigger aria-label="Account menu">
<Avatar />Ada Rivers
</NavbarDropdownTrigger>
<NavbarDropdownContent>
<NavbarDropdownItem href="/profile">Profile</NavbarDropdownItem>
<NavbarDropdownItem href="/settings">Settings</NavbarDropdownItem>
<NavbarDropdownItem href="/usage" disabled>Usage (unavailable)</NavbarDropdownItem>
<NavbarDivider />
<NavbarDropdownItem onSelect={signOut}>Sign out</NavbarDropdownItem>
</NavbarDropdownContent>
</NavbarDropdown>
</NavbarItem>
</NavbarSection>
```
## Props
### `<Navbar>`
| Name | Type | Default | Description |
|---|---|---|---|
| `open` | `boolean` | — | Mobile-menu open state (controlled). |
| `defaultOpen` | `boolean` | `false` | Initial mobile-menu open state (uncontrolled). |
| `onOpenChange` | `(open: boolean) => void` | — | Called whenever the mobile menu requests to open or close. |
| `label` | `string` | `"Main"` | Accessible name of the `<nav>` landmark. |
| `breakpoint` | `"sm" \| "md" \| "lg"` | `"md"` | Breakpoint below which the desktop content collapses into the mobile navigation. |
| `variant` | `"default" \| "transparent"` | `"default"` | `transparent` removes the surface + bottom border for use over a page header. |
| `className` | `string` | — | Extra classes on the `<nav>` (e.g. `sticky top-0 z-40`). |
| `children` | `ReactNode` | — | Brand, content, toggle, and mobile region. |
### `<NavbarBrand>`
| Name | Type | Default | Description |
|---|---|---|---|
| `href` | `string` | `"/"` | Home URL the brand points at. |
| `className` | `string` | — | Extra classes (e.g. desktop centering for the centered pattern). |
| `children` | `ReactNode` | — | Any brand content: logo mark, wordmark, or both. |
A real `<a>`; every native anchor attribute is forwarded.
### `<NavbarContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the desktop content row. |
| `children` | `ReactNode` | — | `NavbarSection` regions (and, for the centered pattern, the brand). |
Hidden below the root `breakpoint` via a Tailwind responsive utility.
### `<NavbarSection>`
| Name | Type | Default | Description |
|---|---|---|---|
| `align` | `"start" \| "center" \| "end"` | `"start"` | Region of the bar: after the brand, centered, or trailing. |
| `className` | `string` | — | Extra classes on the region. |
| `children` | `ReactNode` | — | `NavbarItem` list items. |
Renders a `<ul role="list">` so the navigation region keeps list semantics.
### `<NavbarItem>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the item. |
| `children` | `ReactNode` | — | One `NavbarLink`, `NavbarAction`, or `NavbarDropdown`. |
A plain `<li>` wrapper — links, actions, and dropdowns are list items in both the desktop sections and the mobile region.
### `<NavbarLink>`
| Name | Type | Default | Description |
|---|---|---|---|
| `href` | `string` | `"#"` | Navigation target. |
| `active` | `boolean` | `false` | Current page: `aria-current="page"` + the active surface. |
| `external` | `boolean` | `false` | Opens in a new tab (`target="_blank" rel="noreferrer"`) with a visible + sr-only indicator. |
| `disabled` | `boolean` | `false` | Renders a non-interactive `aria-disabled` span — never a dead anchor. |
| `className` | `string` | — | Extra classes. |
| `children` | `ReactNode` | — | Visible label. |
A real `<a>`. Inside `NavbarMobileContent` it automatically switches to full-width stacked styling; activating it also closes an open mobile menu.
### `<NavbarAction>`
| Name | Type | Default | Description |
|---|---|---|---|
| `variant` | `"primary" \| "outline" \| "ghost"` | `"primary"` | Visual weight. |
| `href` | `string` | — | When present, renders a real `<a>` (e.g. a "Get started" link); otherwise a real `<button type="button">`. |
| `className` | `string` | — | Extra classes. |
| `children` | `ReactNode` | — | Visible label. |
Bar-height (36px) action sharing the Buttons family's primary/outline/ghost language. Native button or anchor attributes are forwarded.
### `<NavbarToggle>`
| Name | Type | Default | Description |
|---|---|---|---|
| `label` | `string` | `"Open/Close navigation menu"` (state-dependent) | Accessible name override. |
| `className` | `string` | — | Extra classes. |
A real `<button type="button">` with `aria-expanded` and `aria-controls` pointing at the mobile region; visible only below the root `breakpoint`. The hamburger/close icon swaps with state (aria-hidden).
### `<NavbarMobile>`
| Name | Type | Default | Description |
|---|---|---|---|
| `placement` | `"panel" \| "side"` | `"panel"` | `panel`: full-width disclosure under the bar. `side`: compact side panel with overlay, body scroll lock, and focus-on-open. |
| `className` | `string` | — | Extra classes on the region. |
| `children` | `ReactNode` | — | `NavbarMobileContent` (plus, for `side`, an optional header row). |
Rendered only while the mobile menu is open; the element carries the id the toggle's `aria-controls` points at. Hidden at and above the root `breakpoint`.
### `<NavbarMobileContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the list. |
| `children` | `ReactNode` | — | `NavbarItem` list items. |
A `<ul role="list">`; marks its subtree as the mobile area so `NavbarLink` renders stacked full-width.
### `<NavbarDropdown>`
| Name | Type | Default | Description |
|---|---|---|---|
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled — the dropdown manages itself). |
| `placement` | `"bottom-start" \| "bottom-end"` | `"bottom-start"` | Panel alignment relative to the trigger; flips horizontally to stay in the viewport. |
| `className` | `string` | — | Extra classes on the relative wrapper. |
| `children` | `ReactNode` | — | `NavbarDropdownTrigger` + `NavbarDropdownContent`. |
### `<NavbarDropdownTrigger>`
| Name | Type | Default | Description |
|---|---|---|---|
| `icon` | `ReactNode` | — | Meaningful leading icon (rendered aria-hidden). |
| `className` | `string` | — | Extra classes. |
| `children` | `ReactNode` | — | Visible trigger label (a chevron is rendered after it). |
A real `<button type="button">` styled as a nav link, with `aria-haspopup="true"`, `aria-expanded`, and `aria-controls`. Click toggles; ArrowDown opens with the first item focused, ArrowUp with the last. Native button attributes (e.g. `aria-label` for icon-forward triggers) are forwarded.
### `<NavbarDropdownContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `aria-label` | `string` | — | Explicit accessible name; otherwise the panel is labelled by its trigger. |
| `className` | `string` | — | Extra classes (e.g. a wider `w-[min(36rem,100vw-2rem)]` for a mega menu). |
| `children` | `ReactNode` | — | `NavbarDropdownItem` entries, `NavbarDivider`, or grouped columns. |
Rendered only while open. Measures itself before paint and flips start ↔ end to stay in the viewport.
### `<NavbarDropdownItem>`
| Name | Type | Default | Description |
|---|---|---|---|
| `href` | `string` | — | Navigation target. When omitted, the item renders a `<button>` action instead of an anchor. |
| `active` | `boolean` | `false` | Current page: `aria-current="page"` + the active surface. |
| `external` | `boolean` | `false` | Opens in a new tab with a visible + sr-only indicator. |
| `disabled` | `boolean` | `false` | Non-interactive `aria-disabled` span — skipped by arrow keys. |
| `icon` | `ReactNode` | — | Meaningful leading icon (rendered aria-hidden). |
| `onSelect` | `() => void` | — | Called on activation before the dropdown closes. |
| `aria-label` | `string` | — | Accessible name override. |
| `children` | `ReactNode` | — | Visible label. |
### `<NavbarDivider>`
A `role="separator"` horizontal rule between dropdown groups. No props beyond `className`.
## Compound Components
Navbar is a compound component. Fifteen primitives compose the pattern:
```tsx
<Navbar>
<NavbarBrand href="/">Forge</NavbarBrand>
<NavbarContent>
<NavbarSection align="start">
<NavbarItem><NavbarLink href="/docs" active>Docs</NavbarLink></NavbarItem>
<NavbarItem><NavbarLink href="/pricing">Pricing</NavbarLink></NavbarItem>
</NavbarSection>
<NavbarSection align="end">
<NavbarItem><NavbarAction variant="ghost">Sign in</NavbarAction></NavbarItem>
<NavbarItem><NavbarAction variant="primary" href="/signup">Get started</NavbarAction></NavbarItem>
</NavbarSection>
</NavbarContent>
<NavbarToggle />
<NavbarMobile>
<NavbarMobileContent>
<NavbarItem><NavbarLink href="/docs" active>Docs</NavbarLink></NavbarItem>
<NavbarItem><NavbarLink href="/pricing">Pricing</NavbarLink></NavbarItem>
</NavbarMobileContent>
</NavbarMobile>
</Navbar>
```
- `Navbar` — the root `<nav>` landmark. Owns the mobile-menu state (controlled via `open` + `onOpenChange`, or uncontrolled via `defaultOpen`), the landmark label, the responsive `breakpoint`, and the `default` / `transparent` surface variant.
- `NavbarBrand` — a real `<a>` home link wrapping any ReactNode brand (logo mark, wordmark, or both).
- `NavbarContent` — the desktop content row, hidden below the breakpoint. Contains `NavbarSection` regions.
- `NavbarSection` — a `<ul>` region aligned `start`, `center`, or `end`; its children are `NavbarItem` list items.
- `NavbarItem` — a `<li>` wrapping one link, action, or dropdown.
- `NavbarLink` — a real `<a>` navigation link with `active` (`aria-current="page"`), `external` (`target="_blank"` + indicator), and `disabled` (non-interactive `aria-disabled` span — never a dead anchor).
- `NavbarAction` — a bar-height action: a real `<button>` by default, a real `<a>` when `href` is passed. `primary` / `outline` / `ghost` variants.
- `NavbarToggle` — the mobile-menu button: `aria-expanded`, `aria-controls` pointing at the mobile region, dynamic accessible name, hamburger/close icon swap.
- `NavbarMobile` — the collapsible mobile region referenced by `aria-controls`. `placement="panel"` (full-width disclosure under the bar) or `placement="side"` (compact side panel with overlay, scroll lock, and focus-on-open).
- `NavbarMobileContent` — the `<ul>` inside the mobile region; links inside it automatically switch to full-width stacked styling.
- `NavbarDropdown` — a navigation dropdown root (disclosure pattern). Owns its open state and panel placement.
- `NavbarDropdownTrigger` — a real `<button>` styled as a nav link, with `aria-haspopup="true"`, `aria-expanded`, `aria-controls`, and a rotating chevron.
- `NavbarDropdownContent` — the absolutely positioned panel, labelled by its trigger. Rendered only while open; flips its alignment to stay in the viewport.
- `NavbarDropdownItem` — one entry: a real `<a>` when `href` is passed, otherwise a real `<button>` action. Supports `active`, `external`, `disabled`, `icon`, and `onSelect`.
- `NavbarDivider` — a `role="separator"` rule between dropdown groups.
The account menu is a `NavbarDropdown` in the `end` section — the exact same architecture as the navigation dropdowns, not a second menu implementation. The trigger's children are free-form (an initials avatar + the user's name); `aria-label="Account menu"` gives it a stable accessible name. Items with `href` are links; the sign-out item omits `href` and renders a `<button>` with `onSelect`.
## Navigation Behavior
The account menu reuses the dropdown behavior wholesale:
- Trigger: click, Enter/Space, ArrowDown/ArrowUp — the chevron rotates while open.
- Panel: profile/settings links (real anchors), a disabled usage entry, a `NavbarDivider`, and a sign-out action (`<button>` + `onSelect`). Arrow keys cycle, Home/End jump, Escape closes and refocuses the trigger, Tab closes naturally, outside pointer closes.
- Selecting sign out runs `onSelect` and closes; the demo flips to a signed-out state with a Sign in ghost action to reverse it.
`placement="bottom-end"` aligns the panel with the trailing trigger; the viewport flip still applies if the panel would overflow.
## Keyboard Interaction
| Key | Context | Behavior |
|---|---|---|
| `Tab` / `Shift+Tab` | bar | Move through brand, links, actions, dropdown triggers, and the toggle in DOM order |
| `Enter` / `Space` | dropdown trigger | Toggle the dropdown; focus moves to the first item |
| `ArrowDown` | dropdown trigger | Open the dropdown, focus the first item |
| `ArrowUp` | dropdown trigger | Open the dropdown, focus the last item |
| `ArrowDown` / `ArrowUp` | dropdown panel | Move focus to the next / previous enabled item, wrapping at the ends |
| `Home` / `End` | dropdown panel | Focus the first / last enabled item |
| `Enter` | link / item | Follow the link / activate the item (native behavior) |
| `Escape` | dropdown panel | Close the dropdown and return focus to its trigger |
| `Tab` | dropdown panel | Close the dropdown and move focus forward naturally |
| `Escape` | anywhere (mobile menu open) | Close the mobile navigation and return focus to the toggle |
The trigger, items, and toggle are native `<button>` / `<a>` elements, so Enter/Space activation and Tab order follow normal browser behavior. Disabled entries use non-interactive `aria-disabled` spans: they are skipped by arrow-key navigation and removed from the tab order. Focus is never trapped — the mobile navigation is a disclosure, not a modal dialog.
## Accessibility
The structure follows the WAI-ARIA disclosure navigation pattern.
- The root is a semantic `<nav>` landmark with an accessible name (`label`, default "Main") — pass a distinct label when more than one navbar is on the page.
- Navigation links are real `<a href>` elements (normal browser navigation, middle-click, and screen-reader link semantics); actions and toggles are real `<button>` elements. No `div` click handlers, no nested interactive elements.
- The mobile toggle is a real `<button>` with `aria-expanded` and `aria-controls` pointing at the actual mobile region; its accessible name reflects the state ("Open/Close navigation menu").
- Dropdown triggers carry `aria-haspopup="true"` + `aria-expanded` + `aria-controls`; the panel is labelled by its trigger. Navigation dropdowns intentionally do NOT use `role="menu"`/`role="menuitem"` — the panel contains real links, and the ARIA menu pattern is for action menus, not navigation.
- The mobile navigation is NOT a modal dialog: focus is never trapped. The `side` placement moves focus into the panel on open and restores it to the toggle on close, but Tab always moves forward naturally.
- Disabled links and dropdown items render as non-interactive spans with `aria-disabled="true"` — they are skipped by arrow keys, removed from the tab order, and never presented as followable links.
- External links announce themselves with `target="_blank" rel="noreferrer"`, a visible (aria-hidden) indicator glyph, and screen-reader-only "(opens in a new tab)" text.
- Every interactive element has a visible `focus-visible` ring via the `--ds-color-focus-ring` token, and all transitions are disabled under `prefers-reduced-motion`.
The trigger has a stable accessible name via `aria-label` (its visible children include an aria-hidden avatar, so the name never depends on initials). The sign-out action is a real `<button>` — actions that change session state are not links.
## Active Navigation
Pass `active` to the `NavbarLink` or `NavbarDropdownItem` that represents the current page. Active items render with the `--ds-color-surface-active` background and foreground text (background + color, never color alone) and expose `aria-current="page"` to assistive technology. In a routed app, derive `active` from the current route:
```tsx
<NavbarLink href="/docs" active={pathname.startsWith("/docs")}>Docs</NavbarLink>
```
Exactly one item in a navigation region should be current at a time.
## Responsive Behavior
The family collapses by breakpoint, not by JavaScript width detection: below the configured `breakpoint` (`sm` / `md` / `lg`, default `md`) `NavbarContent` is hidden with a Tailwind responsive utility and the `NavbarToggle` appears; the `NavbarMobile` region is likewise hidden at and above the breakpoint. No resize listeners are involved.
- The bar is a single 56px row (`h-14`) with `max-w-6xl` content width and fluid horizontal padding (`px-4 sm:px-6`); long link labels truncate (`min-w-0` + `truncate`) instead of forcing overflow.
- The `panel` mobile placement is absolutely positioned under the bar, so opening/closing it never shifts page layout; it caps its height at `100dvh - 4rem` and scrolls internally.
- Dropdown panels cap their width at `100vw - 1.5rem` and height at `min(24rem, 100dvh - 6rem)` with internal scrolling, and flip their horizontal alignment (start ↔ end) to stay inside the viewport.
- All controls keep comfortable touch targets: 36px (h-9) actions/toggles, 32px+ link hit areas.
The user name is hidden below `sm` (`hidden sm:inline`), leaving the 36px avatar trigger — a comfortable touch target. The `bottom-end` panel stays attached to the trailing trigger at every width, flipping if needed. On mobile the account links are listed flat in the mobile region.
## Controlled and Uncontrolled State
The mobile menu supports both state modes:
- **Uncontrolled** (default) — `<Navbar>` owns the state; optionally seed it with `defaultOpen`.
- **Controlled** — pass `open` + `onOpenChange`; the parent owns the state. Every internal request (toggle click, Escape, outside pointer, link activation) flows through `onOpenChange`.
```tsx
const [open, setOpen] = useState(false);
<Navbar open={open} onOpenChange={setOpen}>…</Navbar>
```
`NavbarDropdown` manages its own open state internally (seed with `defaultOpen`); it closes itself on selection, Escape, Tab, or outside pointer interaction, and restores focus to its trigger.
The dropdown manages its own open state; the mobile menu is uncontrolled. The signed-in state in the preview is ordinary showcase state.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This navbar variant uses the semantic color, radius, shadow, typography, and motion tokens, and follows the navigation rules (48–56px top-nav height, subtle bottom border, quiet state changes).
## Notes and Limitations
Keep account menus short: profile, settings, optionally billing, a separator, sign out. Destructive-adjacent actions like sign out use the default item tone — reserve `destructive` styling for irreversible actions.
- The desktop content collapses purely through Tailwind responsive utilities at the configured `breakpoint` (default `md`); there is no JavaScript width detection. If the viewport is resized past the breakpoint while the mobile menu is open, the region hides visually while the state remains open — close it via the toggle or Escape before resizing, or manage `open` yourself.
- The mobile navigation is a disclosure, not a dialog: focus is never trapped, even in the `side` placement. If you need a true modal navigation drawer, compose the DevSnips Dialog family instead.
- Dropdown panels anchor to their trigger with `absolute` positioning inside a `relative` wrapper — no positioning library. The viewport flip covers horizontal overflow; a navbar at the very bottom edge of a short viewport can still clip a tall panel vertically (the panel caps its height and scrolls internally instead). 1002 lines UTF-8 · LF · Spaces: 2
DevSnips AI ready Claude · Cursor · Windsurf
Agent skill & Model Context Protocol
Pass this component straight into your AI workflow. DevSnips exposes each resource as a structured, addressable registry entry, so your agent installs the real files instead of approximating them.
devsnips://components/navbar/navbar-with-user-menu I want to use this DevSnips resource in production: Page: https://devsnips.site/library/react/components/navbar/navbar-with-user-menu/ Install: npx devsnips add React/Components/Navbar/navbar-with-user-menu This is the Navbar with User Menu — A navbar containing a user/account menu built from the same dropdown architecture as every other variant: profile and settings links, a separator, a destructive-adjacent sign-out action, full keyboard support, Escape, outside-pointer close, and focus restoration. Use this DevSnips resource as the starting point for my project. Adapt it to my project's stack and conventions, fix any issues, and make it production-ready while preserving its intended design and behavior.
Paste into ChatGPT, Claude or your agent — it already carries the page URL and the exact install path.
Resource URI
devsnips://components/navbar/navbar-with-user-menu