"use client";

import { MorphingMenu, type MenuItem } from "./MorphingMenu";
import type { ReactNode } from "react";

function Icon({ children }: { children: ReactNode }) {
  return <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">{children}</svg>;
}

// Edit this array to make the menu your own. Add href or onSelect to any leaf.
// Filled paths set stroke="none" so the shared SVG stroke cannot alter their artwork.
// A simple notes app. Actions stay on the page for this demo.
// Notebooks opens directly from the dock; Settings is available only in the expanded More list.
export const menuItems: readonly MenuItem[] = [
  {
    id: "home", label: "Home",
    icon: <Icon>
      <path d="M9.5 16.5V20H7C5.34315 20 4 18.6569 4 17V10.5383C4 9.57254 4.4649 8.66586 5.24909 8.10222L10.2491 4.50847C11.2953 3.75653 12.7047 3.75653 13.7509 4.50847L18.7509 8.10222C19.5351 8.66586 20 9.57254 20 10.5383V17C20 18.6569 18.6569 20 17 20H14.5V16.5C14.5 15.1193 13.3807 14 12 14C10.6193 14 9.5 15.1193 9.5 16.5Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
    </Icon>,
    activeIcon: <Icon>
      <path d="M14.3345 3.69652C12.9396 2.69394 11.0604 2.69393 9.66546 3.69652L4.66546 7.29027C3.61987 8.04178 3 9.25069 3 10.5383V17.0001C3 19.2092 4.79086 21.0001 7 21.0001H8.5C9.05228 21.0001 9.5 20.5524 9.5 20.0001V16.5001C9.5 15.1194 10.6193 14.0001 12 14.0001C13.3807 14.0001 14.5 15.1194 14.5 16.5001V20.0001C14.5 20.5524 14.9477 21.0001 15.5 21.0001H17C19.2091 21.0001 21 19.2092 21 17.0001V10.5383C21 9.25069 20.3801 8.04178 19.3345 7.29027L14.3345 3.69652Z" fill="currentColor" stroke="none" />
    </Icon>,
  },
  {
    id: "search", label: "Discover",
    icon: <Icon>
      <path d="M11 18C14.866 18 18 14.866 18 11C18 7.13401 14.866 4 11 4C7.13401 4 4 7.13401 4 11C4 14.866 7.13401 18 11 18Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
      <path d="M20 20L16.05 16.05" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
    </Icon>,
    activeIcon: <Icon>
      <path d="M11 18C14.866 18 18 14.866 18 11C18 7.13401 14.866 4 11 4C7.13401 4 4 7.13401 4 11C4 14.866 7.13401 18 11 18Z" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
      <path d="M20 20L16.05 16.05" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
    </Icon>,
  },
  {
    id: "favorites", label: "Favorites",
    icon: <Icon>
      <path d="M21 10C21 15.75 12.75 20 12 20C11.25 20 3 15.75 3 10C3 6 5.5 4 8 4C10.5 4 12 5.5 12 5.5C12 5.5 13.5 4 16 4C18.5 4 21 6 21 10Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
    </Icon>,
    activeIcon: <Icon>
      <path d="M12.5836 3.8721C12.3615 3.99329 12.1665 4.11496 12 4.22818C11.8335 4.11496 11.6385 3.99329 11.4164 3.8721C10.6185 3.4369 9.45449 3 8 3C6.48169 3 4.96498 3.60857 3.83296 4.81606C2.69616 6.02865 2 7.78592 2 10C2 13.3448 4.37277 16.1023 6.58187 17.9272C7.71336 18.8619 8.86688 19.6065 9.7917 20.1203C10.2539 20.377 10.6687 20.5816 11.004 20.7253C11.1707 20.7967 11.3289 20.858 11.4705 20.9033C11.5784 20.9378 11.7841 21 12 21C12.2159 21 12.4216 20.9378 12.5295 20.9033C12.6711 20.858 12.8293 20.7967 12.996 20.7253C13.3313 20.5816 13.7461 20.377 14.2083 20.1203C15.1331 19.6065 16.2866 18.8619 17.4181 17.9272C19.6272 16.1023 22 13.3448 22 10C22 7.78592 21.3038 6.02865 20.167 4.81606C19.035 3.60857 17.5183 3 16 3C14.5455 3 13.3815 3.4369 12.5836 3.8721Z" fill="currentColor" stroke="none" />
    </Icon>,
  },
  {
    id: "notebooks", label: "Notebooks",
    icon: <Icon>
      <path d="M7 4H17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M5 7.5H19" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M18.8687 11H5.13128C3.80864 11 2.85025 12.2609 3.20425 13.5353L4.3897 17.8029C4.75039 19.1014 5.93263 20 7.28026 20H16.7197C18.0674 20 19.2496 19.1014 19.6103 17.8029L20.7958 13.5353C21.1498 12.2609 20.1914 11 18.8687 11Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </Icon>,
    activeIcon: <Icon>
      <path d="M18.868 10C20.8517 10.0003 22.2886 11.8914 21.7577 13.8027L20.5731 18.0703C20.0922 19.8016 18.5154 20.9999 16.7186 21H7.27917C5.48235 21 3.9056 19.8016 3.42468 18.0703L2.24011 13.8027C1.7092 11.8913 3.14603 10.0003 5.12976 10H18.868Z" fill="currentColor" stroke="none" />
      <path d="M18.9989 6.5C19.5511 6.50007 19.9989 6.94776 19.9989 7.5C19.9989 8.05224 19.5511 8.49993 18.9989 8.5H4.9989C4.44661 8.5 3.9989 8.05228 3.9989 7.5C3.9989 6.94772 4.44661 6.5 4.9989 6.5H18.9989Z" fill="currentColor" stroke="none" />
      <path d="M16.9989 3C17.5511 3.00007 17.9989 3.44776 17.9989 4C17.9989 4.55224 17.5511 4.99993 16.9989 5H6.9989C6.44661 5 5.9989 4.55228 5.9989 4C5.9989 3.44772 6.44661 3 6.9989 3H16.9989Z" fill="currentColor" stroke="none" />
    </Icon>,
    children: [
      {
        id: "personal", label: "Personal",
        icon: <Icon>
          <path d="M15.5 6.5C15.5 8.433 13.933 10 12 10C10.067 10 8.5 8.433 8.5 6.5C8.5 4.567 10.067 3 12 3C13.933 3 15.5 4.567 15.5 6.5Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
          <path d="M11.9997 13C9.02123 13 6.67402 14.5539 5.43304 16.8621C4.59593 18.4191 6.02717 20 7.79494 20H16.2044C17.9722 20 19.4034 18.4191 18.5663 16.8621C17.3254 14.5539 14.9781 13 11.9997 13Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
        </Icon>,
        activeIcon: <Icon>
          <path d="M12 2C9.51472 2 7.5 4.01472 7.5 6.5C7.5 8.98528 9.51472 11 12 11C14.4853 11 16.5 8.98528 16.5 6.5C16.5 4.01472 14.4853 2 12 2Z" fill="currentColor" stroke="none" />
          <path d="M12.0001 12C8.64237 12 5.96263 13.7661 4.55269 16.3886C3.92347 17.5589 4.16063 18.7737 4.85818 19.6414C5.53148 20.479 6.62375 21 7.79536 21H16.2049C17.3765 21 18.4687 20.479 19.142 19.6414C19.8396 18.7737 20.0768 17.5589 19.4475 16.3886C18.0376 13.7661 15.3578 12 12.0001 12Z" fill="currentColor" stroke="none" />
        </Icon>,
      },
      {
        id: "work", label: "Work",
        icon: <Icon>
          <path d="M21 10C21 8.34315 19.6569 7 18 7H6C4.34315 7 3 8.34315 3 10V17C3 18.6569 4.34315 20 6 20H18C19.6569 20 21 18.6569 21 17V10Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          <path d="M8.00391 7V6C8.00391 4.34315 9.34705 3 11.0039 3H13.0039C14.6607 3 16.0039 4.34315 16.0039 6V7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          <path d="M3 11.5L12 13L21 11" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          <path d="M12 12V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
        </Icon>,
        activeIcon: <Icon>
          <path d="M22 17C22 19.2091 20.2091 21 18 21H6C3.79086 21 2 19.2091 2 17V12.3467L11 13.8467V14C11 14.5523 11.4477 15 12 15C12.5523 15 13 14.5523 13 14V13.8018L22 11.8018V17Z" fill="currentColor" stroke="none" />
          <path fillRule="evenodd" clipRule="evenodd" d="M13.0039 2C15.213 2.00001 17.0039 3.79086 17.0039 6H18C20.1265 6 21.864 7.65942 21.9912 9.75391L12.9688 11.7588C12.8607 11.3233 12.469 11 12 11C11.5084 11 11.1005 11.3548 11.0166 11.8223L2 10.3193V10C2 7.79086 3.79086 6 6 6H7.00391C7.00391 3.79086 8.79477 2 11.0039 2H13.0039ZM11.0039 4C9.89934 4 9.00391 4.89543 9.00391 6H15.0039C15.0039 4.89544 14.1085 4.00001 13.0039 4H11.0039Z" fill="currentColor" stroke="none" />
        </Icon>,
      },
      {
        id: "ideas", label: "Ideas",
        icon: <Icon>
          <path d="M12 3C8.13401 3 5 6.13401 5 10C5 12.5463 6.35958 14.7751 8.39241 16C8.75337 16.2175 9 16.5949 9 17.0163V19C9 20.6569 10.3431 22 12 22C13.6569 22 15 20.6569 15 19V17.0163C15 16.5949 15.2466 16.2175 15.6076 16C17.6404 14.7751 19 12.5463 19 10C19 6.13401 15.866 3 12 3Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
          <path d="M15 18H9" stroke="currentColor" strokeWidth="2" strokeLinecap="square" strokeLinejoin="round" />
        </Icon>,
        activeIcon: <Icon>
          <path d="M4 10C4 5.58172 7.58172 2 12 2C16.4183 2 20 5.58172 20 10C20 12.9113 18.4443 15.4582 16.1237 16.8565C16.0827 16.8812 16.0415 16.9056 16 16.9296V17H8V16.9296C7.95853 16.9056 7.9173 16.8812 7.87631 16.8565C5.55568 15.4582 4 12.9113 4 10Z" fill="currentColor" stroke="none" />
          <path d="M8 19C8 21.2091 9.79086 23 12 23C14.2091 23 16 21.2091 16 19H8Z" fill="currentColor" stroke="none" />
        </Icon>,
      },
    ],
  },
  {
    id: "settings", label: "Settings", showInBar: false,
    icon: <Icon>
      <path fillRule="evenodd" clipRule="evenodd" d="M15 10C16.6575 10 18 8.6575 18 7C18 5.3425 16.6575 4 15 4C13.3425 4 12 5.3425 12 7C12 8.6575 13.3425 10 15 10Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M12 7H4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M20 7H18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      <path fillRule="evenodd" clipRule="evenodd" d="M9 20C10.6575 20 12 18.6575 12 17C12 15.3425 10.6575 14 9 14C7.3425 14 6 15.3425 6 17C6 18.6575 7.3425 20 9 20Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
      <path d="M6 17H4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
      <path d="M20 17H12.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
    </Icon>,
    activeIcon: <Icon>
      <path d="M9 13C10.8645 13 12.4302 14.2744 12.874 16H20C20.5523 16 21 16.4477 21 17C21 17.5523 20.5523 18 20 18H12.874C12.4302 19.7256 10.8645 21 9 21C7.13554 21 5.56982 19.7256 5.12598 18H4C3.44772 18 3 17.5523 3 17C3 16.4477 3.44772 16 4 16H5.12598C5.56982 14.2744 7.13554 13 9 13Z" fill="currentColor" stroke="none" />
      <path d="M15 3C16.8645 3 18.4302 4.27443 18.874 6H20C20.5523 6 21 6.44772 21 7C21 7.55228 20.5523 8 20 8H18.874C18.4302 9.72557 16.8645 11 15 11C13.1355 11 11.5698 9.72557 11.126 8H4C3.44772 8 3 7.55228 3 7C3 6.44772 3.44772 6 4 6H11.126C11.5698 4.27443 13.1355 3 15 3Z" fill="currentColor" stroke="none" />
    </Icon>,
    children: [
      {
        id: "profile", label: "Profile",
        icon: <Icon>
          <path d="M16.5 3H7.5C5.84315 3 4.5 4.34315 4.5 6V18C4.5 19.6569 5.84315 21 7.5 21H16.5C18.1569 21 19.5 19.6569 19.5 18V6C19.5 4.34315 18.1569 3 16.5 3Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          <circle cx="12" cy="12" r="2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          <path d="M16 21C16 18.7909 14.2091 17 12 17C9.79086 17 8 18.7909 8 21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          <path d="M10 6.5H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
        </Icon>,
        activeIcon: <Icon>
          <path fillRule="evenodd" clipRule="evenodd" d="M16.5 2C18.7091 2 20.5 3.79086 20.5 6V18C20.5 20.2091 18.7091 22 16.5 22H7.5C5.29086 22 3.5 20.2091 3.5 18V6C3.5 3.79086 5.29086 2 7.5 2H16.5ZM12 16C10.2797 16 8.17818 17.1256 7.62402 19.0273C7.46958 19.5576 7.93307 20.0156 8.48535 20.0156H15.5156C16.0677 20.0154 16.5304 19.5574 16.376 19.0273C15.8218 17.1256 13.7203 16 12 16ZM12 9.5C10.6193 9.5 9.5 10.6193 9.5 12C9.5 13.3807 10.6193 14.5 12 14.5C13.3807 14.5 14.5 13.3807 14.5 12C14.5 10.6193 13.3807 9.5 12 9.5ZM10 4C9.44772 4 9 4.44772 9 5C9 5.55228 9.44772 6 10 6H14C14.5523 6 15 5.55228 15 5C15 4.44772 14.5523 4 14 4H10Z" fill="currentColor" stroke="none" />
        </Icon>,
      },
      {
        id: "appearance", label: "Appearance",
        icon: <Icon>
          <path d="M19 11V4C19 3.44772 18.5523 3 18 3H11.5L10 5L8.5 3H6C5.44772 3 5 3.44771 5 4V11C5 12.6569 6.34315 14 8 14H9.5L9.19956 18.5066C9.09156 20.1266 10.3764 21.5 12 21.5C13.6236 21.5 14.9084 20.1266 14.8004 18.5066L14.5 14H16C17.6569 14 19 12.6569 19 11Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          <path d="M5 10H19" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
        </Icon>,
        activeIcon: <Icon>
          <path d="M19.874 12C19.43 13.7252 17.8638 15 16 15H14.5L14.8008 19.5068C14.9087 21.1267 13.6235 22.5 12 22.5C10.3765 22.5 9.09135 21.1267 9.19922 19.5068L9.5 15H8C6.13616 15 4.57002 13.7252 4.12598 12H19.874Z" fill="currentColor" stroke="none" />
          <path d="M8 2C8.31476 2 8.61095 2.14858 8.7998 2.40039L10 4L11.2002 2.40039C11.3891 2.14858 11.6852 2 12 2H18C19.1046 2 20 2.89543 20 4V10H4V4C4 2.89543 4.89543 2 6 2H8Z" fill="currentColor" stroke="none" />
        </Icon>,
      },
      {
        id: "upgrade", label: "Upgrade",
        icon: <Icon>
          <path d="M5.75 9.25C7.26878 9.25 8.5 10.4812 8.5 12C8.5 13.5188 7.26878 14.75 5.75 14.75C4.23122 14.75 3 13.5188 3 12C3 10.4812 4.23122 9.25 5.75 9.25Z" stroke="currentColor" strokeWidth="2" strokeLinecap="square" />
          <path d="M18.25 9.25C19.7688 9.25 21 10.4812 21 12C21 13.5188 19.7688 14.75 18.25 14.75C16.7312 14.75 15.5 13.5188 15.5 12C15.5 10.4812 16.7312 9.25 18.25 9.25Z" stroke="currentColor" strokeWidth="2" strokeLinecap="square" />
          <path d="M12 15.5C13.5188 15.5 14.75 16.7312 14.75 18.25C14.75 19.7688 13.5188 21 12 21C10.4812 21 9.25 19.7688 9.25 18.25C9.25 16.7312 10.4812 15.5 12 15.5Z" stroke="currentColor" strokeWidth="2" strokeLinecap="square" />
          <path d="M12 3C13.5188 3 14.75 4.23122 14.75 5.75C14.75 7.26878 13.5188 8.5 12 8.5C10.4812 8.5 9.25 7.26878 9.25 5.75C9.25 4.23122 10.4812 3 12 3Z" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="square" />
        </Icon>,
        activeIcon: <Icon>
          <path fillRule="evenodd" clipRule="evenodd" d="M12 2C9.92893 2 8.25 3.67893 8.25 5.75C8.25 7.82107 9.92893 9.5 12 9.5C14.0711 9.5 15.75 7.82107 15.75 5.75C15.75 3.67893 14.0711 2 12 2ZM10.25 5.75C10.25 4.7835 11.0335 4 12 4C12.9665 4 13.75 4.7835 13.75 5.75C13.75 6.7165 12.9665 7.5 12 7.5C11.0335 7.5 10.25 6.7165 10.25 5.75Z" fill="currentColor" stroke="none" />
          <path d="M2 12C2 9.92893 3.67893 8.25 5.75 8.25C7.82107 8.25 9.5 9.92893 9.5 12C9.5 14.0711 7.82107 15.75 5.75 15.75C3.67893 15.75 2 14.0711 2 12Z" fill="currentColor" stroke="none" />
          <path d="M14.5 12C14.5 9.92893 16.1789 8.25 18.25 8.25C20.3211 8.25 22 9.92893 22 12C22 14.0711 20.3211 15.75 18.25 15.75C16.1789 15.75 14.5 14.0711 14.5 12Z" fill="currentColor" stroke="none" />
          <path d="M8.25 18.25C8.25 16.1789 9.92893 14.5 12 14.5C14.0711 14.5 15.75 16.1789 15.75 18.25C15.75 20.3211 14.0711 22 12 22C9.92893 22 8.25 20.3211 8.25 18.25Z" fill="currentColor" stroke="none" />
        </Icon>,
      },
      {
        id: "invite", label: "Invite a friend",
        icon: <Icon>
          <path d="M5 11V6C5 4.34315 6.34315 3 8 3H16C17.6569 3 19 4.34315 19 6V11M10 8H14M3 12.3874V17C3 18.6569 4.34315 20 6 20H18C19.6569 20 21 18.6569 21 17V12.3874C21 11.7049 20.3313 11.2229 19.6838 11.4387L12.9487 13.6838C12.3329 13.889 11.6671 13.889 11.0513 13.6838L4.31623 11.4387C3.66869 11.2229 3 11.7049 3 12.3874Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
        </Icon>,
        activeIcon: <Icon>
          <path d="M10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9H14C14.5523 9 15 8.55228 15 8C15 7.44772 14.5523 7 14 7H10Z" fill="currentColor" stroke="none" />
          <path fillRule="evenodd" clipRule="evenodd" d="M4 10.3859V6C4 3.79086 5.79086 2 8 2H16C18.2091 2 20 3.79086 20 6V10.3859C21.0641 10.3842 22 11.2455 22 12.3874V17C22 19.2091 20.2091 21 18 21H6C3.79086 21 2 19.2091 2 17V12.3874C2 11.2455 2.9359 10.3842 4 10.3859ZM6 6C6 4.89543 6.89543 4 8 4H16C17.1046 4 18 4.89543 18 6V10.9459L12.6325 12.7351C12.2219 12.8719 11.7781 12.8719 11.3675 12.7351L6 10.9459V6Z" fill="currentColor" stroke="none" />
        </Icon>,
      },
      { id: "sign-out", label: "Sign out", icon: <Icon>
      <path d="M10 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5M10 12h11m-4-4 4 4-4 4" />
    </Icon> },
    ],
  },
];

export default function MenuExample() {
  return <MorphingMenu items={menuItems} defaultActiveId="home" />;
}
"use client";

import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { animate } from "motion";
import { useReducedMotion } from "motion/react";
import "./MorphingMenu.css";

export type MenuAction = {
  id: string;
  label: string;
  icon: ReactNode;
  activeIcon?: ReactNode;
  href?: string;
  onSelect?: () => void;
};

// One level of children keeps the component small and the navigation predictable.
export type MenuItem = MenuAction & {
  children?: readonly MenuAction[];
  /** Set false to keep this item in More without adding a dock shortcut. */
  showInBar?: boolean;
};

export type MorphingMenuProps = {
  items: readonly MenuItem[];
  defaultActiveId?: string;
  activeId?: string;
  onActiveChange?: (id: string) => void;
  label?: string;
  moreLabel?: string;
  backLabel?: string;
  className?: string;
  style?: CSSProperties;
};

type View = { kind: "collapsed" } | { kind: "main" } | { kind: "group"; id: string };
const spring = { type: "spring", duration: 0.4, bounce: 0.24 } as const;
const compress = { duration: 0.1, ease: [0.4, 0, 0.2, 1] } as const;
const HOVER_DELAY = 400;
const HOVER_GRACE_PERIOD = 300;

export function MorphingMenu({
  items, defaultActiveId, activeId, onActiveChange,
  label = "App navigation", moreLabel = "More", backLabel = "Back", className = "", style,
}: MorphingMenuProps) {
  const id = useId();
  const [view, setView] = useState<View>({ kind: "collapsed" });
  const [selection, setSelection] = useState(defaultActiveId ?? items[0]?.id);
  const [tooltip, setTooltip] = useState<{ label: string; x: number } | null>(null);
  const rootRef = useRef<HTMLElement>(null);
  const shellRef = useRef<HTMLDivElement>(null);
  const barRef = useRef<HTMLDivElement>(null);
  const previousView = useRef<View>(view);
  const returnTarget = useRef("more");
  const openedWithKeyboard = useRef(false);
  const restoringFocus = useRef(false);
  const suppressHoverUntilMove = useRef(false);
  const focusNext = useRef<string | null>(null);
  const tooltipTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const tooltipResetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const hintsRevealed = useRef(false);
  const barHovered = useRef(false);
  const reducedMotion = useReducedMotion();
  const selected = activeId ?? selection;
  const expanded = view.kind !== "collapsed";
  const barItems = items.filter((item) => item.showInBar !== false);
  const groups = items.filter((item) => item.children?.length);

  function hideTooltip() {
    clearTimeout(tooltipTimer.current);
    setTooltip(null);
  }

  function dismissTooltip() {
    hideTooltip();
    clearTimeout(tooltipResetTimer.current);
    hintsRevealed.current = false;
  }

  function showTooltip(element: HTMLElement, text: string, immediate = false) {
    clearTimeout(tooltipTimer.current);
    const root = rootRef.current;
    const bar = barRef.current;
    if (!root || !bar || expanded || restoringFocus.current) return;
    if (suppressHoverUntilMove.current && !immediate) return;
    // Use layout coordinates so the shell's temporary scale cannot offset hints.
    const next = { label: text, x: (root.clientWidth - bar.offsetWidth) / 2 + element.offsetLeft + element.offsetWidth / 2 };
    clearTimeout(tooltipResetTimer.current);
    const reveal = () => {
      hintsRevealed.current = true;
      setTooltip(next);
    };
    if (immediate || hintsRevealed.current) reveal();
    else tooltipTimer.current = setTimeout(reveal, HOVER_DELAY);
  }

  function open(next: View, origin: string, keyboard: boolean) {
    dismissTooltip();
    if (!expanded) {
      returnTarget.current = origin;
      openedWithKeyboard.current = keyboard;
    }
    focusNext.current = "first";
    setView(next);
  }

  function close(restoreFocus = true, target = returnTarget.current) {
    dismissTooltip();
    suppressHoverUntilMove.current = true;
    focusNext.current = restoreFocus && openedWithKeyboard.current ? target : null;
    setView({ kind: "collapsed" });
  }

  function back() {
    if (view.kind !== "group") return;
    focusNext.current = view.id;
    setView({ kind: "main" });
  }

  function select(item: MenuAction) {
    setSelection(item.id);
    onActiveChange?.(item.id);
    if (expanded) close(true, view.kind === "group" ? view.id : item.id);
    else dismissTooltip();
    item.onSelect?.();
  }

  // The shell stays mounted. Interrupting an animation cancels its next phase,
  // then the next transition starts from the currently rendered geometry.
  useLayoutEffect(() => {
    const root = rootRef.current;
    const shell = shellRef.current;
    const bar = barRef.current;
    if (!root || !shell || !bar) return;
    const old = previousView.current;
    previousView.current = view;
    const panels = [...root.querySelectorAll<HTMLElement>(".morphing-menu__panel")];
    const panel = panels.find((element) => element.getAttribute("aria-hidden") === "false");
    const running: ReturnType<typeof animate>[] = [];
    let cancelled = false;
    const track = (animation: ReturnType<typeof animate>) => { running.push(animation); return animation; };
    const targetSize = () => ({ width: panel?.offsetWidth ?? bar.offsetWidth, height: panel?.offsetHeight ?? bar.offsetHeight });
    const setSize = () => {
      const size = targetSize();
      shell.style.width = `${size.width}px`;
      shell.style.height = `${size.height}px`;
    };
    const changed = old.kind !== view.kind || (old.kind === "group" && view.kind === "group" && old.id !== view.id);
    const crossingBar = (old.kind === "collapsed") !== (view.kind === "collapsed");
    const snap = reducedMotion || !changed;

    if (snap) setSize();
    else if (crossingBar) {
      const width = Math.min(bar.offsetWidth, bar.offsetWidth > 250 ? 280 : 200);
      const compression = track(animate(shell, { width, height: bar.offsetHeight > 52 ? 32 : 28 }, compress));
      void compression.finished.then(() => {
        if (!cancelled) track(animate(shell, targetSize(), { ...spring, bounce: expanded ? 0.24 : 0.15 }));
      }).catch(() => {}); // A stopped transition must never resume its second phase.
    } else {
      track(animate(shell, targetSize(), { ...spring, duration: 0.25, bounce: 0.1 }));
    }

    track(animate(bar, {
      opacity: expanded ? 0 : 1, scale: expanded && !reducedMotion ? 0.8 : 1,
      filter: expanded && !reducedMotion ? "blur(8px)" : "blur(0px)",
    }, {
      duration: snap ? 0 : expanded ? 0.15 : 0.22,
      delay: !snap && !expanded ? 0.08 : 0,
      ...(!expanded ? { ease: "easeOut" as const } : {}),
    }));

    for (const layer of panels) {
      const visible = layer === panel;
      for (const [index, row] of [...layer.querySelectorAll<HTMLElement>(".morphing-menu__row")].entries()) {
        if (visible && crossingBar && !snap) {
          Object.assign(row.style, { opacity: "0", transform: "translateY(48px)", filter: "blur(4px)" });
        }
        track(animate(row, {
          opacity: visible ? 1 : 0,
          y: visible || reducedMotion ? 0 : 16,
          filter: visible || reducedMotion ? "blur(0px)" : "blur(2px)",
        }, {
          ...spring, duration: snap ? 0 : visible ? (crossingBar ? 0.4 : 0.25) : 0.12,
          bounce: crossingBar ? 0.3 : 0,
          delay: !snap && visible ? (crossingBar ? 0.2 : 0) + index * 0.02 : 0,
        }));
      }
    }

    const destination = expanded ? panel : bar;
    const controls = [...(destination?.querySelectorAll<HTMLElement>("[data-menu-item]") ?? [])];
    const focus = focusNext.current;
    focusNext.current = null;
    if (focus) {
      // Restoring navigation position is not a fresh request to show a hint.
      restoringFocus.current = true;
      const target = focus === "first" ? controls[0] : controls.find((element) => element.dataset.menuItem === focus);
      // Items shown only in More have no collapsed shortcut to return to.
      (target ?? (!expanded ? controls.find((element) => element.dataset.menuItem === "more") : undefined))?.focus({ preventScroll: true });
      restoringFocus.current = false;
    }

    // Geometry is measured from CSS, including the larger touch target tier.
    const resize = () => setView((current) => ({ ...current }));
    window.addEventListener("resize", resize);
    return () => { cancelled = true; running.forEach((animation) => animation.stop()); window.removeEventListener("resize", resize); };
  }, [view, reducedMotion, items]);

  useEffect(() => {
    if (!expanded) return;
    const outside = (event: PointerEvent) => {
      if (event.target instanceof Node && !rootRef.current?.contains(event.target)) close(false);
    };
    document.addEventListener("pointerdown", outside);
    return () => document.removeEventListener("pointerdown", outside);
  }, [expanded]);

  useEffect(() => () => {
    clearTimeout(tooltipTimer.current);
    clearTimeout(tooltipResetTimer.current);
  }, []);

  function control(item: MenuItem, inBar: boolean) {
    const hasChildren = Boolean(item.children?.length);
    // Store only the destination; its parent reflects selection without owning state.
    const active = selected === item.id || Boolean(item.children?.some((child) => child.id === selected));
    const content = <>
      <span className="morphing-menu__icon" aria-hidden="true">{active && item.activeIcon ? item.activeIcon : item.icon}</span>
      {!inBar && <span className="morphing-menu__label">{item.label}</span>}
      {!inBar && hasChildren && <Chevron />}
    </>;
    const props = {
      className: inBar ? "morphing-menu__shortcut" : "morphing-menu__row",
      "data-menu-item": item.id,
      "aria-label": inBar ? item.label : undefined,
      "aria-current": active ? (hasChildren ? "true" as const : "page" as const) : undefined,
      "aria-expanded": hasChildren ? view.kind === "group" && view.id === item.id : undefined,
      "aria-controls": hasChildren ? `${id}-group-${item.id}` : undefined,
      onClick: (event: React.MouseEvent<HTMLElement>) => hasChildren ? open({ kind: "group", id: item.id }, item.id, event.detail === 0) : select(item),
      onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {
        if (inBar && event.pointerType === "mouse") showTooltip(event.currentTarget, item.label);
      },
      onPointerLeave: hideTooltip,
      onPointerDown: dismissTooltip,
      onFocus: (event: React.FocusEvent<HTMLElement>) => {
        if (inBar && event.currentTarget.matches(":focus-visible")) showTooltip(event.currentTarget, item.label, true);
      },
      onBlur: hideTooltip,
    };
    return item.href && !hasChildren
      ? <a key={item.id} href={item.href} {...props}>{content}</a>
      : <button key={item.id} type="button" {...props}>{content}</button>;
  }

  return (
    <nav ref={rootRef} aria-label={label} className={`morphing-menu ${className}`} data-view={view.kind}
      style={{ "--mm-count": barItems.length + 1, ...style } as CSSProperties}
      onPointerMove={(event) => {
        // A collapsing bar can move under a stationary cursor and fire enter.
        // Only an actual pointer movement should revive a dismissed hover hint.
        if (!suppressHoverUntilMove.current || event.pointerType !== "mouse" || (!event.movementX && !event.movementY)) return;
        suppressHoverUntilMove.current = false;
        const shortcut = event.target instanceof Element ? event.target.closest<HTMLElement>(".morphing-menu__shortcut") : null;
        if (shortcut) showTooltip(shortcut, shortcut.getAttribute("aria-label") ?? "");
      }}
      onKeyDown={(event) => {
        if (event.key !== "Escape") return;
        suppressHoverUntilMove.current = true;
        dismissTooltip();
        if (!expanded) return;
        event.preventDefault();
        event.stopPropagation();
        if (view.kind === "group") back(); else close();
      }}
      onBlur={(event) => {
        if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
        if (expanded) {
          if (event.relatedTarget instanceof Node) close(false);
        }
        // Keyboard-only visits have no pointer-leave event to expire the session.
        else if (!barHovered.current) dismissTooltip();
      }}>
      <div ref={shellRef} className="morphing-menu__shell">
        <div ref={barRef} className="morphing-menu__bar" aria-hidden={expanded} inert={expanded}
          onPointerEnter={(event) => {
            if (event.pointerType !== "mouse") return;
            barHovered.current = true;
            clearTimeout(tooltipResetTimer.current);
          }}
          onPointerLeave={(event) => {
            if (event.pointerType !== "mouse") return;
            barHovered.current = false;
            hideTooltip();
            clearTimeout(tooltipResetTimer.current);
            // Padding keeps hints ready; only leaving the whole bar starts grace.
            tooltipResetTimer.current = setTimeout(() => { hintsRevealed.current = false; }, HOVER_GRACE_PERIOD);
          }}>
          {barItems.map((item) => control(item, true))}
          <button type="button" className="morphing-menu__shortcut" data-menu-item="more" aria-label={moreLabel}
            aria-expanded={expanded} aria-controls={`${id}-main`} onClick={(event) => open({ kind: "main" }, "more", event.detail === 0)}
            onPointerEnter={(event) => { if (event.pointerType === "mouse") showTooltip(event.currentTarget, moreLabel); }}
            onPointerLeave={hideTooltip} onPointerDown={dismissTooltip} onBlur={hideTooltip}
            onFocus={(event) => { if (event.currentTarget.matches(":focus-visible")) showTooltip(event.currentTarget, moreLabel, true); }}>
            <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m8.5 17 2.1 2.1a2 2 0 0 0 2.8 0l2.1-2.1M8.5 7l2.1-2.1a2 2 0 0 1 2.8 0L15.5 7" /></svg>
          </button>
        </div>
        <div id={`${id}-main`} className="morphing-menu__panel" aria-hidden={view.kind !== "main"} inert={view.kind !== "main"}>
          {items.map((item) => control(item, false))}
        </div>
        {groups.map((group) => {
          const visible = view.kind === "group" && view.id === group.id;
          return <div key={group.id} id={`${id}-group-${group.id}`} className="morphing-menu__panel" aria-label={group.label} aria-hidden={!visible} inert={!visible}>
            <button type="button" className="morphing-menu__row" data-menu-item="back" onClick={back}>
              <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m10 6-6 6 6 6M5 12h15" /></svg><span>{backLabel}</span>
            </button>
            {group.children!.map((item) => control(item, false))}
          </div>;
        })}
      </div>
      {tooltip && !expanded && <div className="morphing-menu__tooltip" aria-hidden="true" style={{ left: tooltip.x }}>{tooltip.label}</div>}
    </nav>
  );
}

function Chevron() {
  return <svg className="morphing-menu__chevron" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m10 8 2.94 2.94a1.5 1.5 0 0 1 0 2.12L10 16" /></svg>;
}
/* Self-contained: inherit the host's color-scheme and font; customize --mm-*.
   The inverted surface matches the original menu in both light and dark mode. */
.morphing-menu {
  --mm-surface: light-dark(#0d0d0d, #fff);
  --mm-ink: light-dark(rgb(255 255 255 / 90%), rgb(13 13 13 / 90%));
  --mm-hover: light-dark(rgb(255 255 255 / 6%), rgb(0 0 0 / 4%));
  --mm-button: 44px;
  --mm-inset: 4px;
  --mm-bar-height: calc(var(--mm-button) + 2 * var(--mm-inset));
  --mm-bar-width: min(calc(var(--mm-count) * var(--mm-button) + 2 * var(--mm-inset)), calc(100vw - 32px));
  --mm-panel-width: min(268px, calc(100vw - 32px));
  --mm-panel-padding: 8px;
  --mm-radius: calc(var(--mm-bar-height) / 2);
  position: relative;
  width: var(--mm-bar-width);
  height: var(--mm-bar-height);
  flex: none;
  font-family: inherit;
  -webkit-tap-highlight-color: transparent;
}

.morphing-menu *, .morphing-menu *::before, .morphing-menu *::after { box-sizing: border-box; }

.morphing-menu__shell {
  position: absolute;
  bottom: 0;
  left: 50%;
  translate: -50% 0;
  width: var(--mm-bar-width);
  height: var(--mm-bar-height);
  border-radius: var(--mm-radius);
  background: var(--mm-surface, #0d0d0d);
  color: var(--mm-ink, #fff);
  box-shadow:
    inset 0 1px 1px light-dark(rgb(255 255 255 / 8%), rgb(255 255 255 / 40%)),
    inset 0 -1px 2px light-dark(rgb(0 0 0 / 50%), rgb(0 0 0 / 20%)),
    0 1px 2px rgb(0 0 0 / 12%);
  overflow: hidden;
  isolation: isolate;
}

.morphing-menu__bar {
  position: absolute;
  bottom: 0;
  left: 50%;
  translate: -50% 0;
  display: flex;
  align-items: center;
  padding: var(--mm-inset);
  width: var(--mm-bar-width);
  height: var(--mm-bar-height);
}

.morphing-menu__shortcut, .morphing-menu__row {
  appearance: none;
  display: flex;
  align-items: center;
  flex: none;
  border: 0;
  margin: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  font-size: 15px;
  font-weight: 500;
  line-height: 20px;
  text-decoration: none;
  cursor: pointer;
  touch-action: manipulation;
  scale: 1;
  transition: background 150ms ease, scale 180ms cubic-bezier(0.22, 1, 0.36, 1);
}

.morphing-menu__shortcut {
  justify-content: center;
  width: 0;
  flex: 1;
  height: var(--mm-button);
  padding: 0;
  border-radius: 999px;
}

.morphing-menu__panel {
  position: absolute;
  top: 0;
  left: 50%;
  translate: -50% 0;
  width: var(--mm-panel-width);
  padding: var(--mm-panel-padding);
  max-height: calc(100dvh - 64px);
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
}

.morphing-menu [aria-hidden="true"] { pointer-events: none; }
.morphing-menu__row {
  gap: 8px;
  width: 100%;
  min-height: 40px;
  padding: 10px 12px;
  border-radius: calc(var(--mm-radius) - var(--mm-panel-padding));
  text-align: left;
}
.morphing-menu__panel[aria-hidden="true"] .morphing-menu__row { opacity: 0; }
.morphing-menu__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.morphing-menu__icon { display: flex; flex: none; }
.morphing-menu svg { width: 20px; height: 20px; flex-shrink: 0; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.morphing-menu__chevron { margin-left: auto; opacity: 0.6; }
.morphing-menu__shortcut:focus-visible, .morphing-menu__row:focus-visible { outline: 2px solid currentColor; outline-offset: -3px; }

.morphing-menu__tooltip {
  position: absolute;
  bottom: calc(100% + 8px);
  translate: -50% 0;
  padding: 6px 10px;
  border-radius: 14px;
  background: light-dark(#fff, #171717);
  color: light-dark(#111, #fefefe);
  box-shadow: 0 0 0 1px light-dark(rgb(0 0 0 / 6%), rgb(255 255 255 / 10%)), 0 1px 2px -1px rgb(0 0 0 / 6%), 0 2px 4px rgb(0 0 0 / 4%);
  font-size: 14px;
  font-weight: 500;
  line-height: 20px;
  white-space: nowrap;
  pointer-events: none;
}

@media (hover: hover) {
  .morphing-menu__shortcut:hover, .morphing-menu__row:hover { background: var(--mm-hover); }
}
@media (pointer: coarse) {
  .morphing-menu { --mm-button: 48px; --mm-bar-width: min(296px, calc(100vw - 32px)); --mm-panel-width: min(296px, calc(100vw - 32px)); --mm-panel-padding: 12px; }
  .morphing-menu__row { min-height: 44px; padding-block: 12px; }
}
@media (prefers-reduced-motion: no-preference) {
  .morphing-menu__shortcut:active, .morphing-menu__row:active { transition-duration: 100ms; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }
  .morphing-menu__shortcut:active { scale: 0.95; }
  .morphing-menu__row:active { scale: 0.96; }
}
@media (prefers-reduced-motion: reduce) {
  .morphing-menu__shortcut, .morphing-menu__row { transition: none; scale: 1; }
}
# Morphing menu

A compact React navigation bar that compresses into an expanded list, with a one-level drill-down. This standalone adaptation of Danny Williams's site menu removes the original theme switch and site integrations.

## Get started

```sh
npm install motion
```

Requires React and React DOM (React 19 is used by this demo). Copy `MorphingMenu.tsx`, `MorphingMenu.css`, and `example.tsx` into the same folder, then render:

```tsx
import MenuExample from "./morphing-menu/example";

export default function App() {
  return <MenuExample />;
}
```

The component imports its CSS. No Tailwind, icon package, Astro router, fonts, or site styles are required. Use a client boundary in frameworks that distinguish client components. The stylesheet inherits your font and `color-scheme`. To follow system appearance, set `color-scheme: light dark` on your page; to use your own theme toggle, set the page's color scheme to the chosen theme.

## Make it yours

Edit `menuItems` in `example.tsx`: change labels and SVGs, add links or callbacks, and replace the Notebooks and Settings drawers' children. IDs must be stable and unique; reserve `more` and `back` for the built-in controls. Each item has `id`, `label`, and `icon`. Supply an `activeIcon` for selectable destinations: it replaces the outline icon in both the dock and expanded list. Selection does not add a background; backgrounds indicate hover. Without `activeIcon`, the icon stays unchanged and `aria-current` still identifies the selected destination.

- Add `href: "/favorites"` for a native link.
- Add `onSelect: () => openDiscover()` for an application action.
- Set `showInBar: false` to show an item only in the expanded More list. Items appear in both views by default.
- Add `children` to open a panel instead of navigating. Child actions support the same fields, without further nesting. A parent with children is always a button; its own href/callback is not used.

The example includes default and active variants for all twelve named icons. Filled artwork uses `stroke="none"` to avoid inheriting the shared outline stroke. The selected destination and its parent use their active variants. Discover uses a heavier 3px outline when selected and a 2px outline otherwise; the other paired icons use filled artwork. Selecting Work fills Notebooks in the dock; selecting Appearance fills Settings in More and leaves the dock destinations unselected.

The demo represents a simple notes app: Home, Discover, Favorites, Notebooks, and More form the dock. Notebooks opens Personal, Work, and Ideas. Settings appears only in More and opens Profile, Appearance, Upgrade, Invite a friend, and Sign out. Selecting any destination, including drawer items, updates selection and dismisses the menu. These are placeholder destinations; no real navigation or account operation runs. Connect real application behavior with your own callbacks.

```tsx
import { MorphingMenu } from "./MorphingMenu";
import { menuItems } from "./example";

<MorphingMenu
  items={menuItems}
  defaultActiveId="home"
  label="App navigation"
  moreLabel="More"
  backLabel="Back"
/>
```

For route-driven state, pass `activeId` and `onActiveChange`. Otherwise, `defaultActiveId` initializes the internal selection. Use the destination ID, including nested IDs such as `work` or `appearance`. Every leaf selection calls `onActiveChange` with that ID; parent highlighting is derived from its children. Opening drawers, going Back, or dismissing with Escape does not change selection. The selected leaf exposes `aria-current="page"`; its parent exposes `aria-current="true"` to identify the current section. The sample uses four shortcuts plus More. Keep the bar compact so each shortcut has a comfortable touch target.

## Position and style

The component occupies the collapsed bar's footprint and expands upward, anchored at its bottom center. Leave room above it and avoid clipping ancestors. It stays in normal document flow; the host owns placement:

```tsx
<div style={{ position: "fixed", bottom: 32, insetInline: 0, display: "flex", justifyContent: "center" }}>
  <MenuExample />
</div>
```

Use `className` or `style` to override the namespaced CSS variables: `--mm-surface`, `--mm-ink`, `--mm-hover`, and `--mm-panel-width`. Defaults invert the surface against the inherited light/dark scheme. Geometry has a larger touch tier. Tune the compression and spring constants near the top of `MorphingMenu.tsx`; press scales and durations live in CSS.

## Interaction and accessibility

- More opens the full list, including Settings. Notebooks opens its children directly from the dock or main list; Settings opens its drawer from the main list.
- Back always returns to the main list. Escape returns from children to main, then collapses the menu.
- Selecting a leaf closes the panel. Outside pointer interaction or tabbing away dismisses without stealing focus.
- Opening a panel moves focus into it. When opened with the keyboard, closing by selection or Escape restores focus to the relevant shortcut (or More for an item without a dock shortcut) without reviving its tooltip. Pointer-opened menus close without focusing a shortcut; a fresh hover or keyboard focus shows hints again.
- Native buttons and links use normal Tab/Shift+Tab navigation. These are navigation disclosures, not ARIA menu widgets.
- Hidden views are inert and excluded from the accessibility tree. Multiple instances use separate generated IDs.
- Tooltips label the icon shortcuts on mouse hover or keyboard focus. Accessible names are always present.
- The first mouse hint requires a continuous 400 ms hover over one shortcut. Once revealed, hints switch immediately across shortcuts; padding hides the current hint while keeping the others ready. Leaving the bar gives 300 ms to return before requiring the initial delay again. Selection, opening a panel, or Escape clears this session.
- Tabbing out of the menu also clears the hint session when the pointer is outside the bar. If the pointer stays inside, hints remain ready for continued mouse use.
- Reduced motion skips the shell morph, cascade, blur, and press scaling.
- Interrupting a transition cancels its pending second phase so rapid input cannot reopen stale content.
Morphing menu