When to use
- Hierarchical org pickers — departments, teams, business units, cost centres, locations.
- Complex multi-level trees where users need to drill into a path (Group → Division → Department → Team).
- Selection scopes for permissions, reporting lines, filters, and audience targeting.
- Form fields that should feel like a
<select>but back a tree.
When NOT to use
- For a flat list — use Select or Combobox.
- For drag-to-reorder of org nodes — use SortableList.
- For tabular bulk assignment — use TransferList or DataTable.
- For pure navigation (no value) — use a Sidebar tree.
Import
import { OrgSelector, type OrgNode } from '@mihcm/ui/OrgSelector';Modes (selection semantics)
| Mode | Value | Behaviour |
|---|---|---|
single | string | null | One node only. |
multi | string[] | Many independent nodes. No propagation. |
cascade | string[] (fully expanded) | Selecting a parent selects every descendant. Indeterminate state for partial subtrees. |
include-descendants | string[] (compact) | Selecting a parent stores only the parent key; the consumer treats it as "this node plus everything beneath". |
Variants (visual shells)
| Variant | When |
|---|---|
popover | Default form-field shape — opens a tree below the trigger. |
inline | Tree is the field. Good for filter sidebars or full-page pickers. |
columns | Miller / Finder-style horizontal drill. Big screens, deep trees. |
drawer | Slides up from the bottom (phones) or in from the side (tablets). |
command | Search-first command palette grouped by ancestor. |
breadcrumb-drill | Optimized for very deep / very large trees — closed trigger shows the ancestor breadcrumb; open panel hints to drill in level-by-level. |
Trigger display modes (triggerDisplay)
How the closed trigger renders the current selection.
| Value | Closed-state output | Best for |
|---|---|---|
chips (default) | Badge chips with +N more overflow | Multi-select with a handful of picks |
breadcrumb | Group › Engineering › Platform | Single-mode pickers where the path matters more than the leaf name |
path | Group / Engineering / Platform | Dense forms where a chevron-styled trail would be too busy |
count | 3 selected | Multi-select with dozens of picks |
<OrgSelector triggerDisplay="breadcrumb" data={ORG} mode="single" ... />Pair triggerDisplay="breadcrumb" with variant="breadcrumb-drill" for the deepest-tree workflow.
Scaling to 20k+ nodes
Three knobs handle large trees:
- Lazy-loading — supply
loadChildren(node)and mark unloaded branches withhasChildren: true. Children are fetched on first expand and cached for the session. - Auto-virtualization — when the total node count crosses
autoVirtualizeThreshold(default1000), the renderer caps the visible window to the first 500 rows and shows aShowing 500 of N — type to narrow.hint. Search and typeahead remain fully responsive. breadcrumb-drillvariant — at this scale, drilling level-by-level beats scrolling a 20k-row tree. The breadcrumb trigger becomes the navigator.
The tree-walk helpers (buildParentMap, flattenVisible, visibleKeysForQuery) are iterative — they will not blow the call stack on pathologically deep trees.
Modes and variants compose freely — <OrgSelector variant="drawer" mode="cascade" /> is a fully valid mobile cascade picker.
Basic usage
const [value, setValue] = useState<string | null>(null);
<OrgSelector
label="Reporting department"
placeholder="Select department"
data={ORG}
mode="single"
value={value}
onChange={(v) => setValue((v as string) ?? null)}
/>Tree data
const ORG: OrgNode[] = [
{
key: 'engineering',
label: 'Engineering',
description: '120 people',
children: [
{ key: 'platform', label: 'Platform' },
{ key: 'product-eng', label: 'Product Engineering' },
],
},
];key— stable identifier (used invalue, ancestry lookups, lazy loading).label— visible text. Searched against by the built-in typeahead.description— optional muted sub-label (location, headcount, job code…).icon/badge— optional ReactNode slots on either side of the label.children— direct children. Omit and sethasChildren: truefor lazy.
Lazy loading
Set hasChildren: true on partial nodes and supply loadChildren:
<OrgSelector
data={[{ key: 'emea', label: 'EMEA', hasChildren: true }]}
loadChildren={async (node) => {
const res = await fetch(`/api/org/${node.key}/children`);
return res.json();
}}
/>loadChildren is called once per node on first expand. Children are appended into the tree and remembered for the session.
Pickable
<OrgSelector pickable="leaf-only" ... />leaf-only keeps parents expandable but read-only — useful when only leaf teams should ever be picked. Defaults to any-level.
Performance
For trees with more than ~1k flattened visible rows, opt into virtualization:
<OrgSelector virtualized data={largeTree} />This depends on @tanstack/react-virtual and is gated by the prop so smaller trees stay zero-dep.
Validation
<OrgSelector
label="Reporting department"
required
errorText={value === null ? 'Please pick a department.' : undefined}
...
/>errorText toggles aria-invalid styling. Combine with required to surface the asterisk in the label.
Composition
- Pair with SectionHeader for filter sidebars.
- Pair with Form for validation alongside other fields.
- Use the Drawer variant in mobile filter sheets.
Related
- Select — flat single-select.
- Combobox — async flat-list search.
- TransferList — bulk assignment between two pools.
- SortableList — drag to reorder.