Skip to content
ShineUI
Install

CascadeSelect

Two levels, one pick.

<sui-cascade-select>SelectionStable

About

Picks one leaf from a tree of options: each branch opens its own list beside the last, and on a phone each level replaces the one before under a back row. Use it for a choice like country, state, then city, where a flat list would repeat the parents. Form-associated: posts the leaf's value under its name.

Usage

<sui-cascade-select label="Detail type" name="detail-type" value="rent" placeholder="Choose a detail type"></sui-cascade-select> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-cascade-select"); el.options = [ { label: "Bank", items: [ { label: "Checking", value: "checking" }, { label: "Savings", value: "savings" }, { label: "Money market", value: "money-market" }, ], }, { label: "Expense", items: [ { label: "Rent or lease", value: "rent" }, { label: "Utilities", value: "utilities" }, { label: "Insurance", value: "insurance" }, ], }, { label: "Income", items: [{ label: "Sales income", value: "sales" }, { label: "Service income", value: "service" }] }, ]; /* SuiCascadeOption[] */ el.addEventListener("suiChange", (e) => console.log(e.detail)); </script>
import { SuiCascadeSelect } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const options = [ { label: "Bank", items: [ { label: "Checking", value: "checking" }, { label: "Savings", value: "savings" }, { label: "Money market", value: "money-market" }, ], }, { label: "Expense", items: [ { label: "Rent or lease", value: "rent" }, { label: "Utilities", value: "utilities" }, { label: "Insurance", value: "insurance" }, ], }, { label: "Income", items: [{ label: "Sales income", value: "sales" }, { label: "Service income", value: "service" }] }, ]; /* SuiCascadeOption[] */ return <SuiCascadeSelect label="Detail type" name="detail-type" value="rent" placeholder="Choose a detail type" options={options} onSuiChange={(e) => console.log(e.detail)} />; }
<script setup> import { SuiCascadeSelect } from "@shineui/vue"; import "@shineui/core/shineui.css"; const options = [ { label: "Bank", items: [ { label: "Checking", value: "checking" }, { label: "Savings", value: "savings" }, { label: "Money market", value: "money-market" }, ], }, { label: "Expense", items: [ { label: "Rent or lease", value: "rent" }, { label: "Utilities", value: "utilities" }, { label: "Insurance", value: "insurance" }, ], }, { label: "Income", items: [{ label: "Sales income", value: "sales" }, { label: "Service income", value: "service" }] }, ]; // SuiCascadeOption[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiCascadeSelect label="Detail type" name="detail-type" value="rent" placeholder="Choose a detail type" :options="options" @sui-change="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiCascadeSelect } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiCascadeSelect], template: `<sui-cascade-select label="Detail type" name="detail-type" value="rent" placeholder="Choose a detail type" [(open)]="open" [options]="options" (suiChange)="onChange($event)"></sui-cascade-select>`, }) export class Example { open = false; options = [ { label: "Bank", items: [ { label: "Checking", value: "checking" }, { label: "Savings", value: "savings" }, { label: "Money market", value: "money-market" }, ], }, { label: "Expense", items: [ { label: "Rent or lease", value: "rent" }, { label: "Utilities", value: "utilities" }, { label: "Insurance", value: "insurance" }, ], }, { label: "Income", items: [{ label: "Sales income", value: "sales" }, { label: "Service income", value: "service" }] }, ]; // SuiCascadeOption[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

The trigger is a combobox with aria-haspopup="tree" and aria-activedescendant on the active row. Every row is a treeitem with aria-level, aria-posinset and aria-setsize; a branch has aria-expanded, a leaf aria-selected, a disabled row aria-disabled. A combobox may own a tree, not a menu, so the keys are the tree ones.

<div style="max-width:320px"> <sui-cascade-select label="Detail type" name="detail-type" value="rent" placeholder="Choose a detail type"></sui-cascade-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('sui-cascade-select').options = detailTypes; }

Three levels

options is a tree of { label, value, disabled, icon, items } nodes to any depth. A node with items is a branch that opens the next level; one without is a leaf, and only a leaf is picked. value defaults to label and is what the form posts. Hamilton is disabled: announced, not picked. On a phone a level fills the menu.

Posts city=CA-ON-OTT.
<div style="display:grid;gap:10px;max-width:320px;min-height:280px;align-content:start"> <sui-cascade-select id="cs-city" label="City" name="city" value="CA-ON-OTT" placeholder="Choose a city"></sui-cascade-select> <output id="cs-city-out" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Posts city=CA-ON-OTT.</output> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#cs-city'); const out = root.querySelector('#cs-city-out'); el.options = places; el.addEventListener('suiChange', (e) => { out.textContent = e.detail ? `Posts city=${e.detail}.` : 'Posts an empty city.'; }); }

Path in the trigger

showPath puts the branches above the pick before it, for a leaf whose label alone is ambiguous, such as a city name two states share. When the box is narrow the branches give way to an ellipsis first and the leaf keeps its words.

<div style="display:grid;gap:14px;max-width:320px"> <sui-cascade-select id="cs-path" label="City" value="US-KY-LOU" show-path></sui-cascade-select> <div style="max-width:200px"><sui-cascade-select id="cs-path-narrow" label="Narrow box" value="CA-BC-VAN" show-path></sui-cascade-select></div> </div>
// setup(root): properties and events setup(root) { root.querySelector('#cs-path').options = places; root.querySelector('#cs-path-narrow').options = places; }

Open flyout

path holds the open branches, one index per level: [1] opens the second root branch. It is a public mutable prop, so hover, a click and the keyboard all move it and announce the move. Opening from the trigger opens the path to the pick.

<div style="max-width:320px;min-height:260px"> <sui-cascade-select id="cs-open" label="Detail type" value="rent" open></sui-cascade-select> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#cs-open'); el.options = detailTypes; el.openPath = [1]; }

As a property

variant="quiet" draws the picked leaf as text in the surrounding font, with no shell at rest: the fill comes on hover, the ring on focus, the shell while the menu is open. The label is not drawn but still names the control. Opened with a pick and no path, the menu opens the path to the pick.

Detail type Service area
<div style="display:grid;grid-template-columns:76px minmax(0,1fr);gap:12px 10px;align-items:center;max-width:320px;font:550 13.5px/1.5 var(--font-sans);color:var(--ink);min-height:240px;align-content:start"> <span style="font:460 12.5px/1.3 var(--font-sans);color:var(--ink-muted)">Detail type</span><sui-cascade-select id="cs-prop-type" variant="quiet" label="Detail type" size="sm" value="rent" open></sui-cascade-select> <span style="font:460 12.5px/1.3 var(--font-sans);color:var(--ink-muted)">Service area</span><sui-cascade-select id="cs-prop-area" variant="quiet" label="Service area" size="sm" placeholder="Unset" clearable></sui-cascade-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#cs-prop-type').options = detailTypes; root.querySelector('#cs-prop-area').options = regions; }

States

An error replaces the help line, paints the trigger in the danger tone and sets aria-invalid. A disabled control posts nothing and never holds the form.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start"> <sui-cascade-select id="cs-help" label="Detail type" help="The detail type sets the report grouping."></sui-cascade-select> <sui-cascade-select id="cs-req" label="Detail type" required placeholder="Choose a detail type"></sui-cascade-select> <sui-cascade-select id="cs-err" label="Detail type" required error="Detail type is required."></sui-cascade-select> <sui-cascade-select id="cs-off" label="Service area" value="louisville" disabled help="Set on the customer record."></sui-cascade-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#cs-help').options = detailTypes; root.querySelector('#cs-req').options = detailTypes; root.querySelector('#cs-err').options = detailTypes; root.querySelector('#cs-off').options = regions; }

Loading, clear and sizes

While loading is set a spinner stands in for the chevron and the panel shows the loading row instead of options. clearable draws an X that clears the pick, and size is the same sm, md, lg vocabulary as the rest of the control family.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:end"> <sui-cascade-select id="cs-loading" label="Detail type" placeholder="Fetching detail types" loading></sui-cascade-select> <sui-cascade-select id="cs-clear" label="Detail type" value="utilities" clearable></sui-cascade-select> <sui-cascade-select id="cs-sm" label="Small" size="sm" value="checking"></sui-cascade-select> <sui-cascade-select id="cs-lg" label="Large" size="lg" value="checking"></sui-cascade-select> </div>
// setup(root): properties and events setup(root) { ['#cs-clear', '#cs-sm', '#cs-lg'].forEach((id) => { root.querySelector(id).options = detailTypes; }); }

Read-only

readonly keeps the trigger named and in the tab order, opens nothing and clears nothing, and the value still posts.

<div style="display:grid;gap:14px;max-width:320px"> <sui-cascade-select id="ro-sui-cascade-select" label="Detail type" name="sui-cascade-select" value="savings" readonly required></sui-cascade-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#ro-sui-cascade-select').options = detailTypes; }

Empty

emptyMessage is the row shown when there are no options, and in a branch whose items is empty. A cascade has no filter, so it carries no emptyFilterMessage.

<div style="max-width:280px"><sui-cascade-select id="cs-empty" label="Detail type" empty-message="No detail types set up yet." open></sui-cascade-select></div>
// setup(root): properties and events setup(root) { root.querySelector('#cs-empty').options = []; }

In a form

The picked leaf posts its value under the host name through ElementInternals, so Utilities posts utilities. An empty control posts an empty string, the way a native select does.

Submit to see what posts.
<form id="cs-form" style="display:grid;gap:14px;max-width:320px"> <sui-cascade-select id="cs-form-type" label="Detail type" name="detail-type" required></sui-cascade-select> <sui-cascade-select id="cs-form-area" label="Service area" name="area"></sui-cascade-select> <sui-button type="submit" variant="brand" label="Save account"></sui-button> <output id="cs-out" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Submit to see what posts.</output> </form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#cs-form'); const out = root.querySelector('#cs-out'); const type = root.querySelector('#cs-form-type'); type.options = detailTypes; type.value = 'utilities'; root.querySelector('#cs-form-area').options = regions; form.addEventListener('submit', (e) => { e.preventDefault(); const entries = [...new FormData(form)].map(([k, v]) => `${k}=${v}`); out.textContent = entries.length ? entries.join(' ') : 'Nothing posted.'; }); }

Events

suiOpenPathChange fires after the element moves the open path itself and carries the new path, one index per level. suiChange carries the picked leaf value. suiOpen fires after it opens, and the cancelable suiClose fires before it closes with a reason of pick, escape, outside or trigger.

Idle.
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start"> <div style="max-width:280px;flex:1 1 240px"><sui-cascade-select id="cs-ev" label="City" placeholder="Choose a city"></sui-cascade-select></div> <span id="cs-ev-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#cs-ev'); const log = root.querySelector('#cs-ev-log'); el.options = places; el.addEventListener('suiOpen', () => { log.textContent = 'Menu open.'; }); el.addEventListener('suiOpenPathChange', (e) => { const open = pathLabels(places, e.detail); log.textContent = open.length ? `Open path [${e.detail.join(', ')}]: ${open.join(' / ')}.` : 'Open path [], the root list alone.'; }); el.addEventListener('suiClose', (e) => { log.textContent = `Menu closing (${e.detail}).`; }); el.addEventListener('suiChange', (e) => { log.textContent = `Picked ${e.detail}.`; }); }

Keyboard

KeyAction
ArrowDown ArrowUpOpens the panel. Once open, moves through the rows of the current level, wrapping at either end and stepping over disabled rows.
ArrowRightOn a branch, opens its level and moves to its first row.
ArrowLeftOn an open branch, closes its level; anywhere else, moves to the branch that opened the current level. On a phone, climbs back one level. Under dir="rtl" the pair swaps, so the arrow that points along the reading direction is always the forward one.
Enter SpaceOpens the panel, opens the active branch, or picks the active leaf.
Home EndMoves to the first or last row of the current level that can be picked.
EscapeCloses the panel and returns focus to the trigger.
TabCloses the panel and moves on.
Delete BackspaceOn the trigger, with clearable set, clears the value.

API

<sui-cascade-select>

Properties

PropertyAttributeTypeDefaultDescription
clearableclearablebooleanfalseX control to clear the value. Delete or Backspace on the trigger clears it from the keyboard.
disableddisabledbooleanfalseDisables the trigger. A disabled control posts nothing and never holds the form.
emptyMessageempty-messagestringstring | undefinedLine shown in the panel when there are no options, and in a branch whose items is empty. This element does not filter, so it has no emptyFilterMessage: there is only one empty state to name.
errorerrorstringstring | undefinedError message; paints the trigger in the danger tone and sets aria-invalid.
helphelpstringstring | undefinedHelp text under the trigger. Replaced by error when set.
invalidinvalidbooleanfalseDanger border without a message, for a form that draws its summary elsewhere.
labellabelstringstring | undefinedLabel above the trigger.
loadingloadingbooleanfalseShows the options are still on their way: a spinner stands in for the chevron and the menu shows the loading row instead of options. Set it while fetching, clear it when options land.
loadingMessageloading-messagestringstring | undefinedText of the loading row.
namenamestringstring | undefinedForm control name. Reflected: the form entry is built from the content attribute.
openopenbooleanfalseOpens the menu. Mutable and the single source of truth: suiOpen announces an open the element performed, and the cancelable suiClose asks before it closes itself on pick, outside click, Escape and Tab.
openPathproperty onlynumber[][]The open branches, one index per level: [] shows the root list alone, [0, 2] opens the first root branch and, beside it, that branch's third. Mutable: hover, click and the arrow keys move it, and every move the element performs itself is announced with suiOpenPathChange, so a binding never goes stale on hover. Opening from the trigger opens the path to the pick. Set it with open to show a level of your choosing. Property, not attribute.
optionsproperty onlySuiCascadeOption[][]The tree: { label, value, disabled, icon, items } nodes to any depth. A node with items is a branch that opens the next level; a node without is a leaf, and only a leaf can be picked. value defaults to label. A disabled row shows but cannot be picked or opened. Property, not attribute.
placeholderplaceholderstringstring | undefinedText shown while nothing is picked.
readOnlyreadonlybooleanfalseDisplay only: the trigger keeps its name and its tab stop, opens nothing and clears nothing, and the value still posts. Neither a read-only nor a disabled control holds the form on valueMissing.
requiredrequiredbooleanfalseMarks the control required for form validation.
showPathshow-pathbooleanfalseThe trigger shows the branches above the pick as well, Canada / Ontario / Toronto, for a leaf whose label alone is ambiguous. The leaf stays whole and the branches give way first when the box is narrow.
sizesize"lg" | "md" | "sm"'md'Trigger height: sm 28, md 34 (default), lg 42, from the --control-* tokens. In the quiet variant it is the height of the box that paints on hover and while open; the text keeps the surrounding font.
tonetone"inverse" | undefinedInk for the quiet variant on the brand chrome. inverse takes the inverse inks, the inverse hover wash and the inverse focus colour, as sui-button's tone of the same name does, so the value reads on a tone="brand" toolbar. Opening the field shows its shell in the card's own inks. The default variant ignores it.
valuevalueSuiKeynumber | string | undefinedThe picked leaf's value, as the leaf gives it (a number stays a number); '' after a clear. The element finds the leaf, and the branches above it, from it. Written as an attribute it is text, and it still finds the leaf whose value reads the same. Mutable; written before suiChange fires.
variantvariant"default" | "quiet"'default'default draws the field shell. quiet draws the picked leaf as text in the surrounding font, with no border or fill at rest, for a record's properties column: the fill comes on hover, the focus ring on focus, and the shell only while the menu is open. The label is not drawn but still names the control, so the host prints the property name. Keyboard, events, validation and the form post are the same in both. Anything else falls back to default.

Events

EventDetailReactVueDescription
suiChangeSuiKeynumber | stringonSuiChange@sui-changeFires when a leaf is picked; detail is the new value: the leaf's value as given, or '' after a clear.
suiCloseSuiCloseReason"cancel" | "close-button" | "escape" | "outside" | "pick" | "scrim" | "trigger"onSuiClose@sui-closeFires before the element closes the menu itself; detail is 'pick' | 'escape' | 'outside' | 'trigger'. Cancelable: preventDefault() keeps it open. Tab reports 'outside', because focus left the control and SuiCloseReason has no member for that. Does not bubble: a preventDefault() in a dialog's own suiClose listener cannot veto this one.
suiOpenvoidonSuiOpen@sui-openFires after the element opens the menu itself (trigger click, ArrowDown, Enter, Space). Does not bubble, so a dialog around the control never takes it for its own.
suiOpenPathChangenumber[]onSuiOpenPathChange@sui-open-path-changeFires after the element moves openPath itself (hover, click or an arrow key over a branch, the back row, or an open from the trigger); detail is the new path. The prop is written first, so a framework binding stays in step. It reports a cursor move inside the menu, not a destination, which is why it is not suiNavigate. Does not bubble: it reports the panel's state, not the value, like suiOpen and suiClose.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the trigger, as el.focus() does.

Types

SuiCascadeOption interfaceOne option of the tree. A node with items is a branch and opens the next level; a node without is a leaf, the only kind that can be picked.
labelstringText of the row: what a screen reader hears and what the trigger shows once picked.
value?SuiKeyWhat value holds when this leaf is picked, as given: a number stays a number through value, suiChange and a framework binding, and the form posts its text. Defaults to label. Keep leaf values unique across the tree: the element finds the pick by its value. A branch's value is never posted.
disabled?booleanShows the row dimmed: it cannot be picked or opened, the arrow keys step over it and it is announced as disabled.
icon?stringFont Awesome class drawn before the label, in the row and, for the picked leaf, in the trigger.
items?SuiCascadeOption[]The next level. Present, even empty, the node is a branch; an empty branch opens the empty line.
SuiCloseReason typeWhy a dismissible surface is closing. Detail of the cancelable suiClose. A consumer expresses "a stray backdrop click must not close this" by calling preventDefault() on the reason it does not accept, which is why none of these needs a prop of its own.
No members.
SuiKey typeA record's or an item's key wherever the library identifies one: a row, a tree node, a list item, a selected value. Numbers stay numbers, so a numeric id round-trips through selection, the events and a framework binding without turning into a string.
No members.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also