hono-preact

Select

A custom listbox select: a button that opens a popup of options and writes the chosen value back. It supports single and multiple selection, carries a generic value type so an option's value is your own data rather than a string, and submits in a real form through a name. Positioning runs on Floating UI; the popup renders in place and promotes to the browser top layer using the Popover API. It ships unstyled: style it through the data-state, data-highlighted, data-selected, data-disabled, data-placeholder, data-side, and data-align contract.

Select.Value shows the selected option's label by reading the registered options client-side, so on the server (before hydration) it falls back to the placeholder. For a label that is correct in server-rendered HTML, pass Select.Value a render prop (or a children function) and resolve the label from your own data, which runs on the server too.

Demo

A single select closes when you pick an option; the trigger shows the chosen label.

A multiple select toggles each option and stays open; the trigger joins the selected labels.

Usage

import { Select } from '@hono-preact/ui';

export function FruitPicker() {
  return (
    <Select.Root name="fruit">
      <Select.Trigger>
        <Select.Value placeholder="Pick a fruit" />
      </Select.Trigger>
      <Select.Positioner>
        <Select.Popup aria-label="Fruit">
          <Select.Option value="apple">Apple</Select.Option>
          <Select.Option value="banana">Banana</Select.Option>
          <Select.Option value="cherry" disabled>
            Cherry
          </Select.Option>
          <Select.OptionGroup>
            <Select.OptionGroupLabel>Citrus</Select.OptionGroupLabel>
            <Select.Option value="orange">Orange</Select.Option>
            <Select.Option value="lemon">Lemon</Select.Option>
          </Select.OptionGroup>
        </Select.Popup>
      </Select.Positioner>
    </Select.Root>
  );
}

Set multiple on Select.Root for multi-selection: picking an option toggles it and keeps the popup open, and the value becomes an array. Pass a value / onValueChange pair to control the selection, or defaultValue to leave it uncontrolled. When name is set, the value submits as a hidden field (one field per selected value in multiple mode), present before hydration.

The value type is generic. By default an option's value is a string, but you can pass objects: give Select.Root an isValueEqual comparator so the component can match selected values by identity, and a serializeValue so the hidden form field has a string to submit.

<Select.Root<User>
  isValueEqual={(a, b) => a.id === b.id}
  serializeValue={(u) => u.id}
  name="assignee"
>
  <Select.Trigger>
    <Select.Value placeholder="Assign to">
      {({ selectedLabels }) => selectedLabels.join(', ') || 'Assign to'}
    </Select.Value>
  </Select.Trigger>
  {/* ... */}
</Select.Root>

Styling

Parts expose data-state (open / closed on the trigger, positioner, and popup). Options expose data-highlighted while active, data-selected when selected, and data-disabled when disabled; Select.Value exposes data-placeholder while empty; the Positioner and Arrow expose data-side and data-align. The Positioner is the fixed-positioned wrapper, so size, z-index, and the entry animation go on the Popup inside it. The demo above uses the styles below; copy a starting point in either flavor:

.docs-select-trigger {
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  min-width: 14rem;
  font-size: 0.875rem;
  padding: 0.5rem 0.75rem;
  border: 1px solid #e4e4e7;
  border-radius: 0.5rem;
  background: #fff;
  color: #18181b;
  cursor: pointer;
}
.docs-select-trigger:hover,
.docs-select-trigger[data-state='open'] {
  border-color: #18181b;
}
.docs-select__value[data-placeholder] {
  color: #71717a;
}
.docs-select-positioner {
  z-index: 50;
}
.docs-select {
  box-sizing: border-box;
  min-width: 14rem;
  max-height: 16rem;
  overflow-y: auto;
  padding: 0.25rem;
  border: 1px solid #e4e4e7;
  border-radius: 0.625rem;
  background: #fff;
  color: #18181b;
  box-shadow:
    0 10px 15px -3px rgb(0 0 0 / 0.25),
    0 4px 6px -4px rgb(0 0 0 / 0.25);
  outline: none;
  opacity: 1;
  transform: translateY(0);
  transition:
    opacity 120ms ease,
    transform 120ms ease;
}
@starting-style {
  .docs-select[data-state='open'] {
    opacity: 0;
    transform: translateY(-4px);
  }
}
.docs-select[data-state='closed'] {
  opacity: 0;
  transform: translateY(-4px);
}
/* Options: the active descendant is data-highlighted (set by hover and arrow
   keys); the selected option is data-selected. Both can be true at once. */
.docs-select__option {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.875rem;
  padding: 0.4rem 0.6rem;
  border-radius: 0.375rem;
  cursor: pointer;
  outline: none;
}
.docs-select__option[data-selected]::after {
  content: '✓';
  margin-left: auto;
}
.docs-select__option[data-highlighted] {
  background: #18181b;
  color: #fafafa;
}
.docs-select__option[data-disabled] {
  color: #a1a1aa;
  pointer-events: none;
}
.docs-select__label {
  padding: 0.25rem 0.6rem;
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: #71717a;
}
@media (prefers-color-scheme: dark) {
  .docs-select-trigger,
  .docs-select {
    border-color: #27272a;
    background: #18181b;
    color: #fafafa;
  }
  .docs-select-trigger:hover,
  .docs-select-trigger[data-state='open'] {
    border-color: #fafafa;
  }
  .docs-select__option[data-highlighted] {
    background: #fafafa;
    color: #18181b;
  }
}
@media (prefers-reduced-motion: reduce) {
  .docs-select {
    transition: none;
  }
}

Keyboard

KeysWhenAction
Enter / SpaceTrigger, closedOpen the listbox.
/ Trigger, closedOpen the listbox.
Alt + Trigger, closedOpen the listbox.
/ Listbox openMove the active option to the next / previous, wrapping at the ends.
Home / EndListbox openMove the active option to the first / last.
printable charactersListbox openTypeahead: activate the next option whose label starts with the typed text.
Enter / SpaceListbox open (single)Select the active option and close.
Enter / SpaceListbox open (multiple)Toggle the active option and stay open.
EscapeListbox openClose without changing the selection; focus stays on the trigger.
TabListbox openClose, then move focus to the next element.

Focus stays on the trigger the whole time; navigation moves the active option via aria-activedescendant rather than moving DOM focus. Disabled options are skipped by arrow keys and typeahead. Set loop={false} on Select.Root to stop arrow navigation from wrapping, and typeahead={false} to disable type-to-activate.

Data attributes

AttributeOnValues
data-stateTrigger, Positioner, Popupopen | closed
data-highlightedOptionpresent while the option is the active descendant
data-selectedOptionpresent when the option is selected
data-disabledOptionpresent when the option is disabled
data-placeholderValuepresent while no option is selected
data-sidePositioner, Arrowtop | right | bottom | left (resolved)
data-alignPositionerstart | center | end (resolved)

The selected option also carries aria-selected="true", so you can style the selection with either the data attribute or the ARIA state.

API reference

Every part accepts a render prop for composition (see useRender) and forwards unknown props to the element it renders. Select.Option passes its { selected, disabled, highlighted } state to a render function; Select.Value passes { selectedLabels }.

Select.Root

Provides selection and open state, ids, refs, navigation config, and placement to the parts. Generic over the value type (Select.Root<Value>); defaults to string. Renders its children plus the hidden form field(s) when name is set.

PropTypeDefaultDescription
valueValue | Value[]-Controlled selection. Pair with onValueChange. An array in multiple mode.
defaultValueValue | Value[]-Initial selection when uncontrolled.
onValueChange(value: Value | Value[]) => void-Called when the selection changes.
multiplebooleanfalseAllow more than one selection; the value becomes an array.
openboolean-Controlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => void-Called when the listbox requests an open or close.
namestring-Submit the value as a hidden field (one per value in multiple mode).
disabledbooleanfalseDisable the trigger and skip the hidden field.
requiredbooleanfalseMark the trigger aria-required.
isValueEqual(a: Value, b: Value) => booleanObject.isCompare values for selection matching (use for object values).
serializeValue(value: Value) => stringStringStringify a value for the hidden form field.
side'top'|'right'|'bottom'|'left''bottom'Preferred side of the trigger to place the popup.
align'start'|'center'|'end''start'Alignment along that side.
offsetnumber8Gap in pixels between the trigger and the popup.
loopbooleantrueWrap arrow navigation at the first / last option.
typeaheadbooleantrueActivate options by typing their leading characters.
childrenComponentChildren-The trigger and positioner.

Select.Trigger

Toggles the listbox on click and anchors it. Owns the keyboard navigation and typeahead while open (focus stays on it). Default element <button type="button"> with role="combobox".

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
childrenComponentChildren-Usually a Select.Value and an indicator.
...propsJSX.HTMLAttributes<HTMLButtonElement>-Forwarded to the element; a passed onClick runs before the listbox toggles.

Sets role="combobox", aria-haspopup="listbox", aria-expanded, aria-controls, aria-activedescendant (while open), aria-required (when required), id, and data-state.

Select.Value

Shows the selected option's label, or the placeholder while empty. Reads the registered option labels client-side, so server-rendered HTML shows the placeholder unless you supply a children / render function. Default element <span>.

PropTypeDefaultDescription
placeholderstring''Text shown while nothing is selected.
renderRenderProp<{ selectedLabels: string[] }>-Compose or replace the element.
children(state: { selectedLabels: string[] }) => ComponentChildren-Render the display from the selected labels (server-accurate).
...propsJSX.HTMLAttributes<HTMLSpanElement>-Forwarded to the element.

Sets data-placeholder while no option is selected.

Select.Positioner

The fixed-positioned wrapper that Floating UI drives. Always mounted (so options register their labels) and hidden while closed. Default element <div>.

PropTypeDefaultDescription
renderRenderProp<{ side: Side; align: Align }>-Compose or replace the element.
childrenComponentChildren-The popup (and optional arrow).
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element. Style positioning via class.

Sets position: fixed, the resolved data-side / data-align, and hidden while closed. The element is promoted to the top layer via the Popover API, so it escapes ancestor clipping.

Select.Popup

The listbox surface. Default element <div> with role="listbox".

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
aria-labelstring-Accessible name. Defaults to the trigger's label.
childrenComponentChildren-Options and option groups.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Sets role="listbox", id, aria-multiselectable (in multiple mode), data-state, and aria-labelledby (the trigger) or aria-label. Owns Escape / outside-press dismissal.

Select.Option

A selectable row. Default element <div> with role="option".

PropTypeDefaultDescription
valueValuerequiredThe value this option selects.
renderRenderProp<{ selected: boolean; disabled: boolean; highlighted: boolean }>-Compose or replace the element.
disabledbooleanfalseSkip the option in navigation and ignore selection.
childrenComponentChildren-Option content; a string child is its label.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Sets role="option", aria-selected, aria-disabled (when disabled), and data-selected / data-highlighted / data-disabled.

Select.OptionGroup

Wraps related options and labels them with a Select.OptionGroupLabel. Default element <div> with role="group".

PropTypeDefaultDescription
renderRenderProp-Compose or replace it.
childrenComponentChildren-A Select.OptionGroupLabel and options.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Wires its label to aria-labelledby.

Select.OptionGroupLabel

The accessible name for a Select.OptionGroup. Presentational, not selectable. Default element <div>.

PropTypeDefaultDescription
renderRenderProp-Compose or replace it.
childrenComponentChildren-Label text.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Select.Arrow

Optional pointer positioned from the Floating UI arrow data. Default element <div>. Place it on the correct edge with CSS keyed on data-side.

PropTypeDefaultDescription
renderRenderProp<{ side: Side }>-Compose or replace the element.
childrenComponentChildren-Optional arrow content.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded to the element.

Sets data-side and position: absolute with the computed offset.

Primitives

@hono-preact/ui exports the building blocks the parts use, for composing your own components. Several have their own page with examples: useListNavigation, usePosition, useDismiss, useRender, useControllableState, and mergeRefs.

Accessibility

The select follows the ARIA combobox-with-listbox pattern. The trigger is a role="combobox" carrying aria-haspopup="listbox", aria-expanded, aria-controls, and aria-activedescendant; the popup is a role="listbox" (aria-multiselectable in multiple mode), named by the trigger or an explicit aria-label. Options are role="option" with aria-selected.

  • Both Enter / Space and the arrow keys open the listbox from the trigger, so it is reachable without a pointer.
  • Focus stays on the trigger while the listbox is open; the active option is tracked with aria-activedescendant rather than moving DOM focus, and the active option is scrolled into view.
  • On open, the active descendant starts on the selected option (or the first option when nothing is selected).
  • Escape closes without changing the selection, an outside pointer press closes it, and Tab closes then moves on. Disabled options are skipped by navigation, typeahead, and selection.
  • Set a name so the value submits in a real form; the hidden field is a real input present before hydration.