Skip to content

Notch Nav

A bar with the logo raised in a center notch and a dot that glides to the current page.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/notch-nav.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/nav/mobile-menu.tsx
    "use client";
    
    import { useEffect, useId, useState } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ChevronDown } from "lucide-react";
    import { cn } from "@/lib/utils";
    import type { NavAction, NavItem } from "@/lib/nav/types";
    
    export interface MobileMenuProps {
      items: NavItem[];
      actions?: NavAction[];
      /** Colors for light or dark navbars. @default "light" */
      tone?: "light" | "dark";
      /** Classes for the toggle button. */
      className?: string;
      /** Classes for the panel, to place it under narrow navbars. */
      panelClassName?: string;
    }
    
    /**
     * The compact menu every navbar falls back to on narrow widths: a hamburger that morphs into an X,
     * and a panel with expandable groups and the actions. Escape closes it and returns focus.
     */
    export function MobileMenu({ items, actions = [], tone = "light", className, panelClassName }: MobileMenuProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const [open, setOpen] = useState(false);
      const [group, setGroup] = useState<string | null>(null);
      const dark = tone === "dark";
    
      useEffect(() => {
        if (!open) return;
        const onKey = (e: KeyboardEvent) => {
          if (e.key !== "Escape") return;
          setOpen(false);
          document.getElementById(`${id}-toggle`)?.focus();
        };
        window.addEventListener("keydown", onKey);
        return () => window.removeEventListener("keydown", onKey);
      }, [open, id]);
    
      const line = cn("absolute left-1/2 h-[1.5px] w-4 -translate-x-1/2 rounded-full transition-transform duration-300 motion-reduce:transition-none", dark ? "bg-white" : "bg-[#0b0b0c]");
    
      return (
        <>
          <button
            id={`${id}-toggle`}
            type="button"
            aria-expanded={open}
            aria-controls={`${id}-panel`}
            aria-label={open ? "Close menu" : "Open menu"}
            onClick={() => setOpen((o) => !o)}
            className={cn("relative grid size-9 shrink-0 cursor-pointer place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/10" : "hover:bg-black/5", className)}
          >
            <span aria-hidden="true" className={cn(line, open ? "top-1/2 rotate-45" : "top-[13px]")} />
            <span aria-hidden="true" className={cn(line, open ? "top-1/2 -rotate-45" : "top-[21px]")} />
          </button>
          <AnimatePresence>
            {open ? (
              <motion.div
                id={`${id}-panel`}
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.98 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, transition: { duration: 0.12 } }}
                transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                className={cn("absolute inset-x-2 top-full z-50 mt-2 origin-top rounded-2xl border p-2 shadow-xl", dark ? "border-white/10 bg-[#111113] text-white" : "border-black/[0.06] bg-white text-[#0b0b0c]", panelClassName)}
              >
                <ul>
                  {items.map((item) =>
                    item.items?.length ? (
                      <li key={item.label}>
                        <button
                          type="button"
                          aria-expanded={group === item.label}
                          onClick={() => setGroup((g) => (g === item.label ? null : item.label))}
                          className={cn("flex h-11 w-full cursor-pointer items-center justify-between rounded-xl px-3 text-[15px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/5" : "hover:bg-black/[0.03]")}
                        >
                          {item.label}
                          <ChevronDown className={cn("size-4 opacity-60 transition-transform", group === item.label && "rotate-180")} aria-hidden="true" />
                        </button>
                        <div className={cn("grid transition-[grid-template-rows] duration-300 motion-reduce:transition-none", group === item.label ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
                          <ul className="min-h-0 overflow-hidden" inert={group !== item.label}>
                            {item.items.map((link) => (
                              <li key={link.label}>
                                <a href={link.href} className={cn("flex items-center gap-2.5 rounded-lg py-2 pr-3 pl-6 text-[14px] outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "text-white/75 hover:text-white" : "text-[#52525b] hover:text-[#0b0b0c]")}>
                                  {link.icon ? <span aria-hidden="true" className="opacity-70 [&_svg]:size-4">{link.icon}</span> : null}
                                  {link.label}
                                </a>
                              </li>
                            ))}
                          </ul>
                        </div>
                      </li>
                    ) : (
                      <li key={item.label}>
                        <a href={item.href ?? "#"} className={cn("flex h-11 items-center rounded-xl px-3 text-[15px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/5" : "hover:bg-black/[0.03]")}>
                          {item.label}
                        </a>
                      </li>
                    ),
                  )}
                </ul>
                {actions.length ? (
                  <div className={cn("mt-2 grid gap-2 border-t pt-3", dark ? "border-white/10" : "border-black/[0.06]")}>
                    {actions.map((a, i) => (
                      <a
                        key={a.label}
                        href={a.href}
                        className={cn(
                          "flex h-11 items-center justify-center rounded-xl text-[14px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring",
                          i === actions.length - 1 ? (dark ? "bg-white text-[#0b0b0c]" : "bg-[#0b0b0c] text-white") : dark ? "border border-white/15" : "border border-black/10",
                        )}
                      >
                        {a.label}
                      </a>
                    ))}
                  </div>
                ) : null}
              </motion.div>
            ) : null}
          </AnimatePresence>
        </>
      );
    }
    lib/nav/types.ts
    import type { ReactNode } from "react";
    
    /** A link in a menu or panel. */
    export interface NavLink {
      label: string;
      href: string;
      /** A short line under the label in rich menus. */
      description?: string;
      /** An icon shown beside the label in rich menus. */
      icon?: ReactNode;
    }
    
    /** A top-level navbar entry: a plain link, or a trigger with links under it. */
    export interface NavItem {
      label: string;
      /** Destination when the item is a plain link. */
      href?: string;
      /** Links shown in the item's menu; makes the item a menu trigger. */
      items?: NavLink[];
    }
    
    /** A call-to-action button. */
    export interface NavAction {
      label: string;
      href: string;
    }
  3. Copy the source

    components/ui/notch-nav/notch-nav.tsx
    "use client";
    
    import { useId, useState, type ReactNode } from "react";
    import { motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { MobileMenu } from "@/lib/nav/mobile-menu";
    import type { NavAction, NavItem } from "@/lib/nav/types";
    
    export interface NotchNavProps {
      /** Mark shown in the center circle. */
      logo: ReactNode;
      /** Where the center circle links. @default "/" */
      logoHref?: string;
      /** Accessible name for the center link. @default "Home" */
      logoLabel?: string;
      /** Links left of the circle. */
      left: NavItem[];
      /** Links right of the circle. */
      right: NavItem[];
      /** Actions shown in the narrow menu. */
      actions?: NavAction[];
      /** Label of the current page; it gets the dot. Use "logo" for the circle. Uncontrolled when left out. */
      active?: string;
      /** Initial current page when `active` is left out. @default "logo" */
      defaultActive?: string;
      /** Called with the label of the link that was chosen. */
      onActiveChange?: (label: string) => void;
      /** Circle and dot color. @default "#2f8f62" */
      accent?: string;
      /** Bar color. @default "#ffffff" */
      surface?: string;
      /** Accessible name for the navigation. @default "Main" */
      label?: string;
      /** Classes for the header. */
      className?: string;
    }
    
    const BAR = 64;
    const NOTCH = 150;
    const DROP = 26;
    
    /** The middle piece of the bar: straight on top, dipping into a smooth cradle for the circle below. */
    function notchPath(w: number, h: number, d: number) {
      const c = w / 2;
      return `M0 0H${w}V${h}C${w - 26} ${h} ${c + 40} ${h} ${c + 34} ${h + d * 0.55}C${c + 26} ${h + d} ${c - 26} ${h + d} ${c - 34} ${h + d * 0.55}C${c - 40} ${h} 26 ${h} 0 ${h}Z`;
    }
    
    /**
     * A rounded bar with the logo in a raised circle at its center, sitting in a smooth notch that dips
     * below the bar. Links split to either side, and a small dot glides under the current page.
     */
    export function NotchNav({
      logo,
      logoHref = "/",
      logoLabel = "Home",
      left,
      right,
      actions = [],
      active,
      defaultActive = "logo",
      onActiveChange,
      accent = "#2f8f62",
      surface = "#ffffff",
      label = "Main",
      className,
    }: NotchNavProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const [own, setOwn] = useState(defaultActive);
      const current = active ?? own;
      const choose = (key: string) => {
        if (active === undefined) setOwn(key);
        onActiveChange?.(key);
      };
      const spring = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 520, damping: 34 };
    
      const dot = (key: string, place = "-bottom-1.5") =>
        current === key ? <motion.span layoutId={`${id}-dot`} aria-hidden="true" className={cn("absolute left-1/2 size-1.5 -translate-x-1/2 rounded-full", place)} style={{ background: accent }} transition={spring} /> : null;
    
      const side = (items: NavItem[], align: "start" | "end") => (
        <ul className={cn("hidden flex-1 items-center gap-1 px-4 @2xl:flex", align === "end" ? "justify-end" : "justify-start")}>
          {items.map((item) => (
            <li key={item.label} className="flex-1 text-center">
              <a
                href={item.href ?? item.items?.[0]?.href ?? "#"}
                aria-current={current === item.label ? "page" : undefined}
                onClick={() => choose(item.label)}
                className={cn(
                  "relative inline-flex h-10 items-center rounded-full px-3 text-[14px] font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                  current === item.label ? "text-[#18181b]" : "text-[#8a8a92] hover:text-[#3f3f46]",
                )}
              >
                {item.label}
                {dot(item.label)}
              </a>
            </li>
          ))}
        </ul>
      );
    
      return (
        <header className={cn("@container sticky top-0 z-40 px-4 pt-4", className)}>
          <nav aria-label={label} className="relative mx-auto max-w-4xl" style={{ height: BAR + DROP }}>
            {/* One silhouette (two rounded halves and the notch) so the shadow follows the curve. */}
            <div aria-hidden="true" className="absolute inset-x-0 top-0 flex drop-shadow-[0_10px_18px_rgb(0_0_0/0.08)]" style={{ color: surface }}>
              <span className="flex-1 rounded-l-[22px] bg-current" style={{ height: BAR }} />
              <svg width={NOTCH} height={BAR + DROP} viewBox={`0 0 ${NOTCH} ${BAR + DROP}`} className="-mx-px shrink-0">
                <path d={notchPath(NOTCH, BAR, DROP)} fill="currentColor" />
              </svg>
              <span className="flex-1 rounded-r-[22px] bg-current" style={{ height: BAR }} />
            </div>
    
            <div className="relative flex items-center" style={{ height: BAR }}>
              {side(left, "start")}
              <div className="flex flex-1 items-center pl-2 @2xl:hidden" />
              <div className="relative shrink-0" style={{ width: NOTCH }}>
                <a
                  href={logoHref}
                  aria-label={logoLabel}
                  aria-current={current === "logo" ? "page" : undefined}
                  onClick={() => choose("logo")}
                  className="group absolute top-[18px] left-1/2 grid size-[64px] -translate-x-1/2 place-items-center rounded-full text-white shadow-[0_8px_20px_-8px_rgb(0_0_0/0.45),inset_0_1px_0_rgb(255_255_255/0.25)] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
                  style={{ background: accent }}
                >
                  <span className="transition-transform duration-500 group-hover:rotate-[20deg] group-hover:scale-110 motion-reduce:transition-none [&_svg]:size-7">{logo}</span>
                  {dot("logo", "-bottom-4")}
                </a>
              </div>
              {side(right, "end")}
              <div className="flex flex-1 justify-end pr-2 @2xl:hidden">
                <MobileMenu items={[...left, ...right]} actions={actions} panelClassName="top-[calc(100%+30px)]" />
              </div>
            </div>
          </nav>
        </header>
      );
    }

Usage

import { NotchNav } from "@/components/ui/notch-nav/notch-nav";

<NotchNav logo={<Leaf />} left={[{ label: "Gardens", href: "/gardens" }]} right={[{ label: "Saved", href: "/saved" }]} />

API reference

PropTypeDefaultDescription
logo*ReactNode–Mark shown in the center circle.
logoHrefstring"/"Where the center circle links.
logoLabelstring"Home"Accessible name for the center link.
left*NavItem[]–Links left of the circle.
right*NavItem[]–Links right of the circle.
actionsNavAction[]–Actions shown in the narrow menu.
activestring–Label of the current page; it gets the dot. Use "logo" for the circle. Uncontrolled when left out.
defaultActivestring"logo"Initial current page when `active` is left out.
onActiveChange(label: string) => void–Called with the label of the link that was chosen.
accentstring"#2f8f62"Circle and dot color.
surfacestring"#ffffff"Bar color.
labelstring"Main"Accessible name for the navigation.
classNamestring–Classes for the header.
  • The bar is two rounded halves and an SVG notch drawn as one silhouette, so the drop shadow follows the curve.
  • Pass `active` to control the current page from your router, or leave it out and the component tracks clicks.
  • `accent` colors the circle and the dot; `surface` colors the bar.

Accessibility

  • The current link carries `aria-current="page"`; the dot is decorative.
  • The center circle is a real link with an accessible name (`logoLabel`).
  • Below the container's 2xl width the side links move into a menu button.