Quick Reference
Parts
| Partial |
Description |
components/combobox |
Root container with Stimulus controller |
components/combobox/trigger |
Button that opens the popover |
components/combobox/content |
Popover container |
components/combobox/input |
Search/filter input field |
components/combobox/list |
Scrollable options container |
components/combobox/option |
Selectable item with check indicator |
components/combobox/empty |
“No results” message |
components/combobox/group |
Logical grouping |
components/combobox/label |
Section heading |
components/combobox/separator |
Visual divider |
Helper Methods
| Method |
Description |
combobox |
Builder pattern for full control |
combobox_simple |
Data-driven simple comboboxes |
Parameters
_combobox.html.erb
| Parameter |
Type |
Default |
Description |
id |
String |
Auto-generated |
Explicit ID for linking trigger to content |
name |
String |
nil |
Form field name |
value |
String |
nil |
Currently selected value |
placeholder |
String |
"Select..." |
Placeholder text for trigger |
css_classes |
String |
"" |
Additional CSS classes |
**html_options |
Hash |
{} |
HTML attributes |
combobox/_trigger.html.erb
| Parameter |
Type |
Default |
Description |
for_id |
String |
Required |
ID of content to control |
placeholder |
String |
"Select..." |
Placeholder text |
variant |
Symbol |
:outline |
Button variant |
size |
Symbol |
:default |
Button size |
css_classes |
String |
"" |
Additional CSS classes |
**html_options |
Hash |
{} |
HTML attributes |
combobox/_content.html.erb
| Parameter |
Type |
Default |
Description |
id |
String |
Required |
Popover ID (matches trigger’s for_id) |
align |
Symbol |
:start |
Alignment: :start, :center, :end |
width |
Symbol |
:default |
Width: :sm, :default, :md, :lg, :full |
css_classes |
String |
"" |
Additional CSS classes |
**html_options |
Hash |
{} |
HTML attributes |
| Parameter |
Type |
Default |
Description |
placeholder |
String |
"Search..." |
Input placeholder |
css_classes |
String |
"" |
Additional CSS classes |
**html_options |
Hash |
{} |
HTML attributes |
combobox/_option.html.erb
| Parameter |
Type |
Default |
Description |
value |
String |
Required |
Option value |
selected |
Boolean |
false |
Whether initially selected |
disabled |
Boolean |
false |
Whether disabled |
css_classes |
String |
"" |
Additional CSS classes |
**html_options |
Hash |
{} |
HTML attributes |
combobox/_empty.html.erb
| Parameter |
Type |
Default |
Description |
text |
String |
"No results found." |
Empty state message |
css_classes |
String |
"" |
Additional CSS classes |
**html_options |
Hash |
{} |
HTML attributes |
Data Attributes
Component Identifiers
| Attribute |
Element |
Description |
data-component="combobox" |
Root container |
Main component identifier |
data-controller="combobox" |
Root container |
Stimulus controller |
data-combobox-part="trigger" |
Button |
Trigger element |
data-combobox-part="content" |
Popover |
Content container |
data-combobox-part="input" |
Input |
Search field |
data-combobox-part="list" |
Div |
Options container |
data-combobox-part="option" |
Div |
Selectable option |
data-combobox-part="check" |
Span |
Check indicator |
data-combobox-part="empty" |
Div |
Empty state |
Stimulus Targets
| Attribute |
Description |
data-combobox-target="trigger" |
Trigger button |
data-combobox-target="content" |
Popover content |
data-combobox-target="input" |
Search input |
data-combobox-target="option" |
Option items |
data-combobox-target="empty" |
Empty state |
data-combobox-target="label" |
Selected value label |
State Attributes
| Attribute |
Description |
data-selected="true" |
Option is selected |
data-has-value="true" |
Trigger has a value selected |
aria-selected="true" |
ARIA selected state |
aria-expanded="true" |
Popover is open |
Basic Usage
Using the Helper (Recommended)
<%= combobox placeholder: "Select framework..." do |cb| %>
<% cb.trigger %>
<% cb.content do %>
<% cb.input placeholder: "Search frameworks..." %>
<% cb.list do %>
<% cb.option value: "nextjs" do %>Next.js<% end %>
<% cb.option value: "sveltekit" do %>SvelteKit<% end %>
<% cb.option value: "nuxt" do %>Nuxt.js<% end %>
<% cb.option value: "remix" do %>Remix<% end %>
<% cb.option value: "astro" do %>Astro<% end %>
<% end %>
<% cb.empty %>
<% end %>
<% end %>
Using Partials
<%= render "components/combobox", placeholder: "Select framework..." do |combobox_id| %>
<%= render "components/combobox/trigger", for_id: combobox_id, placeholder: "Select framework..." %>
<%= render "components/combobox/content", id: combobox_id do %>
<%= render "components/combobox/input", placeholder: "Search..." %>
<%= render "components/combobox/list" do %>
<%= render "components/combobox/option", value: "nextjs" do %>Next.js<% end %>
<%= render "components/combobox/option", value: "sveltekit" do %>SvelteKit<% end %>
<%= render "components/combobox/option", value: "nuxt" do %>Nuxt.js<% end %>
<% end %>
<%= render "components/combobox/empty" %>
<% end %>
<% end %>
Simple Data-Driven
<%= combobox_simple placeholder: "Select framework...",
options: [
{ value: "nextjs", label: "Next.js" },
{ value: "sveltekit", label: "SvelteKit" },
{ value: "nuxt", label: "Nuxt.js" },
{ value: "remix", label: "Remix" },
{ value: "astro", label: "Astro" }
] %>
Examples
With Pre-selected Value
<%= combobox_simple placeholder: "Select framework...",
value: "remix",
options: [
{ value: "nextjs", label: "Next.js" },
{ value: "sveltekit", label: "SvelteKit" },
{ value: "remix", label: "Remix" }
] %>
With Disabled Options
<%= combobox_simple placeholder: "Select framework...",
options: [
{ value: "nextjs", label: "Next.js" },
{ value: "angular", label: "Angular (deprecated)", disabled: true },
{ value: "remix", label: "Remix" }
] %>
With Groups
<%= combobox placeholder: "Select technology..." do |cb| %>
<% cb.trigger %>
<% cb.content width: :md do %>
<% cb.input %>
<% cb.list do %>
<% cb.group do %>
<% cb.label "Frontend Frameworks" %>
<% cb.option value: "react" do %>React<% end %>
<% cb.option value: "vue" do %>Vue<% end %>
<% cb.option value: "svelte" do %>Svelte<% end %>
<% end %>
<% cb.separator %>
<% cb.group do %>
<% cb.label "Backend Frameworks" %>
<% cb.option value: "rails" do %>Ruby on Rails<% end %>
<% cb.option value: "django" do %>Django<% end %>
<% cb.option value: "phoenix" do %>Phoenix<% end %>
<% end %>
<% end %>
<% cb.empty %>
<% end %>
<% end %>
Width Variants
<%= combobox placeholder: "Size" do |cb| %>
<% cb.trigger %>
<% cb.content width: :sm do %>
<% end %>
<% end %>
<%= combobox placeholder: "Select a very long option name..." do |cb| %>
<% cb.trigger %>
<% cb.content width: :lg do %>
<% end %>
<% end %>
Custom Alignment
<%= combobox placeholder: "Select..." do |cb| %>
<% cb.trigger %>
<% cb.content align: :end do %>
<% end %>
<% end %>
Real-World Patterns
Country Selector
<%= combobox placeholder: "Select country...", name: "user[country]" do |cb| %>
<% cb.trigger %>
<% cb.content width: :md do %>
<% cb.input placeholder: "Search countries..." %>
<% cb.list do %>
<% Country.all.each do |country| %>
<% cb.option value: country.code, selected: @user.country == country.code do %>
<%= country.flag %> <%= country.name %>
<% end %>
<% end %>
<% end %>
<% cb.empty text: "No countries found." %>
<% end %>
<% end %>
User Selector
<%= combobox placeholder: "Assign to...", id: "assignee-select" do |cb| %>
<% cb.trigger variant: :ghost %>
<% cb.content width: :lg do %>
<% cb.input placeholder: "Search team members..." %>
<% cb.list do %>
<% @team_members.each do |member| %>
<% cb.option value: member.id do %>
<div class="flex items-center gap-2">
<%= image_tag member.avatar_url, class: "size-6 rounded-full" %>
<span><%= member.name %></span>
<span class="text-muted-foreground text-xs"><%= member.role %></span>
</div>
<% end %>
<% end %>
<% end %>
<% cb.empty %>
<% end %>
<% end %>
Status Selector
<%= combobox placeholder: "Set status...", value: @task.status do |cb| %>
<% cb.trigger %>
<% cb.content do %>
<% cb.input placeholder: "Search statuses..." %>
<% cb.list do %>
<% cb.option value: "backlog" do %>
<%= render "components/badge", variant: :secondary do %>Backlog<% end %>
<% end %>
<% cb.option value: "todo" do %>
<%= render "components/badge", variant: :default do %>Todo<% end %>
<% end %>
<% cb.option value: "in_progress" do %>
<%= render "components/badge", variant: :warning do %>In Progress<% end %>
<% end %>
<% cb.option value: "done" do %>
<%= render "components/badge", variant: :success do %>Done<% end %>
<% end %>
<% cb.option value: "cancelled" do %>
<%= render "components/badge", variant: :destructive do %>Cancelled<% end %>
<% end %>
<% end %>
<% cb.empty %>
<% end %>
<% end %>
<%= form_with model: @project do |f| %>
<div class="space-y-4">
<%= render "components/label", for: "project_framework" do %>Framework<% end %>
<%= combobox_simple id: "project_framework",
name: "project[framework]",
value: @project.framework,
placeholder: "Select framework...",
options: Framework.all.map { |fw|
{ value: fw.slug, label: fw.name }
} %>
<%= f.submit "Save", data: { component: "button", variant: "primary" } %>
</div>
<% end %>
Listening for Changes (Stimulus)
<div data-controller="project-form">
<%= combobox placeholder: "Select framework...",
data: { action: "combobox:change->project-form#frameworkChanged" } do |cb| %>
<% end %>
</div>
// project_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
frameworkChanged(event) {
const { value, label } = event.detail
console.log(`Selected: ${label} (${value})`)
}
}
Browser Support & Polyfill
The combobox uses the HTML5 Popover API for light-dismiss behavior. For browsers without native support, use a polyfill.
Browser Support
| Browser |
Version |
Native Support |
| Chrome |
114+ |
Yes |
| Edge |
114+ |
Yes |
| Safari |
17+ |
Yes |
| Firefox |
125+ |
Yes |
| Safari iOS |
17+ |
Yes |
Installing the Polyfill
Option 1: NPM Package
npm install @oddbird/popover-polyfill
# or
yarn add @oddbird/popover-polyfill
// app/javascript/application.js
import "@oddbird/popover-polyfill"
Option 2: CDN
<script src="https://cdn.jsdelivr.net/npm/@oddbird/popover-polyfill@latest/dist/popover.min.js" crossorigin="anonymous" defer></script>
Option 3: Import Map (Rails 7+)
# config/importmap.rb
pin "@oddbird/popover-polyfill", to: "https://cdn.jsdelivr.net/npm/@oddbird/popover-polyfill@latest/dist/popover.min.js"
// app/javascript/application.js
import "@oddbird/popover-polyfill"
Keyboard Navigation
| Key |
Action |
Enter / Space |
Open combobox (on trigger), select option |
Escape |
Close combobox |
↓ |
Focus next option |
↑ |
Focus previous option |
Home |
Focus first option |
End |
Focus last option |
| Type characters |
Filter options |
Theme Variables
The combobox component uses these CSS variables from your theme:
/* Popover/Content */
var(--popover)
var(--popover-foreground)
var(--border)
/* Options */
var(--accent)
var(--accent-foreground)
var(--muted-foreground)
/* Focus ring (inherited from button) */
var(--ring)
var(--background)
Accessibility
- Trigger has
role="combobox" with aria-expanded and aria-haspopup="listbox"
- Content has
role="listbox"
- Options have
role="option" with aria-selected
- Disabled options have
aria-disabled="true"
- Keyboard navigation fully supported
- Focus returns to trigger when closed
- Light-dismiss (click outside) closes the popover
File Structure
app/views/components/
├── _combobox.html.erb
└── combobox/
├── _trigger.html.erb
├── _content.html.erb
├── _input.html.erb
├── _list.html.erb
├── _option.html.erb
├── _empty.html.erb
├── _group.html.erb
├── _label.html.erb
└── _separator.html.erb
app/assets/stylesheets/combobox.css
app/javascript/controllers/combobox_controller.js
app/helpers/maquina_components/combobox_helper.rb
docs/combobox.md
| Feature |
Combobox |
Dropdown Menu |
| Purpose |
Selection from many options |
Actions/navigation |
| Search |
Yes (filterable) |
No |
| Selection |
Single value with indicator |
No selection state |
| ARIA Role |
combobox + listbox |
menu + menuitem |
| Popover API |
Yes (HTML5 native) |
No (Stimulus-managed) |
| Items |
Options with values |
Action links/buttons |