Skip to content

Cascader

Form

Multi-level category selection with a breadcrumb path and column popup.

Three-level selectionLeaf completionClear pathDisabled
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

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
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.

Cascader props
PropType / DefaultDescription
optionsCascaderNode[]

Default: required

Hierarchy of ids, labels, disabled flags, and children.
value / defaultValuestring[]

Default: []

Controlled or initial path of ids.
onValueChange(path: string[]) => void

Default: —

Receives partial and complete selections.
label / placeholderstring

Default: Category / Choose a category

Accessible context and empty trigger text.
disabledboolean

Default: false

Disable the trigger.

Build with these too