Skip to content

Cascader

A select whose options form a tree: drill into a level, search it and commit a value at any depth.

Category
Selection
Adoption
Not used yet
import { Cascader } from "@oration/canon/components/cascader";
packages/canon/src/components/cascader.tsx

INV-20418

Halcyon Packaging, $4,812.50, due October 12

import {  Cascader,  CascaderContent,  CascaderEmpty,  CascaderList,  CascaderPanel,  CascaderStatus,  CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {  CascaderBreadcrumb,  CascaderInput,  CascaderNav,  CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const accounts: CascaderNode[] = [        {            value: "5000",            label: "Cost of goods sold",            children: [                { value: "5100", label: "Freight in" },                { value: "5200", label: "Packaging" },                { value: "5300", label: "Duties and tariffs" },            ],        },        {            value: "6000",            label: "Operating expenses",            children: [                {                    value: "6100",                    label: "Facilities",                    children: [                        { value: "6110", label: "Rent" },                        { value: "6120", label: "Utilities" },                        { value: "6130", label: "Repairs and maintenance" },                    ],                },                {                    value: "6200",                    label: "Software",                    children: [                        { value: "6210", label: "Subscriptions" },                        { value: "6220", label: "Cloud hosting" },                    ],                },                { value: "6300", label: "Travel" },                { value: "6400", label: "Professional services" },            ],        },        { value: "1400", label: "Prepaid expenses" },    ];    const id = React.useId();    const [account, setAccount] = React.useState("6120");    return (        <div className="flex w-full max-w-sm flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex flex-col gap-0.5">                <p className="text-sm font-medium">INV-20418</p>                <p className="text-13 text-muted-foreground">                    Halcyon Packaging,{" "}                    <span className="tabular-nums">$4,812.50</span>, due October                    12                </p>            </div>            <div className="flex flex-col gap-1.5">                <Label htmlFor={id}>GL account</Label>                <Cascader                    items={accounts}                    value={account}                    onValueChange={(value, details) => {                        setAccount(value);                        toast.add({                            type: "success",                            title: "GL account updated",                            description: `INV-20418 now posts to ${details.node?.label ?? value}.`,                        });                    }}                >                    <CascaderTrigger id={id} className="w-full">                        <CascaderValue placeholder="Choose an account" />                    </CascaderTrigger>                    <CascaderContent className="w-(--anchor-width)">                        <CascaderPanel>                            <CascaderNav>                                <CascaderInput placeholder="Search accounts" />                            </CascaderNav>                            <CascaderBreadcrumb />                            <CascaderEmpty>No accounts match.</CascaderEmpty>                            <CascaderList>                                <CascaderItems />                            </CascaderList>                            <CascaderStatus />                        </CascaderPanel>                    </CascaderContent>                </Cascader>            </div>        </div>    );}

Usage#

Cascader picks one value, or a few, from a tree you walk one level at a time, built on Base UI Combobox. It is the picker for hierarchies that are deep enough that a flat list would be noise: a chart of accounts, a category tree, a remit-to directory, an entity rollup. One root, three layouts — drill replaces the level in place, columns fans the trail out side by side, tree expands branches down — and levels can arrive from the server as you drill. The common mistake is reaching for it on a flat list, where the extra level is friction; without a real parent/child shape, use a combobox or a select.

When to use

  • To choose a leaf from a known tree people read top-down: a GL account under an account type, a city under a country.
  • For a category or taxonomy with two to four levels, where showing every leaf at once would be hundreds of rows.
  • When each level is fetched on demand with getChildren, so a large directory loads a branch at a time.
  • For several values across a tree with multiple, optionally cascading a branch to its whole subtree with cascade.

When not to use

  • For a flat list long enough to search but with no hierarchy, such as suppliers or people. Use Combobox
  • For a short fixed list with no levels, such as payment terms or currency. Use Select
  • For a typed value in a settings row, config page or sheet form. Use Select field
  • For a time zone. It already searches by city, zone and offset. Use Timezone select
  • For a list of actions such as Export or Delete. Actions belong in a menu. Use Dropdown menu

Every field has a label

Link a visible Label to the trigger's id, or give the trigger an aria-label. The trigger shows the selected path, which is the field's value, never its name, so a screen reader hears what is picked but not what it is for; the component warns in development when a trigger is unnamed.

The trigger matches inputs

CascaderTrigger is a select trigger: 32px (28px at sm), 10px corners, the Field Stroke, 10px left and 8px right padding, and the same indigo focus ring as every input, so a cascader sits in a form column without a seam.

Leaves commit, branches navigate

By default only leaves are selectable; pressing a branch drills into it. Pass selectable="any" or a predicate to let a branch be committed, and the drill-in chevron becomes the separate target so a press can still open it.

Anatomy#

FacilitiesUtilities
Search Facilities
Operating expensesFacilities
Rent
Utilities
Repairs and maintenance
Create GL account…
  1. Trigger. CascaderTrigger with a CascaderValue inside: a 32px select trigger showing the collapsed selection path (Facilities › Utilities) and a 16px chevron in Slate Meta. className sets the width.
  2. Nav. CascaderNav wraps CascaderInput: a 32px search field with a leading 24px back button (drill mode, below the root) over a hairline (border-b at 60%). The placeholder names the level: Search Facilities.
  3. Breadcrumb. CascaderBreadcrumb: the ancestor trail in 12px Slate Meta, the current level in medium ink with aria-current="page". Earlier crumbs are buttons that jump back a level; it collapses the middle past three segments.
  4. Item. CascaderItem (rendered by CascaderItems): a 14px row with 8px corners and 6px inset. A leaf reserves the check gutter; a branch shows a trailing count and a chevron.
  5. Indicator. Single-select shows a 16px check at the inline-end on the chosen row. Multi-select swaps it for a 16px checkbox that fills Quiet Indigo when checked, and a dash when a cascade branch is partly selected.
  6. Branch. A node with children shows a 16px chevron at the inline-end (and a count when there is one). Pressing it drills in; the row under the pointer or arrow keys fills Menu Hover.
  7. Footer. CascaderFooter with CascaderAction rows: commands pinned below the list over a top hairline, never options. A CascaderAction with items opens a side CascaderSubmenu flyout.

Examples#

Drill down a tree

The default drill mode replaces the level in place, with a back button and a breadcrumb. Pass items as a CascaderNode tree; typing filters the current level.

import {  Cascader,  CascaderContent,  CascaderEmpty,  CascaderList,  CascaderPanel,  CascaderStatus,  CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {  CascaderBreadcrumb,  CascaderInput,  CascaderNav,  CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import * as React from "react";export function Basic() {    const regions: CascaderNode[] = [        {            value: "na",            label: "North America",            children: [                {                    value: "na-us",                    label: "United States",                    children: [                        { value: "na-us-chi", label: "Chicago" },                        { value: "na-us-atl", label: "Atlanta" },                        { value: "na-us-sea", label: "Seattle" },                    ],                },                {                    value: "na-ca",                    label: "Canada",                    children: [                        { value: "na-ca-tor", label: "Toronto" },                        { value: "na-ca-van", label: "Vancouver" },                    ],                },            ],        },        {            value: "eu",            label: "Europe",            children: [                { value: "eu-ams", label: "Amsterdam" },                { value: "eu-dub", label: "Dublin" },            ],        },    ];    const [value, setValue] = React.useState("");    return (        <Cascader items={regions} value={value} onValueChange={setValue}>            <CascaderTrigger aria-label="Remit-to office" className="w-64">                <CascaderValue placeholder="Choose an office" />            </CascaderTrigger>            <CascaderContent className="w-64">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput placeholder="Search offices" />                    </CascaderNav>                    <CascaderBreadcrumb />                    <CascaderEmpty>No offices match.</CascaderEmpty>                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

Several values

With multiple, rows get checkboxes and the value is a string[]. CascaderValue display="count" summarises the selection in the trigger.

import {  Cascader,  CascaderContent,  CascaderEmpty,  CascaderList,  CascaderPanel,  CascaderStatus,  CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {  CascaderBreadcrumb,  CascaderInput,  CascaderNav,  CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import * as React from "react";export function Multiple() {    const teams: CascaderNode[] = [        {            value: "finance",            label: "Finance",            children: [                { value: "ap", label: "Accounts payable" },                { value: "treasury", label: "Treasury" },                { value: "controller", label: "Controller" },            ],        },        {            value: "ops",            label: "Operations",            children: [                { value: "procurement", label: "Procurement" },                { value: "logistics", label: "Logistics" },            ],        },        { value: "revenue", label: "Revenue" },    ];    const [value, setValue] = React.useState<string[]>(["ap", "treasury"]);    return (        <Cascader multiple items={teams} value={value} onValueChange={setValue}>            <CascaderTrigger aria-label="Teams notified" className="w-64">                <CascaderValue display="count" placeholder="Choose teams" />            </CascaderTrigger>            <CascaderContent className="w-64">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput placeholder="Search teams" />                    </CascaderNav>                    <CascaderBreadcrumb />                    <CascaderEmpty>No teams match.</CascaderEmpty>                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

Columns

mode="columns" fans the open trail out side by side, one panel per level. Only the deepest column is interactive; ArrowLeft and ArrowRight move between columns.

import {  Cascader,  CascaderContent,  CascaderEmpty,  CascaderPanel,  CascaderStatus,  CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderColumns } from "@oration/canon/components/cascader/columns";import { CascaderInput, CascaderNav, CascaderValue } from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import * as React from "react";export function Columns() {    const categories: CascaderNode[] = [        {            value: "logistics",            label: "Logistics",            children: [                {                    value: "freight",                    label: "Freight",                    children: [                        { value: "ltl", label: "Less than truckload" },                        { value: "ftl", label: "Full truckload" },                        { value: "ocean", label: "Ocean" },                    ],                },                { value: "warehousing", label: "Warehousing" },            ],        },        {            value: "materials",            label: "Materials",            children: [                { value: "packaging", label: "Packaging" },                { value: "raw", label: "Raw materials" },            ],        },        { value: "services", label: "Professional services" },    ];    const [value, setValue] = React.useState("ltl");    return (        <Cascader            mode="columns"            items={categories}            value={value}            onValueChange={setValue}            maxHeight={240}        >            <CascaderTrigger aria-label="Supplier category" className="w-80">                <CascaderValue placeholder="Choose a category" />            </CascaderTrigger>            <CascaderContent className="w-auto min-w-0">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput placeholder="Search categories" />                    </CascaderNav>                    <CascaderEmpty>No categories match.</CascaderEmpty>                    <CascaderColumns />                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

Tree with cascade

mode="tree" expands branches in place. With multiple, cascade and selectable="any", checking a branch selects its whole subtree and reconciles ancestors to a dash.

import {  Cascader,  CascaderContent,  CascaderEmpty,  CascaderList,  CascaderPanel,  CascaderStatus,  CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import { CascaderInput, CascaderNav, CascaderValue } from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import * as React from "react";export function Tree() {    const entities: CascaderNode[] = [        {            value: "cedarline-us",            label: "Cedarline Inc.",            children: [                { value: "us-ap", label: "AP operations" },                { value: "us-treasury", label: "Treasury" },            ],        },        {            value: "cedarline-ca",            label: "Cedarline Canada",            children: [                { value: "ca-ap", label: "AP operations" },                { value: "ca-tax", label: "Tax" },            ],        },    ];    const [value, setValue] = React.useState<string[]>(["us-ap"]);    return (        <Cascader            mode="tree"            multiple            cascade            selectable="any"            items={entities}            value={value}            onValueChange={setValue}            defaultExpanded={["cedarline-us"]}        >            <CascaderTrigger aria-label="Approval scope" className="w-64">                <CascaderValue display="count" placeholder="Choose a scope" />            </CascaderTrigger>            <CascaderContent className="w-64">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput placeholder="Search entities" />                    </CascaderNav>                    <CascaderEmpty>No entities match.</CascaderEmpty>                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

Load levels on demand

Pass getChildren to fetch each level as someone drills into it. The pressed branch keeps the old rows and spins its chevron until its children land.

import {  Cascader,  CascaderContent,  CascaderEmpty,  CascaderList,  CascaderPanel,  CascaderStatus,  CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {  CascaderBreadcrumb,  CascaderInput,  CascaderNav,  CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import * as React from "react";export function Async() {    // Each level arrives from the server when someone drills into it.    const directory: Record<string, CascaderNode[]> = {        root: [            {                value: "northwind",                label: "Northwind Freight",                hasChildren: true,            },            { value: "halcyon", label: "Halcyon Packaging", hasChildren: true },            { value: "orchard", label: "Orchard Street", hasChildren: true },        ],        northwind: [            { value: "northwind-chi", label: "Chicago remit-to" },            { value: "northwind-atl", label: "Atlanta remit-to" },        ],        halcyon: [{ value: "halcyon-main", label: "Main remit-to" }],        orchard: [            { value: "orchard-nyc", label: "New York remit-to" },            { value: "orchard-bos", label: "Boston remit-to" },        ],    };    const [value, setValue] = React.useState("");    return (        <Cascader            items={[]}            getChildren={(node) =>                new Promise<CascaderNode[]>((resolve) =>                    setTimeout(                        () => resolve(directory[node?.value ?? "root"] ?? []),                        600,                    ),                )            }            value={value}            onValueChange={setValue}        >            <CascaderTrigger aria-label="Remit-to address" className="w-64">                <CascaderValue placeholder="Choose a remit-to address" />            </CascaderTrigger>            <CascaderContent className="w-64">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput placeholder="Search suppliers" />                    </CascaderNav>                    <CascaderBreadcrumb />                    <CascaderEmpty>No suppliers match.</CascaderEmpty>                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

Inline panel

inline drops the popup and renders the CascaderPanel on its own, for an add-a-column menu or a sidebar picker that is always open.

Use the Right Arrow key to open a branch and the Left Arrow key to go back.
Top level, 4 items
import {  Cascader,  CascaderEmpty,  CascaderList,  CascaderPanel,  CascaderStatus,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import { CascaderBreadcrumb, CascaderInput, CascaderNav } from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import { toast } from "@oration/canon/components/toast";import { BuildingIcon } from "lucide-react";import * as React from "react";export function Inline() {    const attributes: CascaderNode[] = [        {            value: "supplier",            label: "Supplier",            icon: <BuildingIcon aria-hidden="true" />,            children: [                { value: "supplier.name", label: "Name" },                { value: "supplier.tier", label: "Tier" },                { value: "supplier.email", label: "Remit-to email" },            ],        },        { value: "amount", label: "Amount" },        { value: "due", label: "Due date" },        { value: "status", label: "Status" },    ];    const [value, setValue] = React.useState("");    return (        <div className="w-64 overflow-hidden rounded-xl bg-card shadow-border">            <Cascader                inline                items={attributes}                value={value}                onValueChange={(next, details) => {                    setValue(next);                    toast.add({                        title: "Column added",                        description: `${details.node?.label ?? next} is now a column in Invoices.`,                    });                }}                maxHeight={220}            >                <CascaderPanel>                    <CascaderNav>                        <CascaderInput                            aria-label="Add a column"                            placeholder="Search attributes"                        />                    </CascaderNav>                    <CascaderBreadcrumb />                    <CascaderEmpty>No attributes match.</CascaderEmpty>                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    <CascaderStatus />                </CascaderPanel>            </Cascader>        </div>    );}

Nested attribute picker

Icons on every level and drill-down navigation, for choosing a field to filter or group by.

import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import {    AtSignIcon,    BuildingIcon,    CalendarClockIcon,    HashIcon,    ReceiptTextIcon,    SquareCheckIcon,    TypeIcon,    UserIcon,} from "lucide-react";import * as React from "react";const text = <TypeIcon aria-hidden="true" />;const number = <HashIcon aria-hidden="true" />;const email = <AtSignIcon aria-hidden="true" />;const date = <CalendarClockIcon aria-hidden="true" />;// Supplier attributes for a column picker. A branch is a related record, so// pressing it drills in; a leaf is a field and commits.const attributes: CascaderNode[] = [    { value: "invoice-number", label: "Invoice number", icon: number },    {        value: "supplier",        label: "Supplier",        icon: <BuildingIcon aria-hidden="true" />,        count: 6,        children: [            { value: "supplier.name", label: "Name", icon: text },            {                value: "supplier.email",                label: "Remittance email",                icon: email,                keywords: ["mail", "ap"],            },            { value: "supplier.tier", label: "Tier", icon: text },            {                value: "supplier.owner",                label: "Owner",                icon: <UserIcon aria-hidden="true" />,                count: 3,                children: [                    { value: "supplier.owner.name", label: "Name", icon: text },                    {                        value: "supplier.owner.email",                        label: "Email",                        icon: email,                    },                    { value: "supplier.owner.team", label: "Team", icon: text },                ],            },        ],    },    { value: "amount", label: "Amount", icon: number },    {        value: "purchase-order",        label: "Purchase order",        icon: <ReceiptTextIcon aria-hidden="true" />,        count: 3,        children: [            {                value: "purchase-order.number",                label: "PO number",                icon: number,            },            { value: "purchase-order.total", label: "PO total", icon: number },            {                value: "purchase-order.approved",                label: "Approved on",                icon: date,            },        ],    },    {        value: "next-task",        label: "Next due task",        icon: <SquareCheckIcon aria-hidden="true" />,        count: 1,        children: [{ value: "next-task.due", label: "Due date", icon: date }],    },    { value: "due", label: "Due date", icon: date },    {        value: "created-by",        label: "Created by",        icon: <UserIcon aria-hidden="true" />,    },];export function AttributePicker() {    const [value, setValue] = React.useState("");    return (        <Cascader items={attributes} value={value} onValueChange={setValue}>            <CascaderTrigger aria-label="Attribute" className="w-72">                <CascaderValue                    placeholder="Choose an attribute"                    maxSegments={3}                />            </CascaderTrigger>            <CascaderContent className="w-80">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput placeholder="Search attributes" />                    </CascaderNav>                    <CascaderBreadcrumb />                    <CascaderEmpty>No attributes match.</CascaderEmpty>                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

Permissions with a selection cap

Checkbox rows under multiple, with a cap that disables the remaining rows once the limit is reached.

import { Button } from "@oration/canon/components/button";import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,    useCascaderSelection,} from "@oration/canon/components/cascader";import {    CascaderAction,    CascaderFooter,} from "@oration/canon/components/cascader/footer";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import { cn } from "@oration/canon/lib/utils";import {    ArrowLeftRightIcon,    BanknoteIcon,    CircleXIcon,    ClipboardListIcon,    CloudUploadIcon,    CornerUpLeftIcon,    CreditCardIcon,    DownloadIcon,    EyeIcon,    FolderIcon,    GlobeIcon,    KeyIcon,    LifeBuoyIcon,    LinkIcon,    PencilIcon,    RefreshCwIcon,    RepeatIcon,    RocketIcon,    RotateCcwIcon,    ShieldCheckIcon,    Trash2Icon,    UserMinusIcon,    UserPlusIcon,    UserRoundCogIcon,    UsersIcon,    XIcon,} from "lucide-react";import * as React from "react";// Area icons name the first level — the thing each permission group governs,// so the icon column adds a type rather than repeating the label.const billing = <CreditCardIcon aria-hidden="true" />;const members = <UsersIcon aria-hidden="true" />;const projects = <FolderIcon aria-hidden="true" />;const deployments = <RocketIcon aria-hidden="true" />;const domains = <GlobeIcon aria-hidden="true" />;const secrets = <KeyIcon aria-hidden="true" />;const audit = <ClipboardListIcon aria-hidden="true" />;const support = <LifeBuoyIcon aria-hidden="true" />;// Action icons name the leaves — the verb, so "View …" repeats on purpose and// everything else is spent on a verb that appears once.const view = <EyeIcon aria-hidden="true" />;const edit = <PencilIcon aria-hidden="true" />;const permissions: CascaderNode[] = [    {        value: "billing",        label: "Billing",        icon: billing,        children: [            { value: "billing.read", label: "View invoices", icon: view },            {                value: "billing.write",                label: "Manage subscription",                icon: <RepeatIcon aria-hidden="true" />,            },            {                value: "billing.refund",                label: "Issue refunds",                icon: <BanknoteIcon aria-hidden="true" />,            },            { value: "billing.tax", label: "Edit tax details", icon: edit },        ],    },    {        value: "members",        label: "Members",        icon: members,        children: [            { value: "members.read", label: "View members", icon: view },            {                value: "members.invite",                label: "Invite members",                icon: <UserPlusIcon aria-hidden="true" />,            },            {                value: "members.remove",                label: "Remove members",                icon: <UserMinusIcon aria-hidden="true" />,            },            {                value: "members.roles",                label: "Assign roles",                icon: <UserRoundCogIcon aria-hidden="true" />,            },        ],    },    {        value: "projects",        label: "Projects",        icon: projects,        children: [            { value: "projects.read", label: "View projects", icon: view },            { value: "projects.write", label: "Edit projects", icon: edit },            {                value: "projects.transfer",                label: "Transfer projects",                icon: <ArrowLeftRightIcon aria-hidden="true" />,            },            {                value: "projects.delete",                label: "Delete projects",                icon: <Trash2Icon aria-hidden="true" />,                disabled: true,            },        ],    },    {        value: "deployments",        label: "Deployments",        icon: deployments,        children: [            {                value: "deployments.read",                label: "View deployments",                icon: view,            },            {                value: "deployments.ship",                label: "Promote to production",                icon: <CloudUploadIcon aria-hidden="true" />,            },            {                value: "deployments.rollback",                label: "Roll back",                icon: <RotateCcwIcon aria-hidden="true" />,            },        ],    },    {        value: "domains",        label: "Domains",        icon: domains,        children: [            { value: "domains.read", label: "View domains", icon: view },            {                value: "domains.attach",                label: "Attach a domain",                icon: <LinkIcon aria-hidden="true" />,            },            {                value: "domains.certs",                label: "Manage certificates",                icon: <ShieldCheckIcon aria-hidden="true" />,            },        ],    },    {        value: "secrets",        label: "Secrets",        icon: secrets,        children: [            { value: "secrets.read", label: "Read secrets", icon: view },            {                value: "secrets.write",                label: "Rotate secrets",                icon: <RefreshCwIcon aria-hidden="true" />,            },        ],    },    {        value: "audit",        label: "Audit log",        icon: audit,        children: [            { value: "audit.read", label: "Read the audit log", icon: view },            {                value: "audit.export",                label: "Export the audit log",                icon: <DownloadIcon aria-hidden="true" />,            },        ],    },    {        value: "support",        label: "Support",        icon: support,        children: [            { value: "support.read", label: "View tickets", icon: view },            {                value: "support.reply",                label: "Reply to tickets",                icon: <CornerUpLeftIcon aria-hidden="true" />,            },        ],    },];// Clear as a footer command. It reads the selection out of context with// `useCascaderSelection` rather than being handed it, and `isEmpty` stops it// offering to empty an already-empty selection.function ClearAction() {    const { clear, isEmpty } = useCascaderSelection();    return (        <CascaderAction            icon={<CircleXIcon aria-hidden="true" />}            disabled={isEmpty}            onSelect={clear}        >            Clear selection        </CascaderAction>    );}export function PermissionsMulti() {    const [value, setValue] = React.useState<string[]>([]);    const hasSelection = value.length > 0;    return (        <Cascader            multiple            max={5}            items={permissions}            value={value}            onValueChange={setValue}        >            {/* The clear button is a SIBLING of the trigger, absolutely positioned          over its inline end: a <button> nested inside CascaderTrigger's own          <button> would be invalid HTML and would reopen the popup. `showIcon`          takes the chevron away while the clear button stands in for it, and          `pe-8` reserves the chevron's room so a long summary truncates before          it reaches the button. */}            <div className="relative w-72">                <CascaderTrigger                    aria-label="Permissions"                    showIcon={!hasSelection}                    render={                        <Button                            variant="outline"                            className={cn(                                "w-full justify-between gap-2 font-normal transition-colors",                                hasSelection && "pe-8",                            )}                        />                    }                >                    <CascaderValue placeholder="Select permissions" />                </CascaderTrigger>                {hasSelection ? (                    <Button                        variant="ghost"                        size="icon-xs"                        aria-label="Clear all permissions"                        onClick={() => setValue([])}                        className="absolute end-1 top-1/2 -translate-y-1/2 transition-none"                    >                        <XIcon aria-hidden="true" className="size-3.5" />                    </Button>                ) : null}            </div>            <CascaderContent className="w-80">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput placeholder="Search permissions" />                    </CascaderNav>                    <CascaderBreadcrumb />                    <CascaderEmpty>No permissions match.</CascaderEmpty>                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    {/* A SIBLING of the list, never a child: the list's own Enter handler              clicks whatever it contains, so a command inside the rows would              fire on the keystroke that commits one. */}                    <CascaderFooter>                        <ClearAction />                    </CascaderFooter>                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

Avatars and role badges

Custom rows that render a person's avatar beside their name and a role badge on the trailing edge.

import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import { Tag, type TagColor } from "@oration/canon/components/tag";import { cn } from "@oration/canon/lib/utils";import * as React from "react";/** * One ladder, not seven vocabularies. * * A role chip whose hue changes has to be colouring ONE axis, so every team * names its people on the same six rungs. Team-local labels like "Tier 2" or * "Enterprise" would put a segment on a seniority scale and the colour would * stop meaning anything. */type Role = "VP" | "Director" | "Manager" | "Lead" | "Senior" | "Rep";interface Member {    tint: TagColor;    role: Role;    status?: "online" | "away";}/** * Rung to tag colour, in one table so the hue cannot drift from the word. The * seniority chip is a select-option value, so it is drawn with the Option Hue * family (`Tag`) rather than ink — four bands over six rungs: who owns the * number (indigo), who runs the team (blue), who carries a book (green) and who * is ramping (amber). Bands, not one hue per role — a scale with six colours is * a palette, not a signal. */const ROLE_COLOR: Record<Role, TagColor> = {    VP: "indigo",    Director: "indigo",    Manager: "blue",    Lead: "blue",    Senior: "green",    Rep: "amber",};/** The role chip. `shrink-0` is restated here (not left to `Tag`) because the * chip now sits inline after the name and is the class that decides which of * the two words gives way in a narrow panel: the name shortens to "Owen * Fitzger…" and "Director" is never clipped to "Direc". */function RoleBadge({ role }: { role: Role }) {    return (        <Tag color={ROLE_COLOR[role]} className="shrink-0">            {role}        </Tag>    );}/** Initials are the fallback for the identity-tinted avatar. */function initials(name: string) {    return name        .split(" ")        .map((part) => part[0])        .join("");}/** * One avatar for both surfaces. The panel row and the trigger show the same * member, so they render the same element at two sizes rather than two * hand-rolled circles that drift apart. No photo: the initials sit on the * person's own identity tint, which is what sorts a scanned list by colour and * never reaches for Quiet Indigo or a status hue. */function MemberAvatar({    member,    className,    fallbackClassName,}: {    member: CascaderNode<Member>;    className: string;    fallbackClassName: string;}) {    const color: TagColor = member.data?.tint ?? "gray";    return (        <Avatar className={className}>            <AvatarFallback                className={cn(                    "bg-(--tag-gray) text-(--tag-gray-fg)",                    color === "blue" && "bg-(--tag-blue) text-(--tag-blue-fg)",                    color === "indigo" &&                        "bg-(--tag-indigo) text-(--tag-indigo-fg)",                    color === "violet" &&                        "bg-(--tag-violet) text-(--tag-violet-fg)",                    color === "pink" && "bg-(--tag-pink) text-(--tag-pink-fg)",                    color === "orange" &&                        "bg-(--tag-orange) text-(--tag-orange-fg)",                    color === "amber" &&                        "bg-(--tag-amber) text-(--tag-amber-fg)",                    color === "green" &&                        "bg-(--tag-green) text-(--tag-green-fg)",                    color === "teal" && "bg-(--tag-teal) text-(--tag-teal-fg)",                    fallbackClassName,                )}            >                {initials(member.label)}            </AvatarFallback>        </Avatar>    );}// A B2B revenue org: each team's members carry a seniority rung and an identity// tint. Six teams keep the panel the same size as the ReUI original.const teams: CascaderNode<Member>[] = [    {        value: "sales",        label: "Sales",        children: [            {                value: "sales.ada",                label: "Ada Whitfield",                data: { tint: "indigo", role: "VP", status: "online" },            },            {                value: "sales.marcus",                label: "Marcus Bell",                data: { tint: "green", role: "Senior", status: "away" },            },            {                value: "sales.priya",                label: "Priya Raman",                data: { tint: "blue", role: "Manager", status: "online" },            },        ],    },    {        value: "marketing",        label: "Marketing",        children: [            {                value: "marketing.june",                label: "June Okafor",                data: { tint: "indigo", role: "Director", status: "online" },            },            {                value: "marketing.theo",                label: "Theo Lindqvist",                data: { tint: "amber", role: "Rep", status: "away" },            },        ],    },    {        value: "revops",        label: "RevOps",        children: [            {                value: "revops.samira",                label: "Samira Haddad",                data: { tint: "indigo", role: "VP", status: "online" },            },            {                value: "revops.owen",                label: "Owen Fitzgerald",                data: { tint: "green", role: "Senior", status: "away" },            },        ],    },    {        value: "cs",        label: "Customer Success",        children: [            {                value: "cs.lena",                label: "Lena Vasquez",                data: { tint: "blue", role: "Lead", status: "online" },            },            {                value: "cs.dmitri",                label: "Dmitri Sokolov",                data: { tint: "amber", role: "Rep", status: "away" },            },        ],    },    {        value: "sdr",        label: "SDR",        children: [            {                value: "sdr.nadia",                label: "Nadia Petrova",                data: { tint: "blue", role: "Lead", status: "online" },            },            {                value: "sdr.tom",                label: "Tom Ashworth",                data: { tint: "amber", role: "Rep", status: "away" },            },        ],    },    {        value: "partnerships",        label: "Partnerships",        children: [            {                value: "partnerships.hana",                label: "Hana Yoshida",                data: { tint: "green", role: "Senior", status: "online" },            },            {                value: "partnerships.felix",                label: "Felix Nkemelu",                data: { tint: "blue", role: "Manager", status: "away" },            },        ],    },];/** * Flat value to member lookup. `CascaderValue` hands its render function the * resolved node typed as `CascaderNode<unknown>`, so the payload is read back * through this map rather than cast at the call site. */const membersByValue = new Map(    teams        .flatMap((team) => team.children ?? [])        .map((member) => [member.value, member] as const),);/** * `renderLabel` replaces only the label block, so avatars and role chips drop * in while the count, chevron and selected check keep working. * * The role chip sits INLINE, straight after the name, and the name is the only * thing in the row that truncates — "who, and at what rung" is one phrase read * in one pass. The mechanics are one class: the name is `min-w-0 truncate` and * NOT `flex-1`, so the slack lands AFTER the chip (where the check gutter * already is) instead of between the name and the chip. * * `CascaderValue` takes a render function for the same reason one level up: an * assignee trigger has to name a person, and a name without a face is the one * thing it cannot afford. It replaces the whole display, placeholder included. */export function AvatarRows() {    const [value, setValue] = React.useState("");    return (        <div className="flex w-full justify-center p-4">            <Cascader                items={teams}                value={value}                onValueChange={setValue}                renderLabel={(node, state) =>                    state.branch ? (                        <span className="w-full truncate text-start font-medium">                            {node.label}                        </span>                    ) : (                        <span className="flex w-full min-w-0 items-center gap-2">                            <span className="relative shrink-0">                                <MemberAvatar                                    member={node}                                    className="size-6"                                    fallbackClassName="text-[10px]"                                />                                <span                                    aria-hidden="true"                                    className={cn(                                        "absolute -end-0.5 -bottom-0.5 size-2 rounded-full ring-2 ring-popover",                                        node.data?.status === "online"                                            ? "bg-success"                                            : "bg-muted-foreground/40",                                    )}                                />                            </span>                            {/* No `flex-1`: the name takes the width it needs and gives it                  back first; the chip stays glued to it and the leftover                  space — including the check gutter — lands after both. */}                            <span className="min-w-0 truncate text-start">                                {node.label}                            </span>                            {node.data ? (                                <RoleBadge role={node.data.role} />                            ) : null}                        </span>                    )                }            >                <CascaderTrigger aria-label="Assignee" className="w-72">                    <CascaderValue placeholder="Assign to someone">                        {(selected) => {                            const node = selected[0];                            if (!node) {                                return (                                    <span className="truncate text-muted-foreground">                                        Assign to someone                                    </span>                                );                            }                            const member = membersByValue.get(node.value);                            return (                                <span className="flex min-w-0 items-center gap-2">                                    {member ? (                                        <MemberAvatar                                            member={member}                                            className="size-5"                                            fallbackClassName="text-[9px]"                                        />                                    ) : null}                                    <span className="truncate">                                        {node.label}                                    </span>                                </span>                            );                        }}                    </CascaderValue>                </CascaderTrigger>                <CascaderContent className="w-80">                    <CascaderPanel>                        <CascaderNav>                            <CascaderInput />                        </CascaderNav>                        <CascaderBreadcrumb />                        <CascaderEmpty />                        <CascaderList>                            <CascaderItems />                        </CascaderList>                        <CascaderStatus />                    </CascaderPanel>                </CascaderContent>            </Cascader>        </div>    );}

Format the trigger path

Control how the chosen path reads in the trigger: a custom separator, the leaf only, or a middle collapsed to an ellipsis.

import { Button } from "@oration/canon/components/button";import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,    useCascaderSelection,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import {    ToggleGroup,    ToggleGroupItem,} from "@oration/canon/components/toggle-group";import {    BuildingIcon,    CalendarIcon,    CreditCardIcon,    FileTextIcon,    FolderIcon,    HeadphonesIcon,    LifeBuoyIcon,    MailIcon,    PhoneIcon,    ReceiptIcon,    TicketIcon,    TrendingUpIcon,    UsersIcon,} from "lucide-react";import * as React from "react";// Source marks. `node.icon` is a ReactNode, so a product glyph drops straight// in. Canon ships no CRM/revenue-tool marks, so each connected source takes a// neutral lucide glyph (not a new SVG file) kept per source, so the trigger —// which only ever renders the LEAF's icon — still says which product a resource// came from.const salesforceLogo = <BuildingIcon aria-hidden="true" className="size-4" />;const hubspotLogo = <TrendingUpIcon aria-hidden="true" className="size-4" />;const stripeLogo = <CreditCardIcon aria-hidden="true" className="size-4" />;const gongLogo = <PhoneIcon aria-hidden="true" className="size-4" />;const outreachLogo = <MailIcon aria-hidden="true" className="size-4" />;const zendeskLogo = <LifeBuoyIcon aria-hidden="true" className="size-4" />;// The middle level is a KIND of thing rather than a product, so it keeps// generic icons. That contrast is the point: a product glyph says which tool a// row belongs to, a generic icon says what the row is.const accountIcon = <BuildingIcon aria-hidden="true" className="size-4" />;const opportunityIcon = (    <TrendingUpIcon aria-hidden="true" className="size-4" />);const contactIcon = <UsersIcon aria-hidden="true" className="size-4" />;const paymentIcon = <CreditCardIcon aria-hidden="true" className="size-4" />;const invoiceIcon = <ReceiptIcon aria-hidden="true" className="size-4" />;const callIcon = <PhoneIcon aria-hidden="true" className="size-4" />;const sequenceIcon = <MailIcon aria-hidden="true" className="size-4" />;const meetingIcon = <CalendarIcon aria-hidden="true" className="size-4" />;const ticketIcon = <TicketIcon aria-hidden="true" className="size-4" />;const folderIcon = <FolderIcon aria-hidden="true" className="size-4" />;const documentIcon = <FileTextIcon aria-hidden="true" className="size-4" />;const inboxIcon = <HeadphonesIcon aria-hidden="true" className="size-4" />;/** * Connected revenue tools, the areas inside them, and one resource to act on. * * The leaf carries the product mark again rather than a third icon, because the * trigger only ever renders the LEAF's icon — so whichever resource is picked, * the closed control still says which product it came from. */const sources: CascaderNode[] = [    {        value: "salesforce",        label: "Salesforce",        icon: salesforceLogo,        children: [            {                value: "salesforce.accounts",                label: "Accounts",                icon: accountIcon,                children: [                    {                        value: "salesforce.accounts.strategic",                        label: "Strategic",                        icon: salesforceLogo,                    },                    {                        value: "salesforce.accounts.at-risk",                        label: "At risk",                        icon: salesforceLogo,                    },                ],            },            {                value: "salesforce.opportunities",                label: "Opportunities",                icon: opportunityIcon,                children: [                    {                        value: "salesforce.opportunities.commit",                        label: "Commit this quarter",                        icon: salesforceLogo,                    },                    {                        value: "salesforce.opportunities.upside",                        label: "Upside",                        icon: salesforceLogo,                    },                ],            },            {                value: "salesforce.contacts",                label: "Contacts",                icon: contactIcon,                children: [                    {                        value: "salesforce.contacts.champions",                        label: "Champions",                        icon: salesforceLogo,                    },                    {                        value: "salesforce.contacts.economic-buyers",                        label: "Economic buyers",                        icon: salesforceLogo,                    },                ],            },        ],    },    {        value: "hubspot",        label: "HubSpot",        icon: hubspotLogo,        children: [            {                value: "hubspot.sequences",                label: "Sequences",                icon: sequenceIcon,                children: [                    {                        value: "hubspot.sequences.outbound",                        label: "Outbound Q3",                        icon: hubspotLogo,                    },                    {                        value: "hubspot.sequences.nurture",                        label: "Nurture",                        icon: hubspotLogo,                    },                ],            },            {                value: "hubspot.lists",                label: "Lists",                icon: folderIcon,                children: [                    {                        value: "hubspot.lists.mql",                        label: "MQLs",                        icon: hubspotLogo,                    },                    {                        value: "hubspot.lists.icp",                        label: "ICP accounts",                        icon: hubspotLogo,                    },                ],            },        ],    },    {        value: "stripe",        label: "Stripe",        icon: stripeLogo,        children: [            {                value: "stripe.payments",                label: "Payments",                icon: paymentIcon,                children: [                    {                        value: "stripe.payments.recent",                        label: "Recent charges",                        icon: stripeLogo,                    },                    {                        value: "stripe.payments.disputes",                        label: "Disputes",                        icon: stripeLogo,                    },                ],            },            {                value: "stripe.invoices",                label: "Invoices",                icon: invoiceIcon,                children: [                    {                        value: "stripe.invoices.drafts",                        label: "Drafts",                        icon: stripeLogo,                    },                    {                        value: "stripe.invoices.overdue",                        label: "Past due",                        icon: stripeLogo,                    },                ],            },        ],    },    {        value: "gong",        label: "Gong",        icon: gongLogo,        children: [            {                value: "gong.calls",                label: "Calls",                icon: callIcon,                children: [                    {                        value: "gong.calls.discovery",                        label: "Discovery",                        icon: gongLogo,                    },                    {                        value: "gong.calls.negotiation",                        label: "Negotiation",                        icon: gongLogo,                    },                ],            },            {                value: "gong.meetings",                label: "Meetings",                icon: meetingIcon,                children: [                    {                        value: "gong.meetings.upcoming",                        label: "Upcoming",                        icon: gongLogo,                    },                    {                        value: "gong.meetings.recordings",                        label: "Recordings",                        icon: gongLogo,                    },                ],            },        ],    },    {        value: "outreach",        label: "Outreach",        icon: outreachLogo,        children: [            {                value: "outreach.sequences",                label: "Sequences",                icon: sequenceIcon,                children: [                    {                        value: "outreach.sequences.cold",                        label: "Cold outbound",                        icon: outreachLogo,                    },                    {                        value: "outreach.sequences.reengage",                        label: "Re-engagement",                        icon: outreachLogo,                    },                ],            },            {                value: "outreach.templates",                label: "Templates",                icon: documentIcon,                children: [                    {                        value: "outreach.templates.intro",                        label: "Intro email",                        icon: outreachLogo,                    },                    {                        value: "outreach.templates.followup",                        label: "Follow-up",                        icon: outreachLogo,                    },                ],            },        ],    },    {        value: "zendesk",        label: "Zendesk",        icon: zendeskLogo,        children: [            {                value: "zendesk.tickets",                label: "Tickets",                icon: ticketIcon,                children: [                    {                        value: "zendesk.tickets.open",                        label: "Open",                        icon: zendeskLogo,                    },                    {                        value: "zendesk.tickets.escalated",                        label: "Escalated",                        icon: zendeskLogo,                    },                ],            },            {                value: "zendesk.inboxes",                label: "Inboxes",                icon: inboxIcon,                children: [                    {                        value: "zendesk.inboxes.support",                        label: "Support",                        icon: zendeskLogo,                    },                    {                        value: "zendesk.inboxes.success",                        label: "Success",                        icon: zendeskLogo,                    },                ],            },        ],    },];type Format = "collapsed" | "full" | "tail" | "leaf" | "custom";/** * A fully headless trigger. `useCascaderSelection` hands back the resolved * nodes and their ancestor chain as plain data, so this renders whatever it * likes — here the product mark taken from the ROOT of the chain next to a * two-line label with a monospace resource slug — without going through * `CascaderValue` at all. */function ResourceValue() {    const { firstPath, isEmpty } = useCascaderSelection();    if (isEmpty) {        return <span className="text-muted-foreground">Select a resource</span>;    }    const brand = firstPath[0];    const leaf = firstPath[firstPath.length - 1];    const slug = leaf?.value.split(".").join("/");    return (        <span className="flex min-w-0 items-center gap-2">            <span className="flex shrink-0 items-center">{brand?.icon}</span>            <span className="flex min-w-0 flex-col items-start leading-tight">                <span className="truncate text-sm font-medium">                    {leaf?.label}                </span>                <span className="truncate font-mono text-[10px] text-muted-foreground">                    {slug}                </span>            </span>        </span>    );}/** * How the trigger renders the selection, all on one cascader instance. * * A connected-tool tree is exactly where a bare leaf label stops being enough: * "Sequences" exists under both HubSpot and Outreach, and "Drafts" says nothing * about which product it came from. Switch the format to see the trade-off * between compactness and being unambiguous — the last option drops * `CascaderValue` entirely for a headless renderer. * * `maxSegments` is 2 rather than the default 3 because a resource path is three * segments deep, and a limit of three would never actually collapse anything. * At two, `collapse="middle"` keeps the product and the resource and folds the * area between them, while `collapse="start"` keeps the tail and folds the * product away. * * The buttons sit BELOW the control and the control's row reserves the height * of the tallest trigger, so switching to the two-line headless renderer does * not shove the buttons down the page mid-comparison. */export function PathFormatting() {    const [value, setValue] = React.useState("");    const [format, setFormat] = React.useState<Format>("collapsed");    return (        <div className="flex w-full flex-col items-center p-4">            <div className="flex w-full max-w-sm flex-col gap-4">                <div className="min-h-12 w-full">                    <Cascader                        items={sources}                        value={value}                        onValueChange={setValue}                    >                        <CascaderTrigger                            aria-label="Resource"                            render={                                <Button                                    variant="outline"                                    className="h-auto min-h-9 w-full justify-between gap-2 py-1.5 font-normal"                                />                            }                        >                            {format === "custom" ? (                                <ResourceValue />                            ) : (                                <CascaderValue                                    placeholder="Select a resource"                                    display={                                        format === "leaf" ? "leaf" : "path"                                    }                                    collapse={                                        format === "full"                                            ? "none"                                            : format === "tail"                                              ? "start"                                              : "middle"                                    }                                    maxSegments={2}                                />                            )}                        </CascaderTrigger>                        <CascaderContent className="w-72">                            <CascaderPanel>                                <CascaderNav>                                    <CascaderInput />                                </CascaderNav>                                <CascaderBreadcrumb />                                <CascaderEmpty />                                <CascaderList>                                    <CascaderItems />                                </CascaderList>                                <CascaderStatus />                            </CascaderPanel>                        </CascaderContent>                    </Cascader>                </div>                <ToggleGroup                    variant="outline"                    size="sm"                    value={[format]}                    onValueChange={(next: string[]) =>                        next[0] && setFormat(next[0] as Format)                    }                    className="self-center"                >                    <ToggleGroupItem value="collapsed">                        Collapsed                    </ToggleGroupItem>                    <ToggleGroupItem value="full">Full</ToggleGroupItem>                    <ToggleGroupItem value="tail">Tail</ToggleGroupItem>                    <ToggleGroupItem value="leaf">Leaf</ToggleGroupItem>                    <ToggleGroupItem value="custom">Headless</ToggleGroupItem>                </ToggleGroup>            </div>        </div>    );}

Embedded panel

The panel rendered straight into the page with no popover, for a picker that stays open.

Use the Right Arrow key to open a branch and the Left Arrow key to go back.
Top level, 5 items
import { Card } from "@oration/canon/components/card";import {    Cascader,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import {    BanknoteIcon,    BellIcon,    BuildingIcon,    CreditCardIcon,    FileTextIcon,    KeyRoundIcon,    LockIcon,    PlugIcon,    ReceiptTextIcon,    ShieldCheckIcon,    SlidersHorizontalIcon,    UserCogIcon,    UserIcon,    UsersIcon,    WebhookIcon,} from "lucide-react";import * as React from "react";// A settings index is scanned, not read, so the icon column is the first thing// the eye lands on and has to be the thing that tells sections apart. Each icon// is declared once and referenced from the tree below, so the data stays an// index rather than a page of JSX.const workspaceIcon = <BuildingIcon aria-hidden="true" />;const membersIcon = <UsersIcon aria-hidden="true" />;const billingIcon = <CreditCardIcon aria-hidden="true" />;const securityIcon = <ShieldCheckIcon aria-hidden="true" />;const integrationsIcon = <PlugIcon aria-hidden="true" />;const profileIcon = <UserIcon aria-hidden="true" />;const notificationsIcon = <BellIcon aria-hidden="true" />;const preferencesIcon = <SlidersHorizontalIcon aria-hidden="true" />;const planIcon = <ReceiptTextIcon aria-hidden="true" />;const paymentIcon = <BanknoteIcon aria-hidden="true" />;const invoicesIcon = <FileTextIcon aria-hidden="true" />;const keysIcon = <KeyRoundIcon aria-hidden="true" />;const webhooksIcon = <WebhookIcon aria-hidden="true" />;const rolesIcon = <UserCogIcon aria-hidden="true" />;const sessionsIcon = <LockIcon aria-hidden="true" />;const auditIcon = <FileTextIcon aria-hidden="true" />;const settings: CascaderNode[] = [    {        value: "workspace",        label: "Workspace",        icon: workspaceIcon,        children: [            { value: "workspace.profile", label: "Profile", icon: profileIcon },            {                value: "workspace.preferences",                label: "Preferences",                icon: preferencesIcon,            },            {                value: "workspace.notifications",                label: "Notifications",                icon: notificationsIcon,            },        ],    },    {        value: "members",        label: "Members",        icon: membersIcon,        children: [            { value: "members.people", label: "People", icon: membersIcon },            { value: "members.roles", label: "Roles", icon: rolesIcon },        ],    },    {        value: "billing",        label: "Billing",        icon: billingIcon,        children: [            { value: "billing.plan", label: "Plan", icon: planIcon },            {                value: "billing.payment",                label: "Payment method",                icon: paymentIcon,            },            {                value: "billing.invoices",                label: "Invoices",                icon: invoicesIcon,            },        ],    },    {        value: "security",        label: "Security",        icon: securityIcon,        children: [            {                value: "security.sessions",                label: "Sessions",                icon: sessionsIcon,            },            { value: "security.audit", label: "Audit log", icon: auditIcon },        ],    },    {        value: "integrations",        label: "Integrations",        icon: integrationsIcon,        children: [            { value: "integrations.keys", label: "API keys", icon: keysIcon },            {                value: "integrations.webhooks",                label: "Webhooks",                icon: webhooksIcon,            },        ],    },];/** * No trigger and no popover: `inline` drops the floating layer and * `CascaderPanel` renders the same nav, list and status straight into the page. * Useful for sidebars, dialog bodies and settings screens, where a popover * inside a popover would be awkward. * * The surround is a Canon `Card` rather than a hand-rolled `rounded-lg border` * div: the radius and the hairline lift come from the card, and `py-0` lets the * panel sit flush inside it. * * `maxHeight` is kept here, and this is the one shape where it is not a * shortcut: `inline` has no positioner, so there is no `--available-height` to * bound the panel and the cap is the only thing that does. Every popover * example drops it and lets the viewport decide. */export function EmbeddedPanel() {    const [value, setValue] = React.useState("");    return (        <div className="flex w-full max-w-md flex-col gap-3">            <Cascader                inline                items={settings}                value={value}                onValueChange={setValue}            >                <Card className="py-0">                    <CascaderPanel>                        <CascaderNav>                            <CascaderInput placeholder="Search settings" />                        </CascaderNav>                        <CascaderBreadcrumb />                        <CascaderEmpty>No settings match.</CascaderEmpty>                        <CascaderList maxHeight={240}>                            <CascaderItems />                        </CascaderList>                        <CascaderStatus />                    </CascaderPanel>                </Card>            </Cascader>        </div>    );}

Flat data with parent pointers

Rows stored as a flat list with a parent field, built into a tree before they reach items.

import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,    useCascaderSelection,} from "@oration/canon/components/cascader";import {    CascaderAction,    CascaderFooter,} from "@oration/canon/components/cascader/footer";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import { RotateCcwIcon } from "lucide-react";import * as React from "react";/** * Cost centers as they come out of a ledger: no nesting, just a parent * pointer. Deliberately unsorted, to show the index does not depend on parents * arriving before their children. */interface Row extends CascaderNode {    parentId: string | null;}const rows: Row[] = [    { value: "3", label: "Marketing", parentId: "1" },    { value: "1", label: "North America", parentId: null },    { value: "5", label: "Sales", parentId: "2" },    { value: "2", label: "Europe", parentId: null },    { value: "4", label: "Engineering", parentId: "1" },    { value: "7", label: "Platform", parentId: "4" },    { value: "6", label: "Support", parentId: "2" },    { value: "8", label: "Product", parentId: "4" },    { value: "9", label: "Design", parentId: "2" },    { value: "11", label: "Field sales", parentId: "10" },    { value: "10", label: "Latin America", parentId: null },    { value: "13", label: "Partnerships", parentId: "12" },    { value: "12", label: "Asia Pacific", parentId: null },    { value: "15", label: "Operations", parentId: "14" },    { value: "14", label: "Middle East", parentId: null },    { value: "16", label: "Africa", parentId: null },    { value: "17", label: "Distribution", parentId: "16" },    { value: "18", label: "Oceania", parentId: null },    { value: "19", label: "Retail", parentId: "18" },    { value: "20", label: "Infrastructure", parentId: "7" },    { value: "21", label: "Developer tools", parentId: "7" },    { value: "22", label: "Customer success", parentId: "12" },];/** * The reset as a footer COMMAND, reading the selection out of context instead * of being handed it. Nothing in here is specific to this example — composed * into any panel it clears that cascader — and `isEmpty` is what stops it * offering to undo nothing. */function ResetAction() {    const { clear, isEmpty } = useCascaderSelection();    return (        <CascaderAction            icon={<RotateCcwIcon aria-hidden="true" />}            disabled={isEmpty}            onSelect={clear}        >            Reset selection        </CascaderAction>    );}/** * Flat adjacency input. Pass the rows as-is plus `getParent`, and the tree is * indexed in one linear pass — no client-side re-nesting step, which is what * keeps a large normalized dataset cheap to render and cheap to update. * * The footer carries a reset command. A drill-down picker has no other way back * to "nothing selected" once a cost center is committed: the trigger shows a * value, and re-picking it in a single-select list only reselects it. * `CascaderFooter` draws the rule itself, with a `border-t` that sits exactly * on the boundary between the list and the footer — the border needs no * arithmetic to be symmetric, because it is the boundary rather than a child of * one side of it. */export function FlatData() {    const [value, setValue] = React.useState("");    return (        <Cascader            items={rows}            getParent={(node) => (node as Row).parentId}            value={value}            onValueChange={setValue}        >            <CascaderTrigger aria-label="Cost center" className="w-72">                <CascaderValue placeholder="Select a cost center" />            </CascaderTrigger>            <CascaderContent className="w-72">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput placeholder="Search cost centers" />                    </CascaderNav>                    <CascaderBreadcrumb />                    <CascaderEmpty>No cost centers match.</CascaderEmpty>                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    {/* A SIBLING of the list, never a child of it: `CascaderList`'s own              Enter handler clicks whatever it contains, so a command living              inside the rows would fire on the keystroke that commits one. */}                    <CascaderFooter>                        <ResetAction />                    </CascaderFooter>                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

Fully controlled

Path, value, query and open state all held by the parent, so external buttons can drive the picker.

path
root
query
(empty)
open
false
change
(none yet)
import { Button } from "@oration/canon/components/button";import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import * as React from "react";const accounts: CascaderNode[] = [    {        value: "assets",        label: "Assets",        children: [            {                value: "assets.current",                label: "Current assets",                children: [                    { value: "assets.current.cash", label: "Cash" },                    {                        value: "assets.current.ar",                        label: "Accounts receivable",                    },                    {                        value: "assets.current.prepaid",                        label: "Prepaid expenses",                    },                ],            },            { value: "assets.ppe", label: "Property and equipment" },            { value: "assets.intangible", label: "Intangible assets" },        ],    },    {        value: "liabilities",        label: "Liabilities",        children: [            { value: "liabilities.ap", label: "Accounts payable" },            { value: "liabilities.accrued", label: "Accrued expenses" },            { value: "liabilities.debt", label: "Long-term debt" },        ],    },    {        value: "equity",        label: "Equity",        children: [            { value: "equity.common", label: "Common stock" },            { value: "equity.retained", label: "Retained earnings" },        ],    },    {        value: "revenue",        label: "Revenue",        children: [            { value: "revenue.product", label: "Product revenue" },            { value: "revenue.services", label: "Services revenue" },        ],    },    {        value: "expenses",        label: "Expenses",        children: [            { value: "expenses.cogs", label: "Cost of goods sold" },            { value: "expenses.opex", label: "Operating expenses" },        ],    },    { value: "suspense", label: "Suspense" },];/** * Value to label for every node, flattened once at module scope so the readout * can name a path without walking the tree on each render. */const LABELS = new Map<string, string>();function indexLabels(nodes: CascaderNode[]) {    for (const node of nodes) {        LABELS.set(node.value, node.label);        if (node.children) indexLabels(node.children);    }}indexLabels(accounts);/** * Every piece of state the cascader holds is driven from outside here: the * selection, the navigation path, the query and the open state. The buttons * write the path and the selection directly, and the readout below prints all * four, so the controlled contract is observable rather than merely claimed. * * Both path buttons also OPEN the popup, and that is the point rather than a * convenience. The path is only ever drawn inside the panel, so writing it * while the popup is shut changes state nothing renders; and a press on a * button outside the popup dismisses an open panel before the handler runs, so * a handler that only writes the path looks dead in both states. Reopening is * what makes an external navigation visible at all. * * `onValueChange` also hands over a details object, so the change line is built * straight from the committed node and its ancestor chain rather than looking * the raw value back up in `accounts`. */export function Controlled() {    const [value, setValue] = React.useState("");    const [path, setPath] = React.useState<string[]>([]);    const [query, setQuery] = React.useState("");    const [open, setOpen] = React.useState(false);    const [lastChange, setLastChange] = React.useState("(none yet)");    // The query is cleared alongside the jump for the same reason the primitive's    // own breadcrumb and back button clear it: a filter typed against one level    // has no meaning against the level you just landed on.    const goTo = (next: string[]) => {        setPath(next);        setQuery("");        setOpen(true);    };    const here = path.length        ? path.map((entry) => LABELS.get(entry) ?? entry).join(" / ")        : "root";    return (        <div className="flex w-full flex-col items-center gap-3">            <Cascader                items={accounts}                value={value}                onValueChange={(next, details) => {                    setValue(next);                    setLastChange(                        `${details.reason}: ${details.path                            .map((node) => node.label)                            .join(" / ")}`,                    );                }}                path={path}                onPathChange={setPath}                inputValue={query}                onInputValueChange={setQuery}                open={open}                onOpenChange={setOpen}                revealSelected={false}            >                <CascaderTrigger aria-label="GL account" className="w-72">                    <CascaderValue placeholder="Pick an account" />                </CascaderTrigger>                <CascaderContent className="w-72">                    <CascaderPanel>                        <CascaderNav>                            <CascaderInput placeholder="Search accounts" />                        </CascaderNav>                        <CascaderBreadcrumb />                        <CascaderEmpty>No accounts match.</CascaderEmpty>                        <CascaderList>                            <CascaderItems />                        </CascaderList>                        <CascaderStatus />                    </CascaderPanel>                </CascaderContent>            </Cascader>            <div className="flex flex-wrap items-center justify-center gap-2">                <Button                    size="sm"                    variant="outline"                    onClick={() => goTo(["assets", "assets.current"])}                >                    Jump to Current assets                </Button>                <Button size="sm" variant="outline" onClick={() => goTo([])}>                    Reset to root                </Button>                <Button                    size="sm"                    variant="outline"                    disabled={!value}                    onClick={() => {                        setValue("");                        // Written from outside, so `onValueChange` never fires and the                        // change line would otherwise still name the node just dropped.                        setLastChange("cleared from outside");                    }}                >                    Clear selection                </Button>            </div>            <dl className="grid w-72 grid-cols-[3.5rem_1fr] gap-x-3 gap-y-1 font-mono text-xs text-muted-foreground">                <dt>path</dt>                <dd className="truncate text-foreground">{here}</dd>                <dt>query</dt>                <dd className="truncate text-foreground">                    {query || "(empty)"}                </dd>                <dt>open</dt>                <dd className="text-foreground">{open ? "true" : "false"}</dd>                <dt>change</dt>                <dd className="truncate text-foreground">{lastChange}</dd>            </dl>        </div>    );}

Translated labels

A country picker whose labels, empty state and screen-reader text switch with the locale.

import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type {    CascaderLabels,    CascaderNode,} from "@oration/canon/components/cascader/types";import { MonogramTile } from "@oration/canon/components/monogram-tile";import type { TagColor } from "@oration/canon/components/tag";import { GlobeIcon } from "lucide-react";import * as React from "react";/* -------------------------------------------------------------------------- *//*                              Country monograms                             *//* -------------------------------------------------------------------------- *//** * The country's ISO code as a monogram tile, straight in the row's icon slot. * * No emoji flags on purpose. A flag is drawn by the platform's own colour * font, and Windows Chrome ships no regional-indicator glyphs at all: it falls * back to two ISO letters whose width changes per pair, so a column of flags * ends up a ruled margin of mismatched boxes. A `MonogramTile` is a Canon * primitive instead — one fixed 20px box, the two ISO letters centred inside * it, the same size across every style — and it carries the region's tint so * the eye can still sort the list by colour while scanning. * * `aria-hidden` is already set by `MonogramTile`, which is the right call here: * the country is named in words right beside it, and the code announced as * well would read the same fact twice. */function monogram(code: string, color: TagColor) {    return <MonogramTile name={code} color={color} size="sm" />;}/** A region has no code of its own, so it takes a globe — same slot, same * fixed column, so the leading edge never sits empty on the way in. */function regionIcon() {    return (        <span            aria-hidden="true"            className="flex size-5 items-center justify-center text-muted-foreground"        >            <GlobeIcon className="size-4" />        </span>    );}/* -------------------------------------------------------------------------- *//*                                    Data                                    *//* -------------------------------------------------------------------------- *//** * Regions, then the countries inside them — written in German, because the UI * strings around them are. A picker whose DATA is still English under a * translated shell is the most common way a half-done localisation ships, so * this example does not demonstrate it. */const laender: CascaderNode[] = [    {        value: "europa",        label: "Europa",        icon: regionIcon(),        children: [            {                value: "europa.de",                label: "Deutschland",                icon: monogram("DE", "blue"),            },            {                value: "europa.fr",                label: "Frankreich",                icon: monogram("FR", "blue"),            },            {                value: "europa.it",                label: "Italien",                icon: monogram("IT", "blue"),            },            {                value: "europa.nl",                label: "Niederlande",                icon: monogram("NL", "blue"),            },            {                value: "europa.at",                label: "Österreich",                icon: monogram("AT", "blue"),            },            {                value: "europa.pl",                label: "Polen",                icon: monogram("PL", "blue"),            },            {                value: "europa.pt",                label: "Portugal",                icon: monogram("PT", "blue"),            },            {                value: "europa.se",                label: "Schweden",                icon: monogram("SE", "blue"),            },            {                value: "europa.ch",                label: "Schweiz",                icon: monogram("CH", "blue"),            },            {                value: "europa.es",                label: "Spanien",                icon: monogram("ES", "blue"),            },        ],    },    {        value: "asien",        label: "Asien",        icon: regionIcon(),        children: [            {                value: "asien.cn",                label: "China",                icon: monogram("CN", "amber"),            },            {                value: "asien.in",                label: "Indien",                icon: monogram("IN", "amber"),            },            {                value: "asien.id",                label: "Indonesien",                icon: monogram("ID", "amber"),            },            {                value: "asien.jp",                label: "Japan",                icon: monogram("JP", "amber"),            },            {                value: "asien.kr",                label: "Südkorea",                icon: monogram("KR", "amber"),            },            {                value: "asien.th",                label: "Thailand",                icon: monogram("TH", "amber"),            },            {                value: "asien.vn",                label: "Vietnam",                icon: monogram("VN", "amber"),            },        ],    },    {        value: "afrika",        label: "Afrika",        icon: regionIcon(),        children: [            {                value: "afrika.eg",                label: "Ägypten",                icon: monogram("EG", "orange"),            },            {                value: "afrika.gh",                label: "Ghana",                icon: monogram("GH", "orange"),            },            {                value: "afrika.ke",                label: "Kenia",                icon: monogram("KE", "orange"),            },            {                value: "afrika.ma",                label: "Marokko",                icon: monogram("MA", "orange"),            },            {                value: "afrika.ng",                label: "Nigeria",                icon: monogram("NG", "orange"),            },            {                value: "afrika.za",                label: "Südafrika",                icon: monogram("ZA", "orange"),            },        ],    },    {        value: "nordamerika",        label: "Nordamerika",        icon: regionIcon(),        children: [            {                value: "nordamerika.ca",                label: "Kanada",                icon: monogram("CA", "red"),            },            {                value: "nordamerika.mx",                label: "Mexiko",                icon: monogram("MX", "red"),            },            {                value: "nordamerika.us",                label: "Vereinigte Staaten",                icon: monogram("US", "red"),            },        ],    },    {        value: "mittelamerika",        label: "Mittelamerika",        icon: regionIcon(),        children: [            {                value: "mittelamerika.bz",                label: "Belize",                icon: monogram("BZ", "green"),            },            {                value: "mittelamerika.cr",                label: "Costa Rica",                icon: monogram("CR", "green"),            },            {                value: "mittelamerika.gt",                label: "Guatemala",                icon: monogram("GT", "green"),            },            {                value: "mittelamerika.pa",                label: "Panama",                icon: monogram("PA", "green"),            },        ],    },    {        value: "suedamerika",        label: "Südamerika",        icon: regionIcon(),        children: [            {                value: "suedamerika.ar",                label: "Argentinien",                icon: monogram("AR", "teal"),            },            {                value: "suedamerika.br",                label: "Brasilien",                icon: monogram("BR", "teal"),            },            {                value: "suedamerika.cl",                label: "Chile",                icon: monogram("CL", "teal"),            },            {                value: "suedamerika.co",                label: "Kolumbien",                icon: monogram("CO", "teal"),            },            {                value: "suedamerika.pe",                label: "Peru",                icon: monogram("PE", "teal"),            },            {                value: "suedamerika.uy",                label: "Uruguay",                icon: monogram("UY", "teal"),            },        ],    },    {        value: "ozeanien",        label: "Ozeanien",        icon: regionIcon(),        children: [            {                value: "ozeanien.au",                label: "Australien",                icon: monogram("AU", "violet"),            },            {                value: "ozeanien.fj",                label: "Fidschi",                icon: monogram("FJ", "violet"),            },            {                value: "ozeanien.nz",                label: "Neuseeland",                icon: monogram("NZ", "violet"),            },            {                value: "ozeanien.pg",                label: "Papua-Neuguinea",                icon: monogram("PG", "violet"),            },        ],    },];/** * The whole localisation surface, in one object. * * `labels` is the ONLY place the cascader takes copy from. Nothing is hardcoded * in the primitive, so a translated build needs no wrapper component and no * fork — and that goes past the strings you can see. The placeholder, the empty * state and the loading line are the obvious half; the other half is the part a * screen reader hears and a sighted user never does: the panel's own name, the * name of the root level, the "n Länder" a branch row carries, the arrow-key * hint, and every announcement the live region reads out on a level change or * after filtering. Translate only the visible half and the control still speaks * English to the one user who depends on it most. * * Every key is a plain string or a function of plain values, so plurals stay * the translator's decision rather than being assembled from an English * template: German counts "1 Land" against "2 Länder" here, and a locale with * three plural forms writes three. * * The keys left out are the ones this example cannot render — paging, load * errors, chips, tree expansion, columns — and they fall back to the English * defaults. A real build translates those too; `Partial<CascaderLabels>` is * what lets a demo say so honestly instead of restating strings it never shows. */const labels: Partial<CascaderLabels> = {    search: (parentLabel) =>        parentLabel ? `${parentLabel} durchsuchen…` : "Land suchen…",    back: "Zurück",    empty: "Keine Ergebnisse gefunden.",    loading: "Wird geladen…",    selectedCount: (count) => `${count} ausgewählt`,    breadcrumbLabel: "Navigationspfad",    panelLabel: "Länderauswahl",    rootLevel: "Alle Regionen",    itemCount: (count) => `${count} ${count === 1 ? "Land" : "Länder"}`,    branchAffordance: "Untermenü",    keyboardHint: (mode) =>        mode === "tree"            ? "Mit der Pfeiltaste nach rechts aufklappen, mit der Pfeiltaste nach links zuklappen."            : mode === "columns"              ? "Mit der Pfeiltaste nach rechts die nächste Spalte öffnen, mit der Pfeiltaste nach links zurück."              : "Mit der Pfeiltaste nach rechts eine Ebene öffnen, mit der Pfeiltaste nach links zurück.",    rootAnnouncement: (count) =>        `Alle Regionen, ${count} ${count === 1 ? "Region" : "Regionen"}`,    levelAnnouncement: (parentLabel, depth, count) =>        `${parentLabel}, Ebene ${depth}, ${count} ${            count === 1 ? "Land" : "Länder"        }`,    resultsAnnouncement: (count) =>        count === 1 ? "1 Ergebnis" : `${count} Ergebnisse`,};/** * The i18n surface, shown on the thing that always needs one. * * A country picker is where localisation stops being a checklist: the data is * translated as well as the chrome, and the labels carry their own plurals. * Everything the user reads or hears here — placeholder, breadcrumb, empty * state, the panel's accessible name, the arrow-key hint and every live-region * announcement — comes out of the one `labels` object above. * * The monograms are decoration on purpose. They speed the list up for someone * scanning it, and they carry nothing the label does not already say, so the * example still reads with the tiles turned off and to a screen reader that * skips them. */export function Localized() {    const [value, setValue] = React.useState("");    return (        <Cascader            items={laender}            value={value}            onValueChange={setValue}            labels={labels}        >            <CascaderTrigger                // German like every other string here: the field's name is announced                // in the same language as the value it introduces.                aria-label="Land"                className="w-72"            >                <CascaderValue placeholder="Land wählen" />            </CascaderTrigger>            <CascaderContent className="w-72">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput />                    </CascaderNav>                    <CascaderBreadcrumb />                    <CascaderEmpty />                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

Two-line rows

Each row carries a short description under its label, and one option is disabled with the reason shown.

import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import { cn } from "@oration/canon/lib/utils";import { BuildingIcon, MapPinIcon } from "lucide-react";import * as React from "react";// A neutral Well Gray tile, not a tinted one: the glyph names the KIND of row// (a region vs a territory inside it), it is not a status, so the tile stays// muted and only frames the icon. Indigo is reserved for selection and focus// per the Quiet Indigo rule, so a data marker never borrows it.function RowTile({ children }: { children: React.ReactNode }) {    return (        <span            aria-hidden="true"            className={cn(                "inline-flex size-8 shrink-0 items-center justify-center rounded-md",                "bg-muted/70 text-muted-foreground [&>svg]:size-4",            )}        >            {children}        </span>    );}const regionTile = (    <RowTile>        <BuildingIcon />    </RowTile>);const territoryTile = (    <RowTile>        <MapPinIcon />    </RowTile>);// Sales territories for a revenue workspace. Each row carries a one-line// description so the panel reads as two lines per option, and the last branch// is blocked rather than dropped so the reason it is unavailable survives.const territories: CascaderNode[] = [    {        value: "amer",        label: "North America",        icon: regionTile,        description: "42 active accounts",        children: [            {                value: "amer.west",                label: "West",                icon: territoryTile,                description: "3 territories",                children: [                    {                        value: "amer.west.sfo",                        label: "San Francisco Bay",                        icon: territoryTile,                        description: "Enterprise · 18 accounts",                    },                    {                        value: "amer.west.pnw",                        label: "Pacific Northwest",                        icon: territoryTile,                        description: "Mid-market · 11 accounts",                    },                ],            },            {                value: "amer.east",                label: "East",                icon: territoryTile,                description: "2 territories",                children: [                    {                        value: "amer.east.nyc",                        label: "New York Metro",                        icon: territoryTile,                        description: "Enterprise · 20 accounts",                    },                ],            },            {                value: "amer.central",                label: "Central",                icon: territoryTile,                description: "1 territory",                children: [                    {                        value: "amer.central.chi",                        label: "Great Lakes",                        icon: territoryTile,                        description: "Mid-market · 9 accounts",                    },                ],            },        ],    },    {        value: "emea",        label: "EMEA",        icon: regionTile,        description: "28 active accounts",        children: [            {                value: "emea.dach",                label: "DACH",                icon: territoryTile,                description: "1 territory",                children: [                    {                        value: "emea.dach.fra",                        label: "Frankfurt",                        icon: territoryTile,                        description: "Enterprise · 12 accounts",                    },                ],            },            {                value: "emea.uki",                label: "UK & Ireland",                icon: territoryTile,                description: "1 territory",                children: [                    {                        value: "emea.uki.lon",                        label: "London",                        icon: territoryTile,                        description: "Enterprise · 16 accounts",                    },                ],            },        ],    },    {        value: "apac",        label: "APAC",        icon: regionTile,        description: "19 active accounts",        children: [            {                value: "apac.anz",                label: "ANZ",                icon: territoryTile,                description: "1 territory",                children: [                    {                        value: "apac.anz.syd",                        label: "Sydney",                        icon: territoryTile,                        description: "Mid-market · 10 accounts",                    },                ],            },            {                value: "apac.jp",                label: "Japan",                icon: territoryTile,                description: "1 territory",                children: [                    {                        value: "apac.jp.tyo",                        label: "Tokyo",                        icon: territoryTile,                        description: "Enterprise · 9 accounts",                    },                ],            },        ],    },    {        value: "latam",        label: "LATAM",        icon: regionTile,        description: "7 active accounts",        children: [            {                value: "latam.br",                label: "Brazil",                icon: territoryTile,                description: "1 territory",                children: [                    {                        value: "latam.br.sao",                        label: "São Paulo",                        icon: territoryTile,                        description: "Mid-market · 7 accounts",                    },                ],            },        ],    },    {        value: "gcc",        label: "Gulf States",        icon: regionTile,        description: "Requires a regional reseller",        disabled: true,    },];// Two-line rows via `renderLabel`: only the label block is replaced, so the// icon tile, the chevron and the check still come from the default row. The// description is drawn here, so the node's own `description` is not repeated.// `leading-tight` on both lines closes the oversized line box a 14px title and// a 12px description would otherwise reserve, giving the panel back visible// rows. A disabled branch stays rendered and `aria-disabled`, so the reason it// is unavailable is still discoverable instead of the option seeming absent.export function DescribedRows() {    const [value, setValue] = React.useState("");    return (        <Cascader            items={territories}            value={value}            onValueChange={setValue}            renderLabel={(node) => (                <>                    <span className="w-full truncate text-start leading-tight">                        {node.label}                    </span>                    {node.description ? (                        <span className="w-full truncate text-start text-xs leading-tight text-muted-foreground">                            {node.description}                        </span>                    ) : null}                </>            )}        >            <CascaderTrigger aria-label="Territory" className="w-72">                <CascaderValue placeholder="Select a territory" />            </CascaderTrigger>            <CascaderContent className="w-80">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput placeholder="Search territories" />                    </CascaderNav>                    <CascaderBreadcrumb />                    <CascaderEmpty>No territories match.</CascaderEmpty>                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

Inside a form

name renders a hidden input so the cascader submits with the form like a native field.

Pick the most specific topic you can.

import { Button } from "@oration/canon/components/button";import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import {    Field,    FieldDescription,    FieldLabel,} from "@oration/canon/components/field";import * as React from "react";// A support-request taxonomy for a revenue workspace. The leaves are the// specific thing a ticket is about; the branches group them.const topics: CascaderNode[] = [    {        value: "billing",        label: "Billing",        children: [            { value: "billing.invoice", label: "Invoice question" },            { value: "billing.refund", label: "Refund request" },            { value: "billing.plan", label: "Change plan" },            { value: "billing.tax", label: "Tax details" },        ],    },    {        value: "pipeline",        label: "Pipeline",        children: [            {                value: "pipeline.sync",                label: "CRM sync",                children: [                    {                        value: "pipeline.sync.auth",                        label: "Connection dropped",                    },                    {                        value: "pipeline.sync.limits",                        label: "Sync rate limits",                    },                    {                        value: "pipeline.sync.webhooks",                        label: "Webhook delivery",                    },                ],            },            { value: "pipeline.import", label: "Import failed" },            { value: "pipeline.dedupe", label: "Duplicate records" },        ],    },    {        value: "account",        label: "Account",        children: [            { value: "account.login", label: "Cannot sign in" },            { value: "account.email", label: "Change email" },            { value: "account.delete", label: "Delete account" },        ],    },    {        value: "team",        label: "Team",        children: [            { value: "team.invite", label: "Invites" },            { value: "team.seats", label: "Seats" },            { value: "team.roles", label: "Roles" },        ],    },    {        value: "licensing",        label: "Licensing",        children: [            { value: "licensing.key", label: "License key" },            { value: "licensing.transfer", label: "Transfer a license" },        ],    },    {        value: "security",        label: "Security",        children: [            { value: "security.report", label: "Report a vulnerability" },            { value: "security.sso", label: "SSO setup" },        ],    },    { value: "feedback", label: "Product feedback" },    { value: "other", label: "Something else" },];// Form integration. `name` makes the cascader submit like any native field, so// it works with a plain form action or any form library without a controller// wrapper. The submitted value is the selected node's `value`, read here off a// `FormData` to prove it rode along. The trigger carries an `id` the Canon// `FieldLabel` points at with `htmlFor`, so the label names the control.export function FormField() {    const [submitted, setSubmitted] = React.useState<string | null>(null);    return (        <form            className="flex w-full max-w-sm flex-col gap-4"            onSubmit={(event) => {                event.preventDefault();                const data = new FormData(event.currentTarget);                setSubmitted(String(data.get("topic") ?? ""));            }}        >            <Field>                <FieldLabel htmlFor="topic-trigger">                    What do you need help with?                </FieldLabel>                <Cascader items={topics} name="topic">                    <CascaderTrigger                        id="topic-trigger"                        className="w-full"                        render={<Button variant="outline" />}                    >                        <CascaderValue placeholder="Select a topic" />                    </CascaderTrigger>                    {/* No width class: `CascaderContent` already carries              `min-w-(--anchor-width)`, so the panel lines up with the              full-width trigger it opens under. */}                    <CascaderContent>                        <CascaderPanel>                            <CascaderNav>                                <CascaderInput placeholder="Search topics" />                            </CascaderNav>                            <CascaderBreadcrumb />                            <CascaderEmpty>No topics match.</CascaderEmpty>                            <CascaderList>                                <CascaderItems />                            </CascaderList>                            <CascaderStatus />                        </CascaderPanel>                    </CascaderContent>                </Cascader>                <FieldDescription>                    Pick the most specific topic you can.                </FieldDescription>            </Field>            <Button type="submit">Submit request</Button>            {submitted ? (                <p className="text-xs text-muted-foreground">                    Submitted{" "}                    <code className="text-foreground">topic={submitted}</code>                </p>            ) : null}        </form>    );}

Columns that scroll on their own

columns mode where each panel scrolls independently, so a long level doesn't move its neighbours.

import { Badge } from "@oration/canon/components/badge";import {    Cascader,    CascaderContent,    CascaderPanel,    CascaderStatus,    CascaderTrigger,    useCascaderSelection,} from "@oration/canon/components/cascader";import { CascaderColumns } from "@oration/canon/components/cascader/columns";import {    CascaderInput,    CascaderNav,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import { MonogramTile } from "@oration/canon/components/monogram-tile";import type { TagColor } from "@oration/canon/components/tag";import { cn } from "@oration/canon/lib/utils";import {    FileTextIcon,    HeadphonesIcon,    LayersIcon,    VideoIcon,} from "lucide-react";import * as React from "react";/* -------------------------------------------------------------------------- *//*                                   Marks                                    *//* -------------------------------------------------------------------------- */// One monogram tile per library, reused by every row in the first column. The// hue is an IDENTIFIER, not a status: it tells "Engineering" from "Design// system" at a glance and says nothing about either. Neighbours in the list sit// in different Tag families so a scrolling column never reads as one long smear// of green, and indigo is skipped entirely — the Quiet Indigo rule keeps it for// selection and focus, never for decorating data.const libraryColors: Record<string, TagColor> = {    start: "blue",    design: "violet",    engineering: "teal",    podcast: "red",    customers: "green",    webinars: "amber",    releases: "pink",    labs: "teal",    courses: "orange",    workshops: "blue",    templates: "violet",    security: "green",    community: "pink",    playbooks: "amber",    support: "blue",    research: "teal",};// The second column is one kind of thing all the way down, so it gets one mark.const collectionIcon = <LayersIcon aria-hidden="true" />;// The leaf marks say what a row IS — watch it, listen to it, read it — and the// SHAPE already says it, so the glyph stays muted and carries no colour. Colour// here would be a second encoding of the same fact.const kindIcons = {    video: <VideoIcon aria-hidden="true" />,    audio: <HeadphonesIcon aria-hidden="true" />,    article: <FileTextIcon aria-hidden="true" />,};type MediaKind = keyof typeof kindIcons;interface Media {    kind: MediaKind;    /** Runtime for video and audio, reading time for an article. */    length: string;}// The runtime chip. ONE neutral `outline` variant across every row is the// point: a ladder of colours by length would invent a status where there is// only a duration, and the two facts a row carries — what kind of thing it is,// how long it takes — are already spoken by the leading glyph and the number.// `tabular-nums` keeps a column of runtimes aligned.function LengthBadge({    children,    className,}: {    children: React.ReactNode;    className?: string;}) {    return (        <Badge            variant="outline"            className={cn("shrink-0 tabular-nums", className)}        >            {children}        </Badge>    );}/* -------------------------------------------------------------------------- *//*                                    Data                                    *//* -------------------------------------------------------------------------- */// `[title, kind, length]`. One line per item on purpose: a forty-four-episode// season written as forty-four objects is the same data over a hundred and// seventy lines, and the shape of a collection stops being readable at a glance.type Row = [title: string, kind: MediaKind, length: string];const slug = (name: string) =>    name        .toLowerCase()        .replace(/[^a-z0-9]+/g, "-")        .replace(/^-|-$/g, "");// One collection, with its items. The parent's value prefixes every child's.function collection(    libraryValue: string,    name: string,    rows: Row[],): CascaderNode<Media> {    const value = `${libraryValue}.${slug(name)}`;    return {        value,        label: name,        icon: collectionIcon,        children: rows.map(([title, kind, length]) => ({            value: `${value}.${slug(title)}`,            label: title,            icon: kindIcons[kind],            data: { kind, length },        })),    };}// One library, with a monogram tile derived from its label and hue.function library(    value: string,    label: string,    collections: CascaderNode<Media>[],): CascaderNode<Media> {    return {        value,        label,        icon: (            <MonogramTile                name={label}                color={libraryColors[value] ?? "gray"}                size="sm"            />        ),        children: collections,    };}const libraries: CascaderNode<Media>[] = [    library("start", "Getting started", [        collection("start", "Install and setup", [            ["Install the CLI", "video", "6:12"],            ["Your first workspace", "video", "8:45"],            ["Theming in five minutes", "video", "5:30"],            ["Framework adapters", "article", "7 min"],            ["When installs go wrong", "article", "4 min"],        ]),        collection("start", "Core concepts", [            ["Anatomy of a record", "video", "11:20"],            ["Fields and data attributes", "article", "9 min"],            ["Composition over config", "video", "14:05"],            ["Controlled or uncontrolled", "article", "6 min"],            ["Server and client boundaries", "video", "12:38"],        ]),        collection("start", "Migration guides", [            ["Moving off a legacy CRM", "video", "16:40"],            ["Codemods in practice", "article", "8 min"],            ["Mapping the old fields", "article", "5 min"],            ["Migration office hours", "audio", "42:18"],        ]),    ]),    library("design", "Design system", [        collection("design", "Foundations", [            ["Colour tokens end to end", "video", "13:24"],            ["A type scale that survives", "video", "9:58"],            ["Spacing and rhythm", "article", "6 min"],            ["Elevation without shadows", "article", "7 min"],            ["Radius as a system", "video", "7:41"],            ["Dark mode by contract", "video", "15:12"],        ]),        collection("design", "Components in depth", [            ["Buttons are harder than that", "video", "18:30"],            ["Forms that forgive", "video", "21:05"],            ["Tables at scale", "video", "24:47"],            ["Empty states worth reading", "article", "5 min"],            ["Spending a motion budget", "article", "8 min"],        ]),        collection("design", "Critique sessions", [            ["Redesigning the pricing page", "video", "46:12"],            ["Dashboard teardown", "video", "38:55"],            ["Onboarding critique", "audio", "51:30"],            ["Icon set review", "video", "29:14"],        ]),    ]),    library("engineering", "Engineering", [        collection("engineering", "Deep dives", [            ["Rendering ten thousand rows", "video", "27:16"],            ["The virtualizer, line by line", "video", "33:02"],            ["Focus management", "article", "12 min"],            ["Portals and layers", "video", "19:48"],            ["Hydration mismatches", "article", "9 min"],            ["Putting the bundle on a diet", "video", "22:35"],        ]),        collection("engineering", "Performance clinic", [            ["Profiling a slow page", "video", "31:20"],            ["The real cost of :has()", "article", "11 min"],            ["Memo, and when not to", "video", "17:44"],            ["Streaming and Suspense", "video", "25:09"],            ["Cache invalidation, again", "audio", "39:52"],        ]),        collection("engineering", "Accessibility", [            ["Keyboard maps that work", "video", "20:11"],            ["A screen reader run-through", "video", "26:38"],            ["Contrast in practice", "article", "7 min"],            ["Live regions, quietly", "article", "10 min"],            ["Testing with axe", "video", "14:52"],        ]),    ]),    library("podcast", "Podcast archive", [        collection("podcast", "Season 5", [            ["Systems that outlive their authors", "audio", "49:31"],            ["The AI-shaped hole in the workflow", "audio", "55:12"],            ["Small teams, large surfaces", "audio", "43:08"],            ["What changed about pipeline", "audio", "51:47"],            ["Pricing, two years later", "audio", "46:22"],            ["The registry as a product", "audio", "58:19"],            ["Reps who ship", "audio", "42:55"],            ["Mid-season mailbag", "audio", "37:14"],        ]),        // The long one. Forty-four episodes is the column that makes the whole        // layout argument: it cannot be read without scrolling, and scrolling it        // must not move the two columns to its left.        collection("podcast", "Season 4", [            ["Designing for the one percent case", "audio", "48:12"],            ["The registry model", "audio", "52:40"],            ["Shipping on a Friday", "audio", "41:05"],            ["What a dashboard cannot tell you", "audio", "57:22"],            ["Naming things, again", "audio", "44:18"],            ["Open source economics", "audio", "1:02:14"],            ["The two-person revenue team", "audio", "39:47"],            ["Migrating a decade of data", "audio", "55:03"],            ["Accessibility as a default", "audio", "47:36"],            ["When to fork a library", "audio", "43:29"],            ["Type systems for operators", "audio", "50:58"],            ["The cost of a config flag", "audio", "36:41"],            ["Documentation nobody reads", "audio", "45:52"],            ["Support as product research", "audio", "49:10"],            ["Pricing a developer tool", "audio", "1:07:33"],            ["Hiring for taste", "audio", "42:26"],            ["The last five percent", "audio", "53:47"],            ["Building in public", "audio", "46:19"],            ["Killing a feature", "audio", "38:54"],            ["A year of releases", "audio", "1:12:08"],            ["Pipeline reviews that end", "audio", "41:52"],            ["The changelog as marketing", "audio", "37:26"],            ["Estimating the unknowable", "audio", "44:09"],            ["One repo or ten", "audio", "48:33"],            ["What versioning teaches you", "audio", "39:41"],            ["The interview that failed", "audio", "35:18"],            ["Refactors nobody asked for", "audio", "52:04"],            ["Reading other people's data", "audio", "43:37"],            ["The support rota", "audio", "31:55"],            ["Designing for the keyboard", "audio", "46:48"],            ["A week without meetings", "audio", "29:12"],            ["The demo that broke", "audio", "40:26"],            ["Selling internal tools", "audio", "45:39"],            ["Metrics we stopped tracking", "audio", "38:17"],            ["The second product", "audio", "57:41"],            ["Writing for engineers", "audio", "42:03"],            ["When the roadmap slips", "audio", "36:29"],            ["Contractors and continuity", "audio", "44:56"],            ["The style guide graveyard", "audio", "33:44"],            ["Shipping without a designer", "audio", "50:12"],            ["Our worst incident", "audio", "1:04:38"],            ["Answering the same question", "audio", "27:31"],            ["The tooling we regret", "audio", "46:07"],            ["Four seasons in", "audio", "1:09:24"],        ]),        collection("podcast", "Season 3", [            ["Design tokens, three years on", "audio", "51:44"],            ["The support inbox as a roadmap", "audio", "44:02"],            ["Componentising a marketing site", "audio", "39:15"],            ["Rewrites we regret", "audio", "58:30"],            ["Working across time zones", "audio", "40:27"],            ["What we got wrong about tables", "audio", "47:51"],        ]),        collection("podcast", "Season 2", [            ["The first hundred components", "audio", "45:12"],            ["Docs as the product", "audio", "39:48"],            ["Choosing a licence", "audio", "51:33"],            ["When users disagree", "audio", "43:21"],            ["The support week from hell", "audio", "47:05"],            ["A rewrite we did not do", "audio", "55:40"],        ]),        collection("podcast", "Season 1", [            ["Why another workspace", "audio", "38:12"],            ["The name we nearly used", "audio", "33:47"],            ["Our first customer", "audio", "41:19"],            ["Design debt, day one", "audio", "44:52"],            ["Shipping the first release", "audio", "49:26"],            ["What we would redo", "audio", "52:38"],        ]),        collection("podcast", "Bonus interviews", [            ["A maintainer's week", "audio", "33:18"],            ["Notes from a design audit", "audio", "28:44"],            ["Reading the changelog aloud", "audio", "22:36"],            ["Show notes, annotated", "article", "6 min"],        ]),        collection("podcast", "Live recordings", [            ["Live from the meetup", "audio", "58:44"],            ["A recording with questions", "audio", "1:03:27"],            ["The unedited take", "audio", "1:14:52"],            ["Backstage notes", "article", "5 min"],        ]),        collection("podcast", "Listener questions", [            ["Questions about theming", "audio", "31:22"],            ["Questions about hiring", "audio", "28:47"],            ["Questions about pricing", "audio", "34:16"],            ["The ones we could not answer", "audio", "26:53"],        ]),        collection("podcast", "Guest hosts", [            ["A designer takes the mic", "audio", "44:31"],            ["An engineer takes the mic", "audio", "47:18"],            ["A support lead takes the mic", "audio", "39:52"],        ]),        collection("podcast", "Show notes", [            ["Season 5, annotated", "article", "7 min"],            ["Season 4, annotated", "article", "12 min"],            ["Transcript archive", "article", "4 min"],        ]),    ]),    library("customers", "Customer stories", [        collection("customers", "Enterprise", [            ["A bank rebuilds its console", "video", "23:40"],            ["Rolling out to nine teams", "video", "18:12"],            ["Compliance without friction", "article", "9 min"],            ["Two workspaces, one tenant", "audio", "44:55"],        ]),        collection("customers", "Startups", [            ["Zero to launch in five weeks", "video", "15:26"],            ["One rep, forty accounts", "video", "12:03"],            ["Choosing boring on purpose", "article", "6 min"],            ["The first hundred users", "audio", "37:41"],        ]),        collection("customers", "Agencies", [            ["Reusing a playbook across clients", "video", "19:57"],            ["Handover that survives", "article", "8 min"],            ["Pitching a system, not a deck", "audio", "35:12"],        ]),    ]),    library("webinars", "Webinars", [        collection("webinars", "Live builds", [            ["Building a settings page", "video", "58:20"],            ["An analytics dashboard", "video", "1:04:37"],            ["A checkout, end to end", "video", "1:11:49"],            ["Search that feels instant", "video", "47:15"],            ["A data grid from scratch", "video", "1:21:06"],        ]),        collection("webinars", "Office hours", [            ["Ask me anything: theming", "video", "52:03"],            ["Ask me anything: forms", "video", "49:28"],            ["Ask me anything: performance", "video", "55:14"],            ["Questions we keep getting", "article", "7 min"],        ]),        collection("webinars", "Partner sessions", [            ["Deploying at the edge", "video", "41:32"],            ["Auth without the tears", "video", "38:09"],            ["Analytics you can trust", "video", "36:44"],        ]),    ]),    library("releases", "Release notes", [        collection("releases", "2026 releases", [            ["v9: the columns rewrite", "video", "9:12"],            ["v8.4: motion primitives", "video", "6:48"],            ["v8.2: the filters overhaul", "video", "7:55"],            ["v8.0: what changed and why", "article", "11 min"],            ["Release recap, quarter one", "audio", "26:33"],        ]),        collection("releases", "2025 releases", [            ["v7: the theming pass", "video", "8:21"],            ["v6.5: keyboard everywhere", "video", "5:39"],            ["v6: the first data grid", "article", "10 min"],            ["A year in changelogs", "audio", "31:07"],        ]),        collection("releases", "Deprecations", [            ["Leaving the old icon API", "article", "5 min"],            ["Retiring the legacy tokens", "article", "6 min"],            ["How we deprecate", "video", "12:44"],        ]),    ]),    library("labs", "Labs", [        collection("labs", "Prompting for UI", [            ["Describing a layout precisely", "video", "16:08"],            ["Prompts that survive a refactor", "article", "8 min"],            ["Generating a theme", "video", "13:52"],            ["Where generation stops", "audio", "34:26"],        ]),        collection("labs", "Agent workflows", [            ["An agent that reads the registry", "video", "24:19"],            ["Guardrails for generated code", "article", "12 min"],            ["Reviewing what a model wrote", "video", "21:33"],            ["Tooling notes", "article", "5 min"],        ]),        collection("labs", "Evaluations", [            ["Scoring a generated screen", "video", "18:47"],            ["Building a taste rubric", "article", "9 min"],            ["What we measure, and why", "audio", "29:58"],        ]),    ]),    library("courses", "Courses", [        collection("courses", "Beginner track", [            ["What a revenue workspace is for", "video", "10:24"],            ["Reading the docs", "article", "5 min"],            ["Your first screen", "video", "17:52"],            ["Layout without fighting it", "video", "14:31"],            ["Forms, gently", "video", "19:06"],        ]),        collection("courses", "Intermediate track", [            ["Composing three primitives", "video", "22:14"],            ["State that survives a refactor", "article", "11 min"],            ["Theming a whole app", "video", "26:48"],            ["Testing what users do", "video", "20:37"],        ]),        collection("courses", "Advanced track", [            ["Writing your own primitive", "video", "34:52"],            ["Headless, but not hostile", "article", "13 min"],            ["Publishing to a registry", "video", "28:19"],            ["Maintaining a fork", "audio", "41:22"],        ]),        collection("courses", "Course clinics", [            ["Homework review, week one", "video", "24:05"],            ["Homework review, week two", "video", "22:48"],            ["Common mistakes", "article", "8 min"],        ]),    ]),    library("workshops", "Workshops", [        collection("workshops", "Hands-on: theming", [            ["Setting up the tokens", "video", "18:22"],            ["Two brands, one build", "video", "25:14"],            ["Worksheet and answers", "article", "9 min"],        ]),        collection("workshops", "Hands-on: data", [            ["A grid you can maintain", "video", "31:47"],            ["Server pagination, honestly", "video", "27:33"],            ["Filters people can read", "video", "21:16"],            ["Exercise notes", "article", "7 min"],        ]),        collection("workshops", "Hands-on: motion", [            ["Timing that feels right", "video", "16:44"],            ["Motion that respects settings", "article", "6 min"],            ["Critique of the exercises", "audio", "33:05"],        ]),    ]),    library("templates", "Templates", [        collection("templates", "Dashboards", [            ["Tour of the admin template", "video", "19:38"],            ["Wiring it to your data", "video", "24:12"],            ["What to delete first", "article", "6 min"],        ]),        collection("templates", "Marketing sites", [            ["The landing page template", "video", "15:47"],            ["Blog and docs together", "video", "21:29"],            ["Swapping the brand", "article", "5 min"],        ]),        collection("templates", "Application shells", [            ["The auth flow, end to end", "video", "29:52"],            ["Settings that scale", "video", "18:07"],            ["Shell teardown", "audio", "36:14"],        ]),    ]),    library("security", "Security notes", [        collection("security", "Threat models", [            ["Trust boundaries in a UI", "video", "23:11"],            ["What a client cannot enforce", "article", "10 min"],            ["Reviewing a third-party widget", "video", "17:26"],        ]),        collection("security", "Practices", [            ["Handling tokens in the browser", "video", "20:44"],            ["Content security policy, calmly", "article", "12 min"],            ["Dependency hygiene", "video", "15:33"],            ["Audit walkthrough", "audio", "38:47"],        ]),        collection("security", "Incident reading", [            ["Anatomy of a supply chain hit", "article", "14 min"],            ["The morning after a leak", "audio", "42:09"],        ]),    ]),    library("community", "Community", [        collection("community", "Show and tell", [            ["Built in a weekend", "video", "12:36"],            ["A workspace for one", "video", "16:52"],            ["The gallery, quarter one", "article", "5 min"],        ]),        collection("community", "Contributor guides", [            ["Your first pull request", "video", "14:18"],            ["How review works here", "article", "7 min"],            ["Issue triage, live", "video", "26:41"],        ]),        collection("community", "Meetups", [            ["Berlin, spring", "video", "47:22"],            ["Remote meetup, June", "video", "51:08"],            ["Lightning talks", "video", "33:56"],        ]),    ]),    library("support", "Support clinic", [        collection("support", "Common issues", [            ["Styles that never apply", "video", "11:42"],            ["The hydration warning", "article", "6 min"],            ["Why the popup is behind", "video", "9:17"],            ["Fonts loading twice", "article", "4 min"],        ]),        collection("support", "Debug walkthroughs", [            ["Reading a stack trace", "video", "22:53"],            ["Bisecting a broken upgrade", "video", "19:31"],            ["A live debugging session", "audio", "45:26"],        ]),        collection("support", "Ask the team", [            ["Office hours, week 12", "audio", "39:14"],            ["Office hours, week 13", "audio", "41:37"],            ["Answers we reuse", "article", "7 min"],        ]),    ]),    library("research", "Research", [        collection("research", "Usability studies", [            ["Five users, one form", "video", "28:44"],            ["Testing a data grid", "video", "32:19"],            ["What the recordings showed", "article", "11 min"],        ]),        collection("research", "Benchmarks", [            ["Bundle size across kits", "article", "13 min"],            ["Interaction latency, measured", "video", "24:07"],            ["Method notes", "article", "8 min"],        ]),        collection("research", "Field notes", [            ["A week with the CLI", "article", "9 min"],            ["Watching a team migrate", "audio", "37:52"],            ["Notes from support tickets", "article", "6 min"],        ]),    ]),];/* -------------------------------------------------------------------------- *//*                                  Pattern                                   *//* -------------------------------------------------------------------------- */// Headless trigger: the selection is read as data and formatted freely. A media// picker answers three questions at once — what was picked, where it lives, and// how long it is — and the hook hands over the resolved path, so all three come// out of one read. The runtime keeps the same chip it wore in the row. With no// label above the control, the empty state names the field by saying what a// pick DOES.function MediaValue() {    const { first, firstPath, isEmpty } = useCascaderSelection<Media>();    if (isEmpty || !first) {        return (            <span className="text-muted-foreground">                Select an item to feature            </span>        );    }    const collectionNode = firstPath[1];    return (        <span className="flex min-w-0 flex-1 items-center gap-2">            <span className="shrink-0 [&>svg]:size-4 [&>svg]:text-muted-foreground">                {first.data ? kindIcons[first.data.kind] : null}            </span>            <span className="min-w-0 truncate font-medium">{first.label}</span>            <span className="min-w-0 truncate text-xs text-muted-foreground">                {collectionNode?.label}            </span>            <LengthBadge className="ms-auto">{first.data?.length}</LengthBadge>        </span>    );}// Columns mode — Miller columns, the whole open trail side by side. A media// library is the shape this layout was built for: the library, the collection// and the item stay on screen together, so you compare two seasons without// losing the library you came from.//// Every column here is longer than the panel is tall, and that is the whole// demonstration. Sixteen libraries overflow the first column, the podcast// archive carries ten collections in the second, and Season 4 runs to// forty-four episodes in the third. Scroll any one of them and the other two do// not move: each pane owns its own thumb.//// Branch rows do NOT get a chip — `CascaderItem` already draws a child count by// the chevron, so a chip there would put two numbers on one row. The count// answers "how much is in here", the chip answers "how long is this", and only// leaves have the second question.//// `w-auto min-w-0` on the content clears the single-column anchor floor so the// panel sizes to its columns. The wrapper pins to the TOP (`self-start`) because// a wide columns popup changes measured height as panes open, so a vertically// centred demo would jump while you navigate.export function MediaColumns() {    const [value, setValue] = React.useState("");    return (        <div className="flex w-full flex-col items-center gap-3 self-start px-4 pt-6 pb-4">            <div className="w-full max-w-sm">                <Cascader                    mode="columns"                    items={libraries}                    value={value}                    onValueChange={setValue}                    renderLabel={(node, state) =>                        // Branches keep the default label. `null` means "not handled", so                        // opting out is a return rather than a second copy of the default                        // markup that would drift from it.                        state.branch ? null : (                            <span className="flex w-full min-w-0 items-center gap-2">                                <span className="min-w-0 flex-1 truncate text-start">                                    {node.label}                                </span>                                <LengthBadge>{node.data?.length}</LengthBadge>                            </span>                        )                    }                >                    <CascaderTrigger                        aria-label="Featured media"                        className="w-full"                    >                        <MediaValue />                    </CascaderTrigger>                    <CascaderContent className="w-auto min-w-0">                        <CascaderPanel>                            <CascaderNav>                                <CascaderInput placeholder="Search this column…" />                            </CascaderNav>                            {/* Three panes side by side agree on a height, and each scrolls                  inside it, so a long season never grows the popup. */}                            <CascaderColumns                                columnWidth={240}                                maxHeight={288}                            />                            <CascaderStatus />                        </CascaderPanel>                    </CascaderContent>                </Cascader>            </div>        </div>    );}

Recently used, then everything

CascaderGroup and CascaderLabel pin the last few picks above the full list. Choosing a view moves its workspace to the top.

import { Button } from "@oration/canon/components/button";import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,    useCascaderActions,    useCascaderState,} from "@oration/canon/components/cascader";import {    CascaderGroup,    CascaderItem,    CascaderLabel,    CascaderSeparator,} from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import { FolderIcon, LayersIcon } from "lucide-react";import * as React from "react";const scopeIcon = <LayersIcon aria-hidden="true" />;const viewIcon = <FolderIcon aria-hidden="true" />;// A revenue workspace, grouped by the team that owns each scope. A scope is a// branch (drill into its saved views); a saved view is a leaf and commits.const scopes: CascaderNode[] = [    {        value: "pipeline",        label: "Pipeline",        icon: scopeIcon,        children: [            { value: "pipeline.open", label: "Open deals", icon: viewIcon },            {                value: "pipeline.commit",                label: "Commit this quarter",                icon: viewIcon,                keywords: ["forecast"],            },            {                value: "pipeline.stalled",                label: "Stalled 30+ days",                icon: viewIcon,            },            {                value: "pipeline.closing",                label: "Closing this week",                icon: viewIcon,            },        ],    },    {        value: "accounts",        label: "Accounts",        icon: scopeIcon,        children: [            { value: "accounts.strategic", label: "Strategic", icon: viewIcon },            {                value: "accounts.expansion",                label: "Expansion-ready",                icon: viewIcon,            },            { value: "accounts.at-risk", label: "At risk", icon: viewIcon },        ],    },    {        value: "forecast",        label: "Forecast",        icon: scopeIcon,        children: [            { value: "forecast.rollup", label: "Team roll-up", icon: viewIcon },            { value: "forecast.gap", label: "Gap to target", icon: viewIcon },            {                value: "forecast.history",                label: "Submitted history",                icon: viewIcon,            },        ],    },    {        value: "renewals",        label: "Renewals",        icon: scopeIcon,        children: [            { value: "renewals.due", label: "Due in 90 days", icon: viewIcon },            {                value: "renewals.autorenew",                label: "Auto-renewing",                icon: viewIcon,            },            {                value: "renewals.churn-risk",                label: "Churn risk",                icon: viewIcon,            },        ],    },    {        value: "activity",        label: "Activity",        icon: scopeIcon,        children: [            { value: "activity.calls", label: "Logged calls", icon: viewIcon },            {                value: "activity.emails",                label: "Email threads",                icon: viewIcon,            },            {                value: "activity.meetings",                label: "Meetings booked",                icon: viewIcon,            },        ],    },    {        value: "quota",        label: "Quota",        icon: scopeIcon,        children: [            { value: "quota.attainment", label: "Attainment", icon: viewIcon },            { value: "quota.pacing", label: "Pacing", icon: viewIcon },            {                value: "quota.leaderboard",                label: "Leaderboard",                icon: viewIcon,            },        ],    },    {        value: "leads",        label: "Leads",        icon: scopeIcon,        children: [            {                value: "leads.inbound",                label: "Inbound queue",                icon: viewIcon,                keywords: ["mql"],            },            { value: "leads.unworked", label: "Unworked", icon: viewIcon },            { value: "leads.recycled", label: "Recycled", icon: viewIcon },        ],    },    {        value: "territories",        label: "Territories",        icon: scopeIcon,        children: [            {                value: "territories.coverage",                label: "Coverage",                icon: viewIcon,            },            {                value: "territories.whitespace",                label: "Whitespace",                icon: viewIcon,            },            {                value: "territories.handoffs",                label: "Handoffs",                icon: viewIcon,            },        ],    },    {        value: "contracts",        label: "Contracts",        icon: scopeIcon,        children: [            {                value: "contracts.pending",                label: "Pending signature",                icon: viewIcon,            },            {                value: "contracts.amendments",                label: "Amendments",                icon: viewIcon,            },            { value: "contracts.expiring", label: "Expiring", icon: viewIcon },        ],    },    {        value: "personal",        label: "My work",        icon: scopeIcon,        children: [            { value: "personal.tasks", label: "My tasks", icon: viewIcon },            {                value: "personal.saved",                label: "Saved searches",                icon: viewIcon,            },        ],    },];/** How many entries the recent run holds before the oldest one falls out. */const RECENT_LIMIT = 3;const scopeByValue = new Map(    scopes.map((scope) => [scope.value, scope] as const),);/** * Every saved view, keyed by the value the cascader commits: the scope it * belongs to, and the full name to report once it is picked. * * One flat map rather than a walk back up the tree. The example already owns * the data, so resolving "which scope did that view come from" is a lookup, and * nothing here has to reach into the primitive's own index to answer it. */const viewIndex = new Map(    scopes.flatMap((scope) =>        (scope.children ?? []).map(            (view) =>                [                    view.value,                    {                        scope: scope.value,                        name: `${scope.label} / ${view.label}`,                    },                ] as const,        ),    ),);/** * The root level, in two named runs. * * `CascaderItems` renders ONE flat run per level, so a level with headings has * to be composed by hand — and the rules it has to keep are all about the array * it is composed from. Base UI sizes its list from `renderedItems` and maps a * highlight index straight back into it, so the rows in the DOM must be exactly * that array, in that order, each node once: split it with `slice`, never * rebuild it with `filter` or by pulling a node in from elsewhere in the tree. * A recent run is therefore a REORDERING of the root level (see `items` below), * not a second copy of three rows that already appear further down. * * Two more rules a hand-written run is on the hook for: * * - `aria-setsize` and `aria-posinset` count across the whole LEVEL, not the *   group. Numbering each run from one would have the second group announce *   "1 of 10" under a heading that is genuinely the fourth row. * - No `index` prop. `CascaderItem` only forwards one while the list is *   windowed; passing it here would make each row self-register and leave *   `aria-activedescendant` pointing at nothing. * * The groups are a ROOT-level affordance and nothing else. Inside a scope there * is no "recently used" run to show, and while a query is running the level is * a set of search hits whose order is the match order — a heading over either * would name a group that does not exist. Both cases fall back to the plain * flat run, which is also where deep-search results pick up their ancestor path. */function ScopeItems({ recentCount }: { recentCount: number }) {    const { isBranch, isSelectable, isSelected, isIndeterminate } =        useCascaderActions();    const { renderedItems, currentParent, query, deepResults } =        useCascaderState();    const row = (node: CascaderNode, index: number) => (        <CascaderItem            key={node.value}            node={node}            showPath={deepResults !== null}            branch={isBranch(node)}            selectable={isSelectable(node)}            selected={isSelected(node)}            indeterminate={isIndeterminate(node)}            aria-setsize={renderedItems.length}            aria-posinset={index + 1}        />    );    if (currentParent !== null || query !== "") {        return <>{renderedItems.map(row)}</>;    }    return (        <>            {/* `CascaderGroup`, not a `<div>` with a heading in it. A listbox drops a          bare heading from the accessibility tree, whereas the group carries the          name, so the run is announced as "Recently used, group". */}            <CascaderGroup>                <CascaderLabel>Recently used</CascaderLabel>                {renderedItems.slice(0, recentCount).map(row)}            </CascaderGroup>            {/* Decorative, and deliberately so: the two runs are already separated for          a screen reader by the groups around them. */}            <CascaderSeparator />            <CascaderGroup>                <CascaderLabel>All scopes</CascaderLabel>                {renderedItems                    .slice(recentCount)                    .map((node, index) => row(node, index + recentCount))}            </CascaderGroup>        </>    );}/** * A grouped level: a short run of recent scopes above the full catalogue. * * Opening a saved view is a two-part question — which scope, then which view — * and drill-down answers it one part at a time, which is the right shape right * up until you notice that most of the day is spent reopening somewhere you * were an hour ago. A flat list of ten scopes makes those three cost the same * as the other seven. Lifting them into their own named run earns the grouping: * it turns the common move into one press and a pick, and it costs the catalogue * nothing, because the recent run is the same ten root entries in a different * order rather than three extra rows. * * Recency is real here, not a decoration. Pick a view and its scope moves to the * head of the run, which is the whole reason `items` is derived from state * instead of being the static array above it. Order is the ONLY thing that * changes: the tree is the same tree, and a scope appears exactly once in it. * * `revealSelected={false}` for the same reason. Reopening onto the level that * holds the current selection is right for a field being edited, but opening a * view is a fresh action every time — it should start at the root, where the * recent run is, not inside whichever scope was used last. */export function RecentGroups() {    const [value, setValue] = React.useState("");    const [recent, setRecent] = React.useState<string[]>([        "forecast",        "pipeline",        "accounts",    ]);    const [log, setLog] = React.useState("");    // Recent first, then everything else in catalogue order. Built by    // concatenation rather than by sorting, so the first `recent.length` entries    // are the recent ones by construction — exactly the promise `ScopeItems`    // slices the level on.    const items = React.useMemo(() => {        const pinned = recent            .map((entry) => scopeByValue.get(entry))            .filter((scope): scope is CascaderNode => scope != null);        const rest = scopes.filter((scope) => !recent.includes(scope.value));        return [...pinned, ...rest];    }, [recent]);    const handleValueChange = React.useCallback((next: string) => {        setValue(next);        const destination = viewIndex.get(next);        if (!destination) {            return;        }        setRecent((previous) =>            [                destination.scope,                ...previous.filter((entry) => entry !== destination.scope),            ].slice(0, RECENT_LIMIT),        );        setLog(`Opened ${destination.name}`);    }, []);    return (        <div className="flex w-full flex-col items-center gap-3">            <Cascader                items={items}                value={value}                onValueChange={handleValueChange}                revealSelected={false}                searchScope="deep"            >                <CascaderTrigger                    aria-label="Workspace scope"                    render={                        <Button                            variant="outline"                            className="w-72 justify-between gap-2 font-normal"                        />                    }                >                    <CascaderValue                        placeholder="Open a saved view"                        maxSegments={2}                    />                </CascaderTrigger>                <CascaderContent className="w-72">                    <CascaderPanel>                        <CascaderNav>                            <CascaderInput placeholder="Search scopes and views" />                        </CascaderNav>                        <CascaderBreadcrumb />                        <CascaderEmpty>No scopes match.</CascaderEmpty>                        <CascaderList>                            <ScopeItems recentCount={recent.length} />                        </CascaderList>                        <CascaderStatus />                    </CascaderPanel>                </CascaderContent>            </Cascader>            <p className="min-h-4 text-xs text-muted-foreground" role="status">                {log}            </p>        </div>    );}

Load before drilling in

An async getChildren that finishes loading a level before the panel moves into it.

Each level is fetched on press, 700ms per request.

import { Button } from "@oration/canon/components/button";import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type {    CascaderLoadContext,    CascaderLoadResult,    CascaderNode,} from "@oration/canon/components/cascader/types";import { Tag, type TagColor } from "@oration/canon/components/tag";import { cn } from "@oration/canon/lib/utils";import { FileTextIcon, FolderIcon } from "lucide-react";import * as React from "react";const folderIcon = <FolderIcon aria-hidden="true" />;const fileIcon = <FileTextIcon aria-hidden="true" />;/* -------------------------------------------------------------------------- *//*                              A pretend backend                             *//* -------------------------------------------------------------------------- *//** * The one fact a row's trailing tag carries. * * Every level has a different one — a workspace's region, a snapshot's channel, * an export's size — and none of them repeat the numeric child count the row * already prints, so the tag always adds something the label cannot. */interface RowMeta {    tag: string;    /**     * Which categorical hue the fact reads in. Set where the row is built,     * because only the level that knows what the string MEANS can colour it. The     * Quiet Indigo Rule keeps indigo off a data tag: these are categorical tag     * hues, and a size worth a second look earns amber rather than the brand.     */    color: TagColor;}/** Past this, the export is worth reading twice before you pick the row. */const LARGE_EXPORT_MB = 5;const WORKSPACES = [    { name: "northstar-revops", region: "us-east" },    { name: "atlas-sales", region: "us-west" },    { name: "orbit-success", region: "eu-west" },    { name: "meridian-finance", region: "eu-central" },    { name: "harbor-marketing", region: "ap-south" },    { name: "summit-partners", region: "ap-northeast" },    { name: "delta-support", region: "us-east" },    { name: "vertex-ops", region: "eu-west" },];const SNAPSHOTS_PER_WORKSPACE = 4;const EXPORTS_PER_SNAPSHOT = 20;const PAGE_SIZE = 8;/** * Nothing is known up front, and `items` still has to be given something. * Hoisted so the array keeps one identity across renders, and so `T` is * inferred as `RowMeta` from it — which is what types `node.data` inside * `renderLabel`. */const NO_ITEMS: CascaderNode<RowMeta>[] = [];/** Every export lives under `workspace / snapshot / file`, generated on demand. */function snapshotsFor(workspace: string) {    return Array.from({ length: SNAPSHOTS_PER_WORKSPACE }, (_, i) => {        const major = SNAPSHOTS_PER_WORKSPACE - i;        return {            value: `${workspace}/v${major}`,            label: `v${major}.0`,            icon: folderIcon,            // Without `hasChildren` an unfetched branch renders as a selectable leaf:            // the cascader has no other way to know a level exists before it is loaded.            hasChildren: true,            count: EXPORTS_PER_SNAPSHOT,            // The newest snapshot is the one you usually want, so it reads green as            // the healthy default pick; the rest are supported, not stale, so they            // read neutral gray rather than as a greyed-out alternative.            data:                i === 0                    ? { tag: "latest", color: "green" as const }                    : { tag: "retained", color: "gray" as const },        };    }) satisfies CascaderNode<RowMeta>[];}function exportsFor(snapshot: string, offset: number) {    const length = Math.min(PAGE_SIZE, EXPORTS_PER_SNAPSHOT - offset);    return Array.from({ length }, (_, i) => {        const n = offset + i + 1;        // Derived from the index rather than randomised. A paged row that showed a        // different size every time it was fetched would read as data moving under        // the user, which is the opposite of what this example is about.        const megabytes = 0.6 + n * 0.37;        return {            value: `${snapshot}/export-${n}`,            label: `accounts-${String(n).padStart(2, "0")}.csv`,            icon: fileIcon,            // A size only earns a colour once it changes your mind about the row, so            // the amber starts at the point an export stops being cheap to pull.            data: {                tag: `${megabytes.toFixed(1)} MB`,                color:                    megabytes >= LARGE_EXPORT_MB                        ? ("amber" as const)                        : ("blue" as const),            },        };    }) satisfies CascaderNode<RowMeta>[];}/** * Deliberately slow. * * The behaviour this example exists to show is what happens BETWEEN the press * and the new level, so a 150ms fake latency would hide it: the spinner would * flash for two frames and the panel would look like it navigated instantly. * 700ms is long enough to read the row you pressed, watch its chevron become a * spinner, and see that the level under it did not move until the children * landed. Real backends are slower than this. */const LATENCY_MS = 700;const wait = (ms: number, signal: AbortSignal) =>    new Promise<void>((resolve, reject) => {        const timer = setTimeout(resolve, ms);        signal.addEventListener("abort", () => {            clearTimeout(timer);            reject(new DOMException("Aborted", "AbortError"));        });    });/** * Async drill-down: load BEFORE you move. * * Nothing is known up front. The root level, each snapshot and each page of * exports is fetched as it comes into view, and `getChildren` is asked for one * level at a time. * * The part worth watching is what a press does while the fetch is in flight. * The panel does NOT navigate and then show a loading screen. It stays on the * level you are reading and turns THAT row's chevron into a spinner in the same * 16px box, so nothing reflows, the rows around it stay readable, and a failed * request leaves you where you were with a retry on the row you pressed rather * than on an error screen for a level you never saw. The sub-level renders only * once its children exist. * * `getChildren` hands back a cursor when there is more, which turns the last * row of a level into a "Load more" affordance — a real, keyboard-reachable * option rather than an invisible scroll sentinel. Loaded pages are cached * until `loadKey` changes, so walking back up the tree and down again costs * nothing. * * Every row is one line: icon, label, trailing tag. `renderLabel` replaces only * the label block, so the count, the chevron and the spinner that takes its * place all keep working while the tag rides along beside them. That tag is a * categorical Canon hue picked per row — never indigo, which the Quiet Indigo * Rule reserves for selection and the one primary action. */export function AsyncDrill() {    const [value, setValue] = React.useState("");    const [failNext, setFailNext] = React.useState(false);    const getChildren = React.useCallback(        async (            node: CascaderNode<RowMeta> | null,            context: CascaderLoadContext,        ): Promise<CascaderLoadResult<RowMeta>> => {            await wait(LATENCY_MS, context.signal);            if (failNext) {                setFailNext(false);                throw new Error("The workspace service is unreachable.");            }            // The root level: the workspaces themselves.            if (node === null) {                return {                    items: WORKSPACES.map((workspace) => ({                        value: workspace.name,                        label: workspace.name,                        icon: folderIcon,                        hasChildren: true,                        count: SNAPSHOTS_PER_WORKSPACE,                        // A workspace's region is a classification, not a verdict on it, so                        // every root row reads at the same weight.                        data: {                            tag: workspace.region,                            color: "violet" as const,                        },                    })),                };            }            // A workspace: its snapshots. One response, no paging.            if (WORKSPACES.some((workspace) => workspace.name === node.value)) {                return { items: snapshotsFor(node.value) };            }            // A snapshot: exports, eight at a time.            const offset = context.cursor ? Number(context.cursor) : 0;            const items = exportsFor(node.value, offset);            const next = offset + items.length;            const more = next < EXPORTS_PER_SNAPSHOT;            return {                items,                nextCursor: more ? String(next) : undefined,                hasMore: more,            };        },        [failNext],    );    return (        <div className="flex w-full flex-col items-center gap-3">            <Cascader                items={NO_ITEMS}                getChildren={getChildren}                value={value}                onValueChange={setValue}                // One line per row: the icon and the trailing affordances stay where the                // default put them, and the tag sits between the label and them. The                // paging row never reaches this — it renders its own body — so the absent                // `data` there needs no special case.                renderLabel={(node) => (                    <span className="flex w-full min-w-0 items-center gap-2">                        <span className="min-w-0 flex-1 truncate text-start">                            {node.label}                        </span>                        {node.data ? (                            <Tag color={node.data.color} className="shrink-0">                                {node.data.tag}                            </Tag>                        ) : null}                    </span>                )}            >                <CascaderTrigger                    aria-label="Export file"                    render={                        <Button                            variant="outline"                            className="w-80 justify-between gap-2 font-normal"                        />                    }                >                    <CascaderValue placeholder="Select an export" />                </CascaderTrigger>                {/* The branch spinner is drawn by the primitive at `size-4`, the size of            the chevron it replaces. Scoped down to 3.5 here, which is what the            paging row's own spinner already uses, so the two loading states in            one panel are the same weight. The 16px affordance box is untouched,            so nothing reflows when the glyph swaps. */}                <CascaderContent                    className={cn("w-80", "**:data-[slot=spinner]:size-3.5")}                >                    <CascaderPanel>                        <CascaderNav>                            <CascaderInput />                        </CascaderNav>                        <CascaderBreadcrumb />                        {/* One element that swaps its children between loading, error and                empty, so an async level never announces "No results found." on                its way to being loaded. */}                        <CascaderEmpty />                        <CascaderList>                            <CascaderItems />                        </CascaderList>                        <CascaderStatus />                    </CascaderPanel>                </CascaderContent>            </Cascader>            <p className="max-w-80 text-balance text-center text-xs text-muted-foreground">                Each level is fetched on press, {LATENCY_MS}ms per request.            </p>            <Button                variant="outline"                size="sm"                disabled={failNext}                onClick={() => setFailNext(true)}            >                {failNext ? "Next request will fail" : "Fail the next request"}            </Button>        </div>    );}

Thousands of rows

A level with thousands of markets, windowed so only the visible rows are rendered.

2,948 markets in 60 countries, 317 to 674 per region across 6 regions.

import { Badge } from "@oration/canon/components/badge";import { Button } from "@oration/canon/components/button";import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,} from "@oration/canon/components/cascader";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import { CascaderVirtualItems } from "@oration/canon/components/cascader/virtual";import { Spinner } from "@oration/canon/components/spinner";import { GlobeIcon } from "lucide-react";import * as React from "react";const globeIcon = <GlobeIcon aria-hidden="true" />;/* -------------------------------------------------------------------------- *//*                                    Data                                    *//* -------------------------------------------------------------------------- *//** * `[iso2, country, cities]`. The cities are one comma-separated string rather * than a nested array so a country stays one readable line. Four real cities * each, which is what the generator below needs to fill a country's share of * its region without repeating itself. The flag is derived from the code. */type CountrySeed = readonly [string, string, string];/** * `[id, label, markets, countries]`. Six regions, ten countries each, plus the * number of markets that region carries. The counts are unequal and un-round on * purpose — see the sizing note above the component. */const REGIONS: readonly (readonly [    string,    string,    number,    readonly CountrySeed[],])[] = [    [        "europe",        "Europe",        612,        [            ["DE", "Germany", "Berlin,Munich,Hamburg,Cologne"],            ["FR", "France", "Paris,Lyon,Marseille,Toulouse"],            ["GB", "United Kingdom", "London,Manchester,Birmingham,Glasgow"],            ["IT", "Italy", "Rome,Milan,Naples,Turin"],            ["ES", "Spain", "Madrid,Barcelona,Valencia,Seville"],            ["PL", "Poland", "Warsaw,Krakow,Gdansk,Wroclaw"],            ["NL", "Netherlands", "Amsterdam,Rotterdam,Utrecht,Eindhoven"],            ["BE", "Belgium", "Brussels,Antwerp,Ghent,Liege"],            ["SE", "Sweden", "Stockholm,Gothenburg,Malmo,Uppsala"],            ["PT", "Portugal", "Lisbon,Porto,Braga,Coimbra"],        ],    ],    [        "americas",        "Americas",        528,        [            ["US", "United States", "New York,Chicago,Los Angeles,Miami"],            ["BR", "Brazil", "Sao Paulo,Rio de Janeiro,Belo Horizonte,Recife"],            ["MX", "Mexico", "Mexico City,Guadalajara,Monterrey,Puebla"],            ["CO", "Colombia", "Bogota,Medellin,Cali,Barranquilla"],            ["AR", "Argentina", "Buenos Aires,Cordoba,Rosario,Mendoza"],            ["CA", "Canada", "Toronto,Montreal,Vancouver,Calgary"],            ["PE", "Peru", "Lima,Arequipa,Trujillo,Cusco"],            ["VE", "Venezuela", "Caracas,Maracaibo,Valencia,Barquisimeto"],            ["CL", "Chile", "Santiago,Valparaiso,Concepcion,Antofagasta"],            ["EC", "Ecuador", "Quito,Guayaquil,Cuenca,Ambato"],        ],    ],    [        "apac",        "Asia Pacific",        674,        [            ["IN", "India", "Mumbai,Delhi,Bengaluru,Chennai"],            ["CN", "China", "Shanghai,Beijing,Shenzhen,Chengdu"],            ["ID", "Indonesia", "Jakarta,Surabaya,Bandung,Medan"],            ["PK", "Pakistan", "Karachi,Lahore,Islamabad,Faisalabad"],            ["JP", "Japan", "Tokyo,Osaka,Nagoya,Fukuoka"],            ["PH", "Philippines", "Manila,Cebu,Davao,Quezon City"],            ["VN", "Vietnam", "Hanoi,Ho Chi Minh City,Da Nang,Can Tho"],            ["TH", "Thailand", "Bangkok,Chiang Mai,Phuket,Khon Kaen"],            ["KR", "South Korea", "Seoul,Busan,Incheon,Daegu"],            ["MY", "Malaysia", "Kuala Lumpur,Penang,Johor Bahru,Ipoh"],        ],    ],    [        "africa",        "Africa",        431,        [            ["NG", "Nigeria", "Lagos,Abuja,Kano,Port Harcourt"],            ["ET", "Ethiopia", "Addis Ababa,Dire Dawa,Mekelle,Hawassa"],            ["EG", "Egypt", "Cairo,Alexandria,Giza,Luxor"],            ["CD", "DR Congo", "Kinshasa,Lubumbashi,Goma,Kisangani"],            ["TZ", "Tanzania", "Dar es Salaam,Dodoma,Mwanza,Arusha"],            ["ZA", "South Africa", "Johannesburg,Cape Town,Durban,Pretoria"],            ["KE", "Kenya", "Nairobi,Mombasa,Kisumu,Nakuru"],            ["SD", "Sudan", "Khartoum,Omdurman,Port Sudan,Nyala"],            ["MA", "Morocco", "Casablanca,Rabat,Marrakesh,Tangier"],            ["GH", "Ghana", "Accra,Kumasi,Tamale,Takoradi"],        ],    ],    [        "mideast",        "Middle East",        386,        [            ["TR", "Turkiye", "Istanbul,Ankara,Izmir,Bursa"],            ["IR", "Iran", "Tehran,Mashhad,Isfahan,Shiraz"],            ["IQ", "Iraq", "Baghdad,Basra,Mosul,Erbil"],            ["SA", "Saudi Arabia", "Riyadh,Jeddah,Dammam,Mecca"],            ["YE", "Yemen", "Sanaa,Aden,Taiz,Hodeidah"],            ["SY", "Syria", "Damascus,Aleppo,Homs,Latakia"],            ["JO", "Jordan", "Amman,Zarqa,Irbid,Aqaba"],            ["IL", "Israel", "Tel Aviv,Jerusalem,Haifa,Beersheba"],            ["AE", "United Arab Emirates", "Dubai,Abu Dhabi,Sharjah,Al Ain"],            ["OM", "Oman", "Muscat,Salalah,Sohar,Nizwa"],        ],    ],    [        "oceania",        "Oceania",        317,        [            ["AU", "Australia", "Sydney,Melbourne,Brisbane,Perth"],            ["NZ", "New Zealand", "Auckland,Wellington,Christchurch,Hamilton"],            ["PG", "Papua New Guinea", "Port Moresby,Lae,Mount Hagen,Madang"],            ["FJ", "Fiji", "Suva,Nadi,Lautoka,Labasa"],            ["SB", "Solomon Islands", "Honiara,Auki,Gizo,Munda"],            ["NC", "New Caledonia", "Noumea,Mont-Dore,Dumbea,Paita"],            ["PF", "French Polynesia", "Papeete,Faaa,Punaauia,Pirae"],            ["VU", "Vanuatu", "Port Vila,Luganville,Isangel,Lakatoro"],            ["WS", "Samoa", "Apia,Vaitele,Faleasiu,Salelologa"],            ["TO", "Tonga", "Nuku'alofa,Neiafu,Haveluloto,Vaini"],        ],    ],] as const;/** * ISO 3166-1 alpha-2 to its regional-indicator pair: "DE" becomes the German * flag. The offset from an ASCII letter to its indicator is a constant, so * sixty flags cost one line. Windows Chrome ships no regional-indicator glyphs * and falls back to the two letters, which still reads as the country. */function flagOf(iso2: string): string {    return String.fromCodePoint(        ...[...iso2].map((letter) => letter.charCodeAt(0) + 127397),    );}interface Market {    /** Territory inside the country, e.g. "Munich Ring". Never a region name. */    area: string;    /** Reachable subscribers, thousands. */    subscribers: number;}/** * How a country's markets are split up, in the order a sales directory would * add them: the city on its own first, then its metro, then the compass * territories, then the ring roads and their segments, then the outskirts. * Four cities times these eighteen zones is seventy-two distinct market names * per country, so no row inside a country is ever a duplicate of another. */const MARKET_ZONES = [    "",    "Metro",    "North",    "South",    "East",    "West",    "Central",    "Northeast",    "Northwest",    "Southeast",    "Southwest",    "Ring",    "Corridor",    "Inner Ring",    "Outer Ring",    "North Ring",    "South Ring",    "Outskirts",] as const;const TOTAL_MARKETS = REGIONS.reduce(    (total, [, , markets]) => total + markets,    0,);const REGION_SIZES = REGIONS.map(([, , markets]) => markets);const SMALLEST_REGION = Math.min(...REGION_SIZES);const LARGEST_REGION = Math.max(...REGION_SIZES);const COUNTRY_COUNT = REGIONS.reduce(    (total, [, , , countries]) => total + countries.length,    0,);/** * Deterministic 32-bit mix. The badge number has to survive scrolling: * `Math.random` here would give a windowed row a new metric every time it left * the window and came back, which reads as a bug rather than as data. */function mix(n: number): number {    let h = (n ^ 0x9e3779b9) >>> 0;    h = Math.imul(h ^ (h >>> 15), 0x85ebca6b) >>> 0;    h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;    return (h ^ (h >>> 16)) >>> 0;}function buildDirectory(): CascaderNode<Market>[] {    return REGIONS.map(([regionId, regionLabel, markets, countries], r) => {        // One flag element per COUNTRY, hoisted out of the row loop: sixty shared        // elements rather than one throwaway per market. `leading-none` stops the        // emoji, which carries a generous line box, from setting the row height.        const flags = countries.map(([iso2]) => (            <span                key={iso2}                aria-hidden="true"                className="text-base leading-none"            >                {flagOf(iso2)}            </span>        ));        const cities = countries.map(([, , list]) => list.split(","));        return {            value: regionId,            label: regionLabel,            icon: globeIcon,            children: Array.from({ length: markets }, (_, i) => {                // Round-robin over the countries rather than a country's whole run and                // then the next one's, so any screenful carries ten flags and ten                // country names instead of one repeated eight times.                const c = i % countries.length;                const [iso2, country] = countries[c] ?? ["", ""];                const list = cities[c] ?? [""];                // Per-country ordinal. The city cycles fastest and the zone advances                // once the cities have been through.                const k = Math.floor(i / countries.length);                const zone =                    MARKET_ZONES[                        Math.floor(k / list.length) % MARKET_ZONES.length                    ] ?? "";                const city = list[k % list.length] ?? "";                const area = zone ? `${city} ${zone}` : city;                return {                    value: `${regionId}.${iso2.toLowerCase()}.${k}`,                    label: country,                    icon: flags[c],                    // Typing "Munich" or "DE" finds the row even though neither word is                    // its label: keywords are matched alongside it.                    keywords: [area, iso2],                    data: {                        area,                        // The region index is folded into the seed as well as the row's, or                        // the same ordinal in two regions could report the same reach.                        subscribers:                            40 +                            (mix(r * 7919 + i * 31 + iso2.charCodeAt(0)) % 960),                    },                };            }),        };    });}/** * Windowed levels. * * `CascaderVirtualItems` is a drop-in replacement for `CascaderItems`: below * `virtualizeThreshold` rows it renders exactly what `CascaderItems` renders, * and above it, it windows. The region level here is six rows and stays plain * DOM; drilling into one switches the same list to windowing without touching * the keyboard model, the search or the row component. * * ## The row * * A windowed row is the one row a demo cannot fake, so it is made a real one: a * flag, a country, the market inside it and a reach figure, all on a single * line — a leading glyph, a label that gives way, a trailing metric. Only the * country and the market give way; the badge holds its width, because a cut * number is worse than a shortened name. The country is the label and the * territory is the muted second slot, which is the way round a reader scans it. * The region is named once, by the breadcrumb above the list, never repeated * down the rows. * * The flag goes on `node.icon` rather than into `renderLabel`: the primitive's * icon slot is already the leading, `shrink-0` position the pattern wants, and * putting it there means the closed trigger shows the flag for free. The reach * figure is a neutral Canon badge in tabular figures — a magnitude reads as a * number, not a verdict, so the metric stays even across the list. * * Rows are one line tall, so the estimate is 36 — but only until they mount. * Every row is measured after it renders, which keeps the scroll position and * the highlight in agreement. * * ## Why the regions are the sizes they are * * `revealSelected` (on by default) navigates to the level holding the current * selection in the same commit that opens the popup, so a deep initial value * would mount the popup onto the big level rather than the six-row root — the * expensive case. Starting unselected means the first open is the six-row root, * and because `CascaderContent` unmounts on close, every reopen after a deep * pick pays that mount again, so the constraint is on the LARGEST level, not the * total: Asia Pacific's 674 is the largest here. Under that ceiling the counts * differ per region (317 through 674, 2,948 rows overall) because a real * catalogue's do — every one still a multiple past `virtualizeThreshold`. */export function VirtualMarkets() {    // Built off the render path: ~3,000 nodes is ~10ms of allocation, and    // blocking the first paint with it is exactly the "nothing happened" moment    // this example is supposed to be free of.    const [items, setItems] = React.useState<CascaderNode<Market>[] | null>(        null,    );    const [value, setValue] = React.useState("");    React.useEffect(() => {        const frame = requestAnimationFrame(() => setItems(buildDirectory()));        return () => cancelAnimationFrame(frame);    }, []);    return (        <div className="flex w-full flex-col items-center gap-2 p-4">            <Cascader                items={items ?? []}                value={value}                onValueChange={setValue}                disabled={items === null}                estimateRowSize={36}                renderLabel={(node, state) => {                    if (state.branch) {                        return (                            <span className="w-full truncate text-start font-medium">                                {node.label}                            </span>                        );                    }                    const subscribers = node.data?.subscribers ?? 0;                    return (                        <span className="flex w-full min-w-0 items-center gap-2">                            <span className="min-w-0 flex-1 truncate text-start">                                {node.label}                            </span>                            <span className="min-w-0 truncate text-xs text-muted-foreground">                                {node.data?.area}                            </span>                            <Badge                                variant="secondary"                                className="shrink-0 tabular-nums"                            >                                {subscribers}k                                {/* The row's accessible name is its text, and a bare "612k"                    read out after a country name means nothing. */}                                <span className="sr-only"> subscribers</span>                            </Badge>                        </span>                    );                }}            >                <CascaderTrigger                    aria-label="Market"                    render={                        <Button                            variant="outline"                            className="w-80 justify-between gap-2 font-normal"                        />                    }                >                    {items === null ? (                        <span className="flex items-center gap-2 text-muted-foreground">                            <Spinner className="size-4" />                            Loading {TOTAL_MARKETS.toLocaleString()} markets...                        </span>                    ) : (                        /* The default trigger renders the whole path, which here would say               "Europe > Germany": the region again, then a country that dozens               of rows share. So it names what was actually picked — country               first, territory after it. */                        <CascaderValue className="gap-2">                            {(selected) => {                                // The render slot hands back a plain `CascaderNode`, so the                                // payload is re-narrowed here rather than inferred from `items`.                                const node = selected[0] as                                    | CascaderNode<Market>                                    | undefined;                                if (!node) {                                    return (                                        <span className="truncate text-muted-foreground">                                            Select a market                                        </span>                                    );                                }                                return (                                    <>                                        {node.icon}                                        <span className="min-w-0 truncate">                                            {node.label}                                        </span>                                        <span className="min-w-0 truncate text-xs text-muted-foreground">                                            {node.data?.area}                                        </span>                                    </>                                );                            }}                        </CascaderValue>                    )}                </CascaderTrigger>                <CascaderContent className="w-80">                    <CascaderPanel>                        <CascaderNav>                            <CascaderInput />                        </CascaderNav>                        <CascaderBreadcrumb />                        <CascaderEmpty />                        {/* Kept deliberately. The windowing IS the subject here, and a                windowed list needs a scrollport with a height it can divide                into rows — so the cap is part of the demonstration. */}                        <CascaderList maxHeight={288}>                            <CascaderVirtualItems />                        </CascaderList>                        <CascaderStatus />                    </CascaderPanel>                </CascaderContent>            </Cascader>            <p className="text-xs text-muted-foreground">                {TOTAL_MARKETS.toLocaleString()} markets in {COUNTRY_COUNT}{" "}                countries, {SMALLEST_REGION.toLocaleString()} to{" "}                {LARGEST_REGION.toLocaleString()} per region across{" "}                {REGIONS.length} regions.            </p>        </div>    );}

Chips outside the trigger

The selection rendered as removable chips beside the picker, each labelled with its full path.

No columns yet. Pick a table to start.

import { Badge } from "@oration/canon/components/badge";import { Button } from "@oration/canon/components/button";import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,    useCascaderSelection,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import {    AtSignIcon,    CalendarClockIcon,    CreditCardIcon,    HashIcon,    ListIcon,    MapPinIcon,    PackageIcon,    PhoneIcon,    ReceiptIcon,    ShoppingCartIcon,    StoreIcon,    TagIcon,    TypeIcon,    UsersIcon,    WalletIcon,    XIcon,} from "lucide-react";import * as React from "react";// One glyph per THING, not one per level: a table wears the icon of its// subject, a field the icon of its type. The run of icons down a level becomes// a second, faster reading of the level's shape — a duty a repeated marker,// which the breadcrumb already covers, could never do.const text = <TypeIcon aria-hidden="true" />;const number = <HashIcon aria-hidden="true" />;const email = <AtSignIcon aria-hidden="true" />;const date = <CalendarClockIcon aria-hidden="true" />;const phone = <PhoneIcon aria-hidden="true" />;const tag = <TagIcon aria-hidden="true" />;// Several tables carry a "Status" or a "Created at" of their own, and two go a// level deeper. The duplicate labels are the point: a bare chip saying "Status"// three times is worthless, so each chip below is built from the node's PATH.const fields: CascaderNode[] = [    {        value: "customers",        label: "Customers",        icon: <UsersIcon aria-hidden="true" />,        children: [            { value: "customers.name", label: "Name", icon: text },            { value: "customers.email", label: "Email", icon: email },            { value: "customers.phone", label: "Phone", icon: phone },            { value: "customers.segment", label: "Segment", icon: tag },            {                value: "customers.address",                label: "Address",                icon: <MapPinIcon aria-hidden="true" />,                children: [                    {                        value: "customers.address.street",                        label: "Street",                        icon: text,                    },                    {                        value: "customers.address.city",                        label: "City",                        icon: text,                    },                    {                        value: "customers.address.postal_code",                        label: "Postal code",                        icon: number,                    },                    {                        value: "customers.address.region",                        label: "Region",                        icon: text,                    },                    {                        value: "customers.address.country",                        label: "Country",                        icon: <MapPinIcon aria-hidden="true" />,                    },                ],            },        ],    },    {        value: "orders",        label: "Orders",        icon: <ShoppingCartIcon aria-hidden="true" />,        children: [            { value: "orders.number", label: "Order number", icon: number },            { value: "orders.total", label: "Total", icon: number },            { value: "orders.status", label: "Status", icon: tag },            {                value: "orders.channel",                label: "Channel",                icon: <StoreIcon aria-hidden="true" />,            },            {                value: "orders.line_items",                label: "Line items",                icon: <ListIcon aria-hidden="true" />,            },            { value: "orders.placed_at", label: "Placed at", icon: date },            {                value: "orders.payment",                label: "Payment",                icon: <CreditCardIcon aria-hidden="true" />,                children: [                    {                        value: "orders.payment.method",                        label: "Method",                        icon: <WalletIcon aria-hidden="true" />,                    },                    {                        value: "orders.payment.card_brand",                        label: "Card brand",                        icon: <CreditCardIcon aria-hidden="true" />,                    },                    {                        value: "orders.payment.last_four",                        label: "Last four",                        icon: number,                    },                    {                        value: "orders.payment.authorized_at",                        label: "Authorized at",                        icon: date,                    },                ],            },        ],    },    {        value: "products",        label: "Products",        icon: <PackageIcon aria-hidden="true" />,        children: [            { value: "products.name", label: "Name", icon: text },            { value: "products.sku", label: "SKU", icon: number },            { value: "products.price", label: "Price", icon: number },            { value: "products.category", label: "Category", icon: tag },            { value: "products.status", label: "Status", icon: tag },            { value: "products.created_at", label: "Created at", icon: date },        ],    },    {        value: "invoices",        label: "Invoices",        icon: <ReceiptIcon aria-hidden="true" />,        children: [            { value: "invoices.number", label: "Number", icon: number },            { value: "invoices.amount_due", label: "Amount due", icon: number },            { value: "invoices.status", label: "Status", icon: tag },            { value: "invoices.issued_at", label: "Issued at", icon: date },            { value: "invoices.due_date", label: "Due date", icon: date },        ],    },];/** * The chip row, rendered by the CONSUMER rather than by the primitive. * * `useCascaderSelection` hands back the resolved nodes and their ancestor * chains plus `remove`, which is everything a chip needs — so a chip is an * ordinary Canon `Badge` with a ghost icon `Button` inside it, styled by the * design system rather than by the primitive. There is no "Clear all" here on * purpose: a second, differently-shaped control in the same flex wrap reads as * one more chip, one that silently throws the whole selection away. `clear` is * on the hook for a page that has a real place to put it. */function ColumnChips() {    const { paths, isEmpty, remove } = useCascaderSelection();    if (isEmpty) {        return (            <p className="text-13 text-muted-foreground">                No columns yet. Pick a table to start.            </p>        );    }    // Driven off `paths`, not `selected`: a path carries the ancestors AND its    // own leaf, and `selected` drops values the index cannot resolve, so the two    // arrays are not guaranteed to line up index for index.    return (        <div className="flex flex-wrap items-center gap-1.5">            {paths.map((path) => {                const node = path[path.length - 1];                if (!node) return null;                // The WHOLE chain, not just the parent. Several tables carry a "Status",                // so a bare label is ambiguous — and with nested groups, one level of                // parent is ambiguous too: "Address / Country" does not say whose                // address. The chip is the only place the choice survives after the                // panel closes, so it carries the path that finds the column again.                const label = path                    .map((ancestor) => ancestor.label)                    .join(" / ");                return (                    <Badge                        key={node.value}                        variant="secondary"                        className="gap-0.5 pr-1"                    >                        {label}                        <Button                            variant="ghost"                            size="icon-xs"                            aria-label={`Remove ${label}`}                            onClick={() => remove(node.value)}                            className="-mr-0.5 size-4 hover:bg-transparent"                        >                            <XIcon className="size-3" />                        </Button>                    </Badge>                );            })}        </div>    );}/** * Chips instead of "3 selected". * * A report builder is the case for it: you need to see what you already picked * and drop one without reopening the panel. Labels repeat across the tables, so * a chip carries its full path rather than the field name on its own. The * wrapper pins to the TOP of the surface so the trigger does not shift as the * chip row wraps onto new lines. */export function ExternalChips() {    const [value, setValue] = React.useState<string[]>([]);    return (        <div className="flex w-full max-w-md flex-col gap-3 self-start">            <Cascader                multiple                items={fields}                value={value}                onValueChange={setValue}            >                <CascaderTrigger aria-label="Report columns" className="w-full">                    <CascaderValue                        placeholder="Add a column…"                        display="count"                    />                </CascaderTrigger>                <CascaderContent className="w-80">                    <CascaderPanel>                        <CascaderNav>                            <CascaderInput placeholder="Search fields" />                        </CascaderNav>                        <CascaderBreadcrumb />                        <CascaderEmpty>No fields match.</CascaderEmpty>                        <CascaderList>                            <CascaderItems />                        </CascaderList>                        <CascaderStatus />                    </CascaderPanel>                </CascaderContent>                <ColumnChips />            </Cascader>        </div>    );}

Cascading tree in a popover

Checking a region selects every country under it, and the trigger names the highest level that's fully covered.

import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,    useCascaderSelection,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderInput,    CascaderNav,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import { CompassIcon, GlobeIcon } from "lucide-react";import * as React from "react";const globe = <GlobeIcon aria-hidden="true" />;const compass = <CompassIcon aria-hidden="true" />;/** * ISO 3166-1 alpha-2 to its regional-indicator pair, so "DE" becomes the German * flag. Deriving it keeps the table down to a code and a name per country, and * it leaves the code as the single source of the flag: a typo cannot show the * right flag beside the wrong country. */function flagOf(iso2: string): string {    return String.fromCodePoint(        ...[...iso2].map((letter) => letter.charCodeAt(0) + 127397),    );}/** * The flag goes in the row's icon slot, not into the label — exactly where the * globe and the compass sit one and two levels up, so every depth keeps one * label column. Fixed width on purpose: platforms without regional-indicator * glyphs fall back to the two letters, whose width changes per pair, so without * `w-5` the country names in one level would each start on a slightly different * column. `aria-hidden` because the country name is already the row's text. */function flagIcon(iso2: string): React.ReactNode {    return (        <span            aria-hidden="true"            className="w-5 text-center text-base leading-none"        >            {flagOf(iso2)}        </span>    );}type CountryTuple = readonly [string, string];type RegionTuple = readonly [string, string, readonly CountryTuple[]];type ContinentTuple = readonly [string, string, readonly RegionTuple[]];const CONTINENTS: readonly ContinentTuple[] = [    [        "europe",        "Europe",        [            [                "western",                "Western Europe",                [                    ["DE", "Germany"],                    ["FR", "France"],                    ["NL", "Netherlands"],                    ["BE", "Belgium"],                    ["AT", "Austria"],                    ["CH", "Switzerland"],                ],            ],            [                "northern",                "Northern Europe",                [                    ["GB", "United Kingdom"],                    ["IE", "Ireland"],                    ["SE", "Sweden"],                    ["NO", "Norway"],                    ["DK", "Denmark"],                    ["FI", "Finland"],                ],            ],            [                "southern",                "Southern Europe",                [                    ["IT", "Italy"],                    ["ES", "Spain"],                    ["PT", "Portugal"],                    ["GR", "Greece"],                ],            ],        ],    ],    [        "americas",        "Americas",        [            [                "north",                "North America",                [                    ["US", "United States"],                    ["CA", "Canada"],                    ["MX", "Mexico"],                ],            ],            [                "south",                "South America",                [                    ["BR", "Brazil"],                    ["AR", "Argentina"],                    ["CL", "Chile"],                    ["CO", "Colombia"],                    ["PE", "Peru"],                ],            ],        ],    ],    [        "apac",        "Asia Pacific",        [            [                "east",                "East Asia",                [                    ["JP", "Japan"],                    ["KR", "South Korea"],                    ["CN", "China"],                    ["TW", "Taiwan"],                ],            ],            [                "southeast",                "Southeast Asia",                [                    ["SG", "Singapore"],                    ["MY", "Malaysia"],                    ["TH", "Thailand"],                    ["VN", "Vietnam"],                    ["ID", "Indonesia"],                    ["PH", "Philippines"],                ],            ],            [                "south",                "South Asia",                [                    ["IN", "India"],                    ["PK", "Pakistan"],                    ["BD", "Bangladesh"],                    ["LK", "Sri Lanka"],                ],            ],        ],    ],    [        "mideast",        "Middle East",        [            [                "gulf",                "Gulf States",                [                    ["AE", "United Arab Emirates"],                    ["SA", "Saudi Arabia"],                    ["QA", "Qatar"],                    ["KW", "Kuwait"],                ],            ],            [                "levant",                "Levant",                [                    ["IL", "Israel"],                    ["JO", "Jordan"],                    ["LB", "Lebanon"],                ],            ],        ],    ],    [        "africa",        "Africa",        [            [                "north",                "North Africa",                [                    ["MA", "Morocco"],                    ["EG", "Egypt"],                    ["TN", "Tunisia"],                    ["DZ", "Algeria"],                ],            ],            [                "sub",                "Sub-Saharan Africa",                [                    ["NG", "Nigeria"],                    ["GH", "Ghana"],                    ["KE", "Kenya"],                    ["ZA", "South Africa"],                ],            ],        ],    ],];const zones: CascaderNode[] = CONTINENTS.map(    ([continent, continentLabel, regions]) => ({        value: continent,        label: continentLabel,        icon: globe,        children: regions.map(([region, regionLabel, countries]) => ({            value: `${continent}.${region}`,            label: regionLabel,            icon: compass,            children: countries.map(([iso2, name]) => ({                // Exactly three dot-separated segments, no segment carrying a dot of                // its own — which is what lets the summary tell a country apart from the                // region and continent above it by counting separators.                value: `${continent}.${region}.${iso2.toLowerCase()}`,                label: name,                icon: flagIcon(iso2),                keywords: [iso2],            })),        })),    }),);/** * A headless trigger, because a cascade cannot be summarised by listing what is * in the value. Ticking Western Europe commits the region AND its countries, so * the honest count is the leaves alone, and naming the six countries back hides * the one press that actually happened. * * `useCascaderSelection` hands back one ancestor chain per selected node, which * fixes both without parsing a value string. A leaf is a chain whose last node * has no children; the sample keeps only the TOP of the selection — a node * whose parent is committed is already spoken for — so six countries collapse * back into "Western Europe" and a lone country still speaks for itself. The * line names the SELECTION first, at the control's own size, and closes with * the leaf total: both facts are needed and neither replaces the other. */function ZoneValue() {    const { selected, paths, isEmpty } = useCascaderSelection();    const committed = new Set(selected.map((node) => node.value));    // A value the tree does not hold resolves to an EMPTY chain, so this guard    // comes before anything reads the last node of one.    const chains = paths.filter((path) => path.length > 0);    if (isEmpty || chains.length === 0) {        return (            <span className="truncate text-muted-foreground">                Select shipping destinations            </span>        );    }    const countries = chains.filter(        (path) => !path[path.length - 1]?.children?.length,    ).length;    const covering = chains        .filter((path) => {            const parent = path[path.length - 2];            return !parent || !committed.has(parent.value);        })        .map((path) => path[path.length - 1]?.label);    return (        <span className="flex min-w-0 flex-1 items-center gap-1.5 text-start">            <span className="min-w-0 truncate">{covering.join(", ")}</span>            <span className="shrink-0 text-muted-foreground">                · {countries} {countries === 1 ? "country" : "countries"}            </span>        </span>    );}/** * A cascading tree in a POPOVER. * * Shipping zones are the case for the cascade: "everywhere in Western Europe" is * one press, not six, and dropping a single country demotes the region from * fully selected to a dash rather than leaving a checkbox that lies. Three * things make it work together: `cascade` propagates a commit over the pressed * node's subtree and reconciles its ancestors, `selectable="any"` lets a branch * be pressed at all, and `mode="tree"` keeps the whole shape visible so the * partial state means something. * * The popup is the reason this is its own example. A tree is the one mode whose * height is under the USER's control — every disclosure adds or removes rows * while the panel is already anchored to the trigger. That is the whole height * contract: `CascaderList` takes NO `maxHeight` here. Under `CascaderContent` * the positioner publishes `--available-height` (trigger to viewport edge), the * list bounds itself at `min(--available-height, 24rem)`, and the scroll area * inside absorbs everything past that. Expanding Africa scrolls the rows rather * than pushing the popup off the bottom of the screen. * * It opens with nothing picked and Western Europe already expanded, so the * first country ticked is also the first indeterminate region. */export function CascadeTree() {    const [value, setValue] = React.useState<string[]>([]);    const [expanded, setExpanded] = React.useState<string[]>([        "europe",        "europe.western",    ]);    return (        <div className="w-full max-w-sm">            <Cascader                multiple                cascade                selectable="any"                mode="tree"                items={zones}                value={value}                onValueChange={setValue}                expanded={expanded}                onExpandedChange={setExpanded}            >                <CascaderTrigger                    aria-label="Shipping destinations"                    className="w-full"                >                    <ZoneValue />                </CascaderTrigger>                {/* Wider than the trigger, on purpose: a cascade is routinely wider than            the control that opens it, and the extra width lands in the deepest            level's label column at every depth. `max-w-(--available-width)`            still clamps it on a small viewport. */}                <CascaderContent className="w-[26rem]">                    <CascaderPanel>                        <CascaderNav>                            {/* Tree mode never drills, so there is no level to go back to. */}                            <CascaderInput                                showBack={false}                                placeholder="Search countries"                            />                        </CascaderNav>                        <CascaderEmpty>No countries match.</CascaderEmpty>                        <CascaderList>                            <CascaderItems />                        </CascaderList>                        <CascaderStatus />                    </CascaderPanel>                </CascaderContent>            </Cascader>        </div>    );}

Status and priority dots

renderLabel adds a coloured status dot beside each label, with the label always carrying the meaning.

import { Button } from "@oration/canon/components/button";import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,    useCascaderSelection,} from "@oration/canon/components/cascader";import {    CascaderAction,    CascaderFooter,} from "@oration/canon/components/cascader/footer";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import {    StatusDot,    type StatusTone,} from "@oration/canon/components/status-dot";import type { TagColor } from "@oration/canon/components/tag";import { cn } from "@oration/canon/lib/utils";import { RotateCcwIcon, XIcon } from "lucide-react";import * as React from "react";/* -------------------------------------------------------------------------- *//*                                    Data                                    *//* -------------------------------------------------------------------------- *//** * The dot's colour, drawn two ways so each stays honest. * * A `tone` is a SEMANTIC status on Canon's four-colour ladder (plus neutral and * live), rendered by `StatusDot` — the right vocabulary for Status, Priority, * Risk and Confidence, where the colour means good/at-risk/blocked and must * read the same everywhere in the workspace. A `color` is a CATEGORICAL tag hue * from the ten-hue family (`--tag-*`), for Type and Release stage, where the * colour is just a stable label with no ranking in it. Keeping the two apart is * the point: a semantic amber and a categorical amber must never be mistaken * for each other. */interface Marker {    tone?: StatusTone;    color?: TagColor;    hint?: string;}/** * The categorical dot fills, as LITERAL class strings. Tailwind scans source * text, so a computed `bg-(--tag-${color}-fg)` would compile to nothing; the * deep `-fg` token is used (not the pale chip fill) so an 8px dot reads at the * same weight as a `StatusDot`. */const tagDot: Record<TagColor, string> = {    gray: "bg-(--tag-gray-fg)",    blue: "bg-(--tag-blue-fg)",    indigo: "bg-(--tag-indigo-fg)",    violet: "bg-(--tag-violet-fg)",    pink: "bg-(--tag-pink-fg)",    red: "bg-(--tag-red-fg)",    orange: "bg-(--tag-orange-fg)",    amber: "bg-(--tag-amber-fg)",    green: "bg-(--tag-green-fg)",    teal: "bg-(--tag-teal-fg)",};/** * The dot a row (and the trigger) shows for a marker. * * A semantic `tone` is the Canon `StatusDot` straight; a categorical `color` * is a same-sized dot tinted from the matching `--tag-*-fg` token, so a Type or * a stage reads as a hue without borrowing a status meaning it does not have. * `aria-hidden`, always: every dot sits beside its written label (the * Label-Beside-Color rule), so the colour is a speed-up for a sighted scan and * never the only carrier of the state. */function MarkerDot({ marker }: { marker?: Marker }) {    if (marker?.tone) {        return <StatusDot tone={marker.tone} />;    }    const color: TagColor = marker?.color ?? "gray";    return (        <span            aria-hidden="true"            className={cn("size-2 shrink-0 rounded-full", tagDot[color])}        />    );}const workflow: CascaderNode<Marker>[] = [    {        value: "status",        label: "Status",        children: [            {                value: "status.backlog",                label: "Backlog",                data: { tone: "neutral" },            },            { value: "status.todo", label: "Todo", data: { tone: "info" } },            {                value: "status.in-progress",                label: "In progress",                data: { tone: "primary" },            },            {                value: "status.in-review",                label: "In review",                data: { tone: "warning" },            },            { value: "status.done", label: "Done", data: { tone: "success" } },            {                value: "status.cancelled",                label: "Cancelled",                data: { tone: "neutral" },            },        ],    },    {        value: "priority",        label: "Priority",        children: [            {                value: "priority.urgent",                label: "Urgent",                data: { tone: "danger", hint: "Same day" },            },            {                value: "priority.high",                label: "High",                data: { tone: "warning", hint: "This week" },            },            {                value: "priority.medium",                label: "Medium",                data: { tone: "info", hint: "This sprint" },            },            {                value: "priority.low",                label: "Low",                data: { tone: "neutral", hint: "When it fits" },            },            {                value: "priority.none",                label: "No priority",                data: { tone: "neutral" },            },        ],    },    {        value: "type",        label: "Type",        children: [            {                value: "type.feature",                label: "Feature",                data: { color: "indigo" },            },            { value: "type.bug", label: "Bug", data: { color: "red" } },            { value: "type.chore", label: "Chore", data: { color: "gray" } },            {                value: "type.docs",                label: "Documentation",                data: { color: "teal" },            },        ],    },    {        value: "severity",        label: "Severity",        children: [            {                value: "severity.s1",                label: "S1 — outage",                data: { tone: "danger" },            },            {                value: "severity.s2",                label: "S2 — degraded",                data: { tone: "warning" },            },            {                value: "severity.s3",                label: "S3 — minor",                data: { tone: "info" },            },            {                value: "severity.s4",                label: "S4 — cosmetic",                data: { tone: "neutral" },            },        ],    },    {        value: "stage",        label: "Release stage",        children: [            { value: "stage.alpha", label: "Alpha", data: { color: "violet" } },            { value: "stage.beta", label: "Beta", data: { color: "blue" } },            {                value: "stage.ga",                label: "General availability",                data: { color: "green" },            },        ],    },    {        value: "risk",        label: "Risk",        children: [            {                value: "risk.blocked",                label: "Blocked",                data: { tone: "danger" },            },            {                value: "risk.at-risk",                label: "At risk",                data: { tone: "warning" },            },            {                value: "risk.on-track",                label: "On track",                data: { tone: "success" },            },        ],    },    {        value: "confidence",        label: "Confidence",        children: [            {                value: "confidence.high",                label: "High",                data: { tone: "success" },            },            {                value: "confidence.medium",                label: "Medium",                data: { tone: "warning" },            },            { value: "confidence.low", label: "Low", data: { tone: "danger" } },        ],    },];/** * The trigger, showing the same dot the row does — so the mark that identified * the option in the list is the mark that identifies it once chosen. * * `leading-4` pins the three pieces (an 8px dot, a 14px label, a 12px group * name) to one 16px line box, so `items-center` can put the dot's centre on the * label's centre instead of letting the taller text drag the row's centre line * down. `shrink-0` on the dot keeps it a circle when a long label squeezes the * row, and `min-w-0` lets the label truncate instead of pushing. */function WorkflowValue() {    const { first, firstPath, isEmpty } = useCascaderSelection<Marker>();    if (isEmpty || !first) {        return <span className="text-muted-foreground">Set a field</span>;    }    const group = firstPath[0];    return (        <span className="inline-flex min-w-0 items-center gap-2 leading-4">            <MarkerDot marker={first.data} />            <span className="min-w-0 truncate">{first.label}</span>            <span className="truncate text-xs text-muted-foreground">                {group?.label}            </span>        </span>    );}/** * The reset as a footer COMMAND, reading the selection out of context instead * of being handed it. Nothing in here is specific to this example — composed * into any panel it clears that cascader — and `isEmpty` is what stops it * offering to undo nothing. */function ResetAction() {    const { clear, isEmpty } = useCascaderSelection();    return (        <CascaderAction            icon={<RotateCcwIcon aria-hidden="true" />}            disabled={isEmpty}            onSelect={clear}        >            Reset selection        </CascaderAction>    );}/* -------------------------------------------------------------------------- *//*                                   Pattern                                  *//* -------------------------------------------------------------------------- *//** * Colour dots through `renderLabel`. * * A status or a priority is a category whose name carries no ordering, so a * colour does the work an icon cannot: "In progress" and "In review" are one * glance apart when one is indigo-live and the other amber, and three words * apart when they are not. Two rules keep it honest. The colour never carries * meaning on its own — every dot sits beside its written label (the * Label-Beside-Color rule), and the dot is `aria-hidden` rather than announced * as a second, colour-shaped copy of the label. And the two colour systems stay * separate: `StatusDot` tones for the semantic ladders, the categorical * `--tag-*` hues for Type and stage. * * The reset lives in the trigger, where the chevron was, and it is a SIBLING of * the trigger rather than a child. `CascaderTrigger` renders a real `<button>`, * so a button inside it would be invalid HTML and would reopen the popup on its * own click. Positioned over the trigger's inline end it gets the same picture * and none of that. `showIcon` withdraws the chevron for exactly as long as the * reset stands in for it so the two never stack, `pe-8` holds the chevron's * room so the dot and its labels truncate rather than run underneath, and * `end-*`/`pe-*` are logical so it all mirrors in RTL. The footer names the * same reset in words: the X clears in one press, the labelled row is in front * of you while the popup is open. */export function StatusDots() {    const [value, setValue] = React.useState("");    return (        <Cascader            items={workflow}            value={value}            onValueChange={setValue}            renderLabel={(node, state) =>                state.branch ? (                    <span className="w-full truncate text-start font-medium">                        {node.label}                    </span>                ) : (                    <span className="flex w-full min-w-0 items-center gap-2">                        <MarkerDot marker={node.data} />                        <span className="min-w-0 flex-1 truncate text-start">                            {node.label}                        </span>                        {node.data?.hint ? (                            <span className="shrink-0 text-xs text-muted-foreground">                                {node.data.hint}                            </span>                        ) : null}                    </span>                )            }        >            <div className="relative w-72">                <CascaderTrigger                    aria-label="Workflow field"                    showIcon={!value}                    render={                        <Button                            variant="outline"                            className={cn(                                "w-full justify-between gap-2 font-normal",                                value && "pe-8",                            )}                        />                    }                >                    <WorkflowValue />                </CascaderTrigger>                {value ? (                    <Button                        variant="ghost"                        size="icon-xs"                        aria-label="Reset field"                        onClick={() => setValue("")}                        className="absolute end-1 top-1/2 -translate-y-1/2"                    >                        <XIcon aria-hidden="true" className="size-3.5" />                    </Button>                ) : null}            </div>            <CascaderContent className="w-72">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput />                    </CascaderNav>                    <CascaderBreadcrumb />                    <CascaderEmpty />                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    {/* A SIBLING of the list, never a child of it: `CascaderList`'s own              Enter handler clicks whatever it contains, so a command living              inside the rows would fire on the keystroke that commits one. */}                    <CascaderFooter>                        <ResetAction />                    </CascaderFooter>                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

Colour down each branch

Expense accounts where every child inherits its top-level category's colour, so a branch reads as one group.

import { Button } from "@oration/canon/components/button";import {    Cascader,    CascaderContent,    CascaderEmpty,    CascaderList,    CascaderPanel,    CascaderStatus,    CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import {    CascaderBreadcrumb,    CascaderInput,    CascaderNav,    CascaderValue,} from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";import {    type MonogramSize,    MonogramTile,} from "@oration/canon/components/monogram-tile";import type { TagColor } from "@oration/canon/components/tag";import { cn } from "@oration/canon/lib/utils";import { XIcon } from "lucide-react";import * as React from "react";/* -------------------------------------------------------------------------- *//*                                   Accents                                  *//* -------------------------------------------------------------------------- *//** * One accent per top-level account: a hue from Canon's categorical tag family. * * The hue is a `TagColor`, not a colour value, and it is stated once per * account. These are the ten-hue `--tag-*` tokens — the same family a spend * tool uses for an account's slice of the pie, its dot in the ledger, its band * on the budget bar — so the colour in the picker is the colour the account * wears everywhere else, which is the only thing that makes it worth putting in * a picker at all. A categorical hue is deliberately NOT indigo-as-decoration: * Tag Indigo is one more label in the family, distinct from the Quiet Indigo * reserved for selection and focus. */const accents = {    travel: "blue",    software: "indigo",    marketing: "pink",    people: "green",    workplace: "amber",    hardware: "violet",    services: "teal",    logistics: "red",} satisfies Record<string, TagColor>;type Account = keyof typeof accents;/** The one payload a row needs past its name: its chart-of-accounts code. */type Ledger = { code: string };/** * How a selected row says so, now that the built-in check is gone. * * `indicator={false}` on the root drops the single-select check AND the * inline-end gutter every style reserves for it, so the count and the chevron * end where the tile starts on the other edge. The row is what publishes the * state instead: `data-selected` lands on the row in every mode, and * `in-data-[selected]` is how the tile paints from an ancestor's attribute. * * Two channels, not one. The ring and the tint are geometry — a tile that was a * flat chip now has an edge it did not — and the label steps to `font-semibold` * under it, so someone who cannot separate blue from teal still sees which row * is marked. `ring-primary` is the one indigo touch here, and it is the correct * one: a ring that marks the SELECTION is live-state, not data decoration. */const TILE_SELECTED_CLASS =    "in-data-[selected]:ring-2 in-data-[selected]:ring-primary/70 in-data-[selected]:outline-none";/** * The account's mark, at whichever size the surface can hold. * * `MonogramTile` carries the per-size Canon radius and the tag tint for free, * and it renders the account's own initials — so the same component serves both * the row tile and the smaller trigger tile with nothing but a `size` change. */function AccountTile({    account,    size = "md",    className,}: {    account: Account;    size?: MonogramSize;    className?: string;}) {    return (        <MonogramTile            name={account}            color={accents[account]}            size={size}            className={className}        />    );}/** * One tile per top-level account, reused by every node beneath it. * * The colour is a per-account IDENTIFIER, not a status, so the chrome stays * quiet and only the tile carries the hue; the one tile that gains a ring is * the selected one, which is the whole reason the rest stay calm. `md` (a 24px * tile) is the step that sits beside the row's two lines of type without * setting the row's height. */const tiles = {    travel: <AccountTile account="travel" className={TILE_SELECTED_CLASS} />,    software: (        <AccountTile account="software" className={TILE_SELECTED_CLASS} />    ),    marketing: (        <AccountTile account="marketing" className={TILE_SELECTED_CLASS} />    ),    people: <AccountTile account="people" className={TILE_SELECTED_CLASS} />,    workplace: (        <AccountTile account="workplace" className={TILE_SELECTED_CLASS} />    ),    hardware: (        <AccountTile account="hardware" className={TILE_SELECTED_CLASS} />    ),    services: (        <AccountTile account="services" className={TILE_SELECTED_CLASS} />    ),    logistics: (        <AccountTile account="logistics" className={TILE_SELECTED_CLASS} />    ),} satisfies Record<Account, React.ReactNode>;/** * Which account a value belongs to. * * Every value is dotted with the account first — "travel.airfare.domestic" — so * the root segment IS the key and the trigger needs no second lookup table to * colour itself. The membership check is what makes the cast honest rather than * hopeful. */function accountOf(value: string): Account | null {    const key = value.split(".")[0];    return key && key in accents ? (key as Account) : null;}/** * Stamps a whole subtree with its account's tile. * * This is the entire mechanism, and it is what makes the colour a TRAIL rather * than a label on the first row only: three levels into Travel, the blue tile * is still on every row, so the panel says which account you are spending * against without anyone having to read the breadcrumb. One shared element * instance per account — React elements are immutable, so reusing one is free * and the colour cannot drift between a parent and its children by mistake. */function account(    name: Account,    node: CascaderNode<Ledger>,): CascaderNode<Ledger> {    return {        ...node,        icon: tiles[name],        children: node.children?.map((child) => account(name, child)),    };}/* -------------------------------------------------------------------------- *//*                                    Data                                    *//* -------------------------------------------------------------------------- *//** * A chart of accounts: a code, a name, and the quarter-to-date spend against * it. Every parent is the sum of its children, because a picker that shows * numbers which do not add up teaches the reader to stop trusting them. * * The codes are shaped the way a ledger shapes them: X00 for a top-level * account, XY0 for a sub-account, XYZ for a leaf. Four fixed digits down the * whole list is what makes them worth showing in `tabular-nums`. The amount * goes in `description` rather than beside the label, so it does not collide * with the branch count at the row's far edge. */const accounts: CascaderNode<Ledger>[] = [    account("travel", {        value: "travel",        label: "Travel",        description: "$38,900",        data: { code: "6100" },        children: [            {                value: "travel.airfare",                label: "Airfare",                description: "$19,480",                data: { code: "6110" },                children: [                    {                        value: "travel.airfare.domestic",                        label: "Domestic",                        description: "$6,400",                        data: { code: "6111" },                    },                    {                        value: "travel.airfare.international",                        label: "International",                        description: "$11,900",                        data: { code: "6112" },                    },                    {                        value: "travel.airfare.changes",                        label: "Change fees",                        description: "$1,180",                        data: { code: "6113" },                    },                ],            },            {                value: "travel.lodging",                label: "Lodging",                description: "$12,350",                data: { code: "6120" },                children: [                    {                        value: "travel.lodging.hotels",                        label: "Hotels",                        description: "$9,240",                        data: { code: "6121" },                    },                    {                        value: "travel.lodging.rentals",                        label: "Short-term rentals",                        description: "$3,110",                        data: { code: "6122" },                    },                ],            },            {                value: "travel.ground",                label: "Ground transport",                description: "$7,070",                data: { code: "6130" },                children: [                    {                        value: "travel.ground.rideshare",                        label: "Rideshare",                        description: "$2,860",                        data: { code: "6131" },                    },                    {                        value: "travel.ground.rail",                        label: "Rail",                        description: "$1,940",                        data: { code: "6132" },                    },                    {                        value: "travel.ground.rental",                        label: "Car rental",                        description: "$2,270",                        data: { code: "6133" },                    },                ],            },        ],    }),    account("software", {        value: "software",        label: "Software",        description: "$22,230",        data: { code: "6200" },        children: [            {                value: "software.design",                label: "Design tools",                description: "$4,320",                data: { code: "6210" },            },            {                value: "software.developer",                label: "Developer tools",                description: "$11,760",                data: { code: "6220" },            },            {                value: "software.analytics",                label: "Analytics",                description: "$6,150",                data: { code: "6230" },            },        ],    }),    account("marketing", {        value: "marketing",        label: "Marketing",        description: "$58,570",        data: { code: "6300" },        children: [            {                value: "marketing.ads",                label: "Paid advertising",                description: "$35,350",                data: { code: "6310" },                children: [                    {                        value: "marketing.ads.search",                        label: "Search",                        description: "$18,400",                        data: { code: "6311" },                    },                    {                        value: "marketing.ads.social",                        label: "Social",                        description: "$12,650",                        data: { code: "6312" },                    },                    {                        value: "marketing.ads.display",                        label: "Display",                        description: "$4,300",                        data: { code: "6313" },                    },                ],            },            {                value: "marketing.events",                label: "Events",                description: "$15,800",                data: { code: "6320" },                children: [                    {                        value: "marketing.events.conferences",                        label: "Conferences",                        description: "$9,800",                        data: { code: "6321" },                    },                    {                        value: "marketing.events.sponsorships",                        label: "Sponsorships",                        description: "$6,000",                        data: { code: "6322" },                    },                ],            },            {                value: "marketing.content",                label: "Content",                description: "$7,420",                data: { code: "6330" },            },        ],    }),    account("people", {        value: "people",        label: "People",        description: "$279,950",        data: { code: "6400" },        children: [            {                value: "people.salaries",                label: "Salaries",                description: "$214,600",                data: { code: "6410" },            },            {                value: "people.contractors",                label: "Contractors",                description: "$38,900",                data: { code: "6420" },            },            {                value: "people.benefits",                label: "Benefits",                description: "$26,450",                data: { code: "6430" },            },        ],    }),    account("workplace", {        value: "workplace",        label: "Workplace",        description: "$38,020",        data: { code: "6500" },        children: [            {                value: "workplace.rent",                label: "Rent",                description: "$31,200",                data: { code: "6510" },            },            {                value: "workplace.utilities",                label: "Utilities",                description: "$4,180",                data: { code: "6520" },            },            {                value: "workplace.supplies",                label: "Supplies",                description: "$2,640",                data: { code: "6530" },            },        ],    }),    account("hardware", {        value: "hardware",        label: "Hardware",        description: "$35,070",        data: { code: "6600" },        children: [            {                value: "hardware.laptops",                label: "Laptops",                description: "$24,300",                data: { code: "6610" },            },            {                value: "hardware.displays",                label: "Displays",                description: "$7,650",                data: { code: "6620" },            },            {                value: "hardware.peripherals",                label: "Peripherals",                description: "$3,120",                data: { code: "6630" },            },        ],    }),    account("services", {        value: "services",        label: "Professional services",        description: "$38,550",        data: { code: "6700" },        children: [            {                value: "services.legal",                label: "Legal",                description: "$16,900",                data: { code: "6710" },            },            {                value: "services.accounting",                label: "Accounting",                description: "$12,400",                data: { code: "6720" },            },            {                value: "services.consulting",                label: "Consulting",                description: "$9,250",                data: { code: "6730" },            },        ],    }),    account("logistics", {        value: "logistics",        label: "Logistics",        description: "$21,410",        data: { code: "6800" },        children: [            {                value: "logistics.freight",                label: "Freight",                description: "$12,900",                data: { code: "6810" },            },            {                value: "logistics.courier",                label: "Courier",                description: "$5,240",                data: { code: "6820" },            },            {                value: "logistics.packaging",                label: "Packaging",                description: "$3,270",                data: { code: "6830" },            },        ],    }),];/* -------------------------------------------------------------------------- *//*                                   Pattern                                  *//* -------------------------------------------------------------------------- *//** * Colour that means something: an expense-account picker. * * Each top-level account owns a hue from the categorical tag family, and that * hue follows the account all the way down its branch, because `account()` * stamps the same tile onto every descendant. A palette applied to rows because * rows look nicer in colour is decoration; a hue that IS the account's identity * in the ledger is a wayfinding mark, and it costs the reader nothing to carry. * * ## The row * * The tile says which account. The label block says which line, twice over: the * name on top, and under it the code and the amount, separated by a HAIRLINE * rather than by colour — the row's highlight repaints every descendant's text * to one grey, so a one-pixel background is the only separator that survives the * pointer. The trailing count says how much is inside a branch; the chevron is * the way in. `renderLabel` replaces only the label block, so the tile stays in * the primitive's own `icon` slot and the count and chevron stay where the * primitive puts them. * * ## Marking the selection * * `indicator={false}` drops the single-select check and its reserved gutter, * because the tile already carries the selection (see `TILE_SELECTED_CLASS`). * `aria-selected` is untouched — a screen reader announces the selection exactly * as before; only the visual mark moved. `selectable="any"` because a receipt * may be filed against "Travel" itself rather than a leaf; the chevron stays the * way into the level, so pressing the row commits and pressing the chevron * drills. * * ## The trigger * * The tile follows the selection out of the panel, one size down, composed * beside `CascaderValue` (which renders without the node's own row-sized tile). * Same component, same accent, same lookup — `accountOf` reads the account back * out of the value. The clear control sits inside the trigger's frame as a * SIBLING of the trigger, not a child: a `<button>` nested in `CascaderTrigger`'s * own `<button>` would be invalid HTML and would reopen the panel on its own * click. `showIcon` takes the chevron away while the clear button stands in for * it, `pe-8` holds its room, and `end-*`/`pe-*` are logical so it mirrors in RTL. */export function ExpenseAccounts() {    const [value, setValue] = React.useState("");    const selectedAccount = value ? accountOf(value) : null;    return (        <Cascader            items={accounts}            value={value}            onValueChange={setValue}            selectable="any"            indicator={false}            renderLabel={(node, state) => (                <>                    <span                        className={cn(                            "w-full truncate text-start",                            // The second channel. A ring is geometry and a weight is                            // geometry; between them the marked row is legible without                            // anyone having to tell blue from teal.                            state.selected ? "font-semibold" : "font-medium",                        )}                    >                        {node.label}                    </span>                    <span className="flex w-full min-w-0 items-center gap-1.5 text-xs">                        <span className="shrink-0 tabular-nums text-muted-foreground">                            {/* A naked four-digit number after a name is a riddle, so the one                  word that makes it a fact is said in the accessible name. */}                            <span className="sr-only">account code </span>                            {node.data?.code}                        </span>                        {/* A background, not a colour: the row's highlight rule repaints                text and leaves this alone, so the two halves stay two halves                under the pointer. */}                        <span                            aria-hidden="true"                            className="h-2.5 w-px shrink-0 bg-border"                        />                        <span className="truncate tabular-nums">                            {node.description}                        </span>                    </span>                </>            )}        >            <div className="relative w-80">                <CascaderTrigger                    aria-label="Expense account"                    showIcon={!value}                    render={                        <Button                            variant="outline"                            className={cn(                                "w-full justify-between gap-2 font-normal",                                value && "pe-8",                            )}                        />                    }                >                    {selectedAccount ? (                        <AccountTile account={selectedAccount} size="sm" />                    ) : null}                    {/* `showIcon={false}`: the node's own icon is the row-sized tile,              which no button is tall enough to hold. `flex-1` so the path takes              the space the tile leaves and truncates in it. */}                    <CascaderValue                        showIcon={false}                        placeholder="Assign an expense account"                        className="min-w-0 flex-1"                    />                </CascaderTrigger>                {value ? (                    <Button                        variant="ghost"                        size="icon-xs"                        aria-label="Clear account"                        onClick={() => setValue("")}                        className="absolute end-1 top-1/2 -translate-y-1/2"                    >                        <XIcon aria-hidden="true" className="size-3.5" />                    </Button>                ) : null}            </div>            <CascaderContent className="w-80">                <CascaderPanel>                    <CascaderNav>                        <CascaderInput placeholder="Search accounts…" />                    </CascaderNav>                    <CascaderBreadcrumb />                    <CascaderEmpty />                    <CascaderList>                        <CascaderItems />                    </CascaderList>                    <CascaderStatus />                </CascaderPanel>            </CascaderContent>        </Cascader>    );}

States#

Empty
Chosen
Invalid
Disabled
import {  Cascader,  CascaderContent,  CascaderList,  CascaderPanel,  CascaderStatus,  CascaderTrigger,} from "@oration/canon/components/cascader";import { CascaderItems } from "@oration/canon/components/cascader/item";import { CascaderValue } from "@oration/canon/components/cascader/nav";import type { CascaderNode } from "@oration/canon/components/cascader/types";export function StatesRow() {    const items: CascaderNode[] = [        { value: "6110", label: "Rent" },        { value: "6120", label: "Utilities" },    ];    return (        <div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">            {(                [                    { label: "Empty", props: {} },                    { label: "Chosen", props: { defaultValue: "6120" } },                    { label: "Invalid", props: { invalid: true } },                    {                        label: "Disabled",                        props: { disabled: true, defaultValue: "6110" },                    },                ] as const            ).map((row) => (                <div key={row.label} className="flex flex-col gap-1.5">                    <span className="text-xs text-muted-foreground">                        {row.label}                    </span>                    <Cascader items={items} {...row.props}>                        <CascaderTrigger                            aria-label={`GL account, ${row.label.toLowerCase()}`}                            className="w-full"                        >                            <CascaderValue placeholder="Choose an account" />                        </CascaderTrigger>                        <CascaderContent className="w-(--anchor-width)">                            <CascaderPanel>                                <CascaderList>                                    <CascaderItems />                                </CascaderList>                                <CascaderStatus />                            </CascaderPanel>                        </CascaderContent>                    </Cascader>                </div>            ))}        </div>    );}
States
StateTreatment
RestTransparent trigger with the Field Stroke and a Slate Meta placeholder; 30% input fill in dark.
Focus visibleIndigo border and a 3px Focus Indigo ring at 50% on the trigger when it has focus.
OpenThe popup fades and zooms in from 95% over 100ms, sliding 8px from the trigger. On open the panel navigates to the level holding the selection (revealSelected).
HighlightedThe row under the pointer or arrow keys fills Menu Hover. In columns mode the open trail row sits at bg-accent 60%.
SelectedA check at the inline-end (single), or a filled Quiet Indigo checkbox (multiple). A cascade branch with some descendants selected shows a dash.
LoadingA fetched level shows its first page as a centered Loading… in the empty surface; a branch being drilled into keeps the old rows and spins its own chevron in place. A later page shows a Load more row.
ErrorA failed level shows Something went wrong with a Retry button in the empty surface; a failed branch turns its chevron into a retry affordance and keeps the intent.
EmptyCascaderEmpty shows its message in 14px Slate Meta when a level or query has no matches.
InvalidWith invalid, a red border and a 3px red ring at 20% on the trigger, the chips and the input (all carry aria-invalid/data-invalid).
Disableddisabled dims the trigger to 50% with a not-allowed cursor. A disabled node dims to 50% and is skipped by the keyboard.

Behavior#

  • Pass items as a CascaderNode[] tree ({ value, label, children }), or a flat list plus getParent for a large dataset. The root builds one index every mode shares.
  • mode chooses the layout: drill (default) replaces the level and shows the back button and breadcrumb; columns renders CascaderColumns with the whole trail side by side; tree renders CascaderList with branches that expand in place via expanded/defaultExpanded.
  • Controlled with value and onValueChange, uncontrolled with defaultValue. Single mode holds a string, multiple an array; onValueChange reports resolved details (node, path, nodes, reason).
  • By default only leaves commit (selectable="leaf"); pressing a branch navigates. selectable="any" or a predicate lets a branch commit, and the drill chevron becomes a separate target.
  • With multiple and cascade, committing a branch selects or deselects every selectable node in the loaded subtree and reconciles ancestors to a dash; max caps the whole gesture rather than truncating it.
  • searchScope="deep" searches the whole subtree under the current level (drill/columns only, each hit shown with its path), searchScope="global" searches the whole tree from whatever level is open; the default "level" filters only the open level. Supply onSearch for server search, getChildren to load each level on demand, and resolveValue to path a server-supplied selection.
  • Escape closes the popup, outside press closes it, and a single-select leaf commit closes it unless closeOnSelect={false}. inline drops the popup for an embedded CascaderPanel.
  • The popup portals to the body and flips to stay in view. Very long levels window their rows when CascaderVirtualItems is mounted (opt-in, past virtualizeThreshold).

Do and don't#

Do. Use a cascader when the values live in a real tree and the path tells people where they are.
Don't. Put a cascader on a flat list of leaves. The drill level is an extra press to a list a select shows at once.
Do. Write an empty message that names the miss, and keep a footer command for the way out.
Don't. Leave CascaderEmpty out. When a level has no matches the panel reads as broken, with no word for what happened.

Content#

  • The label names the field: GL account, Supplier category, Remit-to address.
  • The placeholder names the choice, not the action: Choose an account, Choose a category. Not Select….
  • The search placeholder names the level being searched: Search accounts, Search Facilities.
  • The empty message names the miss: No accounts match. Keep it short; the panel already shows the level.
  • A footer command is a verb that leaves the picker: Create GL account…, with the ellipsis when it opens a dialog.
  • Node labels are the real names in their natural order (the chart of accounts, not alphabetical); a description adds a 12px Slate Meta second line when two leaves share a name.

Accessibility#

  • The search input is the role="combobox" with aria-expanded, aria-controls and aria-activedescendant; focus stays in it while arrow keys move the highlight through the list. Focus lands on the input on open.
  • Name the field with a Label linked to the trigger's id, or aria-label on CascaderTrigger. The trigger shows the value, so an unnamed one is announced only as its selection; the component warns in development.
  • Drill and columns rows are role="option" in a listbox; tree rows are role="treeitem" with aria-level, aria-expanded, aria-setsize and aria-posinset. A branch carries aria-haspopup="listbox" outside tree mode.
  • A branch's trailing number is spoken through an sr-only detail (24 items, opens a submenu), since a bare count would read as part of the label. Columns trail rows are plain buttons, so their selected and partial states are said in words.
  • A polite CascaderStatus live region announces each level, result count, expand/collapse and a max or cascade outcome, which the visual breadcrumb would otherwise hide; the result count is debounced ~150ms so a stream of keystrokes doesn't replay stale counts.
  • Set invalid for errors (it writes aria-invalid on the trigger, chips and input) and link the message with aria-describedby. The footer flyout turns one Escape into two so it closes before the panel.
Keyboard interactions
KeysAction
↓↑Move the highlight down and up. From the end of the list, ↓ hands focus to the footer commands.
EnterCommits the highlighted leaf, or drills into a branch. In multiple mode, toggles it.
→Drill: at the end of the text, opens the highlighted branch. Tree: expands a branch, then moves to its first child. (Mirrors to ← in RTL.)
←Drill: at the start of the text, goes back one level. Tree: collapses an expanded branch, else moves to the parent. (Mirrors to → in RTL.)
BackspaceWith an empty query, goes back one level (drill).
EscCloses the popup, or first closes an open footer flyout. Focus returns to the trigger.

Design tokens#

Design tokens
TokenUsed for
--inputTrigger and chips stroke; 30% fill in dark
--ringFocus border and 3px ring at 50%
--destructiveInvalid border and ring at 20%
--popoverPanel surface
shadow-mdPanel lift, with a 1px ink ring at 10%
--accentHighlighted row and the open trail row
--primaryChecked checkbox fill in multi-select
--muted-foregroundPlaceholder, chevron, breadcrumb, counts, empty and footer icons
--mutedChip fill
--borderNav, footer and separator hairlines at 60%
--radius-lg10px trigger and panel corners
--radius-md8px row corners

API reference#

Cascader

The root, built on Base UI Combobox.Root. Holds the value, path, expansion and open state and renders no element of its own. The item payload T is inferred from items, so node.data arrives typed.

Props of Cascader
PropTypeDefaultDescription
itemsRequiredCascaderNode<T>[]No defaultThe tree, { value, label, children?, icon?, count?, hasChildren?, disabled? }. Pass [] with getChildren for a fully async tree.
getParent(node) => string | null | undefinedNo defaultOpt into flat adjacency input: return each node's parent value instead of nesting children.
mode"drill" | "columns" | "tree""drill"The panel layout. Drill replaces the level, columns fans the trail out, tree expands in place.
multiplebooleanfalseSeveral values as a string[]; rows get checkboxes. Enables max and cascade.
value / defaultValuestring | string[] | undefinedNo defaultControlled or initial selection. A string in single mode, an array with multiple.
onValueChange(value, details: CascaderChangeDetails<T>) => voidNo defaultCalled with the new value and resolved details (node, path, nodes, reason).
selectable"leaf" | "any" | ((node) => boolean)"leaf"Which nodes may be committed. Default leaves only.
cascadebooleanfalseMulti-select only: a branch commit sweeps its whole loaded, selectable subtree and reconciles ancestors.
maxnumberNo defaultMulti-select cap. Further picks are refused and announced; existing selections survive.
getChildrenCascaderGetChildren<T>No defaultFetches one level on demand (node is null for the root). Returns an array or a CascaderLoadResult for paging.
onSearchCascaderOnSearch<T>No defaultServer search, debounced by searchDebounce, replacing the local scan while a query is set. Ignored in tree mode.
resolveValueCascaderResolveValue<T>No defaultResolves a server-supplied value into its ancestor chain so the trigger can show its path.
searchScope"level" | "deep" | "global""level"Whether a query filters the current level, the whole subtree under it, or the whole tree from any level (drill/columns).
path / defaultPath / onPathChangestring[] / string[] / (path, { reason }) => voidNo defaultThe open trail in drill and columns modes.
expanded / defaultExpanded / onExpandedChangestring[] / string[] / (expanded) => voidNo defaultThe expanded branches in tree mode.
open / defaultOpen / onOpenChangeboolean / boolean / (open, { reason }) => voidNo defaultControl the popup.
closeOnSelectbooleantrueWhether a single-select leaf commit closes the popup. Ignored under multiple.
revealSelectedbooleantrueOn open, navigate to the level holding the selection.
inlinebooleanfalseRender without a popup, for an embedded CascaderPanel.
maxHeightnumber | stringNo defaultCaps the list height; the panel still shrinks to the viewport. Default 24rem.
actionsCascaderActionItem[]No defaultFooter commands rendered by a childless CascaderFooter.
disabled / invalid / required / readOnlybooleanNo defaultForm state. invalid draws the red border and ring on the trigger, chips and input.
name / form / id / inputRefstring / string / string / Ref<HTMLInputElement>No defaultNative form wiring, forwarded to Base UI's hidden input.
renderItem / renderLabel(node, state) => ReactNodeNo defaultReplace the whole row, or only its label block (icon, count, chevron and check kept).

CascaderTrigger

The button that shows the value and opens the popup, with a trailing chevron. Matches a select trigger.

Other props spread onto Base UI Combobox.Trigger (native <button>).

Props of CascaderTrigger
PropTypeDefaultDescription
size"sm" | "default""default"32px, or 28px with 8px corners for toolbars.
showIconbooleantrueHides the trailing chevron for a trigger with its own.
idstringNo defaultPoint a <Label htmlFor> at it to name the field.
classNamestringNo defaultMerged after the base classes. Set the width here.

CascaderValue

The selection inside the trigger. Defaults to the collapsed path rather than a bare leaf, which is ambiguous in a tree.

Other props spread onto <span> via useRender.

Props of CascaderValue
PropTypeDefaultDescription
display"path" | "leaf" | "count""path"The full trail, only the leaf, or a count (3 selected).
maxSegments / collapsenumber / "middle" | "start" | "none"No defaultHow a long path is collapsed. Default 3, middle.
placeholderReactNodeNo defaultShown in Slate Meta when there is no value.
children(selected, path) => ReactNodeNo defaultReplaces the whole rendering with the resolved nodes.

CascaderContent

Portal, positioner and floating panel surface. Unlike a combobox popup it does not clamp to the trigger width.

Other props spread onto Base UI Combobox.Popup.

Props of CascaderContent
PropTypeDefaultDescription
side"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end""bottom"Preferred side; it flips to stay in view.
sideOffsetnumber6Gap from the trigger in px.
align / alignOffset"start" | "center" | "end" / number"start" / 0Alignment along the trigger.
anchorRefObject<HTMLDivElement | null>No defaultElement to position against. Pass the useCascaderAnchor() ref used on CascaderChips.
containerPortal containerNo defaultWhere the portal mounts, for a shadow root.

CascaderPanel

The nav + list(s) + footer container, with no positioning. Inside CascaderContent for the popup, alone for an inline cascader. Owns the panel's tab order.

Other props spread onto <div> via useRender.

No props of its own.

CascaderNav

The header: the back control, the search input, and the hairline under them.

Other props spread onto <div> via useRender.

No props of its own.

CascaderInput

The search field. Must render inside the positioner. Owns the level-navigation keys.

Other props spread onto Base UI Combobox.Input.

Props of CascaderInput
PropTypeDefaultDescription
showBackbooleantrueRenders the back control inline before the field (drill mode, below the root).
placeholderstringNo defaultDefaults to a per-level label; set it to name the search.

CascaderBreadcrumb

The ancestor trail with the current level last; earlier crumbs jump back. Rendered only in drill mode.

Other props spread onto <nav> via useRender.

Props of CascaderBreadcrumb
PropTypeDefaultDescription
maxSegmentsnumber3Visible node segments before the middle collapses.
collapse"middle" | "start" | "none""middle"Where the trail is collapsed.
interactivebooleantrueWhether a crumb navigates back to its level.

CascaderList

The scroll container for one level (drill/tree). The height is min(available height, maxHeight).

Other props spread onto Base UI Combobox.List.

Props of CascaderList
PropTypeDefaultDescription
maxHeightnumber | stringNo defaultCaps this level's height. Default 24rem.

CascaderItems

Renders the active view's rows — the current level, the deepest column, or the flattened tree. Pass a function child (node, index) => ReactNode to replace the default row.

No props of its own.

CascaderItem

One row: label, optional icon and description, a trailing count and chevron on a branch, and the check or checkbox. A branch press navigates; a leaf press commits.

Other props spread onto Base UI Combobox.Item.

Props of CascaderItem
PropTypeDefaultDescription
nodeRequiredCascaderNodeNo defaultThe node this row renders.
as"option" | "button""option"An option in the listbox, or a plain button for a columns trail row.
depth / indentnumber / numberNo defaultTree indentation. indent is pixels per level (default 16).

CascaderColumns

Miller columns: the open trail side by side, one panel per level, in mode="columns". Only the deepest column is a real listbox.

Other props spread onto <div>.

Props of CascaderColumns
PropTypeDefaultDescription
columnWidthnumber | string220Width of each column's list.
maxHeightnumber | stringNo defaultHeight cap per column; falls back to the root maxHeight.
children(column: CascaderColumn) => ReactNodeNo defaultReplaces the default column, for a windowed one.

CascaderChips

The multi-select trigger surface: one removable chip per selection, where CascaderValue would collapse to a count. Anchor the popup to it with useCascaderAnchor().

Other props spread onto Base UI Combobox.Chips.

Props of CascaderChips
PropTypeDefaultDescription
placeholderReactNodeNo defaultShown when nothing is selected.
strategy"all" | "parent" | "child""all"How a cascade selection condenses into chips (display only).
childrenReactNode | ((nodes: CascaderNode[]) => ReactNode)No defaultReplaces the chip list with the resolved selection in selection order.
refRefObject<HTMLDivElement | null>No defaultPass the useCascaderAnchor() ref.

CascaderFooter

Commands pinned below the list, a sibling of CascaderList. Children win over the root's actions; with neither it renders nothing.

Other props spread onto <div>.

No props of its own.

CascaderAction

One footer command, shaped like a row but a real <button>, never an option. Fires onSelect on press.

Other props spread onto <button>.

Props of CascaderAction
PropTypeDefaultDescription
iconReactNodeNo defaultA leading icon in Slate Meta.
onSelect() => voidNo defaultFires on press, after onClick, never when disabled.
disabledbooleanNo defaultDims the command but keeps it focusable (via aria-disabled).

CascaderEmpty

The empty, loading and error surface in one element. Shows a Retry on a failed level.

Other props spread onto Base UI Combobox.Empty.

No props of its own.

CascaderStatus

A polite live region announcing the level, count and outcomes. Render one per cascader.

Other props spread onto Base UI Combobox.Status.

No props of its own.

useCascaderLoader

The headless async engine behind getChildren / onSearch / resolveValue: per-level pages merged onto the items index, with abort, paging and prefetch. The root wires it for you; use it only for a custom data layer.

No props of its own.

useCascaderSelection

Headless access to the resolved selection (selected, paths, first, count, remove, clear) for a trigger that is not CascaderValue.

No props of its own.

useCascaderAnchor

Returns a RefObject<HTMLDivElement | null> to share between CascaderChips and CascaderContent.

No props of its own.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The panel draws its edge with ring-1 plus shadow-md rather than the single composite overlay shadow the Hairline-and-Lift Rule asks for — the same deviation Combobox has, inherited from the shared popup.

The open animation is 100ms in from a 95% scale, where DESIGN.md asks popovers for 160ms in from 0.97 and 110ms out.

CascaderChip is 21px with 4px (rounded-sm) corners, off the tag ramp (20px, 8px corners); chips aren't tags, but the size isn't on any ramp.

revealSelected reopens on the level holding the first selection, so a multi-selection spanning branches reopens at the root rather than at any one branch.

The component is new and used in few product surfaces, so its patterns are less proven than Select's and Combobox's. The eight registry styles are spelled out inline rather than themed, which is deliberate but verbose.