Custom Shadcn Cascader for React and Tailwind CSS. A nested multi-level combobox with drill-down navigation, breadcrumbs, search and custom rows.
Cascader is a select whose options form a tree. Instead of scrolling one long list you drill from a category into its children and commit a value at whatever depth matters. It is the control for attribute pickers, category trees, org charts, region pickers, permission scopes and file paths.
What it adds over a flat combobox is level navigation: pressing a branch opens it instead of committing a selection, the popup stays open, and a breadcrumb and a back control say where you are. The trigger then shows the selection's full path rather than a bare leaf label, because in a nested picker the leaf alone is frequently ambiguous.
import {
Cascader,
CascaderContent,
Browse 20 production-ready Shadcn Cascader components for dashboards, forms, and product UI. These examples follow the Radix UI implementation with accessible primitives from the Radix stack and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.
Browse all 20 Shadcn Cascader components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.
const items: CascaderNode[] = [
{ value: "record-id", label: "Record ID" },
{
value: "person",
label: "Person",
children: [
{ value: "person.name", label: "Name" },
{ value: "person.email", label: "Email addresses" },
],
},
]
export function AttributePicker() {
const [value, setValue] = useState("person.name")
return (
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderInput />
</CascaderNav>
<CascaderBreadcrumb />
<CascaderEmpty />
<CascaderList>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
)
}That is the minimum worth shipping. Three of those parts are load-bearing beyond their own markup - CascaderInput owns the level keys, CascaderValue resolves a selection whose node is not in items, and CascaderStatus is the panel's only live region - and Anatomy below has the full shape, what each part is worth, and the placement rules that are enforced at runtime rather than by the types.
One thing no part supplies for you is a NAME for the field. The trigger's contents are the field's VALUE - they change with the selection - so exactly like a native <select>, the accessible name has to come from the author: an aria-label, an aria-labelledby, or a <label> pointed at the trigger's id. Without one a screen reader hears what is picked and never what the field is for, and the cascader warns about it once in development (trigger-unnamed), checking the DOM rather than props so a <label for> pairing counts.
Cascader is composed rather than configured. Nothing inside the panel is implied: every part you leave out is simply not drawn, and there is no arrangement the root will silently correct for you. This is the whole shape, with every part that carries a placement rule in it.
<Cascader items={items} value={value} onValueChange={setValue}>
{/* TRIGGER SURFACE. Exactly one of CascaderTrigger and CascaderChips, and
it sits OUTSIDE CascaderContent: it is the anchor the popup positions
against, so it cannot live inside the portal it anchors. Both are
omitted only with <Cascader inline>. */}
<CascaderTrigger aria-label="Attribute">
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
{/* PORTAL, POSITIONER AND POPUP, in one part. Dropped by `inline`. */}
<CascaderContent className="w-80">
{/* THE PANEL. Also owns the panel's Tab order, which is what keeps the
footer one press from the field in every mode. */}
<CascaderPanel>
<CascaderNav>
{/* Must render in here, inside the positioner. Owns the level keys,
so a panel without one has no keyboard path into a branch. */}
<CascaderInput />
</CascaderNav>
{/* Drill mode only. Renders null in columns and in tree. */}
<CascaderBreadcrumb />
{/* Empty, loading and error, all three out of one element. */}
<CascaderEmpty />
{/* THE ROWS. In mode="columns", CascaderColumns replaces this pair. */}
<CascaderList>
<CascaderItems />
</CascaderList>
{/* A SIBLING of the list, never a child of it: inside the list it
would be a row, and would vanish the moment a query matched
nothing - which is when a command is most useful. */}
<CascaderFooter>
<CascaderAction onSelect={createAttribute}>
Create new attribute
</CascaderAction>
</CascaderFooter>
{/* The panel's only announcement channel. */}
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>These are enforced at runtime, not by the types, so a part in the wrong place still compiles. Most of them fail quietly: the part renders, and something else stops working.
CascaderInput goes inside CascaderContent. Base UI only skips refilling the input from the committed selection when the input lives inside the popup, and that refill fights every level swap. It is also what makes the popup a role="dialog", which is why labels.panelLabel exists.CascaderTrigger and CascaderChips go outside CascaderContent, as siblings of it. They are the anchor and the focusable element outside the popup, so ref and onBlur belong there too.CascaderChips REPLACES CascaderTrigger. The popup then has to be anchored by hand: useCascaderAnchor() returns the ref and CascaderContent takes it as anchor.CascaderFooter is a SIBLING of CascaderList, inside CascaderPanel.CascaderItem belongs inside CascaderList, or inside a CascaderColumns column. Base UI collects its options through the list's own collection, so a row rendered outside one is invisible to the arrow keys and to aria-activedescendant.CascaderLabel belongs inside CascaderGroup. Outside a group it still draws, and it names nothing: a heading loose inside a listbox is dropped from the accessibility tree entirely.CascaderSubmenuTrigger and CascaderSubmenuContent belong inside CascaderSubmenu, whose state hook throws by name outside one.CascaderColumns replaces CascaderList and renders null outside mode="columns", warning in development when it is mounted in the wrong mode. CascaderBreadcrumb and CascaderBack render null outside mode="drill" SILENTLY, so a columns or tree panel that mounts one gets an invisible part and no hint about why.virtualize. CascaderVirtualItems goes inside CascaderList; CascaderVirtualColumn goes through the CascaderColumns children slot. virtualize with neither mounted windows nothing, and says so in the console.mode="columns" renders the whole open trail side by side, one pane per level (Miller columns), for when comparing siblings across levels matters more than screen width.
mode="tree" expands branches in place so several groups stay open at once, which is what a multi-select over a whole tree needs and what drill-down actively works against.
The other fourteen ship exactly as these do and stay browsable on the Cascader components page: deep search with path-annotated results, custom rows with avatars, color-coded rows and color-coded categories, trigger path formatting, an embedded panel, flat adjacency data, controlled state, translated labels, descriptions and disabled options, a form field, hand-composed groups and labels, consumer-rendered chips, and a windowed level of thousands of rows.
The surface is tiered, and every exported symbol sits in exactly one tier.
Cascader down to the end of Guides: what you compose or call, with a full prop table each. This is what the twenty shipped examples are built out of.Six parts render a plain element and accept Radix's asChild prop: CascaderPanel, CascaderNav, CascaderBack, CascaderBreadcrumb, CascaderValue and CascaderStatus. Each becomes the single child you give it while keeping its own classes, data attributes and behaviour.
<CascaderPanel asChild>
<Frame />
</CascaderPanel>
<CascaderBack asChild>
<Button variant="ghost" size="icon" />
</CascaderBack>asChild merges props onto that child rather than replacing them, so the part's own onClick, aria-label and data-slot survive, and anything you pass wins over the default.
Two of the six BUILD their own children: CascaderValue renders the collapsed selection path, and CascaderBreadcrumb renders the trail. With asChild those children are cloned INTO the element you pass, so the element is a container and not a replacement - and whatever children it was written with are replaced by the part's own.
<CascaderValue asChild placeholder="Select an attribute">
<strong />
</CascaderValue>CascaderValue's children is therefore either a render callback OR that single element, never both, and the type says so. If asChild is set with no element child, the part renders its own tag rather than disappearing - a mistake you can see beats one you cannot.
CascaderTrigger, CascaderContent, CascaderInput, CascaderList, CascaderEmpty, CascaderItem, CascaderChips, CascaderChip, CascaderGroup, CascaderLabel, CascaderSeparator and CascaderSubmenuContent are Base UI parts in this twin too - the Radix registry's combobox is Base UI-backed - so those twelve take Base UI's render prop, not asChild. Two footnotes inside that list. CascaderLabel is a Base UI GroupLabel inside a CascaderGroup and a plain rendered element outside one, and takes render either way. And CascaderItem forwards render only on its default as="option" path: as="button" renders a plain <button> outside Base UI's listbox for the columns trail, where aria-setsize, aria-posinset and aria-level are explicitly DELETED as illegal on role="button". render is not deleted - it is simply never read on that path, so it reaches the DOM as an unrecognised attribute and nothing says so: an element lands there as render="[object Object]" in silence, and only the callback form trips React's own Invalid value for prop error.
Everything else composes through children rather than through its own element, and accepts neither prop: CascaderFooter, CascaderColumns, CascaderColumnPanel, CascaderItems, CascaderVirtualItems, CascaderVirtualColumn, CascaderAction, CascaderSubmenu and CascaderSubmenuTrigger.
T is inferred from items. multiple is the discriminant rather than an ordinary boolean: value, defaultValue, onValueChange and max all narrow off it, so the two mode-dependent shapes are listed separately below.
Five pieces of state are independently controllable, each with an uncontrolled default* twin and a change callback: value, path, expanded, open and inputValue. Decide per prop and stay with it - switching one between controlled and uncontrolled mid-life strands half its state, because the first update after the switch reads from whichever source is no longer authoritative, and the cascader warns about it in development.
The skeleton nearly every cascader writes, listed in composition order.
The field itself: a real <button> carrying the trailing chevron, and the focusable element outside the popup, so ref and onBlur belong here. See forms. A clear control belongs BESIDE it rather than inside it - a button nested in a button is invalid markup, fails the nested-interactive axe rule, and reopens the popup on the way up from its own click - so position one over the trigger's inline end, turn showIcon off for as long as it stands in for the chevron, and keep the chevron's width reserved so a long summary truncates before it reaches the button.
Renders the selection's path in the trigger, with the middle collapsed to an ellipsis. It feeds the same collapsing helper the in-panel breadcrumb does, so the two can never disagree about how a deep path reads. It also resolves a selection whose node is not in items - async children that have not been fetched, or an item removed after being chosen - remembering the label of anything that has been selected and falling back to the raw value, so the trigger never renders blank.
Portal, positioner and floating panel in one. Unlike the shadcn ComboboxContent wrapper it does not clamp the popup to the anchor's width - a cascader panel is routinely wider than its trigger, and columns mode wider again - but it does floor it there, so an ordinary single-column popup still lines up under the trigger. It takes Base UI Combobox.Popup props plus the positioner surface below and the portal's container, forwarded as-is so an unusual anchor situation - a sticky toolbar, a scroll container that clips, a fixed layout - does not force a rebuild of the whole content stack. There is deliberately no trackAnchor in the list: Base UI 1.5.0's positioner has no such prop.
Give it w-auto min-w-0 in mode="columns": the min-w-(--anchor-width) floor is what stops a columns panel shrinking to the width its columns actually need, and clearing it lets the panel size to its content in both directions.
The nav, list and footer container, with no positioning of its own. Use it inside CascaderContent for the popover case, and on its own with <Cascader inline> for an embedded one. It also owns the panel's tab order - see keyboard.
An embedded panel has no positioner, so there is no --available-height and the 24rem cap alone applies. To make one fill its container instead, the container chain has to give it a height: CascaderPanel is already flex max-h-full min-h-0 w-full flex-col, but every flex ancestor between it and whatever owns the height needs min-h-0 too. A flex child without it refuses to shrink below its content, which is the single most common reason a scroll area silently never scrolls.
Header holding the back control and the search input. The breadcrumb is not part of it: it belongs to the list below the separator, because it describes where the rows come from rather than what the field searches.
The search field, and the element that owns the level keys.
The trail of the level on screen AND its ancestors, rendered between the nav separator and the list. One drill in shows Company; two show Person > Company. The last crumb is the level you are on: it is the only segment that is not a button, and it carries aria-current="page". CascaderValue renders the same full path from the trigger, using the same collapsing helper.
Renders nothing at the root, and nothing outside mode="drill" - columns draws its own per-pane headings and tree has no single current level to name. Mount it anyway if the mode is dynamic; it costs one null.
The scroll surface for one level. It is bounded by the room the popup actually has - min(--available-height, cap) - and the rows live in a shadcn ScrollArea, so the list gets a real, styled thumb instead of a hidden native scrollbar. The scrollport carries each style's own list padding as scroll-padding, so a row arrowed into view is never left tucked under the panel edge.
Renders one flat run per level, so its indices line up with Base UI's listRef and with the virtualizer's.
One row. CascaderItems renders these for you, so reach for it directly only when you are laying rows out yourself.
CascaderRowEvent is React.MouseEvent<HTMLElement> widened with Base UI's optional preventBaseUIHandler(), which is the only supported way to stop a press committing - calling preventDefault() does not reach Base UI's own handler. The hook is optional because an as="button" row lives outside Base UI's listbox and gets a plain React event, and the element is widened so one handler serves the option, the button row and the trailing chevron without a cast. The row already vetoes for you on a branch that cannot be committed; this is for the cases it cannot know about.
branch, selectable, selected and indeterminate are optional and fall back to the cascader's own answers, so a hand-written row is always correct. Pass them when the caller already knows: the row is memoised, and four booleans let it skip a re-render that a context read would have forced. The three questions behind them are also answerable directly with isCascaderBranch, isCascaderSelectable and getCascaderCount from helpers.
Loading a branch happens BEFORE navigation, not after it. Pressing a branch whose children are not loaded does not move you: you stay on the level you are reading, that row's trailing chevron becomes a spinner IN PLACE - same box, same width, nothing reflows - and the panel changes level only once the children have arrived. Drill holds the level swap, columns holds the new column, and tree holds the EXPANSION, so a failure is a non-event: nothing moved, so nothing has to be undone, and pressing the row again refires that level. Going back, jumping to a crumb, or closing the popup all abandon the held navigation. The spinner is aria-hidden; the load state is spoken once, by CascaderStatus.
In mode="tree" the leading expander is its own pointer target, which matters as soon as branches are selectable: with selectable="any" a row press COMMITS, so without a separate expander there would be no way to open a branch at all with a pointer - and under cascade that press would quietly select the whole subtree of a row that never opened. Row press selects, expander press expands. A leaf reserves the expander's box even though it has no expander to put in it, so one depth keeps one label column. With multiple, the tree checkbox sits at the HEAD of the row, after the expander, rather than in the trailing gutter: labels are staggered by depth and a trailing column of boxes is not, so a grandchild's box would sit directly under its parent's with nothing to say they are different levels.
The empty, loading and error surface, and the one element all three come out of. A level that has never been fetched says it is loading, one that came back with nothing shows the empty message, and one that failed shows the error and a real <button> retry. It SWAPS ITS CHILDREN rather than unmounting, because Base UI's Combobox.Empty renders whenever the rendered list is empty - which is as true of a level still fetching as of one that came back with nothing - so anything that mounted and unmounted around it would announce "No results found." over every async level on its way to loading. There are no skeleton rows: they promised a shape the level did not necessarily have, and paid for it with a second layout when the real rows arrived.
The visually hidden live region. Include one; it is the only announcement channel the panel has.
Extra parts you mount only when a specific feature calls for them.
Commands pinned below the list, where they stay put while the list scrolls, filters down to nothing, or changes level - which is exactly the moment a command like "Create new attribute" is most useful and the moment a row inside the list would have disappeared. Render it as a SIBLING of CascaderList, inside CascaderPanel; inside the list it would be a row. It renders nothing at all when it has neither children nor a root actions array, rather than reserving an empty strip under every panel.
This is not a fourth navigation mode. Drill, columns and tree stay the only ways to move through the data tree, and nothing in the footer joins the selection, the filter set or the highlight: a footer row is a plain <button>, never a Combobox.Item, because an item would be arrow-reachable, would be committed by Enter, and would vanish the moment a query matched nothing. The arrows still reach it - ↓ past the last row hands focus down, and either end of the strip hands it back - and keys pressed in the footer stay in the footer, so Enter, Space, the VERTICAL arrows, Home/End and PageUp/PageDown are swallowed there. ← and → are not: the horizontal pair is what opens and closes a footer flyout from its own row. Escape and Tab are deliberately let through as well.
import {
CascaderAction,
CascaderFooter,
CascaderSubmenu,
CascaderSubmenuContent,
CascaderSubmenuTrigger,
} from "@/components/byosui/cascader/cascader-footer"
;<CascaderPanel>
<CascaderNav>
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList>
<CascaderItems />
</CascaderList>
{/* A SIBLING of the list, never a child of it. */}
<CascaderFooter>
<CascaderAction icon={<PlusIcon />} onSelect={createAttribute}>
Create new attribute
</CascaderAction>
<CascaderSubmenu>
<CascaderSubmenuTrigger icon={<ImportIcon />}>
Import from
</CascaderSubmenuTrigger>
<CascaderSubmenuContent>
<CascaderAction onSelect={importCsv}>CSV file</CascaderAction>
<CascaderAction onSelect={importCrm}>Salesforce</CascaderAction>
</CascaderSubmenuContent>
</CascaderSubmenu>
</CascaderFooter>
</CascaderPanel>Or, when the actions are data rather than markup, hand them to the root as actions and render <CascaderFooter /> with no children. Children win over actions when both are supplied, so the data path is a shortcut rather than a different component.
One footer command. A real <button>, never a Combobox.Item. Also usable inside a CascaderSubmenuContent.
disabled publishes aria-disabled and data-disabled rather than the native attribute, and that is the whole design rather than an oversight. A natively disabled <button> is not a tab stop, and the panel's own Tab order is computed from real tab stops, so a footer whose only row was disabled had no stop after the search field and was unreachable from the keyboard. ARIA's authoring practices answer it the other way round: a disabled command stays focusable and announces itself as disabled, so a keyboard user can discover that it exists. The three things the native attribute did for free are re-implemented by hand instead - pointer activation is vetoed in the click handler, Enter and Space are prevented rather than merely ignored, and the greyed look keys off aria-disabled instead of :disabled.
A footer row plus the side-anchored flyout it opens. Controlled or uncontrolled. One level deep on purpose: a command list that nests is a menu bar in disguise.
The footer row that opens the flyout, and the element the flyout is anchored to.
disabled cannot be passed through to Base UI's popover trigger: that runs it through useButton, which writes the NATIVE attribute on a <button> and takes the row out of the panel's Tab ring - reproducing on this row the defect CascaderAction avoids. So the prop is intercepted, republished as aria-disabled plus a data-disabled hook, and the three routes that would still open the flyout are closed by hand: the opening arrow key, Enter and Space before they can synthesize a click, and the click itself through preventBaseUIHandler(), since Base UI's useClick does not consult defaultPrevented.
The flyout. Takes Base UI Popover.Popup props plus the positioning four.
A second floating layer inside a combobox popup normally fights the combobox on four fronts at once: it gets aria-hidden, the click that opens it reads as an outside press, the focus that lands in it reads as a focus-out, and the popup's overflow-hidden clips it. This one is a Base UI Popover rendered as a REACT CHILD of the combobox popup, with its own Portal and no container prop, and Base UI resolves a nested portal's container to the parent portal node - so the flyout ends up a DOM SIBLING of the combobox popup, unclipped and not aria-hidden, while staying a React DESCENDANT of it, which is what the combobox's outside-press and focus-out whitelists are computed from. modal stays false, so the combobox keeps its own dismissal behaviour, and because Combobox builds no FloatingTree the same Escape reaches both layers: the root cancels its own escape-key close while any flyout is registered as open, so the flyout closes first and the cascader second.
The multi-select trigger surface. A multi-select trigger otherwise collapses to labels.selectedCount - "3 selected" - which is right for a narrow trigger and useless the moment the user wants to drop one of the three without reopening the panel. The chips container REPLACES CascaderTrigger, so the popup has to be positioned against it: useCascaderAnchor() returns the ref for that, and CascaderContent takes it as anchor. Pressing anywhere in the container that is not a chip opens the panel.
import {
CascaderChips,
useCascaderAnchor,
} from "@/components/byosui/cascader/cascader"
const anchor = useCascaderAnchor()
<Cascader multiple items={items} value={value} onValueChange={setValue}>
<CascaderChips ref={anchor} placeholder="Add a column..." />
<CascaderContent anchor={anchor}>
<CascaderPanel>...</CascaderPanel>
</CascaderContent>
</Cascader>Three things it does that a hand-rolled chip list routinely does not: it carries an ancestor path on a chip only where its label alone would be ambiguous (per selection, so nothing is padded for consistency's sake), it names every remove button through labels.removeChip, and it names the container through labels.chipsLabel - Base UI gives it role="toolbar" WHILE the selection is non-empty, so NVDA keeps focus mode while arrowing between chips, and an unnamed toolbar is announced as just "toolbar". With nothing selected there are no chips to arrow between, so the role is dropped and the container is an aria-labelled plain <div> showing the placeholder. Removal goes through Base UI's ChipRemove, which reports the SHORTENED selection to the root, so it lands in the same setSelection every other deselection does and cascade applies to a chip press exactly as it does to a row press. Chips carry the same field, pill and remove-button treatment the shadcn combobox uses, spelled out per style in the primitive itself, so they match every style without depending on byosui's theme CSS.
A run of related rows. role="group", named by the CascaderLabel inside it. Reach for the group rather than a label on its own: a heading sitting loose inside a listbox names nothing and is dropped from the accessibility tree entirely, so it looks right and reads as though it were not there.
There is no group field on CascaderNode, and that is a decision rather than an omission - CascaderItems renders one flat run per level so its indices line up with Base UI's listRef and with the virtualizer's, and grouping inside it would have to renumber both. Compose your own CascaderItem runs when a level needs headings, under three rules about the array you compose them from:
slices of renderedItems, in order, each node once. Base UI sizes its listRef from that array and maps a highlight index straight back into it, so a run built with filter, or holding a node pulled in from elsewhere in the tree, desyncs the highlight and breaks Enter. Apply recency by reordering items, not by adding rows.aria-setsize and aria-posinset count across the LEVEL, not the group. Numbering each run from one has the second heading announce "1 of 7" over what is genuinely the fourth row of ten.index prop outside a windowed list. See CascaderItem.<CascaderGroup>
<CascaderLabel>Recent</CascaderLabel>
<CascaderItem node={recent} />
</CascaderGroup>
<CascaderSeparator />
<CascaderGroup>
<CascaderLabel>All properties</CascaderLabel>
<CascaderItems />
</CascaderGroup>A heading. Inside a CascaderGroup it becomes that group's accessible name; outside one it is drawn as a plain element, because the footer flyout is a popover rather than a listbox and a heading there is read in document order. It carries the same type treatment a combobox heading has in each style - uppercase and tracked in sera, not in the other seven - and overrides only its inset, so a heading lines up with the rows under it.
A rule between two runs. Decorative: it renders role="presentation" and aria-hidden, because a listbox may not own a role="separator" and a run that needs separating for a screen reader needs a CascaderGroup, not a line. Pass role="separator" yourself to take the role back where one is legal. It takes its negative margin from --cascader-list-pad, so the rule reaches the panel edge in the list, in the footer, and in lyra, whose list has no padding at all.
Renders the trail in mode="columns". Replaces CascaderList. Only the deepest column is the listbox, because Base UI owns one list: ↑ and ↓ move within the active column, → opens the highlighted branch into a new column, and ← steps back one. The trail behind renders as plain buttons and is pointer-first - pressing a trail row jumps straight back to that branch. Each column gets its own scroll area, which matters more here than anywhere else: three side-by-side panes with no scrollbars give no hint that any of them has more rows below the fold.
Windowed replacement for CascaderItems in drill and tree modes: under virtualizeThreshold rows it renders exactly what CascaderItems renders, and above it, it windows. Nothing else about the cascader changes - the same keyboard model, the same search, the same row component. It lives in its own file, which @byosui/cascader installs along with @tanstack/react-virtual.
import { CascaderVirtualItems } from "@/components/byosui/cascader/cascader-virtual"
;<CascaderList maxHeight={288}>
<CascaderVirtualItems />
</CascaderList>Two rules are not stylistic preferences: windowing a Base UI combobox only works if they are followed. Pass elements to the list, never a function child, because Combobox.List implicitly wraps a function child in a Collection and a Collection renders every filtered item. And do not hand CascaderItem an index of your own outside a windowed list - see the index prop on CascaderItem. The highlighted row stays mounted even when scrolled far out of the window, so aria-activedescendant always points at a real element, and the panel scrolls the highlight into view itself, since Base UI's own scroll-into-view cannot see a row that is not rendered.
Windowing fixes the level; it does not fix MOUNTING the popup onto one, and that second cost is the one that gets mistaken for the first. With revealSelected on, a cascader that boots with a deep value opens straight onto that level, so the popup's very first mount is a full render of it - Base UI's per-item work at mount, which windowing cannot remove - and CascaderContent unmounts on close, so every REOPEN pays it again. Size the level, not just the total.
The item payload is inferred from items, so node.data arrives typed in every callback without a type argument anywhere:
interface Member {
initials: string
role: string
}
const teams: CascaderNode<Member>[] = [...]
<Cascader
items={teams}
// `node.data` is `Member | undefined`, not `any`.
renderLabel={(node) => <Avatar>{node.data?.initials}</Avatar>}
/>multiple discriminates the props, so the value and the callback narrow together. Single-select hands back a string, multi-select a string[], and max only exists on the multi-select side:
// Single: `next` is a string.
<Cascader items={items} onValueChange={(next) => setValue(next)} />
// Multiple: `next` is a string array, and `max` is available.
<Cascader multiple max={5} items={items} onValueChange={(next) => setValues(next)} />
// Type error: `max` caps a multi-selection, and single mode has nothing to cap.
<Cascader items={items} max={5} />useCascaderSelection takes no arguments, so pass the payload explicitly when you need it: useCascaderSelection<Member>().
Everything else a consumer ever names is one of these:
CascaderNode, CascaderActionItem, CascaderLabels, CascaderLoadResult.CascaderChangeDetails, CascaderItemState, CascaderLoadContext, CascaderSearchContext, CascaderLoadState, and CascaderRowEvent on a row handler - see CascaderItem.CascaderProps, CascaderBaseProps, CascaderSingleProps, CascaderMultipleProps, and CascaderSelection, which is what useCascaderSelection() returns. CascaderGetChildren, CascaderOnSearch and CascaderResolveValue type a loader declared outside JSX.CascaderMode, CascaderSearchScope, CascaderCollapse, CascaderValueDisplay, CascaderChangeReason, CascaderPathChangeReason, CascaderCheckedStrategy and CascaderLoadReason. None of them needs a section of its own. CascaderSelectable<T> is the near-exception: its predicate arm is generic over the item payload, so an annotated (node: CascaderNode<Member>) => boolean satisfies CascaderSelectable<Member> - a per-call generic would refuse it - and the unknown default keeps a bare CascaderSelectable meaning what it always meant.Every part's *Props type is exactly as public as the part itself, and is named <PartName>Props without exception. The types published on the context hooks - CascaderIndex, CascaderFlatNode, CascaderColumn, CascaderHighlight and the three context shapes - are extension surface and live under Advanced.
They come from three modules. The data and i18n types are in cascader-types, the context ones in cascader-context (re-exported by cascader as well), and the three loader signatures in cascader-async.
Items are supplied either nested, each node carrying children, or as a flat adjacency list where each row carries a parent pointer and you pass getParent. Both normalize to the same internal index.
The second argument of renderItem and renderLabel: everything the default row had already worked out, so a custom row does not derive it a second time.
The second argument of onValueChange, so the usual follow-up questions need no lookup at the call site.
<Cascader
items={items}
onValueChange={(value, details) => {
setValue(value)
// "Person / Company / Domain", with no lookup into `items`.
setLabel(details.path.map((node) => node.label).join(" / "))
}}
/>nodes is resolved the same way the trigger resolves its label, so a selection whose node is not in items still comes back rather than being silently dropped. reason is "clear" only when the whole selection is dropped in one go, which is what useCascaderSelection().clear() does; deselecting the last remaining node reports "deselect" with that node, so the information is never lost.
The shape of one entry in the root's actions array.
What getChildren and onSearch may return instead of a bare array. Return a nextCursor and the level grows a "Load more" row after its last loaded child; pressing it calls getChildren again with that cursor and appends the result. That row is a REAL option in the rendered list, not an invisible scroll sentinel, because every index Base UI hands out is an index into the same array - and being a node also makes it keyboard reachable, which an IntersectionObserver sentinel never is. It is never selectable, whatever selectable is set to. Paging is latched per level: a page that comes back with nothing new while the server still reports hasMore: true cannot be asked for again, or one click becomes an unbounded request loop.
The second argument handed to getChildren.
A level load is NOT aborted merely because the user navigated elsewhere: the page is still worth caching. Responses are additionally guarded by a per-level request id, so a slow response that lands after a newer one is discarded rather than overwriting it. A loader that throws SYNCHRONOUSLY lands in the same error state rather than taking the render down with it.
The second argument handed to onSearch. Not the same shape as CascaderLoadContext: a search belongs to no level, so it carries no cursor and no reason, and it carries the current path instead.
path is what scopes a server query to the level the user is actually looking at: path[path.length - 1] is the current parent, and an empty array means the root.
One level's async state. Read it through useCascaderLoadState() rather than reaching into loadStates by hand.
There is deliberately no status field. MAP MEMBERSHIP is the discriminator between "declared a branch but never fetched" and "fetched and genuinely empty": a level with no entry has never been loaded, and one with an entry, no loading, no error and no children came back empty for real. A status field would be a second source of the same truth and the two would eventually disagree.
The three hooks a composed surface reaches for. The context hooks the panel's own parts read - useCascaderState(), useCascaderActions() and the rest - are extension surface and live under Advanced.
The headless trigger. CascaderValue covers the common shapes and CascaderChips covers chips; when the trigger needs to be something else entirely - a two-line label, a table cell, an avatar stack, or chips that sit beside a trigger that stays a trigger - this hook returns the resolved nodes and their ancestor chains as plain data, plus the mutators a custom surface needs.
function ResourceValue() {
const { firstPath, isEmpty } = useCascaderSelection()
if (isEmpty) return <span>Select a resource</span>
const brand = firstPath[0]
const leaf = firstPath[firstPath.length - 1]
return (
<span>
{brand?.icon}
{leaf?.label} <code>{leaf?.value.split(".").join("/")}</code>
</span>
)
}Drive a hand-rolled chip list off paths rather than 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. The three things CascaderChips does for free - a disambiguating path, a named remove button, a named container - are yours to supply here, and findAmbiguousCascaderLabels in helpers is the rule behind the first of them. Note also that remove() and setSelection() replace the selection exactly as given, with NO cascade propagation: they are the deliberate escape hatch for a caller that has already decided what the selection should be.
Inside a CascaderSubmenu, returns the flyout's own state. Throws outside one.
The interface itself is not exported, so name the return with ReturnType<typeof useCascaderSubmenu> if you need to pass it around.
Returns the ref for the chips container, to be handed to CascaderContent's anchor. It is a useRef and nothing more - it exists so the wiring has a name rather than being an undocumented convention.
Cross-cutting concerns that touch every part: form submission, writing direction and copy.
The cascader submits through a hidden input Base UI owns, so it behaves like a native field rather than needing a controller wrapper around it.
<Cascader
items={items}
name="attribute"
id="attribute-field"
form="settings"
required
invalid={!!errors.attribute}
inputRef={register("attribute").ref}
/>invalid is a boolean and carries no message: the message belongs next to the field, in whatever Field or FormMessage the form library already renders. The chips container keys its error treatment off aria-invalid on a descendant, so setting invalid is all there is to wire.
onBlur and ref go on CascaderTrigger, which is the focusable element outside the popup and therefore the one a "touched" signal should come from. With react-hook-form specifically, Controller wires all of it in one place - the value through field, the error state through fieldState:
<Controller
name="attribute"
control={control}
defaultValue=""
rules={{ required: "Pick an attribute" }}
render={({ field, fieldState }) => (
<Cascader
items={items}
value={field.value}
onValueChange={field.onChange}
invalid={fieldState.invalid}
inputRef={field.ref}
>
<CascaderTrigger onBlur={field.onBlur}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent>...</CascaderContent>
</Cascader>
)}
/>defaultValue="" keeps value controlled from the first render instead of switching mid-life, which the cascader warns about.
The panel mirrors under dir="rtl", including the parts that are not visual:
← opens a branch and → goes back. Tree mode's expand and collapse keys swap with them. The caret-edge guards do not swap: selectionStart === 0 is the logical start of the value in both directions. labels.keyboardHint receives the resolved direction alongside the mode, so the default hint names the mirrored keys rather than teaching exactly the wrong ones.pr-8 and a right-2 that match neither each other nor the style's own start padding. The cascader restates them on its own rows as padding-inline-* and inset-inline-end against one variable, --cascader-row-inset, so there is no second rule for RTL to keep in step and a row's two insets are equal in both directions. Set that variable on a row, or on anything above it, to change both at once without !important.The direction is resolved from Base UI's DirectionProvider first, then from the nearest dir attribute, then from the computed style. <html dir="rtl"> is enough on its own. Mount a DirectionProvider when dir is scoped to a subtree of the page instead: the popup is portalled to the body, so a subtree attribute never reaches it - and Base UI's own RTL behaviour reads the same provider.
labels is the entire i18n story: every user-facing string the cascader can render comes from it, aria-labels and live-region announcements included, so a translated build needs no wrapper component. It is shallow-merged over the English defaults, so pass one key without restating the rest.
Callbacks take plain strings rather than nodes on purpose. A CascaderNode<T> parameter would force the labels object to carry the item generic, and a concrete CascaderNode<string> would then fail to satisfy it.
Visible copy
Names for things the visible markup cannot say
Live-region announcements
Read by CascaderStatus, which is the panel's only announcement channel.
Four of those defaults compose an item count rather than interpolating a bare number, which is why the English copy reads "1 item" and not "1 items": rootAnnouncement, levelAnnouncement, expandedAnnouncement and cascadeAnnouncement. resultsAnnouncement pluralises the same way on its own. levelAnnouncement's depth is one-based and matches the aria-level tree mode gives the same rows, so the spoken numbering and the ARIA numbering can never disagree by one.
Overriding itemCount does NOT reach those four. labels is a shallow merge, and each of the four English defaults closes over the module-local count helper rather than reading labels.itemCount back out, so a replacement changes only what the four do not draw: the branch row's own name. rootAnnouncement has the same relationship with rootLevel. Translate the announcements themselves, not the pieces they look like they are built from - which is also cheaper, since each one is a single template string.
Casing is never applied for display: a label is already written the way its author wants it read, and toLowerCase() is locale-hostile - it maps Turkish "İ" to a two-code-point sequence. Matching is a different matter and folds BOTH the query and the label through toLocaleLowerCase(), so the two sides always fold the same way whatever the host locale is.
Several ways of misconfiguring a cascader are absorbed SILENTLY at runtime, because degrading is the right behaviour for a rendering primitive: a duplicate value is dropped, a getParent cycle is clamped, a prop belonging to another mode is ignored. Each of those is also an afternoon someone will not get back, so in development the cascader says so. Each one is a console.warn behind a shared once-per-key ledger, never fires in production, and never throws. Keys that name an offending value - a duplicate, a cycle - warn once per value; the rest warn once per page.
The last row is the one exception to the paragraph above: it is a raw console.error, fired from a setTimeout(..., 0) inside an effect and outside the once-per-key ledger, because the windowing renderer registers from a layout effect and a synchronous check would report every correctly wired cascader exactly once. Different channel, different ledger, same development-only gate.
Extension surface. Everything here is exported on purpose and is supported, but reaching for one of these means you are building something the parts above do not ship: a custom trigger, a hand-laid-out row, a windowed column, a test harness. Each entry gets a line of purpose rather than a prop table - the parts above are where the prop tables live.
Lower-level parts. Each of these is rendered for you by a part above, or replaces one that is. Reach for one only when you are laying that piece out yourself.
Context hooks. The panel's internals are published on three contexts plus one external store, because they change at very different rates. Subscribing to the narrowest one is what keeps a long level cheap to type into: a keystroke rebuilds the entire derived view, and a component that only reads configuration must not re-render for it.
useCascaderActions() and useCascaderState() THROW by name outside a Cascader, and so do the three that read them: useCascaderLoadState(), useCascaderHasActions() and useCascader(). The other two answer a harmless default instead - useCascaderRender() an empty object, useCascaderHighlight() a shared fallback store reading { index: -1, value: null } - because neither has anything to fail loudly about.
function LevelCount() {
// Re-renders on every keystroke, which is exactly what this component is for.
const { renderedItems } = useCascaderState()
return <span>{renderedItems.length} results</span>
}
function ResetButton() {
// Never re-renders while the user types: the actions context is stable.
const { goToDepth, setQuery } = useCascaderActions()
return (
<button
onClick={() => {
goToDepth(0)
setQuery("")
}}
>
Back to the top
</button>
)
}invalidateLevel(value) on the actions context evicts one branch's pages and load state, clears its paging latch and aborts its in-flight request, so the level reads as never loaded and refetches the next time it is on screen. null targets the root, and resolved chains and search hits are deliberately untouched, so the trigger keeps its labels while the fresh page is in flight.
The highlight is an external store read through useSyncExternalStore, not context and not state. It moves on every arrow key and on every pointer move across the list, so routing it through a re-render would undo everything above. Read it with useCascaderHighlight() in the one component that needs it, and use getHighlighted() from the actions context anywhere that only needs to ask inside an event handler. CascaderItem is wrapped in React.memo, and CascaderItems hands each row its branch, selectable and selected answers as props: that is what makes the memo hold, so typing re-filters a level without re-rendering a single row whose own state did not change.
Types published on those hooks. Each is exported so a component taking one as a prop can name it.
Pure helpers. cascader-lib holds the functions the primitive is built on. Fifteen of them answer questions a hand-written row, trigger or chip list has to answer anyway, and re-deriving those by hand is how a custom surface ends up disagreeing with the panel beside it. The tree and selection functions take the CascaderIndex published on useCascaderState() as their first argument; the matching and formatting ones need no index at all.
import {
collapseCascaderPath,
findAmbiguousCascaderLabels,
getCascaderPath,
} from "@/components/byosui/cascader/cascader-lib"Four more.
import { getCascaderMoreProps } from "@/components/byosui/cascader/cascader-item"
const { loadStates } = useCascaderState()
;<CascaderItem node={node} {...getCascaderMoreProps(node, loadStates)} />The eleven modules export more than the two tiers above. Those remaining symbols exist ONLY because the registry ships one file per module and TypeScript needs cross-file access to them. They are not public API, they carry no compatibility promise, and they are listed here so you can recognise one when your editor offers it and reach for the documented equivalent instead.
CascaderStateContext, CascaderActionsContext, CascaderRenderContext, CascaderHighlightContext. Read them through their hooks instead: the first two throw a named error rather than handing back undefined, and the other two carry a safe default so a useContext of your own cannot get a bare undefined either.CASCADER_ACTION_CLASS, CASCADER_LIST_HEIGHT_CLASS, CASCADER_LIST_PAD_CLASS, CASCADER_ROWS_CLASS, CASCADER_SCROLL_CLASS, CASCADER_MORE_PREFIX, CASCADER_LABELS. The last is the English default bundle, which labels is shallow-merged over for you. CASCADER_ROOT_KEY is the one constant that is NOT internal - see Advanced.buildCascaderIndex, mergeCascaderIndex, flattenCascaderTree, filterCascaderLevel, applyCascadeSelection, getCascaderIndeterminateFrom, createCascaderMoreNode, isCascaderMoreNode, getCascaderMoreParent. Calling one of these yourself produces a second answer the panel does not share.getCascaderFooterStops, getCascaderTabTarget, isCascaderRtl.warnCascaderOnce, resetCascaderWarnings, findCascaderDataIssues, CascaderDataIssues. See for what they report.resolveCascaderLabels, resolveCascaderSearchLabel. labels is resolved for you on the way into the context.useCascaderLoader, UseCascaderLoaderOptions, CascaderLoader, CascaderLoaderStore. The root's own loader. Consumers reach it through the getChildren, onSearch and resolveValue root props, and through loadMore / retryLevel / invalidateLevel on the actions context.Arrow keys only navigate levels when the caret is already at the relevant edge of the search field, so they never steal caret movement while you are editing a query. The level keys are logical, not physical: under dir="rtl" ← and → swap, in every mode. See right to left.
Branches expand in place rather than replacing the panel, so the level keys follow the APG tree pattern instead:
A CascaderSubmenu is a real menu, not a popover with buttons in it: role="menu", menuitem entries, and roving focus rather than a Tab stop per command. In the footer strip itself the vertical arrows move between commands - the flyout opens from the arrow that points at it, never from ↓, so the strip cannot stutter where the flyout row sits.
Opening with the pointer parks focus on the flyout rather than on an entry, so a click never paints a focus ring on a command nobody asked for; the arrow keys still work from there.
Tab is answered by CascaderPanel rather than left to the browser, and the reason is the scroll area between the field and the footer: its viewport makes ITSELF tabbable whenever the content overflows, so the footer sat one press away on a short level, two on a long one, and one more per column in mode="columns" - each extra stop an unnamed role="presentation" div that drew a focus ring around the whole list, which reads as "the footer is unreachable". The panel steps over that viewport and moves focus between the panel's real controls in DOM order instead, so the footer is one press away in every mode and in an embedded panel.
It never wraps and never traps: off either end there is no target, the key is left to the browser, and leaving the cascader is what dismisses it. A Tab your own onKeyDown has already default-prevented is left alone. The move itself is not announced - the control it lands on says what it is, and a live-region message on every Tab would be noise.
role, aria-expanded, aria-controls and aria-activedescendant wiring. An embedded panel (inline) supplies aria-expanded and aria-controls itself, because it is permanently expanded and Base UI omits both in that case.role="listbox" in drill and columns, role="tree" in mode="tree". In columns mode the container around the panes is a role="group" named by labels.columnsLabel, and inside it exactly ONE pane is the listbox - the deepest, active one, which is the only pane Base UI owns. Every pane behind it is a role="group" named by its parent. So a screen reader hears levels of one control rather than several competing lists, and there is never a second listbox for the arrow keys to be ambiguous about.labels.rootLevel at the root. The popup is named too, since Base UI gives it role="dialog" once the search input lives inside it.nav named by labels.breadcrumbLabel. Its ancestors are buttons that navigate; the last segment is the level currently being listed, so it is not pressable and carries aria-current="page".role="option" allows a far narrower set of attributes than role="treeitem", so the same row exposes different things depending on where it is rendered:role, no tabIndex, aria-hidden. A focusable element inside a role="option" row is a nested-interactive violation, and neither could take focus in any case, because focus stays in the search input. → is the keyboard path into a branch and ← the way back out. The tree row itself carries aria-expanded, so its expander announcing the same state again would be noise.aria-hidden, so nothing is announced twice. Once something inside the branch is selected the visible number switches to that count and takes the accent color, and the name says so in words - "Person, 24 items, 3 selected, submenu" - because a color is not an announcement.CascaderStatus is the only live region. It announces level changes, a return to the root, tree expand and collapse, result counts, and the two presses whose effect is otherwise invisible: a pick refused past max (labels.maxReachedAnnouncement) and a cascade commit's fan-out over a subtree that is mostly off screen (labels.cascadeAnnouncement). It is built on Base UI's Combobox.Status so it inherits the initial text mutation that makes Safari and VoiceOver read the first announcement at all.aria-level tree mode gives the same rows, so the spoken numbering and the ARIA numbering can never disagree by one.aria-setsize. It is never selectable, and its retry affordance is plain text rather than a button.aria-hidden. The load state is spoken once, by CascaderStatus, which announces loading, loadingMore and error ahead of any result count - so a level that is still fetching is never announced as having no results. searchingAnnouncement is announcement-only, named apart from loadingMore because a search is not the next page of anything.labels.keyboardHint for the current mode AND writing direction - the level keys mirror in RTL, so the hint names the mirrored pair there - and it is the only thing that advertises the level keys; nothing on screen does.role="group" named by labels.actionsLabel, so its buttons do not read as more options, and a submenu trigger appends labels.submenuAffordance so it is not announced identically to a plain command. The footer is a deterministic Tab destination: CascaderPanel owns the panel's tab order and steps over the scroll area's viewport, which makes itself tabbable whenever a level overflows, so the number of presses between the search field and the commands does not change with the length of the list, the mode, or whether the panel is embedded. The flyout is a real popover with its own focus management, reachable by Tab from the footer row. See .cascade carries aria-checked="mixed" - one of the four attributes role="option" allows on top of the globals, and one role="treeitem" takes too. It is only ever set when it is mixed: a plain selected row already says so with aria-selected, and two selection attributes on one row is noise. Columns-trail rows are role="button", which allows neither, so for those the state is spelled out in the accessible name via labels.partiallySelectedState.role="toolbar" WHENEVER it holds chips, and every remove button carries labels.removeChip(label). An icon-only remove button with no name is announced as "button", once per selection. Base UI drops the role on an empty selection, where there is nothing to arrow between and the container is showing its placeholder; the labels.chipsLabel name stays either way.aria-disabled rather than removed from the accessibility tree.dir="rtl". The check gutter and the check itself need no mirror: both are driven by --cascader-row-inset through logical properties. See .labels, so the accessible surface translates alongside the visible one. See for the full key list.Each of these is a place where the obvious thing is the wrong thing, so none of them is an oversight to be fixed.
tabIndex, no focusable rows, no per-row tab stop. This is a combobox: the field keeps focus and the list is addressed through aria-activedescendant, which is also what lets typing keep working while the highlight moves.CascaderSeparator is decorative. A listbox may not own a role="separator", and a run that needs separating for a screen reader needs a CascaderGroup, not a line.CascaderEmpty has no live region of its own. It would be a second announcement of what CascaderStatus has already said, so an empty level is announced once rather than twice.role="option" row is a nested-interactive violation. The retry inside CascaderEmpty IS a real button, since that element sits outside the listbox - and the footer flyout is the one place a real menu with real focus management lives inside the popup, which is exactly why it is a popover rather than part of the list.indicator={false} removes only the visual check. aria-selected is Base UI's and stays on every row in every mode."use client"
import { useState } from "react"
import {
Cascader,
CascaderContent,
CascaderEmpty,
CascaderList,
CascaderPanel,
CascaderStatus,
CascaderTrigger,
} from "@/components/byosui/cascader/cascader"
import { CascaderItems } from "@/components/byosui/cascader/cascader-item"
import {
CascaderBreadcrumb,
CascaderInput,
CascaderNav,
CascaderValue,
} from "@/components/byosui/cascader/cascader-nav"
import type { CascaderNode } from "@/components/byosui/cascader/cascader-types"
import { Button } from "@/components/ui/button"
import { AtSignIcon, CalendarClockIcon, HashIcon, SquareCheckIcon, TypeIcon, UserIcon, UsersIcon } from 'lucide-react'
const textIcon = (
<TypeIcon className="size-4" />
)
const hashIcon = (
<HashIcon className="size-4" />
)
const usersIcon = (
<UsersIcon className="size-4" />
)
const mailIcon = (
<AtSignIcon className="size-4" />
)
const checkIcon = (
<SquareCheckIcon className="size-4" />
)
const clockIcon = (
<CalendarClockIcon className="size-4" />
)
const userIcon = (
<UserIcon className="size-4" />
)
const attributes: CascaderNode[] = [
{ value: "record-id", label: "Record ID", icon: hashIcon },
{
value: "person",
label: "Person",
icon: usersIcon,
count: 24,
children: [
{ value: "person.name", label: "Name", icon: textIcon },
{
value: "person.email",
label: "Email addresses",
icon: mailIcon,
keywords: ["mail"],
},
{ value: "person.title", label: "Job title", icon: textIcon },
{
value: "person.company",
label: "Company",
icon: usersIcon,
count: 8,
children: [
{
value: "person.company.name",
label: "Company name",
icon: textIcon,
},
{ value: "person.company.domain", label: "Domain", icon: textIcon },
{
value: "person.company.employees",
label: "Employees",
icon: hashIcon,
},
],
},
],
},
{
value: "email",
label: "Primary email address",
icon: mailIcon,
keywords: ["mail"],
},
{ value: "user-id", label: "User ID", icon: textIcon },
{
value: "workspaces",
label: "Workspaces",
icon: usersIcon,
count: 9,
children: [
{ value: "workspaces.name", label: "Name", icon: textIcon },
{ value: "workspaces.plan", label: "Plan", icon: textIcon },
{ value: "workspaces.seats", label: "Seats", icon: hashIcon },
],
},
{
value: "next-task",
label: "Next due task",
icon: checkIcon,
count: 1,
children: [{ value: "next-task.due", label: "Due date", icon: clockIcon }],
},
{ value: "created-at", label: "Created at", icon: clockIcon },
{ value: "created-by", label: "Created by", icon: userIcon },
]
export function Pattern() {
const [value, setValue] = useState("")
return (
<div className="flex w-full justify-center p-4">
<Cascader items={attributes} value={value} onValueChange={setValue}>
<CascaderTrigger
aria-label="Attribute"
render={
<Button
variant="outline"
className="w-72 justify-between gap-2 font-normal"
/>
}
>
<CascaderValue placeholder="Select an attribute" maxSegments={3} />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderInput />
</CascaderNav>
<CascaderBreadcrumb />
<CascaderEmpty />
<CascaderList>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
</div>
)
}
"use client"
import { useState } from "react"
import {
Cascader,
CascaderContent,
CascaderEmpty,
CascaderList,
CascaderPanel,
CascaderStatus,
CascaderTrigger,
useCascaderSelection,
} from "@/components/byosui/cascader/cascader"
import {
CascaderAction,
CascaderFooter,
} from "@/components/byosui/cascader/cascader-footer"
import { CascaderItems } from "@/components/byosui/cascader/cascader-item"
import {
CascaderBreadcrumb,
CascaderInput,
CascaderNav,
CascaderValue,
} from "@/components/byosui/cascader/cascader-nav"
import type { CascaderNode } from "@/components/byosui/cascader/cascader-types"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
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'
/* -------------------------------------------------------------------------- */
/* Area icons */
/* -------------------------------------------------------------------------- */
/**
* One icon per row, declared once here and referenced from the data below.
*
* The first level names an AREA of the product, so every root carries the thing
* it governs: a card for billing, a globe for domains, a key for secrets. The
* second level names an ACTION, so every leaf carries the verb instead. Handing
* all eight areas the same shield and all twenty-four permissions the same key
* made the icon column pure decoration - it repeated what the label already
* said and told you nothing about the row you were about to tick.
*/
const billingIcon = (
<CreditCardIcon className="size-4" />
)
const membersIcon = (
<UsersIcon className="size-4" />
)
const projectsIcon = (
<FolderIcon className="size-4" />
)
const deploymentsIcon = (
<RocketIcon className="size-4" />
)
const domainsIcon = (
<GlobeIcon className="size-4" />
)
const secretsIcon = (
<KeyIcon className="size-4" />
)
const auditIcon = (
<ClipboardListIcon className="size-4" />
)
const supportIcon = (
<LifeBuoyIcon className="size-4" />
)
/* -------------------------------------------------------------------------- */
/* Action icons */
/* -------------------------------------------------------------------------- */
/**
* `viewIcon` is the one glyph that repeats, and the repetition is the point:
* every "View ..." permission is the same verb, so giving each of them its own
* mark would invent a distinction the model does not have. Everything else is
* spent on a verb that appears once.
*/
const viewIcon = (
<EyeIcon className="size-4" />
)
const editIcon = (
<PencilIcon className="size-4" />
)
const deleteIcon = (
<Trash2Icon className="size-4" />
)
const exportIcon = (
<DownloadIcon className="size-4" />
)
const inviteIcon = (
<UserPlusIcon className="size-4" />
)
const removeMemberIcon = (
<UserMinusIcon className="size-4" />
)
const roleIcon = (
<UserRoundCogIcon className="size-4" />
)
const subscriptionIcon = (
<RepeatIcon className="size-4" />
)
const refundIcon = (
<BanknoteIcon className="size-4" />
)
const transferIcon = (
<ArrowLeftRightIcon className="size-4" />
)
const promoteIcon = (
<CloudUploadIcon className="size-4" />
)
/**
* The counter-clockwise arrow belongs HERE rather than in the footer: rolling a
* deployment back really does restore a previous state, which is exactly what
* the glyph promises.
*/
const rollbackIcon = (
<RotateCcwIcon className="size-4" />
)
const attachIcon = (
<LinkIcon className="size-4" />
)
const certificateIcon = (
<ShieldCheckIcon className="size-4" />
)
const rotateIcon = (
<RefreshCwIcon className="size-4" />
)
const replyIcon = (
<CornerUpLeftIcon className="size-4" />
)
/**
* The footer command's mark, and the one icon in the file chosen against an
* alternative rather than from the label.
*
* A counter-clockwise arrow is the other candidate, but it promises to RESTORE
* something, and this command has nothing to restore: it empties the selection
* and leaves you where you started. The circled X says "clear", which is what
* actually happens, and it is the same mark the trigger's clear button carries
* - the two entry points are one command offered twice, so they should not be
* wearing different faces. It also survives the icon switcher intact: all five
* sets draw an X in a circle, where the counter-clockwise family degrades into
* an undo hook in two of them.
*/
const clearIcon = (
<CircleXIcon className="size-4" />
)
const permissions: CascaderNode[] = [
{
value: "billing",
label: "Billing",
icon: billingIcon,
children: [
{ value: "billing.read", label: "View invoices", icon: viewIcon },
{
value: "billing.write",
label: "Manage subscription",
icon: subscriptionIcon,
},
{ value: "billing.refund", label: "Issue refunds", icon: refundIcon },
{ value: "billing.tax", label: "Edit tax details", icon: editIcon },
],
},
{
value: "members",
label: "Members",
icon: membersIcon,
children: [
{ value: "members.read", label: "View members", icon: viewIcon },
{ value: "members.invite", label: "Invite members", icon: inviteIcon },
{
value: "members.remove",
label: "Remove members",
icon: removeMemberIcon,
},
{ value: "members.roles", label: "Assign roles", icon: roleIcon },
],
},
{
value: "projects",
label: "Projects",
icon: projectsIcon,
children: [
{ value: "projects.read", label: "View projects", icon: viewIcon },
{ value: "projects.write", label: "Edit projects", icon: editIcon },
{
value: "projects.transfer",
label: "Transfer projects",
icon: transferIcon,
},
{
value: "projects.delete",
label: "Delete projects",
icon: deleteIcon,
disabled: true,
},
],
},
{
value: "deployments",
label: "Deployments",
icon: deploymentsIcon,
children: [
{ value: "deployments.read", label: "View deployments", icon: viewIcon },
{
value: "deployments.ship",
label: "Promote to production",
icon: promoteIcon,
},
{ value: "deployments.rollback", label: "Roll back", icon: rollbackIcon },
],
},
{
value: "domains",
label: "Domains",
icon: domainsIcon,
children: [
{ value: "domains.read", label: "View domains", icon: viewIcon },
{ value: "domains.attach", label: "Attach a domain", icon: attachIcon },
{
value: "domains.certs",
label: "Manage certificates",
icon: certificateIcon,
},
],
},
{
value: "secrets",
label: "Secrets",
icon: secretsIcon,
children: [
{ value: "secrets.read", label: "Read secrets", icon: viewIcon },
{ value: "secrets.write", label: "Rotate secrets", icon: rotateIcon },
],
},
{
value: "audit",
label: "Audit log",
icon: auditIcon,
children: [
{ value: "audit.read", label: "Read the audit log", icon: viewIcon },
{
value: "audit.export",
label: "Export the audit log",
icon: exportIcon,
},
],
},
{
value: "support",
label: "Support",
icon: supportIcon,
children: [
{ value: "support.read", label: "View tickets", icon: viewIcon },
{ value: "support.reply", label: "Reply to tickets", icon: replyIcon },
],
},
]
/**
* The clear 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
* empty an already empty selection.
*/
function ClearAction() {
const { clear, isEmpty } = useCascaderSelection()
return (
<CascaderAction icon={clearIcon} disabled={isEmpty} onSelect={clear}>
Clear selection
</CascaderAction>
)
}
/**
* Multi-select. Rows render checkboxes, the popup stays open while you pick,
* and `max` caps the selection. Deleting projects is disabled to show that a
* blocked permission stays visible and announced rather than hidden.
*
* The clear control stands exactly where the chevron stood, and it is a SIBLING
* of the trigger rather than something inside it. `CascaderTrigger` renders a
* real `<button>`, so a button nested in it would be invalid HTML, would fail
* the `nested-interactive` axe rule, and would open the popup on the way up
* from its own click. Absolutely positioning it over the trigger's inline end
* buys the same picture and none of that: the press lands on the clear button
* and stops there.
*
* Two details make the swap read as one control rather than two. `showIcon`
* takes the chevron away for exactly as long as the clear button is standing in
* for it, so the two never stack. And `pe-8` reserves the room the chevron used
* to occupy, so a five-permission summary truncates before it reaches the
* button instead of sliding underneath it. Both insets are logical (`end-*`,
* `pe-*`), so the whole arrangement mirrors in RTL.
*
* The clear is then offered a SECOND time in the footer, and the two are
* additive rather than duplicates. The X is the one press out for someone who
* can already see the summary; the footer row is the named one, in front of you
* while the popup is open and a Tab away from the search field now that the
* panel routes Tab past the scroll area, so it is a usable surface rather than a
* decoration. `CascaderFooter` draws the rule
* itself, with a `border-t` that sits exactly on the boundary between the list
* and the footer. An explicit `CascaderSeparator` in here looked equivalent and
* was not: it stacks its own block margin on top of BOTH containers' padding,
* which measured 12px above the rule against 6px below, where the panel's own
* rhythm is 4px after the search field and 0 between rows. The border needs no
* arithmetic to be symmetric, because it is the boundary rather than a child of
* one side of it.
*/
export function Pattern() {
const [value, setValue] = useState<string[]>([])
const hasSelection = value.length > 0
return (
<div className="flex w-full justify-center p-4">
<Cascader
multiple
max={5}
items={permissions}
value={value}
onValueChange={setValue}
>
<div className="relative w-72">
<CascaderTrigger
aria-label="Permissions"
showIcon={!hasSelection}
render={
<Button
variant="outline"
className={cn(
/* `transition-colors`, not the Button's own `transition-all`:
the gutter below toggles `pe-8`, and `all` animates that
padding over 150ms, so clearing slid the label and chevron
sideways under the vanishing clear button. Colour still
eases for hover and focus. */
"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([])}
/* `transition-none`: this button only ever appears and
disappears, so there is no state for the Button's inherited
`transition-all` to ease between - it only smeared the exit. */
className="absolute end-1 top-1/2 -translate-y-1/2 transition-none"
>
<XIcon />
</Button>
) : null}
</div>
<CascaderContent className="w-80">
<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>
<ClearAction />
</CascaderFooter>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
</div>
)
}
"use client"
import { useState, type ReactNode } from "react"
import { Badge } from "@/components/byosui/badge"
import {
Cascader,
CascaderContent,
CascaderPanel,
CascaderStatus,
CascaderTrigger,
useCascaderSelection,
} from "@/components/byosui/cascader/cascader"
import { CascaderColumns } from "@/components/byosui/cascader/cascader-columns"
import {
CascaderInput,
CascaderNav,
} from "@/components/byosui/cascader/cascader-nav"
import type { CascaderNode } from "@/components/byosui/cascader/cascader-types"
import { IconTile } from "@/components/byosui/icon-tile"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { BookIcon, CodeIcon, CompassIcon, FileTextIcon, FlaskConicalIcon, HeadphonesIcon, LayersIcon, LayoutTemplateIcon, LifeBuoyIcon, MegaphoneIcon, MessageCircleIcon, MicIcon, MonitorIcon, PaletteIcon, RocketIcon, ShieldCheckIcon, SparklesIcon, UsersIcon, VideoIcon, WrenchIcon } from 'lucide-react'
/* -------------------------------------------------------------------------- */
/* Marks */
/* -------------------------------------------------------------------------- */
/**
* One 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. So the tile itself stays
* neutral - `outline` is a plain bordered surface with no tint of its own - and
* only the glyph carries the colour. Sixteen filled swatches down a column would
* read as sixteen alerts.
*
* Three things about those colour classes, in order of how often they bite:
*
* 1. **They are literals.** Tailwind scans source TEXT, so a computed
* `text-${hue}-600` compiles to nothing and the column ships uncoloured.
* 2. **Each is a light/dark PAIR.** A 600 that is comfortable on a white card
* goes muddy on a near-black popup, so the dark half steps up to a 400.
* 3. **Each is `!`, at BOTH levels.** The shared combobox sheet paints the
* highlighted row with `data-highlighted:**:text-accent-foreground`, a
* DESCENDANT selector that reaches the `<path>` inside the svg. Since the
* path draws with `currentColor`, pinning the svg alone changes nothing -
* `text-*!` and `**:text-*!` have to be set together or the accent drains
* out of whichever row the pointer is on, which is the one row being looked
* at.
*
* With sixteen libraries the hues also have to survive being READ IN ORDER, so
* neighbours in the list are neighbours nowhere on the wheel: no two adjacent
* rows sit in the same family, which is what stops "teal, emerald, lime" from
* arriving as one long green smear when the column scrolls past.
*
* The glyph carries no `size-*`. `IconTile` sizes its children through
* `--icon-tile-icon-size`, and any `size-` class on the svg opts that child out
* of the tile's scale.
*/
function LibraryTile({ children }: { children: ReactNode }) {
return (
<IconTile variant="outline" size="xs">
{children}
</IconTile>
)
}
const libraryTiles = {
start: (
<LibraryTile>
<RocketIcon className="text-sky-600! **:text-sky-600! dark:text-sky-400! dark:**:text-sky-400!" />
</LibraryTile>
),
design: (
<LibraryTile>
<PaletteIcon className="text-violet-600! **:text-violet-600! dark:text-violet-400! dark:**:text-violet-400!" />
</LibraryTile>
),
engineering: (
<LibraryTile>
<CodeIcon className="text-indigo-600! **:text-indigo-600! dark:text-indigo-400! dark:**:text-indigo-400!" />
</LibraryTile>
),
podcast: (
<LibraryTile>
<MicIcon className="text-rose-600! **:text-rose-600! dark:text-rose-400! dark:**:text-rose-400!" />
</LibraryTile>
),
customers: (
<LibraryTile>
<UsersIcon className="text-emerald-600! **:text-emerald-600! dark:text-emerald-400! dark:**:text-emerald-400!" />
</LibraryTile>
),
webinars: (
<LibraryTile>
<MonitorIcon className="text-amber-600! **:text-amber-600! dark:text-amber-400! dark:**:text-amber-400!" />
</LibraryTile>
),
releases: (
<LibraryTile>
<MegaphoneIcon className="text-fuchsia-600! **:text-fuchsia-600! dark:text-fuchsia-400! dark:**:text-fuchsia-400!" />
</LibraryTile>
),
labs: (
<LibraryTile>
<SparklesIcon className="text-cyan-600! **:text-cyan-600! dark:text-cyan-400! dark:**:text-cyan-400!" />
</LibraryTile>
),
courses: (
<LibraryTile>
<BookIcon className="text-orange-600! **:text-orange-600! dark:text-orange-400! dark:**:text-orange-400!" />
</LibraryTile>
),
workshops: (
<LibraryTile>
<WrenchIcon className="text-teal-600! **:text-teal-600! dark:text-teal-400! dark:**:text-teal-400!" />
</LibraryTile>
),
templates: (
<LibraryTile>
<LayoutTemplateIcon className="text-purple-600! **:text-purple-600! dark:text-purple-400! dark:**:text-purple-400!" />
</LibraryTile>
),
security: (
<LibraryTile>
<ShieldCheckIcon className="text-green-600! **:text-green-600! dark:text-green-400! dark:**:text-green-400!" />
</LibraryTile>
),
community: (
<LibraryTile>
<MessageCircleIcon className="text-pink-600! **:text-pink-600! dark:text-pink-400! dark:**:text-pink-400!" />
</LibraryTile>
),
playbooks: (
<LibraryTile>
<CompassIcon className="text-yellow-600! **:text-yellow-600! dark:text-yellow-400! dark:**:text-yellow-400!" />
</LibraryTile>
),
support: (
<LibraryTile>
<LifeBuoyIcon className="text-blue-600! **:text-blue-600! dark:text-blue-400! dark:**:text-blue-400!" />
</LibraryTile>
),
research: (
<LibraryTile>
<FlaskConicalIcon className="text-lime-600! **:text-lime-600! dark:text-lime-400! dark:**:text-lime-400!" />
</LibraryTile>
),
}
/** The second column is one kind of thing all the way down, so it gets one mark. */
const collectionIcon = (
<LayersIcon className="size-4" />
)
/**
* The leaf marks, and the one place in this file where a colour is deliberately
* NOT pinned.
*
* These say what a row IS - watch it, listen to it, read it - and the SHAPE
* already says it. Colour would be a second encoding of the same fact, so the
* glyph inherits `text-muted-foreground` from the row's icon slot and is left to
* follow the highlight like every other muted thing on the row. The trap in the
* tiles above is only a trap when the colour carries meaning the shape does not.
*/
const kindIcons = {
video: (
<VideoIcon className="size-4" />
),
audio: (
<HeadphonesIcon className="size-4" />
),
article: (
<FileTextIcon className="size-4" />
),
}
/**
* The runtime chip. One component, both surfaces, one variant for every row.
*
* `info-outline` rather than plain `outline`: the plain one is `bg-transparent`
* in light mode, so the row highlight washes straight through the chip and the
* runtime lands on a tinted band instead of on its own surface. The semantic
* outline variants are `bg-background`, an opaque plate the highlight cannot
* reach, and they spend their colour on the text rather than on a fill.
*
* ONE 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
* already carries - what kind of thing it is, how long it takes - are spoken by
* the leading glyph and by the number itself.
*
* `text-info-foreground!` re-states the variant's own colour so the highlighted
* row cannot repaint it. Only ONE level of pin is needed here, unlike the
* library tiles above: the chip holds text, not an svg, so there is no `<path>`
* further down drawing itself in `currentColor`.
*
* Default `size`, not `sm`. At the default the chip is `h-5` with `text-xs`,
* which is exactly the line box of a `text-sm` row, so the runtime reads at the
* same weight as the title beside it without making a single row taller.
*/
function LengthBadge({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<Badge
variant="info-outline"
className={cn("text-info-foreground! shrink-0 tabular-nums", className)}
>
{children}
</Badge>
)
}
/* -------------------------------------------------------------------------- */
/* Data */
/* -------------------------------------------------------------------------- */
type MediaKind = keyof typeof kindIcons
interface Media {
kind: MediaKind
/** Runtime for video and audio, reading time for an article. */
length: string
}
/**
* `[title, kind, length]`. One line per item on purpose: a forty-four-episode
* season written as forty-four objects is the same data spread 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 },
})),
}
}
const libraries: CascaderNode<Media>[] = [
{
value: "start",
label: "Getting started",
icon: libraryTiles.start,
children: [
collection("start", "Install and setup", [
["Install the CLI", "video", "6:12"],
["Your first component", "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 primitive", "video", "11:20"],
["Slots and data attributes", "article", "9 min"],
["Composition over props", "video", "14:05"],
["Controlled or uncontrolled", "article", "6 min"],
["Server and client boundaries", "video", "12:38"],
]),
collection("start", "Migration guides", [
["Moving off a legacy kit", "video", "16:40"],
["Codemods in practice", "article", "8 min"],
["Mapping the old tokens", "article", "5 min"],
["Migration office hours", "audio", "42:18"],
]),
],
},
{
value: "design",
label: "Design system",
icon: libraryTiles.design,
children: [
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"],
]),
],
},
{
value: "engineering",
label: "Engineering",
icon: libraryTiles.engineering,
children: [
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"],
]),
],
},
{
value: "podcast",
label: "Podcast archive",
icon: libraryTiles.podcast,
children: [
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 the web", "audio", "51:47"],
["Pricing, two years later", "audio", "46:22"],
["The registry as a product", "audio", "58:19"],
["Designers 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 Figma cannot tell you", "audio", "57:22"],
["Naming things, again", "audio", "44:18"],
["Open source economics", "audio", "1:02:14"],
["The two-person design team", "audio", "39:47"],
["Migrating a decade of CSS", "audio", "55:03"],
["Accessibility as a default", "audio", "47:36"],
["When to fork a library", "audio", "43:29"],
["Type systems for designers", "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"],
["Design 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 CSS", "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 library", "audio", "38:12"],
["The name we nearly used", "audio", "33:47"],
["Our first contributor", "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"],
]),
],
},
{
value: "customers",
label: "Customer stories",
icon: libraryTiles.customers,
children: [
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 design systems, one app", "audio", "44:55"],
]),
collection("customers", "Startups", [
["Zero to launch in five weeks", "video", "15:26"],
["One engineer, forty screens", "video", "12:03"],
["Choosing boring on purpose", "article", "6 min"],
["The first hundred users", "audio", "37:41"],
]),
collection("customers", "Agencies", [
["Reusing a kit across clients", "video", "19:57"],
["Handover that survives", "article", "8 min"],
["Pitching a system, not a page", "audio", "35:12"],
]),
],
},
{
value: "webinars",
label: "Webinars",
icon: libraryTiles.webinars,
children: [
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"],
]),
],
},
{
value: "releases",
label: "Release notes",
icon: libraryTiles.releases,
children: [
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"],
]),
],
},
{
value: "labs",
label: "Labs",
icon: libraryTiles.labs,
children: [
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"],
]),
],
},
{
value: "courses",
label: "Courses",
icon: libraryTiles.courses,
children: [
collection("courses", "Beginner track", [
["What a component library 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"],
]),
],
},
{
value: "workshops",
label: "Workshops",
icon: libraryTiles.workshops,
children: [
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"],
]),
],
},
{
value: "templates",
label: "Templates",
icon: libraryTiles.templates,
children: [
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"],
]),
],
},
{
value: "security",
label: "Security notes",
icon: libraryTiles.security,
children: [
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"],
]),
],
},
{
value: "community",
label: "Community",
icon: libraryTiles.community,
children: [
collection("community", "Show and tell", [
["Built in a weekend", "video", "12:36"],
["A design system 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"],
]),
],
},
{
value: "playbooks",
label: "Playbooks",
icon: libraryTiles.playbooks,
children: [
collection("playbooks", "Rollout", [
["Piloting with one team", "article", "9 min"],
["Winning over the sceptics", "audio", "34:41"],
["Measuring adoption", "video", "18:59"],
]),
collection("playbooks", "Governance", [
["Who owns a component", "article", "8 min"],
["Requesting a new primitive", "video", "13:24"],
["Deprecating with notice", "article", "6 min"],
]),
collection("playbooks", "Handbooks", [
["The design system handbook", "article", "21 min"],
["Engineering handbook", "article", "18 min"],
["Onboarding in a week", "video", "25:37"],
]),
],
},
{
value: "support",
label: "Support clinic",
icon: libraryTiles.support,
children: [
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"],
]),
],
},
{
value: "research",
label: "Research",
icon: libraryTiles.research,
children: [
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 has to answer 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 with no lookup back into `libraries`. The
* runtime keeps the same chip it wore in the row: the thing that identified an
* item in the list is the thing that identifies it once chosen, down to the
* variant, which is why both surfaces render `LengthBadge` rather than two
* badges that would drift apart the first time one of them is tweaked.
*
* With no label above the control, the empty state is the only thing naming the
* field, so it says what a pick DOES ("Select an item to feature") instead of
* echoing a heading that is no longer there.
*
* `min-w-0` on both texts and `truncate` on each is what lets the title give
* way before the collection name does. The chip is `shrink-0` so a long title
* never squeezes a runtime into an ellipsis.
*/
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">
{first.data ? kindIcons[first.data.kind] : null}
</span>
<span className="min-w-0 truncate font-medium">{first.label}</span>
<span className="text-muted-foreground min-w-0 truncate text-xs">
{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, and stepping back is a glance
* rather than a Back button. Arrow Left and Right move between columns, Up and
* Down within one.
*
* 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 seasons and side 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, so losing your place in
* a long list of episodes never costs you the library you came from. A demo
* where every column fits would show the layout and hide the reason for it.
*
* Each column earns its width. The first is identity: a neutral `IconTile` with
* a coloured glyph, one hue per library. The second is structure: one repeated
* stack mark, because a column of one kind of thing does not need sixteen
* different marks to say so. The third is the payload: a type glyph on the
* lead, the title, and the runtime as a trailing chip, all on ONE line.
*
* Branch rows do NOT get a chip. `CascaderItem` already draws a child count
* next to the chevron, so a chip there would put two numbers on one row with
* nothing to tell them apart. The count answers "how much is in here" and the
* chip answers "how long is this", and only leaves have the second question.
*
* `columnWidth` runs a little over the primitive's 220px default rather than
* well under it. The leaf row spends real width on a leading glyph and on a
* trailing chip that now sits at the badge's default size - `text-xs`, not the
* `sm` variant's 10px - and a runtime like "1:12:08" is seven glyphs wide at
* that size. At the 180 this example used to pass, a title had about a hundred
* pixels left and truncated after two words.
*
* `maxHeight` is a cap, not a height, and it moved up with the chip. At the
* badge's default size the chip is `h-5`, which is exactly the line box of a
* `text-sm` row - Nova and its siblings do not grow by a pixel - but it stands
* a few pixels above the `text-xs` line box of the tighter styles, so their
* rows do grow. Holding the old 260 would have quietly cost those styles a
* visible row, so the cap moves with the row rather than the other way round.
*
* `w-auto min-w-0` on the content is load-bearing. `CascaderContent` carries
* `min-w-(--anchor-width)` so a single-column popup lines up under its trigger,
* and in columns mode that floor stops the popup shrinking to the width its
* columns actually need. Clearing it lets the panel size to its content in both
* directions.
*
* The wrapper pins itself to the TOP of the preview surface (`self-start`).
* Both the docs frame and the catalog card centre their child vertically, and
* a wide columns popup changes the measured height as panes open, so a centred
* demo jumps while you navigate. `pt-6` keeps the top edge deliberate rather
* than flush. `items-center` stays: in a column that is horizontal centring.
*/
export function Pattern() {
const [value, setValue] = 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. `customLabel ?? default` treats
// null as "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"
render={
<Button
variant="outline"
className="w-full justify-between gap-2 font-normal"
/>
}
>
<MediaValue />
</CascaderTrigger>
<CascaderContent className="w-auto min-w-0">
<CascaderPanel>
<CascaderNav>
<CascaderInput placeholder="Search this column..." />
</CascaderNav>
{/* Kept: three panes side by side have to agree on a height, or the
popup grows and shrinks as you move between columns of very
different lengths. Each pane still scrolls inside it. */}
<CascaderColumns columnWidth={240} maxHeight={288} />
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
</div>
</div>
)
}
"use client"
import { useState } from "react"
import {
Cascader,
CascaderContent,
CascaderEmpty,
CascaderList,
CascaderPanel,
CascaderStatus,
CascaderTrigger,
useCascaderSelection,
} from "@/components/byosui/cascader/cascader"
import { CascaderItems } from "@/components/byosui/cascader/cascader-item"
import {
CascaderInput,
CascaderNav,
} from "@/components/byosui/cascader/cascader-nav"
import type { CascaderNode } from "@/components/byosui/cascader/cascader-types"
import { Button } from "@/components/ui/button"
import { CompassIcon, GlobeIcon } from 'lucide-react'
const globeIcon = (
<GlobeIcon className="size-4" />
)
const compassIcon = (
<CompassIcon className="size-4" />
)
/**
* ISO 3166-1 alpha-2 to its regional indicator pair, so "DE" becomes the German
* flag. Deriving it keeps the table below down to a code and a name per country
* rather than sixty pasted emoji, 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) {
return String.fromCodePoint(
...[...iso2].map((letter) => letter.charCodeAt(0) + 127397)
)
}
/**
* The flag goes in the row's icon slot, not into the label. In a multi-select
* tree the checkbox leads the row, and the icon is the very next column, so the
* flag lands exactly where the globe and the compass sit one and two levels up
* and every depth keeps one label column.
*
* Fixed width on purpose. Windows Chrome ships no regional indicator glyphs and
* falls 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, and
* announcing the flag as well would just read it twice.
*/
function flagIcon(iso2: string) {
return (
<span aria-hidden="true" className="w-5 text-center text-base leading-none">
{flagOf(iso2)}
</span>
)
}
/** `[iso2, name]`. The flag is derived from the code, see `flagOf`. */
type CountryTuple = readonly [string, string]
/** `[slug, label, countries]`. */
type RegionTuple = readonly [string, string, readonly CountryTuple[]]
/** `[slug, label, regions]`. */
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"],
],
],
[
"central",
"Central Europe",
[
["PL", "Poland"],
["CZ", "Czechia"],
["HU", "Hungary"],
["RO", "Romania"],
],
],
],
],
[
"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"],
],
],
[
"central",
"Central America",
[
["CR", "Costa Rica"],
["PA", "Panama"],
["GT", "Guatemala"],
["DO", "Dominican Republic"],
],
],
],
],
[
"apac",
"Asia Pacific",
[
[
"east",
"East Asia",
[
["JP", "Japan"],
["KR", "South Korea"],
["CN", "China"],
["TW", "Taiwan"],
["HK", "Hong Kong"],
],
],
[
"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"],
["BH", "Bahrain"],
["OM", "Oman"],
],
],
[
"levant",
"Levant",
[
["IL", "Israel"],
["JO", "Jordan"],
["LB", "Lebanon"],
],
],
],
],
[
"africa",
"Africa",
[
[
"north",
"North Africa",
[
["MA", "Morocco"],
["EG", "Egypt"],
["TN", "Tunisia"],
["DZ", "Algeria"],
],
],
[
"west",
"West Africa",
[
["NG", "Nigeria"],
["GH", "Ghana"],
["SN", "Senegal"],
["CI", "Cote d'Ivoire"],
],
],
[
"east",
"East Africa",
[
["KE", "Kenya"],
["TZ", "Tanzania"],
["ET", "Ethiopia"],
["UG", "Uganda"],
],
],
[
"south",
"Southern Africa",
[
["ZA", "South Africa"],
["BW", "Botswana"],
["NA", "Namibia"],
],
],
],
],
[
"oceania",
"Oceania",
[
[
"anz",
"Australia and New Zealand",
[
["AU", "Australia"],
["NZ", "New Zealand"],
],
],
[
"pacific",
"Pacific Islands",
[
["FJ", "Fiji"],
["PG", "Papua New Guinea"],
["NC", "New Caledonia"],
["PF", "French Polynesia"],
],
],
],
],
]
const zones: CascaderNode[] = CONTINENTS.map(
([continent, continentLabel, regions]) => ({
value: continent,
label: continentLabel,
icon: globeIcon,
children: regions.map(([region, regionLabel, countries]) => ({
value: `${continent}.${region}`,
label: regionLabel,
icon: compassIcon,
children: countries.map(([iso2, name]) => ({
// Exactly three dot separated segments, and no segment carries a dot of
// its own. That is what lets the summary below tell a country apart
// from the region and the continent above it by counting separators.
value: `${continent}.${region}.${iso2.toLowerCase()}`,
label: name,
icon: flagIcon(iso2),
// So the search field finds Germany on "DE" as well as on the name.
keywords: [iso2],
})),
})),
})
)
/**
* A headless trigger, because a cascade cannot be summarised by listing what is
* in the value.
*
* Two things go wrong if you try. The count is inflated: ticking Western Europe
* commits the region AND its six countries, so the honest number of shipping
* destinations is the leaves alone. And the labels are the wrong ones: the six
* countries under a fully committed region were never pressed individually, and
* naming them back at the user hides the one press that actually happened.
*
* `useCascaderSelection` hands back one ancestor chain per selected node, which
* is enough to fix both without parsing a single 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 too is already spoken for by
* that parent - so six countries collapse back into "Western Europe" and a lone
* country still speaks for itself.
*
* ## What the line says, and why it is ONE size
*
* It used to read "6 countries" in the control's own type with the names beside
* it in `text-xs` muted, which is the trigger equivalent of a footnote: the
* user's actual choice was set in the smallest type on the control, under the
* one fact they could have worked out themselves. So the sizes are gone and
* the order is reversed. The line now 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. The names alone cannot
* say how big the selection is once a region stands in for six countries; the
* count alone cannot say which six. The deepest common ancestor was the other
* candidate and it was rejected: it reads beautifully for one branch and
* collapses to "everywhere" the moment a second continent is ticked, which is
* exactly the selection worth describing.
*
* The "+N" is gone with the small type. A truncated list already says there is
* more, and a "+4" sitting next to a "12 countries" was two numbers counting
* different things a few pixels apart. So the names take `min-w-0 truncate` and
* give way, and the count is `shrink-0` and survives any trigger width - the
* middle dot travels with it so the line never ends on a dangling separator.
*/
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 the guard
// comes before anything reads the last node of one.
const chains = paths.filter((path) => path.length > 0)
// `isEmpty` covers the usual case; `chains` covers the one where every value
// in the selection is a stale id the tree can no longer resolve, which would
// otherwise render a confident "0 countries" beside nothing at all.
if (isEmpty || chains.length === 0) {
return (
<span className="text-muted-foreground truncate">
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="text-muted-foreground shrink-0">
· {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 has to demote the region
* from fully selected to partial rather than leaving a checkbox that lies. The
* comment deliberately avoids the shorter phrasing there: `verify-registry`
* scans for `from "..."` to collect a file's imports, and a quoted word after
* `from` inside a COMMENT is read as an undeclared dependency. Continent,
* region, country is the shallowest tree where that is worth showing. At two
* levels a branch is full or empty at a glance and the partial state carries
* nothing.
*
* Three things make the selection work together. `cascade` propagates a commit
* over the pressed node's subtree and reconciles its ancestors afterwards.
* `selectable="any"` is what lets a branch be pressed at all - without it there
* is nothing for a cascade to start from. And tree mode keeps the whole shape
* visible, which is the only way a partial state means anything.
*
* The popup is the reason this exists as its own example rather than as a
* variant of an inline one. A tree is the single mode whose height is under the
* USER's control: every disclosure adds or removes rows while the panel is
* already anchored to the trigger, so expanding Africa asks the popup to
* re-measure on a press that had nothing to do with opening it. Get the bound
* wrong and the last rows land off the bottom of the screen, or the popup flips
* to the other side of the trigger mid-interaction.
*
* Which is the whole height contract: `CascaderList` takes NO `maxHeight` here.
* Inline there is no positioner, nothing publishes `--available-height`, and an
* explicit cap is the only bound in existence - which is why this example
* carried `maxHeight={260}` while it was inline. Under `CascaderContent` the
* positioner publishes the distance from the trigger to the edge of the
* viewport, the list bounds itself at `min(--available-height, 24rem)`, and the
* scroll area inside absorbs everything past that. The popup stops at the
* viewport, and expanding a branch scrolls the rows rather than growing it.
*
* It opens with nothing picked and Western Europe already expanded, so the
* first country ticked is also the first indeterminate region. The number on a
* branch is the primitive's own: children until something inside is selected,
* then the selected total in the accent colour, which is the second reading of
* "some" for anyone who cannot see the box's third state.
*
* The heading over the control is gone. It said "Countries this store ships
* to" above a trigger whose empty state already says "Select shipping
* destinations" and whose filled state names what was picked, so it was the
* same sentence twice with the field between them. A real form has a `Field`
* and a `Label` doing accessible-name work; a one-control example has neither,
* and a bare `<p>` is decoration that only pretends to.
*/
export function Pattern() {
const [value, setValue] = useState<string[]>([])
const [expanded, setExpanded] = useState<string[]>([
"europe",
"europe.western",
])
return (
<div className="flex w-full justify-center p-4">
<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"
render={
<Button
variant="outline"
className="w-full justify-between gap-2 font-normal"
/>
}
>
<ZoneValue />
</CascaderTrigger>
{/* WIDER THAN THE TRIGGER, on purpose, and the one measurement that
fixes the deepest level.
Where a country row's width goes, in nova, with the popup at the
trigger's own 384px: 8px of list padding, then a 6px start inset
plus 32px of indent (two levels at the row's default 16), a 6px
end inset, an 18px expander slot the leaf reserves but cannot
use, a 16px checkbox, a 20px flag and three 8px gaps. 130px of
the 384 is gone before the country name starts, and in a narrow
embed - a catalog card, a phone - the popup falls back to its own
floor and the name is down to about 190px.
`w-80` was that floor, and at this demo's width it was INERT:
320px never beats the `min-w-(--anchor-width)` the popup already
takes from a 384px trigger, so the class changed no pixel anyone
ever saw. `CascaderContent` deliberately does not clamp to the
anchor - a cascade is routinely wider than the control that opens
it - so the fix is to name a width ABOVE the anchor. 416px puts
every one of those 32px into the label column at every depth and
raises the narrow-embed floor by the same amount. It stays
bounded: `max-w-(--available-width)` still clamps it on a small
viewport.
Three alternatives were rejected. A smaller per-depth `indent` is
the tempting one, but `CascaderItem` only takes it from a row you
render yourself, and `CascaderItems`' tree-mode render prop hands
back a node and an index - no `expanded`, no `aria-setsize` or
`aria-posinset`. `depth` still resolves itself off the tree
index, `expanded` has no such fallback, so every branch would
draw a collapsed chevron and the tree would lose its position
metadata. That is accessibility traded for 8px a level. Trimming
the row has nothing left to take: the flag IS the icon slot, the
name IS the label, and the empty expander slot and the leading
checkbox are per-list decisions that keep one label column per
depth. And widening the CONTROL to `max-w-md` buys the same
pixels by changing what the example is about - a normal-width
field with a big tree behind it. */}
<CascaderContent className="w-[26rem]">
<CascaderPanel>
<CascaderNav>
{/* Tree mode never drills, so there is no level to go back to. */}
<CascaderInput showBack={false} />
</CascaderNav>
<CascaderEmpty />
<CascaderList>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
</div>
</div>
)
}
"use client"
import { useCallback, useState } from "react"
import { Badge, type BadgeProps } from "@/components/byosui/badge"
import {
Cascader,
CascaderContent,
CascaderEmpty,
CascaderList,
CascaderPanel,
CascaderStatus,
CascaderTrigger,
} from "@/components/byosui/cascader/cascader"
import { CascaderItems } from "@/components/byosui/cascader/cascader-item"
import {
CascaderBreadcrumb,
CascaderInput,
CascaderNav,
CascaderValue,
} from "@/components/byosui/cascader/cascader-nav"
import type {
CascaderLoadContext,
CascaderLoadResult,
CascaderNode,
} from "@/components/byosui/cascader/cascader-types"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { FileTextIcon, FolderIcon } from 'lucide-react'
const folderIcon = (
<FolderIcon className="size-4" />
)
const fileIcon = (
<FileTextIcon className="size-4" />
)
/* -------------------------------------------------------------------------- */
/* A pretend backend */
/* -------------------------------------------------------------------------- */
/**
* The one fact a row's trailing badge carries.
*
* Every level has a different one - a repository's kind, a release's channel,
* an asset's size - and none of them repeat the numeric child count the row
* already prints, so the badge always adds something the label cannot.
*/
interface RowMeta {
badge: string
/**
* How loudly that fact should read. Set where the row is built, because only
* the level that knows what the string MEANS can say whether it is the
* default pick, a supported alternative, or a download worth a second look.
*/
tone: Tone
}
type Tone = "primary" | "success" | "info" | "warning"
/**
* Tone to chip.
*
* All four are SEMANTIC outline variants. Plain `outline` is `bg-transparent`
* in light mode, so a highlighted row washed straight through the chip; these
* are `bg-background`, an actual fill, and they carry the meaning in their text
* colour rather than in a second element.
*
* `pin` repeats each variant's own text colour with `!`. A highlighted row
* repaints every descendant with `text-accent-foreground`, which would grey the
* chip out under the pointer, the way the primitive pins its own accent count.
*/
const TONE_BADGE: Record<
Tone,
{ variant: BadgeProps["variant"]; pin: string }
> = {
primary: { variant: "primary-outline", pin: "text-primary!" },
success: { variant: "success-outline", pin: "text-success-foreground!" },
info: { variant: "info-outline", pin: "text-info-foreground!" },
warning: { variant: "warning-outline", pin: "text-warning-foreground!" },
}
/** Past this, the size is worth reading twice before you pick the row. */
const LARGE_ASSET_MB = 5
const REPOSITORIES = [
{ name: "reui", kind: "components" },
{ name: "keenicons", kind: "icons" },
{ name: "metronic", kind: "templates" },
{ name: "reui-blocks", kind: "blocks" },
{ name: "reui-icons", kind: "icons" },
{ name: "reui-mcp", kind: "mcp" },
{ name: "reui-templates", kind: "templates" },
{ name: "reui-cli", kind: "cli" },
]
const RELEASES_PER_REPOSITORY = 4
const ASSETS_PER_RELEASE = 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 asset lives under `repository / release / file`, generated on demand. */
function releasesFor(repository: string) {
return Array.from({ length: RELEASES_PER_REPOSITORY }, (_, i) => {
const major = RELEASES_PER_REPOSITORY - i
return {
value: `${repository}/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: ASSETS_PER_RELEASE,
// The newest release is the one you usually want, so it takes the brand
// accent; the rest are supported, not stale, so they read as `success`
// rather than as a greyed-out alternative.
data:
i === 0
? { badge: "latest", tone: "primary" as const }
: { badge: "stable", tone: "success" as const },
}
}) satisfies CascaderNode<RowMeta>[]
}
function filesFor(release: string, offset: number) {
const length = Math.min(PAGE_SIZE, ASSETS_PER_RELEASE - 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: `${release}/asset-${n}`,
label: `asset-${String(n).padStart(2, "0")}.tar.gz`,
icon: fileIcon,
// A size only earns a colour once it changes your mind about the row, so
// the amber starts at the point a download stops being free.
data: {
badge: `${megabytes.toFixed(1)} MB`,
tone:
megabytes >= LARGE_ASSET_MB
? ("warning" as const)
: ("info" 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 registries 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 release and each page of
* assets 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 badge. `renderLabel` replaces
* only the label block, so the count, the chevron and the spinner that takes
* its place all keep working while the badge rides along beside them. A second
* line under the label would push the loading affordance further from the text
* it belongs to and halve the number of rows a level can show.
*
* That badge is a semantic outline variant, picked per row from `TONE_BADGE`.
* The row it sits on turns `bg-accent` under the pointer, so the chip needs a
* fill of its own and a pinned text colour to survive the highlight.
*/
export function Pattern() {
const [value, setValue] = useState("")
const [failNext, setFailNext] = useState(false)
const getChildren = 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 registry is unreachable.")
}
// The root level: the repositories themselves.
if (node === null) {
return {
items: REPOSITORIES.map((repository) => ({
value: repository.name,
label: repository.name,
icon: folderIcon,
hasChildren: true,
count: RELEASES_PER_REPOSITORY,
// A repository's kind is a classification, not a verdict on it, so
// every root row reads at the same weight.
data: { badge: repository.kind, tone: "info" as const },
})),
}
}
// A repository: its releases. One response, no paging.
if (REPOSITORIES.some((repository) => repository.name === node.value)) {
return { items: releasesFor(node.value) }
}
// A release: assets, eight at a time.
const offset = context.cursor ? Number(context.cursor) : 0
const items = filesFor(node.value, offset)
const next = offset + items.length
const more = next < ASSETS_PER_RELEASE
return {
items,
nextCursor: more ? String(next) : undefined,
hasMore: more,
}
},
[failNext]
)
return (
<div className="flex w-full flex-col items-center gap-3 p-4">
<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 badge 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 ? (
<Badge
variant={TONE_BADGE[node.data.tone].variant}
className={cn("shrink-0", TONE_BADGE[node.data.tone].pin)}
>
{node.data.badge}
</Badge>
) : null}
</span>
)}
>
<CascaderTrigger
aria-label="Release asset"
render={
<Button
variant="outline"
className="w-80 justify-between gap-2 font-normal"
/>
}
>
<CascaderValue placeholder="Select a release asset" />
</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="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="text-muted-foreground max-w-80 text-center text-xs text-balance">
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>
)
}
"use client"
import { useState } from "react"
import {
Cascader,
CascaderContent,
CascaderEmpty,
CascaderList,
CascaderPanel,
CascaderStatus,
CascaderTrigger,
} from "@/components/byosui/cascader/cascader"
import {
CascaderAction,
CascaderFooter,
CascaderSubmenu,
CascaderSubmenuContent,
CascaderSubmenuTrigger,
useCascaderSubmenu,
} from "@/components/byosui/cascader/cascader-footer"
import {
CascaderGroup,
CascaderItems,
CascaderLabel,
CascaderSeparator,
} from "@/components/byosui/cascader/cascader-item"
import {
CascaderBreadcrumb,
CascaderInput,
CascaderNav,
CascaderValue,
} from "@/components/byosui/cascader/cascader-nav"
import type { CascaderNode } from "@/components/byosui/cascader/cascader-types"
import { Dropbox } from "@/components/ui/svgs/dropbox"
import { GithubDark } from "@/components/ui/svgs/githubDark"
import { GithubLight } from "@/components/ui/svgs/githubLight"
import { GoogleDrive } from "@/components/ui/svgs/googleDrive"
import { Redis } from "@/components/ui/svgs/redis"
import { Slack } from "@/components/ui/svgs/slack"
import { Stripe } from "@/components/ui/svgs/stripe"
import { Supabase } from "@/components/ui/svgs/supabase"
import { Button } from "@/components/ui/button"
import { AlignLeftIcon, BarChart3Icon, CalendarCheckIcon, CalendarDaysIcon, ClockIcon, CloudDownloadIcon, Columns3Icon, CreditCardIcon, EyeIcon, FileTextIcon, FlagIcon, FlameIcon, GitBranchIcon, HashIcon, LinkIcon, PackageIcon, PaletteIcon, RouteIcon, SlidersHorizontalIcon, SquarePenIcon, TrendingUpIcon, TruckIcon, TypeIcon, UserCheckIcon, UserIcon, UsersIcon, UsersRoundIcon } from 'lucide-react'
/* -------------------------------------------------------------------------- */
/* Group icons */
/* -------------------------------------------------------------------------- */
/**
* A property picker is read by TYPE before it is read by name, so the icon
* column is where the type belongs.
*
* Each group carries the subject it collects and each leaf carries the shape of
* the value behind it: text for a title, a person for an owner, a calendar for
* a due date, a flag for a status, a card for a plan. Every icon is declared
* once here and referenced below, so the data stays a list of properties rather
* than a wall of JSX.
*/
const basicsIcon = (
<FileTextIcon className="size-4" />
)
const peopleIcon = (
<UsersIcon className="size-4" />
)
const datesIcon = (
<CalendarDaysIcon className="size-4" />
)
const workflowIcon = (
<RouteIcon className="size-4" />
)
const metricsIcon = (
<BarChart3Icon className="size-4" />
)
const linksIcon = (
<LinkIcon className="size-4" />
)
const billingIcon = (
<CreditCardIcon className="size-4" />
)
/* -------------------------------------------------------------------------- */
/* Property icons */
/* -------------------------------------------------------------------------- */
const textIcon = (
<TypeIcon className="size-4" />
)
const longTextIcon = (
<AlignLeftIcon className="size-4" />
)
const slugIcon = (
<HashIcon className="size-4" />
)
const personIcon = (
<UserIcon className="size-4" />
)
const approverIcon = (
<UserCheckIcon className="size-4" />
)
const watcherIcon = (
<EyeIcon className="size-4" />
)
const timestampIcon = (
<ClockIcon className="size-4" />
)
const deadlineIcon = (
<CalendarCheckIcon className="size-4" />
)
const shippedIcon = (
<TruckIcon className="size-4" />
)
const statusIcon = (
<FlagIcon className="size-4" />
)
const priorityIcon = (
<FlameIcon className="size-4" />
)
const stageIcon = (
<Columns3Icon className="size-4" />
)
const effortIcon = (
<SlidersHorizontalIcon className="size-4" />
)
const impactIcon = (
<TrendingUpIcon className="size-4" />
)
const repositoryIcon = (
<GitBranchIcon className="size-4" />
)
const designIcon = (
<PaletteIcon className="size-4" />
)
const planIcon = (
<PackageIcon className="size-4" />
)
const seatsIcon = (
<UsersRoundIcon className="size-4" />
)
const noteIcon = (
<SquarePenIcon className="size-4" />
)
const importIcon = (
<CloudDownloadIcon className="size-4" />
)
/**
* Product marks, not icons.
*
* `CascaderAction` takes any ReactNode for `icon`, so each entry can carry the
* logo of the product it names. That is the whole reason the flyout is a list
* of SOURCES rather than a list of field types: seven marks are told apart at a
* glance, where seven variations on a generic glyph are not. The full-colour
* marks need no theme handling; GitHub's is one flat shape, so both files ship
* and `dark:` picks between them at paint time.
*/
const slackLogo = <Slack className="size-4" aria-hidden="true" />
const githubLogo = (
<>
<GithubLight className="size-4 dark:hidden" aria-hidden="true" />
<GithubDark className="hidden size-4 dark:block" aria-hidden="true" />
</>
)
const driveLogo = <GoogleDrive className="size-4" aria-hidden="true" />
const dropboxLogo = <Dropbox className="size-4" aria-hidden="true" />
const supabaseLogo = <Supabase className="size-4" aria-hidden="true" />
const stripeLogo = <Stripe className="size-4" aria-hidden="true" />
const redisLogo = <Redis className="size-4" aria-hidden="true" />
const properties: CascaderNode[] = [
{
value: "basics",
label: "Basics",
icon: basicsIcon,
children: [
{ value: "basics.title", label: "Title", icon: textIcon },
{ value: "basics.summary", label: "Summary", icon: longTextIcon },
{ value: "basics.slug", label: "Slug", icon: slugIcon },
],
},
{
value: "people",
label: "People",
icon: peopleIcon,
children: [
{ value: "people.owner", label: "Owner", icon: personIcon },
{ value: "people.reviewers", label: "Reviewers", icon: approverIcon },
{ value: "people.watchers", label: "Watchers", icon: watcherIcon },
],
},
{
value: "dates",
label: "Dates",
icon: datesIcon,
children: [
{ value: "dates.created", label: "Created at", icon: timestampIcon },
{ value: "dates.due", label: "Due date", icon: deadlineIcon },
{ value: "dates.shipped", label: "Shipped at", icon: shippedIcon },
],
},
{
value: "workflow",
label: "Workflow",
icon: workflowIcon,
children: [
{ value: "workflow.status", label: "Status", icon: statusIcon },
{ value: "workflow.priority", label: "Priority", icon: priorityIcon },
{ value: "workflow.stage", label: "Stage", icon: stageIcon },
],
},
{
value: "metrics",
label: "Metrics",
icon: metricsIcon,
children: [
{ value: "metrics.effort", label: "Effort", icon: effortIcon },
{ value: "metrics.impact", label: "Impact", icon: impactIcon },
],
},
{
value: "links",
label: "Links",
icon: linksIcon,
children: [
{ value: "links.repo", label: "Repository", icon: repositoryIcon },
{ value: "links.design", label: "Design file", icon: designIcon },
],
},
{
value: "billing",
label: "Billing",
icon: billingIcon,
children: [
{ value: "billing.plan", label: "Plan", icon: planIcon },
{ value: "billing.seats", label: "Seats", icon: seatsIcon },
],
},
{ value: "notes", label: "Notes", icon: noteIcon },
]
/**
* The flyout body.
*
* Its own component because `useCascaderSubmenu` has to be called from INSIDE
* the submenu, and `close()` is what makes a command list behave like one: a
* command runs and the list goes away, rather than sitting open as if the
* entries were toggles.
*
* Seven entries is enough that a flat list stops reading as a list and starts
* reading as a wall, so they are split into two named runs. `CascaderGroup`
* rather than a `<div>` with a heading in it: the group is what carries the
* name, so a screen reader says "Apps, group" before the four entries instead
* of reading out a line of text that belongs to nothing. `CascaderSeparator`
* draws the break for everyone else.
*/
function ImportSourceMenu({
onImport,
}: {
onImport: (source: string) => void
}) {
const { close } = useCascaderSubmenu()
const run = (source: string) => () => {
onImport(source)
close()
}
return (
<>
<CascaderGroup className="gap-0.5">
<CascaderLabel>Apps</CascaderLabel>
<CascaderAction icon={slackLogo} onSelect={run("Slack")}>
Slack
</CascaderAction>
<CascaderAction icon={githubLogo} onSelect={run("GitHub")}>
GitHub
</CascaderAction>
<CascaderAction icon={driveLogo} onSelect={run("Google Drive")}>
Google Drive
</CascaderAction>
<CascaderAction icon={dropboxLogo} onSelect={run("Dropbox")}>
Dropbox
</CascaderAction>
</CascaderGroup>
<CascaderSeparator />
<CascaderGroup className="gap-0.5">
<CascaderLabel>Data sources</CascaderLabel>
<CascaderAction icon={supabaseLogo} onSelect={run("Supabase")}>
Supabase
</CascaderAction>
<CascaderAction icon={stripeLogo} onSelect={run("Stripe")}>
Stripe
</CascaderAction>
<CascaderAction icon={redisLogo} onSelect={run("Redis")}>
Redis
</CascaderAction>
</CascaderGroup>
</>
)
}
/**
* A pinned footer, and a flyout that opens to the side.
*
* "The property I want is not in this list, it lives in another product" is a
* normal outcome of a property picker, and the answer to it is a COMMAND rather
* than another row. `CascaderFooter` pins its actions below the list, where
* they stay put while the list scrolls, changes level, or filters down to
* nothing - which is exactly the moment "Import properties from" is most
* useful, and exactly the moment a row inside the list would have disappeared.
*
* `CascaderSubmenu` turns that row into a side-anchored flyout, so the seven
* connected sources do not have to be seven footer rows. It is one level deep
* on purpose: a footer is for commands, and a command list that nests is a menu
* bar in disguise. One command is all this picker needs, and a footer holding a
* single row is still worth having - it is the border and the pinning that make
* the row read as an action rather than an eighth option.
*
* Nothing in the footer joins the option list. It is not in the arrow-key ring,
* it is not filtered by the query, and it never becomes a selection - press
* Escape once to close the flyout and again to close the cascader.
*
* The wrapper centres the demo horizontally (`w-full` + `items-center`, so the
* trigger sits on the middle of the frame whichever surface renders it) and
* pins it to the TOP (`self-start`). Both the docs frame and the catalog card
* centre their child vertically, and this example grows a second line the
* moment a source is imported, so a vertically centred demo would slide up
* under the reader's cursor as it reports back. `pt-6` then keeps that top edge
* off the frame.
*/
export function Pattern() {
const [value, setValue] = useState("")
const [log, setLog] = useState("")
return (
<div className="flex w-full flex-col items-center gap-3 self-start px-4 pt-6 pb-4">
<Cascader items={properties} value={value} onValueChange={setValue}>
<CascaderTrigger
aria-label="Property"
render={
<Button
variant="outline"
className="w-72 justify-between gap-2 font-normal"
/>
}
>
<CascaderValue placeholder="Select a property" />
</CascaderTrigger>
<CascaderContent className="w-72">
<CascaderPanel>
<CascaderNav>
<CascaderInput />
</CascaderNav>
<CascaderBreadcrumb />
<CascaderEmpty />
<CascaderList>
<CascaderItems />
</CascaderList>
{/* A SIBLING of the list, never a child of it: anything rendered
inside the list would be clicked by the list's own Enter
handler. */}
<CascaderFooter>
<CascaderSubmenu>
<CascaderSubmenuTrigger icon={importIcon}>
Import properties from
</CascaderSubmenuTrigger>
<CascaderSubmenuContent>
<ImportSourceMenu
onImport={(source) => setLog(`Importing from ${source}`)}
/>
</CascaderSubmenuContent>
</CascaderSubmenu>
</CascaderFooter>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
<p className="text-muted-foreground min-h-4 text-xs" role="status">
{log}
</p>
</div>
)
}