{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drilldown-menu",
  "type": "registry:ui",
  "title": "Drilldown Menu",
  "description": "A list that drills into itself. The row you click stays put, fades to a grey breadcrumb with a return arrow, and its children arrive one indent deeper — one layout animation, no sliding panels.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/ruixenui/drilldown-menu.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  motion,\n  usePresence,\n  useReducedMotion,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Drilldown Menu — a list that drills into itself.\n *\n * Clicking a row with children does not push a panel over the top. The row\n * stays exactly where it is, fades to grey, grows a return arrow in the gutter,\n * and its children arrive one indent step further right. Click the breadcrumb\n * to come back out.\n *\n * Movement craft — most of it is about NOT moving:\n *  - Rows are placed absolutely, by index, on a grid of one `ROW_PITCH`. A row\n *    that leaves takes no space with it, so nothing behind it has to reflow.\n *    Left in normal flow, leaving rows hold their space for a frame, arriving\n *    rows lay out around them, and then the whole list has to spring back\n *    together from wherever that put it — well over a hundred pixels of travel\n *    per row for a change that should read as a crossfade.\n *  - Rows are one flat list keyed by id. Trail rows and choices are siblings,\n *    so the row you clicked is the same element before and after and simply\n *    travels; it is never destroyed on one side and rebuilt on the other.\n *  - Nothing moves sideways. Indent is a function of the depth an item lives\n *    at, not of what is open, so becoming a breadcrumb is a pure vertical move\n *    and the label never slides out from under the cursor that clicked it.\n *  - Arriving rows do not travel at all. They are born at their final spot.\n *\n * The text carries the transition instead: each label is set per character, and\n * the characters scale and unblur into place on a short stagger, then reverse\n * out back-to-front. Only the block's height animates, so the stack breathes\n * around its centre as the row count changes.\n */\n\nexport interface DrilldownMenuItem {\n  /** Stable identifier. Must be unique among its siblings. */\n  id: string;\n  /** Row text. Also the accessible name. */\n  label: string;\n  /** Children. A row with children drills in; one without is a leaf. */\n  items?: DrilldownMenuItem[];\n  /** Fires when a leaf row is chosen. */\n  onSelect?: () => void;\n}\n\ninterface DrilldownMenuProps {\n  /** The tree. Content lives with the caller, never in here. */\n  items: DrilldownMenuItem[];\n  className?: string;\n  /** Ids of the branches to open on mount, outermost first. */\n  defaultPath?: string[];\n  /** Fires when a leaf row is chosen, with the trail that led to it. */\n  onSelect?: (item: DrilldownMenuItem, trail: DrilldownMenuItem[]) => void;\n}\n\n/**\n * Near-critically damped: rows settle in about 300ms with no overshoot.\n * Overshoot is wrong here — every row is carrying a word someone is reading.\n */\nconst SPRING = {\n  type: \"spring\" as const,\n  stiffness: 520,\n  damping: 46,\n  mass: 0.9,\n};\n\n/** Livelier, because a character travels a few pixels, not a few rows. */\nconst CHAR_SPRING = {\n  type: \"spring\" as const,\n  stiffness: 500,\n  damping: 30,\n  mass: 1,\n};\n\n/** Layout grid, in em, so the whole menu scales off one font size. */\nconst ROW_PITCH = 1.85;\nconst ROW_HEIGHT = 1.53;\nconst INDENT = 0.9;\n\n/** Per-character cadence. Out is quicker than in, and runs back to front. */\nconst STAGGER_IN = 0.015;\nconst STAGGER_OUT = 0.008;\n\nconst CHAR_VARIANTS = {\n  hidden: {\n    opacity: 0,\n    scale: 0,\n    filter: \"blur(4px)\",\n    // A tween out, not a spring: a spring's tail keeps the row mounted long\n    // after it is invisible, and leaving should feel quicker than arriving.\n    transition: { duration: 0.16, ease: [0.4, 0, 1, 1] as const },\n  },\n  visible: {\n    opacity: 1,\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: CHAR_SPRING,\n  },\n};\n\n/** Return arrow: points back the way you came, tail curling away below. */\nfunction ReturnArrow() {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"h-[0.9em] w-[0.9em]\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={2}\n      viewBox=\"0 0 24 24\"\n    >\n      <path d=\"M9 5 4 10l5 5\" />\n      <path d=\"M4 10h9a6 6 0 0 1 6 6v2\" />\n    </svg>\n  );\n}\n\n/** Walk `defaultPath` down the tree, stopping at the first id that misses. */\nfunction resolvePath(\n  items: DrilldownMenuItem[],\n  ids: string[],\n): DrilldownMenuItem[] {\n  const trail: DrilldownMenuItem[] = [];\n  let level = items;\n  for (const id of ids) {\n    const next = level.find((item) => item.id === id);\n    if (!next?.items?.length) break;\n    trail.push(next);\n    level = next.items;\n  }\n  return trail;\n}\n\nexport function DrilldownMenu({\n  items,\n  className,\n  defaultPath,\n  onSelect,\n}: DrilldownMenuProps) {\n  const [trail, setTrail] = React.useState<DrilldownMenuItem[]>(() =>\n    defaultPath ? resolvePath(items, defaultPath) : [],\n  );\n  const reduceMotion = useReducedMotion();\n\n  const level = trail.length ? (trail[trail.length - 1].items ?? []) : items;\n\n  // ONE list, not a trail list plus a choice list. Two sibling arrays under the\n  // same AnimatePresence scope keys per array, so the row you click gets torn\n  // down on one side and rebuilt on the other: it fades and reappears instead\n  // of travelling, and anything mid-flight inside it is stranded.\n  const rows = [\n    ...trail.map((item, depth) => ({ item, depth, isTrail: true })),\n    ...level.map((item) => ({ item, depth: trail.length, isTrail: false })),\n  ];\n\n  const handleItem = (item: DrilldownMenuItem) => {\n    if (item.items?.length) {\n      setTrail((current) => [...current, item]);\n      return;\n    }\n    item.onSelect?.();\n    onSelect?.(item, trail);\n  };\n\n  return (\n    <div\n      className={cn(\n        \"flex min-h-[16rem] flex-col justify-center text-2xl sm:min-h-[20rem] sm:text-3xl\",\n        className,\n      )}\n    >\n      {/* Height is a CSS transition rather than an animated value: it is the\n          one property here expressed in `em`, and CSS interpolates units\n          natively where an animation library has to re-read them in px. */}\n      <div\n        className={cn(\n          \"relative\",\n          !reduceMotion && \"transition-[height] duration-300 ease-out\",\n        )}\n        style={{\n          height: `${(rows.length - 1) * ROW_PITCH + ROW_HEIGHT}em`,\n        }}\n      >\n        <AnimatePresence initial={false}>\n          {rows.map(({ item, depth, isTrail }, index) => (\n            <Row\n              depth={depth}\n              index={index}\n              isTrail={isTrail}\n              item={item}\n              key={item.id}\n              onActivate={() =>\n                isTrail\n                  ? setTrail((current) => current.slice(0, depth))\n                  : handleItem(item)\n              }\n              reduceMotion={!!reduceMotion}\n            />\n          ))}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\ninterface RowProps {\n  item: DrilldownMenuItem;\n  depth: number;\n  index: number;\n  isTrail: boolean;\n  onActivate: () => void;\n  reduceMotion: boolean;\n}\n\n/**\n * Position and presence are deliberately on two different elements.\n *\n * The outer one owns x/y as a plain `animate` object, because that is the only\n * form Motion re-resolves when the values change under an unchanged animation.\n * Express the same thing as a variant target and a row that stays `visible`\n * while its index changes never moves — it silently keeps the slot it had, and\n * lands on top of whatever is there now.\n *\n * The inner one owns presence, so it can orchestrate the characters in and out\n * on a stagger. It drives its own removal through `usePresence`, which is what\n * lets the exit run per character instead of collapsing the row in one step.\n */\nfunction Row({\n  item,\n  depth,\n  index,\n  isTrail,\n  onActivate,\n  reduceMotion,\n}: RowProps) {\n  const [isPresent, safeToRemove] = usePresence();\n\n  // Backstop: if the exit animation never reports completion, the row would\n  // stay mounted forever — invisible, and still in the tab order.\n  React.useEffect(() => {\n    if (isPresent) return;\n    const timer = window.setTimeout(() => safeToRemove?.(), 900);\n    return () => window.clearTimeout(timer);\n  }, [isPresent, safeToRemove]);\n\n  return (\n    <motion.div\n      // Placed by CSS, moved by `layout`. Animating x/y directly cannot work\n      // here: an em offset of zero is written as `transform: none`, and Motion\n      // then has nothing but px to interpolate from when the row's slot\n      // changes — so a row returning from the trail to the middle of a list\n      // silently keeps the slot it had. `layout` measures boxes instead, so it\n      // is indifferent to what units the position was authored in.\n      className={cn(\"absolute\", !isPresent && \"pointer-events-none\")}\n      layout={reduceMotion ? false : \"position\"}\n      style={{\n        left: `${depth * INDENT}em`,\n        top: `${index * ROW_PITCH}em`,\n      }}\n      transition={reduceMotion ? { duration: 0 } : SPRING}\n    >\n      <motion.button\n        animate={isPresent ? \"visible\" : \"hidden\"}\n        aria-label={isTrail ? `Back to ${item.label}` : undefined}\n        initial=\"hidden\"\n        onAnimationComplete={() => {\n          if (!isPresent) safeToRemove?.();\n        }}\n        onClick={onActivate}\n        transition={\n          reduceMotion\n            ? { duration: 0 }\n            : {\n                staggerChildren: isPresent ? STAGGER_IN : STAGGER_OUT,\n                staggerDirection: isPresent ? 1 : -1,\n              }\n        }\n        type=\"button\"\n        className={cn(\n          \"rounded-[0.25em] px-[0.22em] py-[0.14em] text-left font-medium leading-[1.25] outline-none transition-colors\",\n          \"hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring\",\n          // Weight is deliberately identical either way: a breadcrumb is the\n          // same word at the same size, greyed. Drop its weight too and the\n          // label reflows its own width the moment you click it.\n          isTrail ? \"text-muted-foreground\" : \"text-foreground\",\n        )}\n      >\n        <span className=\"relative inline-block whitespace-pre\">\n          {/* CSS, not Motion. The arrow's state depends on a prop that flips\n              while the row stays mounted and stays \"visible\" — exactly the case\n              Motion does not re-resolve, whether it is expressed as a value\n              inside a variant or as a label on a child of a variant tree. A\n              class list is recomputed on every render and cannot get stranded. */}\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none absolute inset-y-0 right-full mr-[0.6em] flex items-center\",\n              !reduceMotion && \"transition-all duration-200 ease-out\",\n              isTrail && isPresent\n                ? \"translate-x-0 opacity-100\"\n                : \"-translate-x-1 opacity-0\",\n            )}\n          >\n            <ReturnArrow />\n          </span>\n          {item.label.split(\"\").map((char, charIndex) => (\n            <motion.span\n              className=\"inline-block\"\n              key={charIndex}\n              variants={CHAR_VARIANTS}\n            >\n              {char}\n            </motion.span>\n          ))}\n        </span>\n      </motion.button>\n    </motion.div>\n  );\n}\n\nexport default DrilldownMenu;\n",
      "type": "registry:ui",
      "target": "components/ruixen/drilldown-menu.tsx"
    }
  ]
}