Navigation Menu
Una barra de navegación horizontal con paneles de links que se abren al hacer clic.
Nuevo1'use client';23import { NavigationMenu } from '@/components/ui/navigation-menu';4import { CodeIcon } from '@/components/ui/code';5import { PaletteIcon } from '@/components/ui/palette';6import { PuzzleIcon } from '@/components/ui/puzzle';78const products = [9 {10 icon: PuzzleIcon,11 title: 'Components',12 description: 'Accessible React primitives, ready to copy.',13 href: '#',14 },15 {16 icon: PaletteIcon,17 title: 'Themes',18 description: 'Design tokens for light and dark out of the box.',19 href: '#',20 },21 {22 icon: CodeIcon,23 title: 'CLI',24 description: 'Pull source files straight into your project.',25 href: '#',26 },27];2829export function Default() {30 return (31 <NavigationMenu className="min-h-64 items-start">32 <NavigationMenu.Item>33 <NavigationMenu.Trigger className="text-sm">Products</NavigationMenu.Trigger>34 <NavigationMenu.Content>35 {products.map((product) => (36 <NavigationMenu.Link37 key={product.title}38 href={product.href}39 title={product.title}40 description={product.description}41 icon={<product.icon size={16} strokeWidth={1.6} />}42 />43 ))}44 </NavigationMenu.Content>45 </NavigationMenu.Item>46 <a47 href="#docs"48 className="text-muted-foreground hover:text-foreground text-sm transition-colors"49 >50 Docs51 </a>52 <a53 href="#pricing"54 className="text-muted-foreground hover:text-foreground text-sm transition-colors"55 >56 Pricing57 </a>58 </NavigationMenu>59 );60}
Instalación
Copia y pega el siguiente código en tu proyecto.
1'use client';23import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from 'motion/react';4import * as React from 'react';5import { cn } from '../lib/cn';6import { floatingOrigin, floatingVariants, reveal } from '../lib/motion';7import { assignRef, useLayer } from '../lib/overlay';89type IconProps = React.SVGProps<SVGSVGElement> & {10 size?: number | string;11};1213function ChevronDownIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {14 return (15 <svg16 xmlns="http://www.w3.org/2000/svg"17 width={size}18 height={size}19 viewBox="0 0 24 24"20 fill="none"21 stroke="currentColor"22 strokeWidth={strokeWidth}23 strokeLinecap="round"24 strokeLinejoin="round"25 aria-hidden="true"26 {...props}27 >28 <path d="m6 9 6 6 6-6" />29 </svg>30 );31}3233const MENU_STYLE = { willChange: 'opacity, transform, filter' } as const;3435const VIEWPORT_PADDING = 8;3637function useViewportShift(ref: React.RefObject<HTMLElement | null>, open: boolean) {38 const [shift, setShift] = React.useState(0);3940 React.useLayoutEffect(() => {41 if (!open) return;42 const measure = () => {43 const el = ref.current;44 if (!el) return;45 const { transform, translate } = el.style;46 el.style.transform = 'none';47 el.style.translate = '';48 const rect = el.getBoundingClientRect();49 el.style.transform = transform;50 el.style.translate = translate;51 const viewport = document.documentElement.clientWidth || window.innerWidth;52 if (!rect.width || !viewport) return;53 let next = 0;54 if (rect.right > viewport - VIEWPORT_PADDING) next = viewport - VIEWPORT_PADDING - rect.right;55 if (rect.left + next < VIEWPORT_PADDING) next = VIEWPORT_PADDING - rect.left;56 setShift(Math.round(next));57 };58 measure();59 window.addEventListener('resize', measure);60 return () => window.removeEventListener('resize', measure);61 }, [open, ref]);6263 return shift;64}6566interface NavigationMenuItemContextType {67 open: boolean;68 setOpen: (open: boolean) => void;69 id: string;70 triggerRef: React.RefObject<HTMLButtonElement | null>;71}7273interface NavigationMenuRootContextType {74 openItem: string | null;75 setOpenItem: React.Dispatch<React.SetStateAction<string | null>>;76}7778const NavigationMenuRootContext = React.createContext<NavigationMenuRootContextType | null>(null);7980const NavigationMenuItemContext = React.createContext<NavigationMenuItemContextType | undefined>(81 undefined,82);8384const useNavigationMenuItem = () => {85 const context = React.use(NavigationMenuItemContext);86 if (!context) {87 throw new Error('NavigationMenu parts must be used within a NavigationMenu.Item');88 }89 return context;90};9192const NavigationMenuRoot = ({93 className,94 ref,95 ...props96}: React.HTMLAttributes<HTMLElement> & { ref?: React.Ref<HTMLElement> }) => {97 const [openItem, setOpenItem] = React.useState<string | null>(null);98 const contextValue = React.useMemo(() => ({ openItem, setOpenItem }), [openItem]);99100 return (101 <NavigationMenuRootContext value={contextValue}>102 <nav ref={ref} className={cn('flex items-center gap-6', className)} {...props} />103 </NavigationMenuRootContext>104 );105};106NavigationMenuRoot.displayName = 'NavigationMenu';107108const NavigationMenuItem = ({109 className,110 children,111 onBlur,112 ref,113 ...props114}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) => {115 const root = React.use(NavigationMenuRootContext);116 const [localOpen, setLocalOpen] = React.useState(false);117 const id = React.useId();118 const containerRef = React.useRef<HTMLDivElement>(null);119 const triggerRef = React.useRef<HTMLButtonElement>(null);120 const open = root ? root.openItem === id : localOpen;121 const layer = useLayer(open, false, containerRef);122 const setRootItem = root?.setOpenItem;123124 const setOpen = React.useCallback(125 (next: boolean) => {126 if (!setRootItem) {127 setLocalOpen(next);128 return;129 }130 setRootItem((current) => (next ? id : current === id ? null : current));131 },132 [setRootItem, id],133 );134135 React.useEffect(() => {136 if (!open) return;137138 const handlePointerDownOutside = (e: PointerEvent) => {139 const container = containerRef.current;140 if (!container || container.contains(e.target as Node)) return;141 if (layer.isInsideUpperLayer(e.target)) return;142 setOpen(false);143 };144145 const handleEscape = (e: KeyboardEvent) => {146 if (e.key !== 'Escape' || e.defaultPrevented || !layer.isTopmost()) return;147 const active = document.activeElement;148 const focusInside = !!active && !!containerRef.current?.contains(active);149 if (!focusInside && active && active !== document.body) return;150 e.preventDefault();151 setOpen(false);152 if (focusInside) triggerRef.current?.focus();153 };154155 document.addEventListener('pointerdown', handlePointerDownOutside);156 document.addEventListener('keydown', handleEscape);157 return () => {158 document.removeEventListener('pointerdown', handlePointerDownOutside);159 document.removeEventListener('keydown', handleEscape);160 };161 }, [open, setOpen, layer]);162163 const handleBlur = React.useCallback(164 (e: React.FocusEvent<HTMLDivElement>) => {165 onBlur?.(e);166 const next = e.relatedTarget;167 if (!open || !(next instanceof Node)) return;168 if (e.currentTarget.contains(next) || layer.isInsideUpperLayer(next)) return;169 setOpen(false);170 },171 [onBlur, open, setOpen, layer],172 );173174 const contextValue = React.useMemo(175 () => ({ open, setOpen, id, triggerRef }),176 [open, setOpen, id],177 );178179 const mergedRef = React.useCallback(180 (node: HTMLDivElement | null) => {181 containerRef.current = node;182 assignRef(ref, node);183 },184 [ref],185 );186187 return (188 <NavigationMenuItemContext value={contextValue}>189 <div ref={mergedRef} onBlur={handleBlur} className={cn('relative', className)} {...props}>190 {children}191 </div>192 </NavigationMenuItemContext>193 );194};195NavigationMenuItem.displayName = 'NavigationMenuItem';196197const NavigationMenuTrigger = ({198 className,199 children,200 onClick,201 ref,202 ...props203}: React.ButtonHTMLAttributes<HTMLButtonElement> & { ref?: React.Ref<HTMLButtonElement> }) => {204 const { open, setOpen, id, triggerRef } = useNavigationMenuItem();205206 const mergedRef = React.useCallback(207 (node: HTMLButtonElement | null) => {208 triggerRef.current = node;209 if (typeof ref === 'function') ref(node);210 else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;211 },212 [ref, triggerRef],213 );214215 return (216 <button217 type="button"218 ref={mergedRef}219 aria-expanded={open}220 aria-controls={open ? id : undefined}221 onClick={(e) => {222 setOpen(!open);223 onClick?.(e);224 }}225 className={cn(226 'text-muted-foreground hover:text-foreground focus-visible:ring-ring ring-offset-background flex min-h-9 cursor-pointer items-center gap-1 rounded-md px-2 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',227 open && 'text-foreground',228 className,229 )}230 {...props}231 >232 {children}233 <ChevronDownIcon234 size={13}235 aria-hidden="true"236 className={cn('transition-transform duration-200', open && 'rotate-180')}237 />238 </button>239 );240};241NavigationMenuTrigger.displayName = 'NavigationMenuTrigger';242243const NavigationMenuContent = ({ className, children, ...props }: HTMLMotionProps<'div'>) => {244 const { open, id } = useNavigationMenuItem();245 const shouldReduceMotion = useReducedMotion();246 const contentRef = React.useRef<HTMLDivElement>(null);247 const shift = useViewportShift(contentRef, open);248 const style = React.useMemo(249 () => ({ ...MENU_STYLE, translate: shift ? `${shift}px` : undefined }),250 [shift],251 );252253 return (254 <AnimatePresence>255 {open && (256 <motion.div257 ref={contentRef}258 id={id}259 variants={shouldReduceMotion ? reveal : floatingVariants.bottom}260 initial="hidden"261 animate="visible"262 exit="exit"263 style={style}264 className={cn(265 'bg-popover text-popover-foreground border-border absolute top-full left-0 z-50 mt-2 w-72 max-w-[calc(100vw-2rem)] rounded-md border p-1 shadow-md',266 floatingOrigin.bottom,267 className,268 )}269 {...props}270 >271 <span272 aria-hidden="true"273 className="border-border bg-popover absolute -top-[5.5px] left-5 size-2.5 rotate-45 rounded-[1px] border-t border-l"274 />275 {children as React.ReactNode}276 </motion.div>277 )}278 </AnimatePresence>279 );280};281NavigationMenuContent.displayName = 'NavigationMenuContent';282283interface NavigationMenuLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {284 asChild?: boolean;285 icon?: React.ReactNode;286 title: string;287 description?: string;288 badge?: React.ReactNode;289}290291const NavigationMenuLink = ({292 asChild,293 icon,294 title,295 description,296 badge,297 className,298 children,299 href,300 onClick,301 ...props302}: NavigationMenuLinkProps) => {303 const { setOpen } = useNavigationMenuItem();304305 const rowClassName = cn(306 'group/navlink hover:bg-muted focus-visible:ring-ring flex items-start gap-3 rounded-md px-2.5 py-2.5 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset',307 className,308 );309310 const content = (311 <>312 {icon && (313 <span className="border-border bg-background text-muted-foreground group-hover/navlink:text-foreground flex size-9 shrink-0 items-center justify-center rounded-md border transition-colors">314 {icon}315 </span>316 )}317 <span className="min-w-0">318 <span className="flex items-center gap-2">319 <span className="text-foreground text-sm font-medium">{title}</span>320 {badge}321 </span>322 {description && (323 <span className="text-muted-foreground mt-0.5 block truncate text-xs">{description}</span>324 )}325 </span>326 </>327 );328329 if (asChild && React.isValidElement(children)) {330 const child = children as React.ReactElement<Record<string, unknown>>;331 const childOnClick = child.props.onClick as ((e: React.MouseEvent) => void) | undefined;332333 return React.cloneElement(334 child,335 {336 className: cn(rowClassName, child.props.className as string | undefined),337 onClick: (e: React.MouseEvent) => {338 childOnClick?.(e);339 setOpen(false);340 },341 },342 content,343 );344 }345346 return (347 <a348 href={href}349 className={rowClassName}350 onClick={(e) => {351 onClick?.(e as React.MouseEvent<HTMLAnchorElement>);352 setOpen(false);353 }}354 {...props}355 >356 {content}357 </a>358 );359};360NavigationMenuLink.displayName = 'NavigationMenuLink';361362const NavigationMenu = Object.assign(NavigationMenuRoot, {363 Item: NavigationMenuItem,364 Trigger: NavigationMenuTrigger,365 Content: NavigationMenuContent,366 Link: NavigationMenuLink,367});368369export { NavigationMenu };370export type { NavigationMenuLinkProps };
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Anatomía
1import { NavigationMenu } from '@/components/ui/navigation-menu';
1<NavigationMenu>2 <NavigationMenu.Item>3 <NavigationMenu.Trigger>Productos</NavigationMenu.Trigger>4 <NavigationMenu.Content>5 <NavigationMenu.Link6 href="/components"7 title="Componentes"8 description="Primitivos de React accesibles, listos para copiar."9 />10 </NavigationMenu.Content>11 </NavigationMenu.Item>12 <a href="/docs">Docs</a>13</NavigationMenu>
Composición
Usa la siguiente composición para construir un
NavigationMenu:NavigationMenu├── NavigationMenu.Item│ ├── NavigationMenu.Trigger│ └── NavigationMenu.Content│ └── NavigationMenu.Link└── (links planos)
La raíz renderiza un
<nav> y distribuye sus hijos en fila, así que los
anchors comunes y los links del router conviven con los items desplegables.
Solo un Item queda abierto a la vez: abrir otro cierra el anterior. Se abren
con clic, nunca con hover, así los paneles solo aparecen cuando el lector los
pide, y se cierran con Escape, con un clic afuera, cuando el foco sale del
item con Tab, o al navegar por un link. El panel lleva una pequeña flecha que
apunta al trigger que lo abrió.Links del router
NavigationMenu.Link renderiza un <a> por defecto. Para mantener la
navegación client-side (o el routing con locale), pasá asChild con el Link
de tu framework como único hijo: el contenido de la fila se inyecta adentro y
el menú igual se cierra al navegar:1<NavigationMenu.Link2 asChild3 title="Componentes"4 description="Primitivos de React accesibles."5 icon={<HugeiconsIcon icon={PuzzleIcon} size={16} />}6>7 <Link href="/docs/elements/ui" />8</NavigationMenu.Link>
Variantes
Con badges
Las filas aceptan un slot
badge al lado del título, y cualquier otra cosa
que pongas dentro de Content se renderiza tal cual, como esta fila
"próximamente" no interactiva:1'use client';23import { Badge } from '@/components/ui/badge';4import { NavigationMenu } from '@/components/ui/navigation-menu';5import { BookIcon } from '@/components/ui/book';6import { PuzzleIcon } from '@/components/ui/puzzle';7import { ServerIcon } from '@/components/ui/server';89const resources = [10 {11 icon: PuzzleIcon,12 title: 'Components',13 description: 'Free and open source, forever.',14 badge: { variant: 'success', label: 'Open source' },15 },16 {17 icon: BookIcon,18 title: 'Changelog',19 description: '12 components shipped since v2.13.',20 badge: { variant: 'info', label: 'New' },21 },22 {23 icon: ServerIcon,24 title: 'MCP server',25 description: 'Pull component source into your editor.',26 badge: { variant: 'warning', label: 'Beta' },27 },28] as const;2930export function Badges() {31 return (32 <NavigationMenu className="min-h-64 items-start">33 <NavigationMenu.Item>34 <NavigationMenu.Trigger className="text-sm">Resources</NavigationMenu.Trigger>35 <NavigationMenu.Content>36 {resources.map((resource) => (37 <NavigationMenu.Link38 key={resource.title}39 href="#"40 title={resource.title}41 description={resource.description}42 badge={<Badge variant={resource.badge.variant}>{resource.badge.label}</Badge>}43 icon={<resource.icon size={16} strokeWidth={1.6} />}44 />45 ))}46 </NavigationMenu.Content>47 </NavigationMenu.Item>48 </NavigationMenu>49 );50}
Referencia de API
NavigationMenu
Renderiza un elemento
<nav>. Acepta todos los atributos de HTMLElement.| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Clases CSS adicionales |
NavigationMenu.Item
Envuelve un par trigger + panel y maneja su estado de apertura.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Clases CSS adicionales |
NavigationMenu.Trigger
Un botón con un chevron que rota mientras el panel está abierto.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Clases CSS adicionales |
NavigationMenu.Content
El panel flotante, anclado debajo del item y animado al abrir/cerrar.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Clases CSS adicionales |
NavigationMenu.Link
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | - | Título de la fila (requerido) |
description | string | - | Descripción de una línea en color muted |
icon | React.ReactNode | - | Se renderiza dentro de la cajita de ícono |
badge | React.ReactNode | - | Se renderiza al lado del título |
asChild | boolean | false | Inyecta la fila en un <Link> de routing hijo |
href | string | - | Destino del anchor cuando no se usa asChild |
className | string | - | Clases CSS adicionales |
¿Encontraste algo que mejorar?
¿Notaste un error, tipografía o detalle faltante en esta página? Ayúdanos a mejorar la documentación abriendo un issue en GitHub.