hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
Composing Hono Middleware
WebSockets
Rooms & Presence
renderPage
Link Prefetch
Build & Deploy
Overview
Dialog
Popover
Tooltip
Menu
Context Menu
Select
Combobox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition
usePositioner
useDismiss
useFocusReturn
useSafeArea
usePresence

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#

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>

Form reset#

Inside a <form>, the component resets to its defaultValue when the form is reset (a reset button, or Form's reset), the same way a native field resets to its default. A reset whose event is preventDefaulted is ignored.

Styling#

Parts expose data-state (open / closed on the trigger 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, 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 renderElement) 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, renderElement, 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.