← All components

NavigationMenu

A glass bar whose one panel travels between triggers, morphing to what each one holds.

The idea

There is one panel, not one per trigger. Moving along the bar it stays open, slides to sit under the new trigger, changes size to fit, and its content slides out one way as the next arrives from the other — so the reader sees one menu turning to its next page, not a door shutting and another opening. A pointer opens with hover intent, touch opens on the tap, and the keyboard gets Enter, the arrows, Tab into the panel and out to the next item, and Escape back to the trigger.

Composes DropMenu.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

NavigationMenu — usage18 lines
import { NavigationMenu, Button } from "mihirmodi-ui";

<NavigationMenu
  brand={<Wordmark />}
  end={<Button size="sm" variant="primary">Sign in</Button>}
  items={[
    {
      label: "Product",
      columns: 2,
      links: [
        { title: "Sessions", description: "Ask a question of your data.", href: "/product/sessions", icon: <ThreadIcon /> },
        { title: "Reports", description: "A deck or a memo, from any answer.", href: "/product/reports", icon: <DocIcon /> },
      ],
    },
    { label: "Docs", href: "/docs" },
    { label: "Pricing", href: "/pricing" },
  ]}
/>

Props

PropNotes
items{ label, href?, links?, columns?, content? }[]A plain link, or a panel of `{ title, description, href, icon? }` links, or your own content.
brand / endReactNodeBefore the items, and pushed to the far end — a wordmark, a sign-in button.
labelstringThe landmark's name.
linkLinkComponentYour router's Link, if there is no UIProvider above.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/navigation/NavigationMenu.tsx412 lines
"use client";

import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { DropMenu } from "../DropMenu.js";
import { ChevronDownIcon } from "../../lib/glyph.js";
import { EASE_OUT_EXPO, SLIDE } from "../../lib/motion.js";
import { useLinkComponent, type LinkComponent } from "../../lib/link.js";

export type NavigationMenuLink = {
  title: string;
  /** One line under the title: what is behind the link, not a restatement of it. */
  description?: string;
  href: string;
  /** A small mark in a tile beside the title. */
  icon?: ReactNode;
};

export type NavigationMenuItem = {
  label: string;
  /** A plain top-level link. Give this or a panel (`links` / `content`), not both. */
  href?: string;
  /** The panel's links, drawn as title and one-line description. */
  links?: NavigationMenuLink[];
  /** Lay `links` out in one column or two. */
  columns?: 1 | 2;
  /** A panel you draw yourself. Give it a width — the panel is sized to what it holds. */
  content?: ReactNode;
};

export type NavigationMenuProps = {
  items: NavigationMenuItem[];
  /** The landmark's name. */
  label?: string;
  /** Before the items — a wordmark. */
  brand?: ReactNode;
  /** After the items — a sign-in button. */
  end?: ReactNode;
  link?: LinkComponent;
  className?: string;
};

/* Hover intent. Opening waits a beat so a pointer crossing the bar on its way
   somewhere else does not flash a panel; closing waits a little longer, so the
   diagonal from a trigger down to a link does not drop the panel on the way. */
const OPEN_MS = 90;
const CLOSE_MS = 180;
/** A click this soon after hover opened the panel is the same intent, not a second one. */
const CLICK_GRACE_MS = 400;
/** How far a panel's content travels as it is swapped for its neighbour's. */
const TRAVEL = 28;

type Geo = { x: number; w: number; h: number; tx: number; tw: number; instant: boolean };

/**
 * A site's top navigation: a glass bar of links, some of which open a panel of
 * richer links — a title and one line on what is behind each.
 *
 * There is ONE panel, not one per trigger. Moving from trigger to trigger, it
 * stays open and morphs: it slides to sit under the new trigger, changes its
 * size to the new content, and the content itself slides out one way while the
 * next slides in from the other, in the direction you moved. Separate panels
 * opening and closing in turn would make the reader watch a door shut and
 * another open; one panel that travels says you are still in the same menu,
 * looking at the next part of it. A raised tile behind the open trigger slides
 * with it, so the bar says which part you are reading.
 *
 * Pointer, keyboard and touch each get their own way in. A pointer opens with
 * hover intent — a short delay to open, a longer one to close — and a click
 * straight after a hover-open keeps it open rather than toggling it shut. Touch
 * ignores hover and opens on the tap. The keyboard opens with Enter, Space or
 * Down; Tab from an open trigger goes into its panel and out of the panel to
 * the next item in the bar; Left and Right move along the bar; Escape closes
 * and puts focus back on the trigger. The panel opens with the same squash and
 * glass as every other menu here, and only its contents travel, so the blur
 * sits still.
 */
export function NavigationMenu({ items, label = "Main", brand, end, link, className = "" }: NavigationMenuProps) {
  const reduce = useReducedMotion();
  const Link = useLinkComponent(link);
  const id = useId();
  const viewportId = `${id}-panel`;
  const [active, setActive] = useState<number | null>(null);
  const open = active !== null;
  const [geo, setGeo] = useState<Geo | null>(null);
  const [focusPanel, setFocusPanel] = useState(false);

  const root = useRef<HTMLElement>(null);
  const panels = useRef<(HTMLDivElement | null)[]>([]);
  const wasOpen = useRef(false);
  const openTimer = useRef<number | undefined>(undefined);
  const closeTimer = useRef<number | undefined>(undefined);
  const openedAt = useRef(0);

  const hasPanel = (it: NavigationMenuItem) => !!(it.links?.length || it.content);
  // The panel last shown, kept while the menu closes so its content leaves with it.
  const last = useRef(0);
  if (active !== null) last.current = active;
  const shown = active ?? last.current;
  const topAt = (i: number) => root.current?.querySelector<HTMLElement>(`[data-top="${i}"]`) ?? null;

  const clearTimers = () => {
    window.clearTimeout(openTimer.current);
    window.clearTimeout(closeTimer.current);
  };
  useEffect(() => clearTimers, []);

  const close = (refocus = false) => {
    clearTimers();
    const was = active;
    setActive(null);
    if (refocus && was !== null) topAt(was)?.focus();
  };

  // Place the panel and the tile. Measured before paint, and snapped rather than
  // tweened on a fresh open: there is nowhere for them to have come from.
  //
  // The panel can still be unlaid-out when this first runs — it mounts inside
  // DropMenu's presence boundary, and a zero read would pin the glass at 2px —
  // so a miss retries on the next frames, and a ResizeObserver keeps the size
  // honest while open (a web font landing, content that grows).
  useLayoutEffect(() => {
    if (active === null) {
      wasOpen.current = false;
      return;
    }
    const instant = !wasOpen.current;
    let frame = 0;
    let tries = 0;
    let ro: ResizeObserver | undefined;
    const measure = () => {
      const node = panels.current[active];
      const trigger = topAt(active);
      const nav = root.current;
      if (!node || !trigger || !nav || !node.offsetWidth) {
        if (tries++ < 10) frame = requestAnimationFrame(measure);
        return;
      }
      if (!ro && typeof ResizeObserver !== "undefined") {
        ro = new ResizeObserver(() => measure());
        ro.observe(node);
      }
      const w = node.offsetWidth;
      const h = node.offsetHeight;
      const navBox = nav.getBoundingClientRect();
      const tBox = trigger.getBoundingClientRect();
      const centre = tBox.left - navBox.left + tBox.width / 2;
      const room = navBox.width - w;
      const x = room <= 0 ? 0 : Math.min(Math.max(centre - w / 2, 0), room);
      setGeo({ x, w, h, tx: trigger.offsetLeft, tw: trigger.offsetWidth, instant });
      wasOpen.current = true;
    };
    measure();
    return () => {
      cancelAnimationFrame(frame);
      ro?.disconnect();
    };
  }, [active]);

  useEffect(() => {
    if (!focusPanel || active === null) return;
    panels.current[active]?.querySelector<HTMLElement>("a, button, [tabindex]")?.focus();
    setFocusPanel(false);
  }, [focusPanel, active]);

  useEffect(() => {
    if (!open) return;
    const onDown = (e: globalThis.PointerEvent) => {
      if (!root.current?.contains(e.target as Node)) close();
    };
    const onKey = (e: globalThis.KeyboardEvent) => {
      if (e.key !== "Escape") return;
      const inside = root.current?.contains(document.activeElement);
      close(!!inside);
    };
    window.addEventListener("pointerdown", onDown);
    window.addEventListener("keydown", onKey);
    return () => {
      window.removeEventListener("pointerdown", onDown);
      window.removeEventListener("keydown", onKey);
    };
  });

  const openAt = (i: number, how: "pointer" | "press") => {
    clearTimers();
    if (how === "pointer") openedAt.current = performance.now();
    setActive(i);
  };

  const onTriggerEnter = (e: PointerEvent, i: number) => {
    if (e.pointerType === "touch") return;
    window.clearTimeout(closeTimer.current);
    window.clearTimeout(openTimer.current);
    if (open) openAt(i, "pointer");
    else openTimer.current = window.setTimeout(() => openAt(i, "pointer"), OPEN_MS);
  };

  const onTriggerClick = (i: number) => {
    if (active === i) {
      if (performance.now() - openedAt.current < CLICK_GRACE_MS) return;
      close();
    } else openAt(i, "press");
  };

  const onRootLeave = (e: PointerEvent) => {
    if (e.pointerType === "touch") return;
    window.clearTimeout(openTimer.current);
    if (open) closeTimer.current = window.setTimeout(() => setActive(null), CLOSE_MS);
  };

  // Along the bar: the arrows walk the top-level items, and an open panel follows focus.
  const onBarKey = (e: KeyboardEvent, i: number) => {
    const n = items.length;
    const go = (j: number) => {
      e.preventDefault();
      topAt(j)?.focus();
      if (open) {
        if (hasPanel(items[j])) setActive(j);
        else close();
      }
    };
    if (e.key === "ArrowRight") go((i + 1) % n);
    else if (e.key === "ArrowLeft") go((i - 1 + n) % n);
    else if (e.key === "Home") go(0);
    else if (e.key === "End") go(n - 1);
    else if (hasPanel(items[i]) && e.key === "ArrowDown") {
      e.preventDefault();
      if (active !== i) openAt(i, "press");
      setFocusPanel(true);
    } else if (hasPanel(items[i]) && e.key === "Tab" && !e.shiftKey && active === i) {
      // The panel follows the bar in the DOM, so Tab would skip it; send it in.
      e.preventDefault();
      setFocusPanel(true);
    }
  };

  const onPanelKey = (e: KeyboardEvent, i: number) => {
    const all = Array.from(panels.current[i]?.querySelectorAll<HTMLElement>("a, button") ?? []);
    const at = all.indexOf(document.activeElement as HTMLElement);
    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      const to = e.key === "ArrowDown" ? Math.min(all.length - 1, at + 1) : at - 1;
      if (to < 0) topAt(i)?.focus();
      else all[to]?.focus();
    } else if (e.key === "Tab" && e.shiftKey && at === 0) {
      e.preventDefault();
      topAt(i)?.focus();
    } else if (e.key === "Tab" && !e.shiftKey && at === all.length - 1) {
      const next = topAt(i + 1);
      if (next) {
        e.preventDefault();
        next.focus();
      }
      close();
    }
  };

  const top =
    "relative inline-flex h-8 items-center gap-1 rounded-[10px] px-3 text-[13px] font-medium leading-none transition-colors duration-150 ease-out";
  const tween = (instant: boolean) => (instant || reduce ? { duration: 0 } : SLIDE);

  return (
    <LazyMotion features={domAnimation} strict>
      <nav
        ref={root}
        aria-label={label}
        onPointerEnter={() => window.clearTimeout(closeTimer.current)}
        onPointerLeave={onRootLeave}
        onBlur={(e) => {
          if (open && !root.current?.contains(e.relatedTarget as Node | null)) close();
        }}
        className={`relative ${className}`}
      >
        <div className="glass-nav dock-shadow flex items-center gap-1 rounded-[14px] border-[0.5px] border-gray-alpha-400 p-1">
          {brand && <div className="flex shrink-0 items-center pl-2 pr-2">{brand}</div>}
          <ul className="relative flex items-center">
            {geo && (
              <m.li
                aria-hidden
                role="presentation"
                className="pointer-events-none absolute inset-y-0 left-0 rounded-[10px] bg-highlight shadow-[0_0_0_0.5px_var(--color-gray-alpha-400),0_2px_2px_rgba(0,0,0,0.04)]"
                initial={false}
                animate={{ x: geo.tx, width: geo.tw, opacity: open ? 1 : 0 }}
                transition={{ x: tween(geo.instant), width: tween(geo.instant), opacity: { duration: open ? 0.15 : 0.12, ease: "easeOut" } }}
              />
            )}
            {items.map((it, i) => {
              const on = active === i;
              return (
                <li key={it.label}>
                  {hasPanel(it) ? (
                    <button
                      data-top={i}
                      type="button"
                      aria-expanded={on}
                      aria-controls={viewportId}
                      onPointerEnter={(e) => onTriggerEnter(e, i)}
                      onPointerLeave={() => window.clearTimeout(openTimer.current)}
                      onClick={() => onTriggerClick(i)}
                      onKeyDown={(e) => onBarKey(e, i)}
                      className={`${top} ${on ? "text-gray-1000" : "text-muted hover:text-gray-1000"}`}
                    >
                      {it.label}
                      <ChevronDownIcon
                        className={`-mr-0.5 h-3 w-3 opacity-70 transition-transform duration-200 ease-geist motion-reduce:transition-none ${on ? "rotate-180" : ""}`}
                      />
                    </button>
                  ) : (
                    <Link
                      href={it.href ?? "#"}
                      data-top={i}
                      onPointerEnter={(e: PointerEvent) => {
                        // A plain link has nothing to show, so pointing at it lets the panel go.
                        if (e.pointerType !== "touch" && open) closeTimer.current = window.setTimeout(() => setActive(null), CLOSE_MS);
                      }}
                      onKeyDown={(e: KeyboardEvent) => onBarKey(e, i)}
                      className={`${top} text-muted hover:text-gray-1000`}
                    >
                      {it.label}
                    </Link>
                  )}
                </li>
              );
            })}
          </ul>
          {end && <div className="ml-auto flex shrink-0 items-center pl-2">{end}</div>}
        </div>

        <m.div
          className="absolute left-0 top-full z-50 pt-2"
          initial={false}
          animate={{ x: geo?.x ?? 0 }}
          transition={tween(geo?.instant ?? true)}
        >
          <DropMenu id={viewportId} open={open} placement="down" origin="origin-top">
            <m.div
              className="glass-menu dock-shadow relative overflow-hidden rounded-[14px] border-[0.5px] border-gray-alpha-400"
              initial={false}
              // Always defined: an `animate` that starts undefined under
              // `initial={false}` ignores the first size it is given, and the
              // glass stays at its 2px of border on the first open.
              animate={{ width: geo?.w ?? 0, height: geo?.h ?? 0 }}
              transition={tween(geo?.instant ?? true)}
            >
              {items.map((it, i) => {
                if (!hasPanel(it)) return null;
                const on = i === shown;
                const side = i < shown ? -1 : 1;
                return (
                  <m.div
                    key={it.label}
                    ref={(el) => {
                      panels.current[i] = el;
                    }}
                    inert={!on}
                    aria-hidden={!on}
                    onKeyDown={(e) => onPanelKey(e, i)}
                    className={`absolute left-0 top-0 w-max ${on ? "" : "pointer-events-none"}`}
                    initial={false}
                    animate={{ opacity: on ? 1 : 0, x: on || reduce ? 0 : side * TRAVEL }}
                    transition={{
                      x: geo?.instant ? { duration: 0 } : SLIDE,
                      opacity: { duration: on ? 0.24 : 0.14, ease: EASE_OUT_EXPO },
                    }}
                  >
                    {it.content ?? <PanelLinks links={it.links ?? []} columns={it.columns ?? 1} Link={Link} onPick={() => close()} />}
                  </m.div>
                );
              })}
            </m.div>
          </DropMenu>
        </m.div>
      </nav>
    </LazyMotion>
  );
}

function PanelLinks({
  links,
  columns,
  Link,
  onPick,
}: {
  links: NavigationMenuLink[];
  columns: 1 | 2;
  Link: LinkComponent | "a";
  onPick: () => void;
}) {
  return (
    <ul className={`grid gap-0.5 p-1.5 ${columns === 2 ? "w-[500px] grid-cols-2" : "w-[280px]"}`}>
      {links.map((l) => (
        <li key={l.href + l.title}>
          <Link
            href={l.href}
            onClick={onPick}
            className="flex gap-3 rounded-[8px] p-2.5 transition-colors duration-150 ease-out hover:bg-gray-alpha-200 focus-visible:bg-gray-alpha-200"
          >
            {l.icon && (
              <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border-[0.5px] border-gray-alpha-400 bg-background-100 text-muted">
                {l.icon}
              </span>
            )}
            <span className="min-w-0">
              <span className="block text-[13px] font-medium leading-[1.3] text-gray-1000">{l.title}</span>
              {l.description && <span className="mt-1 block text-[12px] leading-[1.45] text-muted">{l.description}</span>}
            </span>
          </Link>
        </li>
      ))}
    </ul>
  );
}
packages/ui/src/components/DropMenu.tsx105 lines
"use client";

import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import type { CSSProperties, ReactNode } from "react";

/* Two springs of deliberately different character — the effect comes from letting
   them disagree. The grow axis is light and underdamped, so it sails past its
   final size; the cross axis is stiffer, heavier-damped and slower, so it arrives
   late. Through the overshoot the panel is briefly taller and narrower than it
   ends up, then it settles: squash and stretch out of physics rather than out of
   a hand-authored timeline. */
const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;

/* Closing is quick and flat. A bounce on the way out reads as indecision — the
   panel looks like it is not sure it wants to go. Open and close are not mirror
   images of each other, and should not be. */
const CLOSE = { duration: 0.13, ease: "easeIn" } as const;

export type DropPlacement = "up" | "down";

export type DropMenuProps = {
  open: boolean;
  /** Which way it grows. `up` for a bottom dock, `down` for a header. */
  placement?: DropPlacement;
  /** `transform-origin` utility. Defaults to the edge the menu grows from. */
  origin?: string;
  id?: string;
  className?: string;
  style?: CSSProperties;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
  children: ReactNode;
};

/**
 * The open/close motion for a floating menu that grows out of a trigger sitting
 * against one edge — a drop-up over a dock, a sheet over a tab bar, a popover
 * under a header.
 *
 * It animates a wrapper and nothing else, so what you put inside is yours: give
 * the child the glass, the border and the padding. That split is deliberate — a
 * `backdrop-filter` on an element that is itself being transformed is weakened
 * or dropped entirely by most engines, so the blur must live on a child that is
 * holding still.
 *
 * The panel mounts only while open, which keeps a closed menu out of the tab
 * order and off the paint. The wrapper is always present, so a trigger's
 * `aria-controls` still resolves to something. Under reduced motion the elastic
 * scaling drops out and a plain fade remains.
 *
 * You own `open` — this is not a popover with its own state. Escape handling,
 * click-outside and hover intent belong to the trigger, which knows what it is.
 */
export function DropMenu({
  open,
  placement = "up",
  origin,
  id,
  className = "",
  style,
  onMouseEnter,
  onMouseLeave,
  children,
}: DropMenuProps) {
  const reduce = useReducedMotion();
  // Start nudged back toward the trigger, so it reads as coming out of it.
  const away = placement === "up" ? 4 : -4;
  const anchor = origin ?? (placement === "up" ? "origin-bottom" : "origin-top");

  return (
    <div
      id={id}
      style={style}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
      // Empty and inert when closed, so it can never sit over the page as a trap.
      className={`${className} ${open ? "" : "pointer-events-none"}`}
    >
      <LazyMotion features={domAnimation} strict>
        <AnimatePresence>
          {open && (
            <m.div
              className={anchor}
              initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: away }}
              animate={reduce ? { opacity: 1 } : { opacity: 1, scaleY: 1, scaleX: 1, y: 0 }}
              exit={
                reduce
                  ? { opacity: 0, transition: CLOSE }
                  : { opacity: 0, scaleY: 0.9, scaleX: 0.97, y: away, transition: CLOSE }
              }
              transition={
                reduce
                  ? { duration: 0.15 }
                  : { scaleY: GROW, y: GROW, scaleX: CROSS, opacity: { duration: 0.14, ease: "easeOut" } }
              }
            >
              {children}
            </m.div>
          )}
        </AnimatePresence>
      </LazyMotion>
    </div>
  );
}
packages/ui/src/lib/link.tsx49 lines
"use client";

import { createContext, useContext, type AnchorHTMLAttributes, type ComponentType, type ReactNode } from "react";

/**
 * Anything that renders an `<a>` given an `href`. Next's `Link`, React Router's
 * `Link` (rename `to`), TanStack's, or nothing at all.
 */
export type LinkComponent = ComponentType<
  { href: string; children?: ReactNode } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">
>;

const LinkContext = createContext<LinkComponent | null>(null);

/**
 * Teaches every component beneath it how to route.
 *
 * Without it the components render plain `<a>` tags, which navigate correctly
 * but reload the page — fine for a marketing site, wrong inside an app. Rather
 * than depend on a router (which would tie this package to one framework), the
 * dependency is inverted: you hand in the link component you already use.
 *
 *     import Link from "next/link";
 *     <UIProvider link={Link}>{children}</UIProvider>
 *
 * Every component also takes a `link` prop, so a single odd one out does not
 * need its own provider.
 */
export function UIProvider({ link, children }: { link?: LinkComponent; children: ReactNode }) {
  return <LinkContext.Provider value={link ?? null}>{children}</LinkContext.Provider>;
}

/** The nearest provided link component, or `"a"` when there is none. */
export function useLinkComponent(override?: LinkComponent): LinkComponent | "a" {
  const ctx = useContext(LinkContext);
  return override ?? ctx ?? "a";
}

/**
 * Whether a destination is the current one — React Router's NavLink semantics,
 * kept: "/" matches exactly, everything else matches itself and its descendants,
 * so a nav entry stays lit while you are reading one of its children.
 *
 * Exported because the dock takes `active` as a boolean rather than computing it:
 * the dock has no idea what your routes mean, but you do.
 */
export function isCurrentPath(pathname: string, to: string): boolean {
  return to === "/" ? pathname === "/" : pathname === to || pathname.startsWith(`${to}/`);
}