Cloud|

@k2b/ui

Selection controls

Single, multi, and compact controlled selection.

Select@k2b/uiMultiSelectInput@k2b/uiSelectChip@k2b/ui

Select filters its static options only when searchable is set; MultiSelectInput renders its search field by default. An option color replaces the icon with a dot in Select, and tints the icon and selected pill in MultiSelectInput. In a narrow trigger, pills that do not fit collapse into a +N summary. SelectChip is the compact form: a 10rem menu with a trailing check marker.

Type in the search field to filter the four static options.

Teams
Teams (narrow)

TSX

Copy
<Select label="Team" description="Type to filter the static options." value={team} options={options} onValueChange={setTeam} searchable clearable />
{/* MultiSelectInput always renders its search field */}
<MultiSelectInput label="Teams" value={teams} onValueChange={setTeams} options={options} clearable />
{/* Pills that do not fit collapse into a +N summary */}
<div style="width:10rem">
<MultiSelectInput label="Teams (narrow)" value={teams} onValueChange={setTeams} options={options} clearable />
</div>
<Select label="Team (no search)" value={team} onValueChange={setTeam} options={options} />
<SelectChip aria-label="Range" value={range()} onValueChange={setRange} icon="ti ti-calendar" options={rangeOptions} />
Select@k2b/ui

Select and MultiSelectInput can narrow one option list with the same optional group chips. Options may belong to several groups; text search stays inside the active group, and All removes the group filter.

Search the active group or switch groups to narrow the catalogue.

Icons

TSX

Copy
<Select
label="Icon"
value={icon}
onValueChange={setIcon}
options={options}
groups={[
{ value: "recommended", label: "Recommended" },
{ value: "food", label: "Food" },
{ value: "work", label: "Work" },
]}
defaultGroup="recommended"
searchable
/>;
<MultiSelectInput
label="Icons"
value={icons}
onValueChange={setIcons}
options={options}
groups={groups}
defaultGroup="recommended"
/>
Select@k2b/ui

An optional, local layout switch keeps the same filtered options and selection semantics while moving between detailed rows and compact tiles. The tile size is a bounded density choice, not consumer-owned CSS.

Use the icon beside the groups to switch between detailed rows and compact tiles.

TSX

Copy
<Select
label="Icon"
value={icon}
onValueChange={setIcon}
options={options}
groups={groups}
defaultGroup="recommended"
viewToggle
defaultView="grid"
gridSize="md"
searchable
/>

Select, MultiSelectInput, and SelectChip cover three different selection tasks. The parent owns every selected value.

Use select inputs

Choose from the state transition, not from the desired visual shape:

Task Component Value contract
Choose one value in a form Select one controlled value or null
Type, validate, and keep one remote value AutocompleteSelect one controlled ID or null
Choose several values MultiSelectInput one controlled ID array
Choose one compact toolbar value SelectChip one controlled value
Filter a result set FilterChip controlled filter state
Find an item, perform an action, then clear Combobox selected item callback
Run a secondary action or open a link Dropdown no field value

Do not rebuild select rows in a Dropdown. That loses the shared field, listbox, or radio semantics and makes alignment and keyboard behavior the consumer's responsibility. Dropdown therefore accepts only declarative actions and choices. See Dropdown and ContextMenu for action menus.

Import

tsx
import {
  MultiSelectInput,
  type MultiSelectInputProps,
  type MultiSelectOption,
  Select,
  SelectChip,
} from "@k2b/ui";

Select

Pass the selected value directly or through a Solid accessor. Selection and clearing are atomic actions, so both onValueChange and onValueCommit receive the complete next value. Clearing emits null.

Static options may be strings, { id, label?, description?, icon?, color? }, or normalized { value, label, description?, icon?, color?, disabled?, groups? } objects.

Select.fetchData(query, signal, group) and MultiSelectInput.fetchData(query, signal, group) accept their convenient source shapes. Their loadOptions(query, signal, group) variants accept normalized options. They run with an empty query when the dropdown opens, debounce later input, and abort stale requests. group is the active group value or null for all options. Pass selectedOption when the current value needs display metadata before the first result arrives, or selectedOptions for MultiSelectInput.

Remote sources always show the search field. Set searchable to add it to a static option list, where it filters labels, descriptions, and values in the browser. In Select, an option color replaces the icon with a color dot on the trigger and in the list. MultiSelectInput tints the option icon and the selected pill with it instead.

Filter one list by group

Pass groups to Select or MultiSelectInput when a long option list benefits from a short set of filters. Each option lists its group values through option.groups; one option may belong to several groups. defaultGroup selects the initial group. The built-in All choice removes the group filter, and allGroupLabel can rename it.

For static options, the active group and text search are combined. For remote options, apply the third group argument in the data source and return the matching page. The component does not filter a partial remote result page in the browser.

tsx
<Select
  label="Icon"
  value={icon()}
  onValueChange={setIcon}
  options={[
    {
      value: "ti ti-briefcase",
      label: "Briefcase",
      groups: ["recommended", "work"],
    },
    {
      value: "ti ti-coffee",
      label: "Coffee",
      groups: ["recommended", "food"],
    },
  ]}
  groups={[
    { value: "recommended", label: "Recommended" },
    { value: "food", label: "Food" },
    { value: "work", label: "Work" },
  ]}
  defaultGroup="recommended"
  searchable
/>;

Switch between list and tile views

Set viewToggle to offer list and tile views for visual options.

defaultView accepts "list" or "grid" and defaults to "list". gridSize accepts "sm", "md", or "lg" and defaults to "md":

Size Tile content Minimum tile width
sm icon or color plus label 4.5rem
md icon, label, and description 7rem
lg roomier label and description 9rem

The switch changes presentation only. Search, groups, remote loading, disabled options, keyboard focus, and selection continue to use the same option list. The chosen view stays local to the mounted Select; remounting starts from defaultView. Without viewToggle, Select always keeps the list layout.

tsx
<Select
  label="Icon"
  value={icon()}
  onValueChange={setIcon}
  options={icons}
  groups={iconGroups}
  defaultGroup="recommended"
  viewToggle
  defaultView="grid"
  gridSize="md"
  searchable
/>;

MultiSelectInput

Pass the selected array directly or through a Solid accessor. Every selection change reports the complete next array through both onValueChange and onValueCommit. Static options may carry a color for selected pills.

It supports the same static or remote option sources. For remote data, selectedOptions supplies labels and metadata for selected IDs that are not in the current result page.

The dropdown always opens with a search field, which filters a static option list in the browser. Pass searchable={false} for a short fixed list.

Show selections in a narrow trigger

The trigger keeps one row at field height. It shows as many whole pills as fit, in selection order, and collapses the rest into a +N summary pill. Screen readers announce the summary as "N more selected" in the inherited locale, and its tooltip lists the hidden labels. The first pill always stays; when even it does not fit beside the summary, its label truncates with an ellipsis. Every pill is at most 9rem wide and shows its full label as a tooltip. The dropdown still lists every selected option, and Backspace in the open dropdown removes the last selection, including a hidden one.

The trigger fills its container. A container sized by its content grows with the pills; the field collapses them only while a maximum width or flex shrinking holds that container narrower, and shows them again once it has room. A flex item that wraps the field needs min-width: 0 to shrink below its pills.

Show a value in a property row

Set appearance="plain" on Select or MultiSelectInput when the value is a property of the item shown around it, such as the priority or labels in a detail panel. The default appearance="field" keeps the boxed form control. A plain trigger shows the value as text: no box, no chevron, the selected option's color dot, and MultiSelectInput pills that wrap instead of collapsing into +N. Hover, keyboard focus, and an open list paint a quiet surface behind it; nothing moves between those states.

As the direct value of a DescriptionList row, the plain control takes over the whole row: the label opens the list too, and the surface covers the row. Elsewhere it covers only the control.

A plain trigger has no room for buttons beside its value:

  • a clearable Select ends its list with an entry named by placeholder, such as No priority, which clears the value;
  • MultiSelectInput pills have no remove button; deselect in the list;
  • the empty value shows placeholder in muted text. When the options carry colors, Select keeps the dot column with an empty ring. Pass placeholderIcon, such as ti ti-plus, for an additive empty state like + Label.

A Select whose parent keeps value at null works as an add action: the trigger stays the placeholder and onValueChange reports each picked option.

tsx
<DescriptionList
  layout="rows"
  size="sm"
  items={[
    {
      term: "Priority",
      description: (
        <Select
          aria-label="Priority"
          appearance="plain"
          placeholder="No priority"
          clearable
          options={priorities}
          value={priority}
          onValueChange={setPriority}
        />
      ),
    },
    {
      term: "Labels",
      description: (
        <MultiSelectInput
          aria-label="Labels"
          appearance="plain"
          placeholder="Label"
          placeholderIcon="ti ti-plus"
          options={labels}
          value={labelIds}
          onValueChange={setLabelIds}
        />
      ),
    },
  ]}
/>;

Show read-only values as plain text in the same row instead of a disabled plain control. A disabled plain control stays readable and only stops reacting.

SelectChip

SelectChip accepts its current value directly or through a Solid accessor. Each selection closes the menu before reporting through both value callbacks, so a consumer update cannot leave stale option content visible. Its options use { value, label }, and their values may be strings or numbers. Optional icon, image, and description metadata supports compact rich choices such as model or environment selectors. The menu sizes to its options, at least 10rem, and long options wrap at the viewport; menuWidth sets an exact width instead.

Set loading while a selection saves. The trigger stays the same size, is disabled and marked aria-busy="true", and shows a spinner in place of its chevron. The icon-only form uses the button's centered busy overlay. With reduced motion, the spinner stands still. Report the result or an error where the page reports results; the busy state does not announce the outcome. A disabled button loses keyboard focus. When a chip that had focus is disabled, by disabled or loading, focus returns to it once it is enabled again, unless focus moved somewhere else in the meantime.

Selection popovers dismiss synchronously. Do not add a generic host-level [popover] display or overlay transition around them; @k2b/ui explicitly keeps interactive choice surfaces immediate.

Keep it for compact toolbars. It supports the same field label, description, reactive error, required, and disabled state, but has no clear state.

Set iconOnly together with an icon and "aria-label" when the surrounding toolbar or section header already explains the control's context. The trigger then uses the shared icon-button treatment while the menu keeps the same radio selection contract. Use size only with this icon-only form.

tsx
const [permission, setPermission] = createSignal("read");

<SelectChip
  aria-label="Permission"
  value={permission}
  onValueChange={setPermission}
  options={[
    { value: "read", label: "View", icon: "ti ti-eye" },
    { value: "write", label: "Edit", icon: "ti ti-pencil" },
    { value: "admin", label: "Manage", icon: "ti ti-shield" },
  ]}
/>;

<SelectChip
  aria-label="Sort notes"
  icon="ti ti-arrows-sort"
  iconOnly
  size="xs"
  value={sort()}
  onValueChange={setSort}
  options={sortOptions}
/>;

API reference

See shared field props for FieldProps, ValueFieldProps<T> and MaybeAccessor<T>.

ts
type ChoiceOption<T extends string = string> = {
  value: T; label: string; description?: string; icon?: string; color?: string; disabled?: boolean;
  groups?: readonly string[];
};

type SelectOption = ChoiceOption<string>;

type SelectSourceOption =
  | string
  | {
      id: string;
      label?: string;
      description?: string;
      icon?: string;
      color?: string;
      groups?: readonly string[];
    }
  | SelectOption;

type SelectGroup = {
  value: string; label: string;
};

type SelectView = "list" | "grid";

type SelectGridSize = "sm" | "md" | "lg";

type ChoiceAppearance = "field" | "plain";

Select props

ts
type SelectProps = ValueFieldProps<string | null> & {
  placeholder?: string; icon?: string; activeIcon?: string; options?: SelectSourceOption[];
  fetchData?: (query: string, signal: AbortSignal, group: string | null) => Promise<SelectSourceOption[]>;
  loadOptions?: (query: string, signal: AbortSignal, group: string | null) => Promise<readonly ChoiceOption<string>[]>;
  selectedOption?: ChoiceOption<string>; selectedLabel?: () => string | undefined; fetchDebounceMs?: number;
  debounceMs?: number; searchable?: boolean;
  filterOptions?: (options: readonly SelectOption[], query: string) => readonly SelectOption[];
  groups?: readonly SelectGroup[]; defaultGroup?: string; groupsAriaLabel?: string; allGroupLabel?: string;
  viewToggle?: boolean; defaultView?: SelectView; gridSize?: SelectGridSize; searchPlaceholder?: string;
  clearable?: boolean; name?: string; appearance?: ChoiceAppearance; placeholderIcon?: string;
};

MultiSelectInput props

ts
type MultiSelectOption =
  | string
  | { id: string; label?: string; description?: string; icon?: string; color?: string; groups?: readonly string[] }
  | ChoiceOption<string>;

type MultiSelectFetchDataFn = (query: string, signal: AbortSignal, group: string | null) => Promise<MultiSelectOption[]>;

type MultiSelectInputProps = ValueFieldProps<string[]> & {
  options?: MultiSelectOption[]; fetchData?: MultiSelectFetchDataFn;
  selectedOptions?: () => MultiSelectOption[]; placeholder?: string; icon?: string; activeIcon?: string;
  fetchDebounceMs?: number; debounceMs?: number;
  loadOptions?: (query: string, signal: AbortSignal, group: string | null) => Promise<readonly ChoiceOption<string>[]>;
  groups?: readonly SelectGroup[]; defaultGroup?: string; groupsAriaLabel?: string; allGroupLabel?: string;
  searchable?: boolean; clearable?: boolean; name?: string;
  renderOption?: (option: ChoiceOption<string>) => JSX.Element;
  renderValue?: (option: ChoiceOption<string>) => JSX.Element; searchPlaceholder?: string;
  loadingLabel?: string; noResultsLabel?: string; emptyLabel?: string; retryLabel?: string;
  clearLabel?: string; appearance?: ChoiceAppearance; placeholderIcon?: string;
};

SelectChip props

ts
type SelectChipOption<T extends string | number = string> = {
  value: T; label: string; description?: string; icon?: string; image?: string; disabled?: boolean;
};

type SelectChipProps<T extends string | number = string> = ValueFieldProps<T> & {
  value: MaybeAccessor<T>; options: SelectChipOption<T>[]; icon?: string; iconOnly?: boolean;
  size?: ButtonSize; placeholder?: string; position?: DropdownPosition; menuWidth?: string; name?: string; loading?: boolean;
  open?: boolean; onOpenChange?: (open: boolean) => void;
};

fetchData takes precedence over loadOptions; either remote source takes precedence over options. Debounce is fetchDebounceMs ?? debounceMs ?? 200 milliseconds. Static Select search defaults off; static MultiSelectInput search defaults on. Remote search is always enabled. Render callbacks belong to MultiSelectInput, not Select. They receive normalized ChoiceOption<string> objects and return Solid content. appearance defaults to "field"; DatePicker, DateTimePicker, DateRangePicker, and NumberInput accept the same ChoiceAppearance.

Use open with onOpenChange to control the SelectChip menu, including opening it from another control and reacting to Escape or outside close. Leave open undefined for the existing uncontrolled menu behavior.

SelectChip.size uses ButtonSize; position uses DropdownPosition. menuWidth is an optional exact CSS length; without it the menu sizes to its options, at least 10rem. SelectChip does not accept null; represent an explicit empty choice with an option of your value type.

Accessibility

Use visible labels on Select and MultiSelectInput. When the surrounding layout already shows the field name, such as a description list term, pass "aria-label" instead; the trigger and the options popover both use it. Their triggers expose combobox, listbox, expanded, selected, required, disabled, description, and error state. Select groups use a labelled radio group; arrow keys, Home, and End change the active group. The optional layout button names the view it will open and does not change the listbox semantics.

On touch devices, each option in the Select and MultiSelectInput list is at least 44 px (2.75rem) tall. Options sit close together, so the option itself grows instead of reaching into its neighbour; devices without a touch screen keep the compact options, and grid tiles are already larger. SelectChip uses menu items, which follow the same rule.

The option list of Select, MultiSelectInput, AutocompleteSelect, and Combobox keeps the trigger's width, so it lines up with its field. A long option label or description wraps there instead of ending in an ellipsis; grid tiles keep their two-line clamp.

Option labels must remain clear without icons or colors. If the surrounding toolbar already names a SelectChip, use the native "aria-label" property instead of repeating a visible label.

A plain trigger keeps the same combobox semantics and its accessible name. Its focus ring is drawn on the row surface. The clear entry is a normal option, selected while the value is empty, so arrow keys reach it.

Runtime

Triggers and selected values render in server HTML. Dropdown positioning, keyboard navigation, remote loading, selection, and clearing require browser code. MultiSelectInput measures its pills in the browser, so server HTML shows every pill until browser measurements collapse those that do not fit.

Example

tsx
const [status, setStatus] = createSignal("open");

<Select
  label="Status"
  placeholder="Choose a status"
  options={[
    { id: "open", label: "Open", icon: "ti ti-circle" },
    { id: "done", label: "Done", icon: "ti ti-check" },
  ]}
  value={status}
  onValueChange={setStatus}
  clearable
/>;

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.