{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hover-highlight-nav",
  "type": "registry:ui",
  "title": "Hover Highlight Nav",
  "description": "Nav links with one highlight pill that springs to the hovered or focused link and rests on the active one.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/hover-highlight-nav.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, type MotionValue } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface HoverHighlightNavItem {\n  /** Unique id. */\n  id: string;\n  label: string;\n  /** Renders an <a>; without it the item is a <button>. */\n  href?: string;\n  /** Optional leading icon. */\n  icon?: React.ReactNode;\n  /** Optional trailing badge, e.g. a count. */\n  badge?: React.ReactNode;\n}\n\nexport interface HoverHighlightNavProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  items: HoverHighlightNavItem[];\n  /** Active item id (controlled). */\n  activeId?: string;\n  /** Initially active item id (uncontrolled). */\n  defaultActiveId?: string;\n  /** Called when an item is clicked. Call event.preventDefault() for client-side routing. */\n  onActiveChange?: (id: string, event: React.MouseEvent<HTMLElement>) => void;\n  /** Lay links out in a row or a column. */\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Highlight pill style. */\n  variant?: \"soft\" | \"outline\" | \"tint\";\n  /** Separate accent underline (or side bar when vertical) under the active link. */\n  showUnderline?: boolean;\n  /** When the pointer leaves, the pill glides back to the active link; otherwise it fades out. */\n  returnToActive?: boolean;\n  /** Spring for the pill and the underline. */\n  spring?: Spring;\n  /** Underline and focus colour (also tints the \"tint\" pill). */\n  accent?: string;\n  /** Accessible name of the navigation landmark. */\n  label?: string;\n}\n\ninterface Box {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst UNDERLINE_INSET = 10; // px trimmed from each end of the underline\n\n/**\n * Navigation links with one highlight pill that glides to whichever link is hovered or keyboard-focused\n * and rests on the active link when the pointer leaves, plus a separate active underline. Link boxes are\n * measured once (and on resize) so pointer movement only drives springs on motion values: no layout reads\n * or React renders per hover. Touch input never moves the pill; it follows the active link only.\n */\nexport function HoverHighlightNav({\n  items,\n  activeId,\n  defaultActiveId,\n  onActiveChange,\n  orientation = \"horizontal\",\n  variant = \"soft\",\n  showUnderline = true,\n  returnToActive = true,\n  spring = { stiffness: 420, damping: 34, mass: 0.6 },\n  accent = \"#FF7448\",\n  label = \"Main\",\n  className = \"\",\n  style,\n  onPointerLeave,\n  onBlur,\n  ...props\n}: HoverHighlightNavProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const itemRefs = React.useRef(new Map<string, HTMLElement>());\n  const boxesRef = React.useRef(new Map<string, Box>());\n  const targetRef = React.useRef<string | null>(null); // hovered / focused id\n  const pillShownRef = React.useRef(false);\n\n  const [internal, setInternal] = React.useState(defaultActiveId ?? items[0]?.id);\n  const active = activeId ?? internal;\n  const vertical = orientation === \"vertical\";\n\n  const pill = { x: useMotionValue(0), y: useMotionValue(0), w: useMotionValue(0), h: useMotionValue(0) };\n  const pillOpacity = useMotionValue(0);\n  const line = { x: useMotionValue(0), y: useMotionValue(0), w: useMotionValue(0), h: useMotionValue(0) };\n  const lineOpacity = useMotionValue(0);\n\n  /* ------------------------------- positioning ------------------------------- */\n\n  const measure = () => {\n    const boxes = new Map<string, Box>();\n    for (const [id, el] of itemRefs.current) boxes.set(id, { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });\n    boxesRef.current = boxes;\n  };\n\n  const springTo = (values: Record<string, MotionValue<number>>, box: Record<string, number>, instant: boolean) => {\n    for (const key of Object.keys(values)) {\n      if (instant || reduceMotion) {\n        values[key].jump(box[key]);\n      } else {\n        animate(values[key], box[key], { type: \"spring\", ...spring });\n      }\n    }\n  };\n\n  const underlineBox = (box: Box) =>\n    vertical\n      ? { x: 0, y: box.y + UNDERLINE_INSET / 2, w: 2, h: Math.max(0, box.h - UNDERLINE_INSET) }\n      : { x: box.x + UNDERLINE_INSET, y: box.y + box.h - 2, w: Math.max(0, box.w - UNDERLINE_INSET * 2), h: 2 };\n\n  /** Moves the pill to the hovered / focused link, or back to the active one (or fades it out). */\n  const placePill = (instant = false) => {\n    const id = targetRef.current ?? (returnToActive ? active : null);\n    const box = id ? boxesRef.current.get(id) : undefined;\n    if (!box) {\n      pillShownRef.current = false;\n      if (reduceMotion || instant) pillOpacity.jump(0);\n      else animate(pillOpacity, 0, { duration: 0.15 });\n      return;\n    }\n    // Appearing from nothing: start at the link instead of sliding in from a stale spot.\n    const appear = !pillShownRef.current;\n    pillShownRef.current = true;\n    springTo(pill, { x: box.x, y: box.y, w: box.w, h: box.h }, instant || appear);\n    if (reduceMotion || instant) pillOpacity.jump(1);\n    else animate(pillOpacity, 1, { duration: appear ? 0.15 : 0.1 });\n  };\n\n  const placeUnderline = (instant = false) => {\n    const box = active ? boxesRef.current.get(active) : undefined;\n    if (!box) {\n      lineOpacity.jump(0);\n      return;\n    }\n    springTo(line, underlineBox(box), instant);\n    lineOpacity.jump(1);\n  };\n\n  const sync = React.useEffectEvent((instant: boolean) => {\n    measure();\n    placePill(instant);\n    placeUnderline(instant);\n  });\n\n  // Measure before paint on mount and whenever the item set or layout props change; re-measure on resize.\n  React.useLayoutEffect(() => {\n    sync(true);\n    const list = listRef.current;\n    if (!list) return;\n    const ro = new ResizeObserver(() => sync(true));\n    ro.observe(list);\n    itemRefs.current.forEach((el) => ro.observe(el));\n    return () => ro.disconnect();\n  }, [items, orientation]);\n\n  const onActiveMoved = React.useEffectEvent(() => {\n    placeUnderline(false);\n    placePill(false);\n  });\n\n  React.useEffect(() => {\n    onActiveMoved();\n  }, [active]);\n\n  /* --------------------------------- events ---------------------------------- */\n\n  const target = (id: string | null) => {\n    targetRef.current = id;\n    placePill();\n  };\n\n  const handleClick = (event: React.MouseEvent<HTMLElement>, id: string) => {\n    if (activeId === undefined) setInternal(id);\n    onActiveChange?.(id, event);\n  };\n\n  /* --------------------------------- render ---------------------------------- */\n\n  const pillClass =\n    variant === \"outline\"\n      ? \"border border-zinc-900/15 bg-white/60 dark:border-white/15 dark:bg-white/[0.03]\"\n      : variant === \"tint\"\n        ? \"bg-[color-mix(in_oklab,var(--hh-accent)_14%,transparent)] dark:bg-[color-mix(in_oklab,var(--hh-accent)_20%,transparent)]\"\n        : \"bg-zinc-900/[0.06] dark:bg-white/[0.08]\";\n\n  return (\n    <nav\n      aria-label={label}\n      className={`relative ${className}`}\n      style={{ [\"--hh-accent\" as string]: accent, ...style }}\n      onPointerLeave={(event) => {\n        if (targetRef.current !== null) target(null);\n        onPointerLeave?.(event);\n      }}\n      onBlur={(event) => {\n        // Focus left the nav entirely: release the pill.\n        const next = event.relatedTarget as Node | null;\n        if (!next || !event.currentTarget.contains(next)) target(null);\n        onBlur?.(event);\n      }}\n      {...props}\n    >\n      <ul ref={listRef} className={`relative flex ${vertical ? \"flex-col gap-0.5\" : \"items-center gap-1\"}`}>\n        <motion.span\n          aria-hidden=\"true\"\n          className={`pointer-events-none absolute left-0 top-0 rounded-lg ${pillClass}`}\n          style={{ x: pill.x, y: pill.y, width: pill.w, height: pill.h, opacity: pillOpacity }}\n        />\n        {showUnderline && (\n          <motion.span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-0 top-0 z-20 rounded-full bg-[var(--hh-accent)]\"\n            style={{ x: line.x, y: line.y, width: line.w, height: line.h, opacity: lineOpacity }}\n          />\n        )}\n        {items.map((item) => {\n          const isActive = item.id === active;\n          const shared = {\n            ref: (el: HTMLElement | null) => {\n              if (el) itemRefs.current.set(item.id, el);\n              else itemRefs.current.delete(item.id);\n            },\n            \"aria-current\": isActive ? (\"page\" as const) : undefined,\n            onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {\n              // Touch and pen have no hover: the pill stays on the active link.\n              if (event.pointerType === \"mouse\") target(item.id);\n            },\n            onFocus: (event: React.FocusEvent<HTMLElement>) => {\n              if (event.currentTarget.matches(\":focus-visible\")) target(item.id);\n            },\n            onClick: (event: React.MouseEvent<HTMLElement>) => handleClick(event, item.id),\n            className: `relative z-10 flex items-center gap-2 rounded-lg text-[13px] font-medium tracking-tight transition-colors duration-150 outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--hh-accent)] ${\n              vertical ? \"h-9 w-full px-3\" : \"h-9 px-3.5\"\n            } ${\n              isActive\n                ? \"text-zinc-950 dark:text-white\"\n                : \"text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100\"\n            }`,\n          };\n          const content = (\n            <>\n              {item.icon && (\n                <span aria-hidden=\"true\" className={`inline-flex shrink-0 [&_svg]:size-4 ${isActive ? \"text-[var(--hh-accent)]\" : \"\"}`}>\n                  {item.icon}\n                </span>\n              )}\n              <span className={vertical ? \"min-w-0 flex-1 truncate text-left\" : \"whitespace-nowrap\"}>{item.label}</span>\n              {item.badge !== undefined && (\n                <span className=\"rounded-full bg-zinc-900/[0.06] px-1.5 py-px font-mono text-[10px] tabular-nums text-zinc-600 dark:bg-white/[0.08] dark:text-zinc-300\">\n                  {item.badge}\n                </span>\n              )}\n            </>\n          );\n          return (\n            <li key={item.id} className={vertical ? \"w-full\" : \"\"}>\n              {item.href !== undefined ? (\n                <a href={item.href} {...shared}>\n                  {content}\n                </a>\n              ) : (\n                <button type=\"button\" {...shared}>\n                  {content}\n                </button>\n              )}\n            </li>\n          );\n        })}\n      </ul>\n    </nav>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/hover-highlight-nav"
}
