Select new
import { useSelect } from "@katanakit/ui";
const select = useSelect({
label: "Country",
name: "country",
options: [
{ value: "es", label: "Spain" },
{ value: "ar", label: "Argentina" },
],
});
document.body.append(select.root);Built on the WAI-ARIA select-only combobox pattern.
Why aria-activedescendant, not roving tabindex
Contrast Tabs, which uses roving tabindex. The difference is lifetime, not preference:
- A tablist is permanently in the DOM, so its children can carry real
tabindexvalues and the browser's tab order can visit them. - A listbox is virtual — it only exists while open. There is no stable element for the tab order to visit.
So focus stays on the button at all times, and the active option is addressed by id with aria-activedescendant. A screen reader still announces each option as the arrows move, and closing the list does not have to move focus back from somewhere it never left.
label is not optional in practice
A role="combobox" with no accessible name is announced as just "combo box". The user has no idea what they are being asked to choose. Always pass one.
Keys
| Key | Action |
|---|---|
| ↓ / ↑ | Open, then move through enabled options. |
| Home / End | First / last enabled option, while open. |
| Enter / Space | Open; commit when already open. |
| Esc | Close without committing — selection is restored. |
| Tab | Close without committing, and move on. |
| Type a letter | Typeahead, jumping to the matching option. |
Disabled options are skipped in both directions and cannot be chosen.
Committing versus browsing
While the list is open, the active row and the selection are different things. The arrows move the active row; nothing is committed until Enter, Space or a click. Esc and Tab both discard the active row and leave the selection where it was.
That distinction matters on long lists: a user can scan with the arrows without changing anything.
Typeahead
Typing jumps to the first option whose label starts with what you typed. The buffer holds for 500 ms, so a then r finds "Argentina", and a mistyped prefix leaves you where you were rather than resetting.
Form participation
Pass name and the component also renders a hidden <input>, so the value joins a normal form submission. Without it the component is presentational and the caller owns the value.
Options
| Option | Type | Default | Notes |
|---|---|---|---|
options | SelectOption[] | — | The choices, in order. |
label | string | — | Accessible name. Always set one. |
value | string | first enabled | Initially selected value. |
placeholder | string | "Select…" | Shown when nothing is selected. |
disabled | boolean | false | Disables the whole control. |
name | string | — | Renders a hidden input so the value joins a form. |
onChange | (value: string) => void | — | Called with the newly selected value. |
The returned elements
const { root, button, list, options, input, getValue, setOpen } = useSelect({ label, options });
document.body.append(root);
getValue(); // "es" | undefined
setOpen(true);SSR
useSelect needs a DOM. selectClass() is pure and safe on the server.
Contract
Every component in the kit follows one shape. Pure builders return a class string and touch nothing; factories build a DOM element and need a browser.
| Surface | Import | Needs a DOM |
|---|---|---|
| Pure class builder | selectClass | No — safe during SSR |
| Element factory | useSelect | Yes — throws with a pointer to the pure surface |
// Server and templates — pure
<div class={ selectClass({ /* … */ }) }></div>
// Browser — imperative
document.body.append(useSelect({ /* … */ }));Class names are unprefixed. The root class is select, not kk-select, and every component rule is wrapped in @layer katana.
That matters because these names collide 1:1 with daisyUI. Unlayered CSS always beats layered CSS regardless of specificity or import order, so loading both is deterministic instead of "whoever came last". See why.