Morphing nav

One dropdown that follows you along the menu, gliding and resizing to each item’s content.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/morphing-nav.json

Overview

Separate dropdowns that blink open and shut make a menu feel like a stack of boxes. Here there is one panel. Point at an item and it drops in underneath, sized to that item’s content; move to the next and the same panel glides across and resizes while the new content slides in from the side you moved toward. A soft highlight slides along under the items as you go. It works fully from the keyboard and closes when you leave.

States

hover
A soft highlight slides and resizes under the item you point at or focus.
open
The panel drops in under the item, sized to its content and kept inside the screen.
move
The panel glides and resizes (380ms) to the next item’s content; old content slides out one way, new content in from the other.
close
Leaving the menu, Escape or a click elsewhere fades the panel away; Escape returns focus to the item.

Usage

import { MorphingNav } from "@/components/morphing-nav";

<MorphingNav
  items={[
    { id: "product", label: "Product", content: <ProductMenu /> },
    { id: "pricing", label: "Pricing", href: "/pricing" },
  ]}
/>

In a site header

Give each panel its own width; the nav does the measuring, placing and morphing.

import { MorphingNav } from "@/components/morphing-nav";

function ProductMenu() {
  return (
    <div className="grid w-[480px] grid-cols-2 gap-1 p-2">
      <a href="/workflows" className="rounded-xl p-3 hover:bg-accent">
        <span className="block text-sm font-medium">Workflows</span>
        <span className="text-xs text-muted-foreground">Chain steps that run on their own.</span>
      </a>
      {/* …more links */}
    </div>
  );
}

export function SiteHeader() {
  return (
    <header className="flex items-center justify-between border-b px-6 py-3">
      <Logo />
      <MorphingNav
        items={[
          { id: "product", label: "Product", content: <ProductMenu /> },
          { id: "solutions", label: "Solutions", content: <SolutionsMenu /> },
          { id: "pricing", label: "Pricing", href: "/pricing" },
        ]}
      />
      <a href="/signup">Start free</a>
    </header>
  );
}

Props

PropTypeDescription
items{ id; label; href?; content? }[]Items with content open the panel; items with only an href are links.
collisionPaddingnumberdefault 16Space kept from the screen edges when placing the panel.

Accessibility and motion

  • A labelled navigation landmark. Items that open a panel are buttons with aria-expanded and aria-controls; links stay links.
  • Left and Right move between items, Enter, Space or Down opens one and moves focus into it, and Escape closes it and returns focus.
  • On touch screens, items open and close with a tap; with a mouse, a short delay lets you cross the gap to the panel.
  • With reduced motion the panel switches content in place without gliding.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.

Source

›Show the full source of morphing-nav.tsx (318 lines)
"use client";

import * as React from "react";

/* ─────────────────────────────────────────────────────────
 * MORPHING NAV: one dropdown that follows you along the menu
 *
 *   hover    a soft highlight slides under the item you point at
 *   open     a single panel drops in under the item, sized to its
 *            content
 *   move     going to another item, the same panel glides over
 *            and resizes to the new content while the content
 *            slides in from the side you moved toward
 *   close    leave the menu (or press Escape) and it fades away
 *
 * Items with content open the panel; items with only an href
 * are plain links. Arrow keys move between items; Enter or Down
 * opens one and moves into it.
 * ───────────────────────────────────────────────────────── */

export interface NavItem {
  id: string;
  label: string;
  /** A plain link. */
  href?: string;
  /** What the panel shows for this item; give it its own width. */
  content?: React.ReactNode;
}

export interface MorphingNavProps extends React.HTMLAttributes<HTMLElement> {
  items: NavItem[];
  /** Space to keep from the screen edges. */
  collisionPadding?: number;
}

type Shown = { id: string; dir: number; key: number };

const EASE = "cubic-bezier(0.16,1,0.3,1)";
const MORPH = 380;
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";

const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
  const query = window.matchMedia(reducedQuery);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);

/** A panel's content, sliding in from one side and out to the other. */
function Slide({ children, dir, leaving, reduced, measureRef }: { children: React.ReactNode; dir: number; leaving: boolean; reduced: boolean; measureRef?: React.Ref<HTMLDivElement> }) {
  const ref = React.useRef<HTMLDivElement>(null);
  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el || reduced || dir === 0) return;
    const shift = 28 * dir;
    const animation = leaving
      ? el.animate([{ opacity: 1, transform: "none" }, { opacity: 0, transform: `translateX(${-shift}px)` }], { duration: MORPH * 0.7, easing: EASE, fill: "forwards" })
      : el.animate([{ opacity: 0, transform: `translateX(${shift}px)` }, { opacity: 1, transform: "none" }], { duration: MORPH, easing: EASE });
    return () => animation.cancel();
  }, [dir, leaving, reduced]);
  return (
    <div
      ref={(el) => {
        ref.current = el;
        if (typeof measureRef === "function") measureRef(el);
        else if (measureRef) (measureRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
      }}
      aria-hidden={leaving || undefined}
      inert={leaving}
      className={`absolute left-0 top-0 w-max max-w-[calc(100vw-32px)] ${leaving ? "pointer-events-none" : ""}`}
    >
      {children}
    </div>
  );
}

export function MorphingNav({ items, collisionPadding = 16, className = "", ...props }: MorphingNavProps) {
  const reduced = useReducedMotion();
  const id = React.useId();
  const rootRef = React.useRef<HTMLElement>(null);
  const triggerRefs = React.useRef(new Map<string, HTMLElement>());
  const contentRef = React.useRef<HTMLDivElement | null>(null);
  const panelRef = React.useRef<HTMLDivElement>(null);
  const closeTimer = React.useRef(0);
  const keyCounter = React.useRef(0);
  const viaKeyboard = React.useRef(false);

  const [open, setOpen] = React.useState<string | null>(null);
  const [shown, setShown] = React.useState<Shown | null>(null);
  const [leaving, setLeaving] = React.useState<Shown[]>([]);
  const [visible, setVisible] = React.useState(false);
  const [box, setBox] = React.useState({ left: 0, width: 0, height: 0, ready: false });
  const [hover, setHover] = React.useState<{ left: number; width: number } | null>(null);

  const index = (itemId: string | null) => items.findIndex((i) => i.id === itemId);

  const show = (itemId: string) => {
    window.clearTimeout(closeTimer.current);
    const item = items.find((i) => i.id === itemId);
    if (!item?.content) return;
    if (open === itemId) return;
    const dir = open ? Math.sign(index(itemId) - index(open)) : 0;
    if (shown && open) setLeaving((l) => [...l, { ...shown, dir }]);
    setShown({ id: itemId, dir, key: ++keyCounter.current });
    setOpen(itemId);
    setVisible(true);
  };

  const close = React.useCallback((delay = 0) => {
    window.clearTimeout(closeTimer.current);
    closeTimer.current = window.setTimeout(() => {
      setOpen(null);
      setVisible(false);
      setHover(null);
    }, delay);
  }, []);

  React.useEffect(() => () => window.clearTimeout(closeTimer.current), []);

  // Once it has faded, forget the panel so the next open starts fresh.
  React.useEffect(() => {
    if (visible) return;
    const timer = window.setTimeout(
      () => {
        setShown(null);
        setLeaving([]);
        setBox((b) => ({ ...b, ready: false }));
      },
      reduced ? 0 : 220
    );
    return () => window.clearTimeout(timer);
  }, [visible, reduced]);

  React.useEffect(() => {
    if (!leaving.length) return;
    const timer = window.setTimeout(() => setLeaving([]), MORPH);
    return () => window.clearTimeout(timer);
  }, [leaving]);

  // Size and place the panel for the content now showing: under its item, inside the screen.
  React.useLayoutEffect(() => {
    const content = contentRef.current;
    const root = rootRef.current;
    const trigger = open ? triggerRefs.current.get(open) : null;
    if (!content || !root || !trigger) return;
    const measure = () => {
      const r = root.getBoundingClientRect();
      const t = trigger.getBoundingClientRect();
      const width = content.offsetWidth;
      const height = content.offsetHeight;
      const viewport = document.documentElement.clientWidth;
      const center = t.left + t.width / 2;
      const left = Math.min(Math.max(center - width / 2, collisionPadding), viewport - collisionPadding - width) - r.left;
      setBox((b) => (b.left === left && b.width === width && b.height === height && b.ready ? b : { left, width, height, ready: b.width > 0 }));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(content);
    return () => observer.disconnect();
  }, [open, shown?.key, collisionPadding]);

  // Opened from the keyboard: move focus into the panel.
  React.useEffect(() => {
    if (!open || !viaKeyboard.current) return;
    viaKeyboard.current = false;
    const first = panelRef.current?.querySelector<HTMLElement>("a[href], button:not([disabled]), [tabindex]:not([tabindex='-1'])");
    first?.focus();
  }, [open, shown?.key]);

  // Escape closes and hands focus back; clicking elsewhere closes.
  React.useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Escape") return;
      triggerRefs.current.get(open)?.focus();
      close();
    };
    const onDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) close();
    };
    document.addEventListener("keydown", onKey);
    document.addEventListener("pointerdown", onDown);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerdown", onDown);
    };
  }, [open, close]);

  const point = (itemId: string) => {
    const el = triggerRefs.current.get(itemId);
    if (el) setHover({ left: el.offsetLeft, width: el.offsetWidth });
  };

  const onTriggerKey = (e: React.KeyboardEvent, item: NavItem) => {
    const i = index(item.id);
    if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
      e.preventDefault();
      const next = items[(i + (e.key === "ArrowRight" ? 1 : -1) + items.length) % items.length];
      triggerRefs.current.get(next.id)?.focus();
      if (open && next.content) show(next.id);
    } else if (item.content && (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")) {
      e.preventDefault();
      viaKeyboard.current = true;
      if (open === item.id) {
        const first = panelRef.current?.querySelector<HTMLElement>("a[href], button:not([disabled])");
        first?.focus();
      } else show(item.id);
    }
  };

  const content = shown ? items.find((i) => i.id === shown.id)?.content : null;
  const size = reduced || !box.ready ? "none" : `left ${MORPH}ms ${EASE}, width ${MORPH}ms ${EASE}, height ${MORPH}ms ${EASE}`;

  return (
    <nav
      ref={rootRef}
      aria-label="Main"
      className={`relative ${className}`}
      onPointerEnter={() => window.clearTimeout(closeTimer.current)}
      onPointerLeave={(e) => e.pointerType === "mouse" && close(140)}
      onBlur={(e) => {
        if (!rootRef.current?.contains(e.relatedTarget as Node)) close();
      }}
      {...props}
    >
      <ul className="relative flex items-center gap-0.5">
        <span
          aria-hidden="true"
          className="absolute inset-y-0 left-0 rounded-full bg-accent"
          style={{
            width: hover?.width ?? 0,
            transform: `translateX(${hover?.left ?? 0}px)`,
            opacity: hover ? 1 : 0,
            transition: reduced ? "none" : `transform 300ms ${EASE}, width 300ms ${EASE}, opacity 200ms ease-out`,
          }}
        />
        {items.map((item) => {
          const isOpen = open === item.id;
          const common = {
            ref: (el: HTMLElement | null) => {
              if (el) triggerRefs.current.set(item.id, el);
              else triggerRefs.current.delete(item.id);
            },
            onPointerEnter: (e: React.PointerEvent) => {
              point(item.id);
              if (e.pointerType === "mouse") {
                if (item.content) show(item.id);
                else if (open) close(140);
              }
            },
            onFocus: () => point(item.id),
            className: `relative flex h-9 items-center gap-1 rounded-full px-3.5 text-[13.5px] transition-colors duration-200 ${FOCUS} ${
              isOpen ? "text-foreground" : "text-muted-foreground hover:text-foreground"
            }`,
          };
          return (
            <li key={item.id}>
              {item.content ? (
                <button
                  {...common}
                  ref={common.ref}
                  type="button"
                  aria-expanded={isOpen}
                  aria-controls={isOpen ? `${id}-panel` : undefined}
                  onClick={() => (isOpen ? close() : show(item.id))}
                  onKeyDown={(e) => onTriggerKey(e, item)}
                >
                  {item.label}
                  <svg
                    aria-hidden="true"
                    viewBox="0 0 12 12"
                    className="size-3 opacity-60"
                    style={{ transform: isOpen ? "rotate(180deg)" : "none", transition: reduced ? "none" : `transform 300ms ${EASE}` }}
                  >
                    <path d="M3 4.5 6 7.5 9 4.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                </button>
              ) : (
                <a {...common} ref={common.ref} href={item.href} onKeyDown={(e) => onTriggerKey(e, item)}>
                  {item.label}
                </a>
              )}
            </li>
          );
        })}
      </ul>

      {shown && (
        <div
          ref={panelRef}
          id={`${id}-panel`}
          className="absolute top-full z-50 mt-2 overflow-hidden rounded-[20px] border border-border bg-popover text-popover-foreground animate-[ui-pop-in_240ms_cubic-bezier(0.16,1,0.3,1)_backwards] motion-reduce:animate-none"
          style={{
            left: box.left,
            width: box.width + 2,
            height: box.height + 2,
            opacity: visible ? 1 : 0,
            transform: visible ? "none" : "translateY(-4px) scale(0.98)",
            transformOrigin: "top center",
            transition: reduced ? "none" : `${size === "none" ? "" : `${size}, `}opacity 200ms ease-out, transform 260ms ${EASE}`,
          }}
        >
          {leaving.map((l) => (
            <Slide key={l.key} dir={l.dir} leaving reduced={reduced}>
              {items.find((i) => i.id === l.id)?.content}
            </Slide>
          ))}
          <Slide key={shown.key} dir={shown.dir} leaving={false} reduced={reduced} measureRef={contentRef}>
            {content}
          </Slide>
        </div>
      )}
    </nav>
  );
}

Registry item: /r/morphing-nav.json