{ "name": "cascader", "type": "registry:ui", "title": "Cascader", "description": "A regular hierarchy picked one level at a time, in side-by-side columns — so the path stays readable and the siblings you passed are still visible.", "category": "Forms", "dependencies": [ "lucide-react@^1.39.0" ], "registryDependencies": [ "lib-motion", "lib-styles", "lib-utils", "primitive-dismissable", "primitive-popper" ], "files": [ { "path": "components/ui/cascader.tsx", "type": "registry:ui", "content": "'use client'\n\nimport { useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Check, ChevronDown, ChevronRight } from 'lucide-react'\nimport { useDismissable } from '@/components/primitives/dismissable'\nimport { usePopper } from '@/components/primitives/popper'\nimport { overlayIn } from '@/lib/motion'\nimport { fieldBase, fieldOutline, fieldSize, menuSurface, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A hierarchy picked one level at a time, in side-by-side columns.\n *\n * **Columns, not a nested tree.** For a hierarchy that is genuinely a\n * classification — country → region → city, category → subcategory → type —\n * columns show you where you are and what the siblings were, which a tree of\n * disclosure triangles hides as soon as it scrolls. Each column is one decision,\n * so the path is readable back out of the control.\n *\n * **Use `Tree` instead when the shape is uneven**, when a user needs several\n * branches open at once, or when depth varies wildly. This is for regular\n * hierarchies of two to four levels.\n *\n * `changeOnSelect` is the difference between \"pick a city\" and \"pick anywhere,\n * at any level\" — with it on, choosing a region is a valid answer and does not\n * force a descent to a leaf.\n */\nexport type CascaderOption = {\n value: string\n label: ReactNode\n children?: CascaderOption[]\n disabled?: boolean\n}\n\ntype CascaderProps = Omit, 'onChange' | 'defaultValue'> & {\n options: CascaderOption[]\n /** The path from the root, e.g. `['eu', 'de', 'berlin']`. */\n value?: string[]\n defaultValue?: string[]\n onChange?: (path: string[], options: CascaderOption[]) => void\n placeholder?: string\n /** Allow a non-leaf to be the answer. */\n changeOnSelect?: boolean\n /** Joins the path for display. */\n separator?: string\n size?: 'sm' | 'md' | 'lg'\n /** Open a level by hovering it as well as clicking. */\n expandOnHover?: boolean\n disabled?: boolean\n invalid?: boolean\n label?: string\n emptyLabel?: string\n}\n\n/** Walk a path, collecting the option at each level. */\nfunction resolve(options: CascaderOption[], path: string[]): CascaderOption[] {\n const out: CascaderOption[] = []\n let level = options\n for (const step of path) {\n const found = level.find((option) => option.value === step)\n if (!found) break\n out.push(found)\n level = found.children ?? []\n }\n return out\n}\n\nfunction Cascader({\n options,\n value,\n defaultValue = [],\n onChange,\n placeholder = 'Select…',\n changeOnSelect = false,\n separator = ' / ',\n size = 'md',\n expandOnHover = true,\n disabled,\n invalid,\n label,\n emptyLabel = 'Nothing here.',\n className,\n ...props\n}: CascaderProps) {\n const anchorRef = useRef(null)\n const [open, setOpen] = useState(false)\n const [internal, setInternal] = useState(defaultValue)\n /** What is being browsed, which is not the same as what is chosen. */\n const [active, setActive] = useState(defaultValue)\n\n const selected = value ?? internal\n const chain = useMemo(() => resolve(options, selected), [options, selected])\n\n const floatingRef = useRef(null)\n const { style, side: resolvedSide } = usePopper({ open, anchorRef, floatingRef, side: 'bottom', align: 'start' })\n useDismissable({ open, onDismiss: () => setOpen(false), refs: [anchorRef, floatingRef] })\n\n /** One column per level of the browsing path, plus the root. */\n const columns = useMemo(() => {\n const out: CascaderOption[][] = [options]\n let level = options\n for (const step of active) {\n const found = level.find((option) => option.value === step)\n if (!found?.children?.length) break\n out.push(found.children)\n level = found.children\n }\n return out\n }, [options, active])\n\n const commit = (path: string[]) => {\n if (value === undefined) setInternal(path)\n onChange?.(path, resolve(options, path))\n }\n\n const pick = (depth: number, option: CascaderOption) => {\n const path = [...active.slice(0, depth), option.value]\n setActive(path)\n\n const leaf = !option.children || option.children.length === 0\n if (leaf || changeOnSelect) commit(path)\n if (leaf) setOpen(false)\n }\n\n const display = chain.length > 0 ? chain.map((option) => option.label) : null\n\n return (\n
\n setOpen((current) => !current)}\n className={cn(\n fieldBase, fieldOutline,\n fieldSize[size],\n 'flex w-full items-center justify-between gap-2 text-start',\n invalid && 'border-[var(--destructive)]',\n )}\n >\n \n {display\n ? display.map((part, index) => (\n \n {index > 0 && {separator}}\n {part}\n \n ))\n : placeholder}\n \n \n \n\n {open && (\n \n {columns.map((column, depth) => (\n 0 && 'border-s',\n )}\n >\n {column.length === 0 ? (\n
  • {emptyLabel}
  • \n ) : (\n column.map((option) => {\n const onPath = active[depth] === option.value\n const chosen = selected[depth] === option.value\n const hasChildren = Boolean(option.children?.length)\n\n return (\n
  • \n pick(depth, option)}\n onMouseEnter={() => {\n // Browsing, not choosing: hovering opens the next\n // column without committing anything.\n if (expandOnHover && hasChildren) {\n setActive([...active.slice(0, depth), option.value])\n }\n }}\n className={cn(\n 'flex w-full items-center gap-2 px-2 py-1.5 text-start text-sm',\n radius.xs,\n 'hover:bg-muted disabled:pointer-events-none disabled:opacity-50',\n onPath && 'bg-muted',\n )}\n >\n {option.label}\n {chosen && !hasChildren && (\n \n )}\n {hasChildren && (\n \n )}\n \n
  • \n )\n })\n )}\n \n ))}\n
    \n )}\n \n )\n}\n\nexport { Cascader }\nexport type { CascaderProps }\n" } ] }