Component
Navbar with Actions
Brand and primary navigation plus a trailing action area: a ghost Sign in button and a primary Get started link, with a realistic sign-in state change driven entirely by local component state.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/navbar/navbar-with-actions/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Navbar/navbar-with-actions React/Components/Navbar/navbar-with-actions 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 actions.
*
* Brand + primary navigation + a trailing action area. Demonstrates the
* three `NavbarAction` weights (ghost Sign in button, outline secondary
* link, primary Get started link) with a realistic local sign-in state
* change. 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 Actions
Brand and primary navigation plus a trailing action area: a ghost Sign in button and a primary Get started link, with a realistic sign-in state change driven entirely by local component state.
## 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, NavbarAction, NavbarToggle, NavbarMobile, NavbarMobileContent,
} from "./navbar";
<Navbar>
<NavbarBrand href="/">Forge</NavbarBrand>
<NavbarContent>
<NavbarSection align="start">
<NavbarItem><NavbarLink href="/overview" active>Overview</NavbarLink></NavbarItem>
<NavbarItem><NavbarLink href="/pricing">Pricing</NavbarLink></NavbarItem>
</NavbarSection>
<NavbarSection align="end">
<NavbarItem><NavbarAction variant="ghost" onClick={signIn}>Sign in</NavbarAction></NavbarItem>
<NavbarItem><NavbarAction variant="primary" href="/get-started">Get started</NavbarAction></NavbarItem>
</NavbarSection>
</NavbarContent>
<NavbarToggle />
<NavbarMobile>…</NavbarMobile>
</Navbar>
```
## 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, NavbarAction, NavbarToggle, NavbarMobile, NavbarMobileContent,
} from "./navbar";
<Navbar>
<NavbarBrand href="/">Forge</NavbarBrand>
<NavbarContent>
<NavbarSection align="start">
<NavbarItem><NavbarLink href="/overview" active>Overview</NavbarLink></NavbarItem>
<NavbarItem><NavbarLink href="/pricing">Pricing</NavbarLink></NavbarItem>
</NavbarSection>
<NavbarSection align="end">
<NavbarItem><NavbarAction variant="ghost" onClick={signIn}>Sign in</NavbarAction></NavbarItem>
<NavbarItem><NavbarAction variant="primary" href="/get-started">Get started</NavbarAction></NavbarItem>
</NavbarSection>
</NavbarContent>
<NavbarToggle />
<NavbarMobile>…</NavbarMobile>
</Navbar>
```
## 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.
Actions live in their own `NavbarSection align="end"` — each wrapped in a `NavbarItem` so the region keeps list semantics. `NavbarAction` renders a `<button>` by default and an `<a>` when `href` is passed, so navigation-style actions (Get started) and command-style actions (Sign in) stay honest elements.
## Navigation Behavior
The action area demonstrates the three `NavbarAction` weights with realistic behavior:
- **Sign in** (ghost, `<button>`) — activates a local signed-in state; the action area swaps to an account label and a Sign out ghost button. The state is demo-only (no auth), driven by `useState` in the showcase.
- **Get started** (primary, `<a>`) — a navigation action pointing at the signup route.
- **Talk to sales** (outline, `<a>`) — a secondary navigation action, hidden below `sm` where the action area would crowd the bar.
Keeping command actions as buttons and navigation actions as anchors preserves honest semantics: middle-clicking "Get started" opens a new tab; "Sign in" does not pretend to be a link.
## 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`.
Actions sit in the same `<ul>` region as any other section content, so assistive technology announces them as list items of the navigation. The ghost Sign in button has a text label — no icon-only ambiguity.
## 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 outline action is hidden below `sm` (`hidden sm:inline-flex` via `className`) so the trailing area never crowds the bar at 375px; the ghost + primary pair fits comfortably. All actions keep the 36px touch target.
## 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 mobile menu is uncontrolled here; the signed-in demo state is ordinary showcase `useState`, unrelated to the navbar's own state model.
## 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
A realistic marketing/app-shell action area without product-specific coupling — the Sign in flow is local state only and is meant to be wired to a real auth handler.
- 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). 1001 lines UTF-8 · LF · Spaces: 2
Continue browsing