DROPDOWN: Custom dropdown for selecting options with rich content
VARIANTS: Simple | Scrollable | Checkable (Multi-select) | With Icons | Sizes | Borderless | Icon
Use dropdowns when there's a limited set of predefined options, options are too numerous to display directly, or when users need to make a single selection from many choices. Requires JavaScript (Stimulus controller).
Simple Dropdown: Basic single-select dropdown with checkmark indicator on selected item. Click to open, select an option to close.
<div class="tw-dropdown" data-controller="dropdown">...</div>
Scrollable: For long lists, add .dropdown-menu-scrollable to constrain height with overflow scroll.
<div class="tw-dropdown-menu tw-dropdown-menu-scrollable">...</div>
Multi-select: Use checkboxes for selecting multiple items. Add data-dropdown-multiple-value="true" to enable multi-select mode. The dropdown stays open after selection.
<div class="tw-dropdown" data-dropdown-multiple-value="true">...</div>
Icons: Add icons to items using .dropdown-item-icon class. Icons appear on the left side of the item text.
<svg class="tw-dropdown-item-icon">...</svg>
Sizes: Use .dropdown-sm or .dropdown-lg on the container to change the size of trigger and menu items.
Small (.dropdown-sm)
Default
Large (.dropdown-lg)
<div class="tw-dropdown tw-dropdown-sm">...</div><div class="tw-dropdown tw-dropdown-lg">...</div>
Disabled: Add disabled attribute to the trigger button to disable the entire dropdown.
<button class="tw-dropdown-trigger" disabled>...</button>
Validation: Add .dropdown-valid or .dropdown-invalid to the container for validation feedback.
Valid
Invalid
<div class="tw-dropdown tw-dropdown-valid">...</div><div class="tw-dropdown tw-dropdown-invalid">...</div>
Borderless: Add .dropdown-borderless to remove the border from the trigger. Focus ring still appears on focus for accessibility.
Borderless
Borderless Small
Borderless Large
<div class="tw-dropdown tw-dropdown-borderless">...</div>
Icon: Add .dropdown-icon to create a square icon-only trigger button (matching .btn-icon dimensions).
Chevron and label are hidden automatically. Always include aria-label for accessibility.
Composes with .dropdown-borderless for toolbar-style transparent triggers.
Bordered (default)
Borderless (toolbar)
Small (.dropdown-sm)
Default
Large (.dropdown-lg)
Menu right-aligned
<div class="tw-dropdown tw-dropdown-icon">...</div><div class="tw-dropdown tw-dropdown-icon tw-dropdown-borderless">...</div><div class="tw-dropdown tw-dropdown-icon tw-dropdown-sm">...</div>
Keyboard Support: Full keyboard navigation is built-in. Try these keys on any dropdown above:
| Key | Action |
|---|---|
Enter / Space |
Open menu (if closed) or select highlighted item |
Escape |
Close menu |
↓ Arrow Down |
Open menu or move to next item |
↑ Arrow Up |
Open menu or move to previous item |
Home |
Jump to first item |
End |
Jump to last item |
Tab |
Close menu and move focus |
Quick Reference
Container: .dropdown | .dropdown-block (full width)
Trigger: .dropdown-trigger | .dropdown-label | .dropdown-placeholder | .dropdown-chevron
Menu: .dropdown-menu | .dropdown-menu-scrollable | .dropdown-menu-end | .dropdown-menu-top
Items: .dropdown-item | .dropdown-item-icon | .dropdown-item-text | .dropdown-item-check | .dropdown-item.active
Checkable: .dropdown-check | .dropdown-check-input | .dropdown-check-label
Extras: .dropdown-divider | .dropdown-header
Sizes: .dropdown-sm | .dropdown-lg
Validation: .dropdown-valid | .dropdown-invalid
Style: .dropdown-borderless | .dropdown-icon
Stimulus: data-controller="dropdown" | data-dropdown-multiple-value="true"
Searchable single-select: Type to filter. Click an option → input fills with that label, menu closes. Re-focusing the input auto-selects its text so typing replaces it. A "No matches" status row appears when the filter has no results.
<div class="tw-dropdown tw-dropdown-search" data-controller="dropdown">...</div>
Searchable multi-select: Type to filter. Click an option to toggle. Menu stays open, input keeps the query, input stays focused. Selected rows show the right-side check indicator. Selected items remain selected even when filtered out of the visible list — the host page is responsible for rendering chosen values outside the control.
<div class="tw-dropdown tw-dropdown-search" data-controller="dropdown" data-dropdown-multiple-value="true">...</div>
Custom value entry: When the typed query has no exact match in the list, an "Add 'xyz'" row appears at the bottom of the menu. Arrow keys navigate onto it; Enter or click commits the custom value. Demonstrated here in single-select mode.
<div class="tw-dropdown tw-dropdown-search" data-controller="dropdown" data-dropdown-allow-custom-value="true">...</div>
ASYNC: data-dropdown-async-value | data-dropdown-min-chars-value | data-dropdown-debounce-value
For lists too long to preload — students, schools, contacts. The controller owns the
states and the debounce; the page owns the fetch. It asks by dispatching
dropdown:search and the page answers with dropdown:resolved
(detail.count) or dropdown:failed. No URL is hardwired in the
design system. Try it: type "ha" for results, "zz" for none, or
"fail" to see the error and retry.
element.addEventListener("dropdown:search", e => { /* fetch */ el.dispatchEvent(new CustomEvent("dropdown:resolved", { detail: { count } })) })
COMPOSES: .tw-tag-input (pattern) + .tw-dropdown-search + .tw-tag
The classic multi-select where chosen values sit as tags inside the field. No new
classes — the Tag Input pattern already is this shape, so it becomes the dropdown's
trigger and its inner field becomes the searchInput target. Selected values are the
host page's to render, as they are for every multi-select.
<div class="tw-dropdown tw-dropdown-search"><div class="tw-tag-input" data-dropdown-target="trigger">…tags + .tw-tag-input-field as searchInput…</div><div class="tw-dropdown-menu">…</div></div>
MENU: .tw-dropdown-menu-fit | .tw-dropdown-menu-wide | .tw-dropdown-menu-end | .tw-dropdown-menu-top
The menu is w-full with a 200px floor, so a narrow toolbar trigger gets a
menu wider than itself. .tw-dropdown-menu-fit drops the floor and lets the menu match the
trigger exactly — it is the supported way to do this, and it replaces the ID-specificity page CSS
narrow toolbar dropdowns were using to fight the min-width.
Use it only where the options are genuinely short (Show 25, Sort A–Z); an option that then truncates
needs the room, not the fit.
Note: .tw-dropdown-sm sets its own 160px floor with a two-class
selector, which out-specifies .tw-dropdown-menu-fit — on a small dropdown the fit has
no effect. Open each menu below to compare.
Default — 200px floor
Fit (.tw-dropdown-menu-fit)
Wide (.tw-dropdown-menu-wide)
<div class="tw-dropdown-menu tw-dropdown-menu-fit">…</div> |
<div class="tw-dropdown-menu tw-dropdown-menu-wide">…</div> |
<div class="tw-dropdown-menu tw-dropdown-menu-end">…</div>