Skip to content

Navigation Menu

A site or app navigation bar with links and panels of links. Built on Radix Navigation Menu. It is separate from CK-UI's Navbar and can be placed inside it.

Installation

Add Navigation Menu to your project with the CK-UI CLI. It copies the source into your project, so the code is yours to change.

Terminal
npx ck-ui-cli@latest add navigation-menu
Adds
NavigationMenu.tsxto your CK-UI folder (src/components/ck-ui by default)
Installs
@radix-ui/react-navigation-menureact-iconsif your project does not have them yet

First time with CK-UI in this project? Run npx ck-ui-cli@latest init once before adding components. The installation guide explains what it sets up.

Manual installationInstall the packages and copy the files yourself.

Install the following dependencies

Run these commands in your terminal to install the required packages

Terminal
npm install @radix-ui/react-navigation-menu react-icons

Create or update your NavigationMenu component

Copy this code to create your NavigationMenu component

/components/ui/NavigationMenu.tsx
"use client";

import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { RiArrowDownSLine, RiArrowRightUpLine } from "react-icons/ri";
import { cn } from "@/lib/utils";

/*
 * Site or app navigation with links and panels, built on Radix NavigationMenu:
 *   <nav> landmark, triggers with aria-expanded, panels shown in a shared viewport,
 *   arrow keys move between top-level items, Tab moves into an open panel, Escape closes it and returns focus.
 * Links are plain links (not menu items). Mark the current page with `active` (aria-current="page").
 * Use `asChild` on Link for a router link. On narrow screens the panel is clamped to the viewport width;
 * for a full mobile menu, put the same links in a Drawer.
 */

export interface NavigationMenuRootProps extends React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root> {
    /** Render panels in the shared, animated viewport (default). Set false to position each panel under its trigger. */
    viewport?: boolean;
}

/** Name it with `aria-label` when a page has more than one navigation landmark. */
const Root = React.forwardRef<React.ElementRef<typeof NavigationMenuPrimitive.Root>, NavigationMenuRootProps>(({ className, children, viewport = true, ...props }, ref) => (
    <NavigationMenuPrimitive.Root
        ref={ref}
        data-viewport={viewport}
        className={cn("group/navigation-menu relative flex max-w-max flex-1 items-center justify-center", className)}
        {...props}
    >
        {children}
        {viewport && <Viewport />}
    </NavigationMenuPrimitive.Root>
));
Root.displayName = "NavigationMenu.Root";

const List = React.forwardRef<React.ElementRef<typeof NavigationMenuPrimitive.List>, React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>>(({ className, ...props }, ref) => (
    <NavigationMenuPrimitive.List ref={ref} className={cn("flex flex-1 list-none flex-wrap items-center justify-center gap-1", className)} {...props} />
));
List.displayName = "NavigationMenu.List";

const Item = React.forwardRef<React.ElementRef<typeof NavigationMenuPrimitive.Item>, React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Item>>(({ className, ...props }, ref) => (
    <NavigationMenuPrimitive.Item ref={ref} className={cn("relative", className)} {...props} />
));
Item.displayName = "NavigationMenu.Item";

const topLevelClasses =
    "inline-flex h-9 w-max items-center justify-center gap-1 rounded-lg px-3 size-label-sm text-foreground transition-colors duration-base ease-standard hover:bg-muted focus-ring disabled:pointer-events-none disabled:opacity-50";

const Trigger = React.forwardRef<React.ElementRef<typeof NavigationMenuPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>>(
    ({ className, children, ...props }, ref) => (
        <NavigationMenuPrimitive.Trigger ref={ref} className={cn("group", topLevelClasses, "data-[state=open]:bg-muted", className)} {...props}>
            {children}
            <RiArrowDownSLine aria-hidden="true" className="size-4 text-muted-foreground transition-transform duration-base group-data-[state=open]:rotate-180" />
        </NavigationMenuPrimitive.Trigger>
    )
);
Trigger.displayName = "NavigationMenu.Trigger";

const Content = React.forwardRef<React.ElementRef<typeof NavigationMenuPrimitive.Content>, React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>>(
    ({ className, ...props }, ref) => (
        <NavigationMenuPrimitive.Content
            ref={ref}
            className={cn(
                "left-0 top-0 w-full p-2 sm:w-auto",
                "group-data-[viewport=true]/navigation-menu:data-[motion^=from-]:animate-in group-data-[viewport=true]/navigation-menu:data-[motion^=from-]:fade-in-0",
                "group-data-[viewport=true]/navigation-menu:data-[motion^=to-]:animate-out group-data-[viewport=true]/navigation-menu:data-[motion^=to-]:fade-out-0",
                "group-data-[viewport=true]/navigation-menu:data-[motion=from-end]:slide-in-from-right-8 group-data-[viewport=true]/navigation-menu:data-[motion=from-start]:slide-in-from-left-8",
                "group-data-[viewport=true]/navigation-menu:data-[motion=to-end]:slide-out-to-right-8 group-data-[viewport=true]/navigation-menu:data-[motion=to-start]:slide-out-to-left-8",
                "group-data-[viewport=true]/navigation-menu:sm:absolute",
                // Without the shared viewport each panel is its own popup under its trigger.
                "group-data-[viewport=false]/navigation-menu:absolute group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:z-dropdown group-data-[viewport=false]/navigation-menu:mt-2 group-data-[viewport=false]/navigation-menu:w-max group-data-[viewport=false]/navigation-menu:max-w-[calc(100vw-2rem)] group-data-[viewport=false]/navigation-menu:rounded-2xl group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:border-border group-data-[viewport=false]/navigation-menu:bg-surface group-data-[viewport=false]/navigation-menu:shadow-medium",
                className
            )}
            {...props}
        />
    )
);
Content.displayName = "NavigationMenu.Content";

/**
 * The shared panel container. Root renders it unless `viewport={false}`.
 * Below sm it spans the menu's own width (so it never overflows the screen); from sm it takes the open content's width.
 */
const Viewport = React.forwardRef<React.ElementRef<typeof NavigationMenuPrimitive.Viewport>, React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>>(
    ({ className, ...props }, ref) => (
        <div className="absolute inset-x-0 top-full z-dropdown flex justify-center sm:right-auto">
            <NavigationMenuPrimitive.Viewport
                ref={ref}
                className={cn(
                    "relative mt-2 h-[var(--radix-navigation-menu-viewport-height)] w-full max-w-[calc(100vw-2rem)] origin-top overflow-hidden rounded-2xl border border-border bg-surface text-surface-foreground shadow-medium sm:w-[var(--radix-navigation-menu-viewport-width)]",
                    "transition-[width,height] duration-base ease-standard",
                    "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
                    className
                )}
                {...props}
            />
        </div>
    )
);
Viewport.displayName = "NavigationMenu.Viewport";

export interface NavigationMenuLinkProps extends React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Link> {
    /** Opens in a new tab with rel="noopener noreferrer", an icon and screen-reader text. */
    external?: boolean;
    /** Text appended for screen readers on external links. */
    externalLabel?: string;
    /** Top-level styling (in NavigationMenu.List). Panel links use the block style. */
    variant?: "top" | "panel";
}

/** A navigation link. `active` marks the current page (aria-current="page"). Selecting a link closes the menu. */
const Link = React.forwardRef<React.ElementRef<typeof NavigationMenuPrimitive.Link>, NavigationMenuLinkProps>(
    ({ className, external = false, externalLabel = "(opens in a new tab)", variant = "panel", asChild, children, target, rel, ...props }, ref) => {
        const classes = cn(
            variant === "top"
                ? [topLevelClasses, "data-[active]:bg-muted"]
                : "flex flex-col gap-1 rounded-xl p-3 size-p-sm text-foreground transition-colors duration-base ease-standard hover:bg-muted focus-ring data-[active]:bg-muted",
            className
        );

        if (asChild) {
            return (
                <NavigationMenuPrimitive.Link ref={ref} asChild className={classes} {...props}>
                    {children}
                </NavigationMenuPrimitive.Link>
            );
        }

        return (
            <NavigationMenuPrimitive.Link
                ref={ref}
                className={classes}
                target={external ? (target ?? "_blank") : target}
                rel={external ? (rel ?? "noopener noreferrer") : rel}
                {...props}
            >
                {children}
                {external && (
                    <>
                        <RiArrowRightUpLine aria-hidden="true" className="inline size-3.5 shrink-0 text-muted-foreground" />{" "}
                        <span className="sr-only">{externalLabel}</span>
                    </>
                )}
            </NavigationMenuPrimitive.Link>
        );
    }
);
Link.displayName = "NavigationMenu.Link";

const Indicator = React.forwardRef<React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>>(
    ({ className, ...props }, ref) => (
        <NavigationMenuPrimitive.Indicator
            ref={ref}
            className={cn(
                "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=visible]:fade-in-0 data-[state=hidden]:animate-out data-[state=hidden]:fade-out-0",
                className
            )}
            {...props}
        >
            <div className="relative top-[60%] size-2 rotate-45 rounded-tl-sm bg-border" />
        </NavigationMenuPrimitive.Indicator>
    )
);
Indicator.displayName = "NavigationMenu.Indicator";

export { Root, List, Item, Trigger, Content, Viewport, Link, Indicator };

Test Your Component

  • 1. Add the component to a test page
  • 2. Check all props and variants work as expected
  • 3. Verify responsive behavior
  • 4. Test with different content/data
  • 5. Ensure accessibility features work correctly

external opens the link in a new tab with rel="noopener noreferrer", an icon and screen-reader text.

Without Viewport

By default panels share one animated viewport under the bar. viewport={false} positions each panel under its own trigger.

Mobile

  • Triggers open on tap; panels are clamped to the screen width and their grids collapse to one column below the sm breakpoint.
  • For a full mobile menu, render the same links in a Drawer behind a menu button and show Navigation Menu from the md breakpoint.

Accessibility

  • Rendered as a <nav> landmark; name it with aria-label when the page has more than one.
  • Triggers are buttons with aria-expanded and aria-controls. Enter or Space opens a panel; ArrowDown or Tab moves into it; Escape closes it and returns focus to the trigger.
  • Left/Right arrows move between top-level items. Items are links, not menu items, so screen readers list them as links.
  • active sets aria-current="page". External links say "(opens in a new tab)".
  • Hover opens panels after a short delay; pointer and keyboard both work, so nothing is hover-only.

API Reference

Import with import * as NavigationMenu from "@/components/ui/NavigationMenu".

See Radix UI docs for base props.

PropTypeDefaultDescription
viewport"boolean""true"

Render panels in the shared viewport.

value / defaultValue / onValueChange"string""undefined"

The open item (controlled).

delayDuration"number""200"

Hover delay before opening (ms).

orientation
"horizontal""vertical"
"horizontal"

Arrow-key direction.

aria-label"string""undefined"

Landmark name.

className"string""undefined"

Additional classes, merged last.

The top-level list.

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

A top-level item (a link, or a trigger with content).

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

Opens the item's panel.

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

The panel. Lay links out inside it.

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

PropTypeDefaultDescription
href"string""undefined"

Destination.

active"boolean""false"

The current page (aria-current="page").

external / externalLabel"boolean / string""false / (opens in a new tab)"

New tab, safe rel, icon and text.

variant
"panel""top"
"panel"

Block link in a panel, or top-level bar link.

onSelect"(event) => void""undefined"

Runs before the menu closes; preventDefault keeps it open.

asChild"boolean""false"

Render your router's link (e.g. Next.js Link).

className"string""undefined"

Additional classes, merged last.

The shared panel container (rendered by Root).

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

Optional arrow under the open trigger.

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

Used in these blocks

Block families with at least one block built on this component. Each page previews its blocks and lists the components and packages they use.

Marketing