Cascader
FormMulti-level category selection with a breadcrumb path and column popup.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { Cascader, type CascaderNode } from "@/components/ui/cascader";
import { useState } from "react";
const options: CascaderNode[] = [
{
id: "clothing",
label: "Clothing",
children: [
{
id: "women",
label: "Women",
children: [
{ id: "jackets", label: "Jackets" },
{ id: "shirts", label: "Shirts" },
],
},
{ id: "men", label: "Men", children: [{ id: "coats", label: "Coats" }] },
],
},
{
id: "home",
label: "Home",
children: [
{ id: "lighting", label: "Lighting" },
{ id: "furniture", label: "Furniture", disabled: true },
],
},
];
export default function CascaderExample() {
const [path, setPath] = useState<string[]>([]);
return (
<div className="w-full max-w-md space-y-4">
<Cascader
options={options}
value={path}
onValueChange={setPath}
label="Product category"
/>
<Cascader
options={options}
defaultValue={["clothing", "women", "jackets"]}
label="Managed category"
disabled
/>
<p role="status" className="text-xs text-muted-foreground">
Selected path: {path.join(" / ") || "None"}
</p>
<p className="text-xs text-muted-foreground">
Choose a category, then narrow it down. Selecting a leaf closes the
picker.
</p>
</div>
);
}Usage
Pass a hierarchy of unique ids and labels. value contains the path of selected ids. onValueChange fires for each level; selecting a leaf closes the popup. A partial path is valid, so check leaf completion when your form requires a final category.
import { Cascader, type CascaderNode } from "@/components/ui/cascader";Shipped as editable source in src/components/ui/cascader.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/cascader.tsx
"use client";
import { useState } from "react";
import { Check, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@dashboardpack/core/components/ui/popover";
import { cn } from "@dashboardpack/core/lib/utils";
export interface CascaderNode {
id: string;
label: string;
disabled?: boolean;
children?: CascaderNode[];
}
export interface CascaderProps {
options: CascaderNode[];
value?: string[];
defaultValue?: string[];
onValueChange?: (path: string[]) => void;
label?: string;
placeholder?: string;
disabled?: boolean;
className?: string;
}
export function Cascader({
options,
value,
defaultValue = [],
onValueChange,
label = "Category",
placeholder = "Choose a category",
disabled,
className,
}: CascaderProps) {
const [internal, setInternal] = useState(defaultValue);
const [open, setOpen] = useState(false);
const path = value ?? internal;
const levels: CascaderNode[][] = [options];
const labels: string[] = [];
for (let i = 0; i < path.length; i++) {
const node = levels[i]?.find((item) => item.id === path[i]);
if (!node) break;
labels.push(node.label);
if (node.children?.length) levels.push(node.children);
}
const update = (next: string[]) => {
setInternal(next);
onValueChange?.(next);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className={cn("w-full justify-between font-normal", className)}
aria-label={`${label}: ${labels.join(" / ") || placeholder}`}
>
<span className="truncate">{labels.join(" / ") || placeholder}</span>
<ChevronDown aria-hidden className="size-4 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto max-w-[calc(100vw-2rem)] p-0"
aria-label={`Choose ${label}`}
>
<div className="flex items-center justify-between gap-4 border-b px-3 py-2">
<span className="text-xs font-medium">{label}</span>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => update([])}
>
Clear
</Button>
</div>
<div className="flex max-h-64 overflow-auto">
{levels.map((nodes, level) => (
<div key={level} className="min-w-36 border-e p-1 last:border-e-0">
<p className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">
Level {level + 1}
</p>
{nodes.length === 0 && (
<p className="p-2 text-sm text-muted-foreground">
No categories
</p>
)}
{nodes.map((node) => (
<button
type="button"
key={node.id}
disabled={node.disabled}
aria-pressed={path[level] === node.id}
onClick={() => {
update([...path.slice(0, level), node.id]);
if (!node.children?.length) setOpen(false);
}}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-start text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
path[level] === node.id && "bg-accent",
)}
>
<span>{node.label}</span>
{node.children?.length ? (
<ChevronRight
aria-hidden
className="size-3 rtl:rotate-180"
/>
) : (
path[level] === node.id && (
<Check aria-hidden className="size-3" />
)
)}
</button>
))}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| options | CascaderNode[]Default: required | Hierarchy of ids, labels, disabled flags, and children. |
| value / defaultValue | string[]Default: [] | Controlled or initial path of ids. |
| onValueChange | (path: string[]) => voidDefault: — | Receives partial and complete selections. |
| label / placeholder | stringDefault: Category / Choose a category | Accessible context and empty trigger text. |
| disabled | booleanDefault: false | Disable the trigger. |