{ "$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 \n \n \n \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(() =>\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 \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 \n \n {rows.map(({ item, depth, isTrail }, index) => (\n \n isTrail\n ? setTrail((current) => current.slice(0, depth))\n : handleItem(item)\n }\n reduceMotion={!!reduceMotion}\n />\n ))}\n \n \n \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 \n {\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 \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 \n \n \n {item.label.split(\"\").map((char, charIndex) => (\n \n {char}\n \n ))}\n \n \n \n );\n}\n\nexport default DrilldownMenu;\n", "type": "registry:ui", "target": "components/ruixen/drilldown-menu.tsx" } ] }