Cascader

A select for a value that lives in a tree: each column narrows the next, and the value is the path rather than a single leaf.

Accessibility

What Cascader does for you, and what it leaves to the page around it.

What it renders

Cascader is two things joined together. The field is a real combobox, and the popup is a row of menus.
  • The trigger is an <input role="combobox"> carrying aria-expanded, aria-haspopup="listbox" and aria-autocomplete="list". It is in the tab order, it takes focus, and it keeps focus the whole time the popup is open.
  • Each column is a <ul role="menu">, one per open level, side by side in a single popup rendered in a portal.
  • Each option is an <li role="menuitemcheckbox"> with aria-checked, in single mode as well as multiple. The row's own title comes from the option's title, or from its label when that label is a string.
  • The checkbox in multiple mode is decorative. It is a <span> with no role of its own. The checked state lives on the row's aria-checked.

Operating it from the keyboard

Focus never leaves the field. The keys move an active path that the popup renders from, so the columns change without anything inside them taking focus.
  • Up and Down move within the current column, wrapping at both ends and skipping disabled options rather than stopping on them.
  • Right enters the next column, landing on its first option that is not disabled. Left goes back a column, and from the first column it closes the popup. Under dir="rtl" the two swap, because the columns do.
  • Backspace is a second Left: it steps back one column whenever there is no search text to delete.
  • Enter selects the active path, with the same rule as the mouse: a leaf always, a branch only with changeOnSelect or multiple. On a branch with changeOnSelect it also closes the popup, where a click leaves it open.
  • Escape closes.
  • While a search is running, Left and Right do nothing. The columns have become one flat list of paths, so Up, Down and Enter are what is left.
expandTrigger="hover" only changes the mouse. The keyboard works the same either way.

What is not announced

Read this before shipping a Cascader on a critical path. The attributes promise more than the markup delivers.
  • The active option is not announced. The combobox never sets aria-activedescendant, and the option rows have no id for it to point at, so arrowing through the columns moves a visible highlight and says nothing.
  • aria-controls and aria-owns point at an element that is not rendered. Both resolve to `${id}_list` while the popup is open, and the columns are plain <ul>s with no id.
  • aria-haspopup says listbox and the popup is a set of menus. Expect the announcement and the structure to disagree, and do not build instructions that lean on either word.
The field's own value is reliable, so make it carry the meaning:
  • The closed field shows the whole path joined with  / , and that is the announcement a reader gets after choosing. Keep it.
  • In multiple mode the default shows only the last label per tag. “Berlin” with no ancestors is ambiguous. Pass a displayRender that keeps enough of the path to name the thing, or put the full path in the tag's own title.
  • Confirm a change somewhere the reader is already looking, in a live region or in the field's own help text, when the path is long enough to be truncated.

Naming the control and its options

The combobox has no name of its own. A placeholder is not a name: it disappears once there is a value.
  • Put it in a Form.Item with a real label wherever there is room. That is the route that also wires up the error text.
  • Otherwise give the control aria-label, or point aria-labelledby at the visible text that already says it. Both land on the input.
  • An option can carry its own ARIA. Every aria-* and data-* key on an option lands on its row, so { value: "ber", label: "Berlin", "aria-label": "Berlin, Germany" } names that row without changing what is drawn.
  • A label that is a node rather than a string loses the row's title fallback, so give a decorated option an explicit title or an aria-label of its own.

Disabled options and the portal

  • disabled on an option is honored in every route: the keyboard skips it, Right will not land on it, and clicking it neither selects nor opens.
  • Disabling a branch disables everything under it, including in the flattened search list. That is usually what you want, and easy to do by accident.
  • Do not let gray be the only signal. Say why an option is disabled, next to the field or in its help text.
  • The popup is a portal. It is appended to the end of the document, not next to the field, so reading order does not follow visual order. The field keeps focus the whole time, which is what holds the two together.
  • Cascader.Panel renders the same columns with no trigger and no portal. It is not a combobox but a set of menus on the page, so give it its own label and say what choosing does.

Contrast and motion

  • Three states share one column and have to stay apart: the active row (the one the arrows are on), the checked row, and hover. Selection is shown by the fill and the font weight together, so a restyle that keeps only a tint loses it.
  • The drill-in chevron is the only thing saying “there is another level”. Check it against the row's background rather than the page's, and do not drop it to a decorative weight.
  • Every color comes from the active theme, so contrast depends on the theme. After changing the seed colors, check text against 4.5:1, since nothing warns when it falls below.
  • The popup fades and slides. Turn both off with ThemeScope theme={{ token: { motion: false } }}. Anything you add on top of them belongs behind motion-safe:.