Navbars & Headersv0.3.0
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.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand 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.tsimport 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; }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
| Prop | Type | Default | Description |
|---|---|---|---|
logo* | ReactNode | – | Mark shown in the center circle. |
logoHref | string | "/" | Where the center circle links. |
logoLabel | string | "Home" | Accessible name for the center link. |
left* | NavItem[] | – | Links left of the circle. |
right* | NavItem[] | – | Links right of the circle. |
actions | NavAction[] | – | Actions shown in the narrow menu. |
active | string | – | Label of the current page; it gets the dot. Use "logo" for the circle. Uncontrolled when left out. |
defaultActive | string | "logo" | Initial current page when `active` is left out. |
onActiveChange | (label: string) => void | – | Called with the label of the link that was chosen. |
accent | string | "#2f8f62" | Circle and dot color. |
surface | string | "#ffffff" | Bar color. |
label | string | "Main" | Accessible name for the navigation. |
className | string | – | 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.