Select
A dropdown for choosing one option from a list.
Installation
Included in poetry-ui — available as
poetry_select
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add select
Default
<%= poetry_select(placeholder: "Select a fruit", "aria-label": "Fruit") do |select| %>
<% select.with_group(label: "Fruits") do |group| %>
<% group.with_item(value: "apple") { "Apple" } %>
<% group.with_item(value: "banana") { "Banana" } %>
<% group.with_item(value: "blueberry") { "Blueberry" } %>
<% group.with_item(value: "grapes", disabled: true) { "Grapes" } %>
<% group.with_item(value: "pineapple") { "Pineapple" } %>
<% end %>
<% end %>
Align item with trigger
Toggle to align the item with the trigger.
<%# align_item_with_trigger: the popup opens OVER the trigger with the
selected item's text sitting on the trigger's text (native-select
feel) and the list scrolled to match; it falls back to plain popper
positioning on touch, near viewport edges, or when squeezed. The
switch drives the option live through a small docs-only harness -
in an app you render align_item_with_trigger: true and are done. %>
<div class="flex w-full max-w-xs flex-col gap-6" data-controller="demo-select-align"
data-action="change->demo-select-align#toggle">
<div class="flex items-center gap-3">
<div class="flex flex-1 flex-col gap-0.5">
<%= poetry_label(for_id: "align-item") { "Align item" } %>
<p class="text-muted-foreground text-sm">Toggle to align the item with the trigger.</p>
</div>
<%= poetry_switch(id: "align-item", name: "align_item", checked: true) %>
</div>
<%= poetry_select(value: "banana", "aria-label": "Fruit", align_item_with_trigger: true,
trigger_class: "w-full",
"data-demo-select-align-target": "select") do |select| %>
<% select.with_item(value: "apple") { "Apple" } %>
<% select.with_item(value: "banana") { "Banana" } %>
<% select.with_item(value: "blueberry") { "Blueberry" } %>
<% select.with_item(value: "grapes") { "Grapes" } %>
<% select.with_item(value: "pineapple") { "Pineapple" } %>
<% end %>
</div>
Disabled
<%# disabled: true disables the trigger and the native <select> together. %>
<%= poetry_select(disabled: true, placeholder: "Select a fruit", "aria-label": "Fruit") do |select| %>
<% select.with_item(value: "apple") { "Apple" } %>
<% select.with_item(value: "banana") { "Banana" } %>
<% end %>
Groups
<%# with_group(label:) names a run of options (the label is presentation
plus aria wiring, never selectable) and with_separator rules between
groups - items, groups, and separators interleave in render order. %>
<%= poetry_select(placeholder: "Select produce", "aria-label": "Produce", class: "w-48") do |select| %>
<% select.with_group(label: "Fruits") do |group| %>
<% group.with_item(value: "apple") { "Apple" } %>
<% group.with_item(value: "banana") { "Banana" } %>
<% group.with_item(value: "blueberry") { "Blueberry" } %>
<% end %>
<% select.with_separator %>
<% select.with_group(label: "Vegetables") do |group| %>
<% group.with_item(value: "carrot") { "Carrot" } %>
<% group.with_item(value: "broccoli") { "Broccoli" } %>
<% group.with_item(value: "spinach") { "Spinach" } %>
<% end %>
<% end %>
Invalid
Please select a fruit.
<%# The Field error chain: error: stamps data-invalid on the field and
control_attributes threads aria-invalid + aria-describedby (pointing
at the error text) onto the trigger - the destructive border and
ring come from the theme's aria-invalid: rules, no Select-side code. %>
<div class="w-full max-w-48">
<%= poetry_field(id: "select-fruit", label_text: "Fruit",
error: "Please select a fruit.") do |field| %>
<%= poetry_select(name: "fruit", placeholder: "Select a fruit",
**field.control_attributes.transform_keys(&:to_sym)) do |select| %>
<% select.with_item(value: "apple") { "Apple" } %>
<% select.with_item(value: "banana") { "Banana" } %>
<% select.with_item(value: "blueberry") { "Blueberry" } %>
<% end %>
<% end %>
</div>
Scrollable
<%# A long grouped list: the listbox caps its height and shows
per-direction scroll buttons at the extremes. %>
<%= poetry_select(placeholder: "Select a timezone", "aria-label": "Timezone") do |select| %>
<% select.with_group(label: "North America") do |group| %>
<% group.with_item(value: "est") { "Eastern Standard Time (EST)" } %>
<% group.with_item(value: "cst") { "Central Standard Time (CST)" } %>
<% group.with_item(value: "mst") { "Mountain Standard Time (MST)" } %>
<% group.with_item(value: "pst") { "Pacific Standard Time (PST)" } %>
<% end %>
<% select.with_separator %>
<% select.with_group(label: "Europe & Africa") do |group| %>
<% group.with_item(value: "gmt") { "Greenwich Mean Time (GMT)" } %>
<% group.with_item(value: "cet") { "Central European Time (CET)" } %>
<% group.with_item(value: "eet") { "Eastern European Time (EET)" } %>
<% group.with_item(value: "west") { "Western European Summer Time (WEST)" } %>
<% end %>
<% select.with_separator %>
<% select.with_group(label: "Asia") do |group| %>
<% group.with_item(value: "ist") { "India Standard Time (IST)" } %>
<% group.with_item(value: "cst_china") { "China Standard Time (CST)" } %>
<% group.with_item(value: "jst") { "Japan Standard Time (JST)" } %>
<% end %>
<% end %>
Small
<%# size: :sm drops the trigger to h-8, matching Input's dense form rows. %>
<%= poetry_select(size: :sm, placeholder: "Per page", "aria-label": "Per page") do |select| %>
<% select.with_item(value: "10") { "10" } %>
<% select.with_item(value: "25") { "25" } %>
<% select.with_item(value: "50") { "50" } %>
<% end %>
Valued
<%# A server-rendered value: the trigger shows the label and the native
<select> ships with the option already selected. %>
<%= poetry_select(value: "banana", placeholder: "Select a fruit", "aria-label": "Fruit") do |select| %>
<% select.with_item(value: "apple") { "Apple" } %>
<% select.with_item(value: "banana") { "Banana" } %>
<% select.with_item(value: "blueberry") { "Blueberry" } %>
<% end %>
API
Poetry::Ui::Select::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| align: | Symbol | one of: start, center, end; defaults to :start |
Popup alignment along the chosen side's edge. |
| align_item_with_trigger: | Boolean | defaults to false |
Opens the popup OVER the trigger with the selected item aligned on it (native-select feel); falls back to regular below-the-trigger positioning on touch, viewport-edge triggers, or squeezed heights. |
| align_offset: | Integer | defaults to 0 |
Pixel shift along the alignment axis. |
| avoid_collisions: | Boolean | defaults to true |
Flips/shifts the popup to keep it inside the viewport. |
| dir: | Symbol | one of: ltr, rtl; |
Text direction for the select and its popup. |
| disabled: | Boolean | defaults to false |
Disables the trigger and the hidden native <select>. |
| id: | String | The trigger's DOM id - what a Field label's for: must point at; giving one also satisfies the accessible-name requirement. | |
| loop: | Boolean | defaults to false |
Arrow-key navigation wraps from the last option back to the first. |
| modal: | Boolean | defaults to true |
While open, blocks pointer interaction outside the popup. |
| name: | String | The form field name, carried by the hidden native <select>. | |
| open: | Boolean | defaults to false |
Server-renders the popup already open. |
| placeholder: | String | Text shown in the trigger until an option is committed; also rendered as the blank native <option> (posts \"\" when untouched). | |
| required: | Boolean | defaults to false |
Marks the hidden native <select> required - native constraint validation blocks submission while unset. |
| side: | Symbol | one of: top, right, bottom, left; defaults to :bottom |
Preferred popup side relative to the trigger. |
| side_offset: | Integer | defaults to 4 |
Gap in pixels between trigger and popup. |
| size: | Symbol | one of: sm, default; defaults to :default |
The trigger size axis. |
| trigger_class: | String | Extra classes merged onto the trigger button (e.g. w-full over the base w-fit); class: styles the root wrapper instead. | |
| value: | String | The committed option value - the item whose value: matches renders as selected and its label fills the trigger. |
Slots
| Writer | Description |
|---|---|
| with_group | The option UNION: item | group (label + items) | separator - one ordered collection (interleaving preserved; items and groups are part COMPONENTS so option registration follows render/DOM order). Scroll buttons, the viewport, and the native select are component-owned anatomy, never caller-placed. |
| with_item | The option UNION: item | group (label + items) | separator - one ordered collection (interleaving preserved; items and groups are part COMPONENTS so option registration follows render/DOM order). Scroll buttons, the viewport, and the native select are component-owned anatomy, never caller-placed. |
| with_separator | The option UNION: item | group (label + items) | separator - one ordered collection (interleaving preserved; items and groups are part COMPONENTS so option registration follows render/DOM order). Scroll buttons, the viewport, and the native select are component-owned anatomy, never caller-placed. |
| with_trigger | Optional custom trigger content rendered BEFORE the value span (rare); the component owns role=combobox + the aria wiring + the chevron regardless, so composition cannot drop the contract. |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=select] | Root wrapper carrying both controllers (select + popper) and the optional dir attribute |
| [data-slot=select-native] | The visually-hidden native <select> - the serialization truth (name/required/disabled + every option); plumbing, never styled or targeted |
| [data-slot=select-trigger] | The role=combobox button the field label reaches - the value display and chevron ride inside |
| [data-slot=select-value] | The value display span - the selected option's label, or the placeholder |
| [data-slot=select-content] | The popper-positioned popup shell (scroll buttons + viewport) - open/closed and the resolved placement ride here |
| [data-slot=select-scroll-up-button] | Hover-scroll affordance above the viewport - rendered always but hidden; the controller unhides it per scroll extremes (aria-hidden) |
| [data-slot=select-scroll-down-button] | Hover-scroll affordance below the viewport (the same contract as the up button) |
| [data-slot=select-viewport] | The role=listbox scroll container - the options' actual parent, labelled from the trigger |
| [data-slot=select-group] | role=group wrapper labelled by its select-label heading |
| [data-slot=select-label] | The group heading - styled, no ARIA role (the group points at it via aria-labelledby) |
| [data-slot=select-item] | One role=option div - selection, disablement, and the committable value ride here |
| [data-slot=select-item-indicator] | The check gutter - server-rendered always; the parent item's data-selected absence hides it |
| [data-slot=select-item-text] | The option's label span - the value display copies from it |
| [data-slot=select-separator] | Decorative divider between options (aria-hidden) |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| select-trigger | data-size | always - the resolved size variant | sm · default |
| select-trigger | data-placeholder | no option is committed (bare; the controller toggles it on every commit) | — |
| select-trigger | data-popup-open | the popup is open (bare while open, absent while closed - the controller flips it with the open state) | — |
| select-value | data-placeholder | placeholder: is given - carries the placeholder text so a later clear can restore it | — |
| select-content | data-open | popup is open (the controller flips the pair at runtime) | — |
| select-content | data-closed | popup is closed or animating out (the server-rendered state) | — |
| select-content | data-side | the placement side - server-rendered from side:, rewritten to the resolved side by popper on open | top · right · bottom · left |
| select-content | data-align | the placement alignment - server-rendered from align:, rewritten by popper on open | start · center · end |
| select-item | data-value | always - the option's committable value (the native <option> twin) | — |
| select-item | data-selected | the option is committed (bare; absent while unselected - the controller twin-writes it with aria-selected) | — |
| select-item | data-disabled | disabled: is set (aria-disabled rides along) | — |
CSS variables
| Part | Variable | Description |
|---|---|---|
| select-content | --transform-origin | popper - the animation origin matching the resolved placement |
| select-content | --available-width | popper - viewport space available to the popup post-flip |
| select-content | --available-height | popper - viewport space available to the popup post-flip |
| select-content | --anchor-width | popper - the trigger's measured width |
| select-content | --anchor-height | popper - the trigger's measured height |
| select-content | --radix-select-trigger-width | the select controller measures the trigger on open - the viewport's min-width binding |
| select-content | --radix-select-trigger-height | the select controller measures the trigger on open - the viewport's MINIMUM-height binding (a hard height collapses the popup to the trigger; the list grows past it) |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--select | registers · value open · value value · value modal · value loop · value align_item_with_trigger |
| root | poetry--core--popper | registers · value side · value align · value side_offset · value align_offset · value avoid_collisions |
| trigger | poetry--core--select | toggle on click · triggerKeydown on keydown |
| trigger | poetry--core--popper | target anchor |
| content | poetry--core--popper | target content |
| item | poetry--core--select | commit on click |
| native | poetry--core--select | nativeChanged on change |
| viewport | poetry--core--select | syncScrollButtons on scroll |
| scroll_button | poetry--core--select | scrollHoldStart on pointerenter · scrollHoldStop on pointerleave |