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
What it does
Capabilities
- Semantic nav landmark
- Real anchors
- Aria-current active item
- Mobile disclosure
- Ghost/outline/primary actions
- Sign-in state demo
- Aria-current='page' active item
- Aria-expanded + aria-controls toggle
- Aria-haspopup dropdown triggers
- Focus restoration on close
- Aria-disabled inactive items
Preview
Live component
9:41 100%
devsnips.site/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
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-actions I want to use this DevSnips resource in production: Page: https://devsnips.site/library/react/components/navbar/navbar-with-actions/ Install: npx devsnips add React/Components/Navbar/navbar-with-actions This is the 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. 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-actions