Components
Select
A list of options in a popup, opened from a button: searchable, keyboard driven, and styled like an input.
No country matches that.
Where the invoice is billed.
<div class="field w-64">
<span class="field-label" id="select-country-label">Country</span>
<div class="select" data-select>
<button type="button" class="select-trigger" data-select-trigger
aria-haspopup="listbox" aria-expanded="false" aria-labelledby="select-country-label"
aria-describedby="select-country-help">
<span class="select-value" data-placeholder="Choose a country"></span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="select-content" role="listbox" aria-labelledby="select-country-label" hidden>
<div class="select-search">
<input class="input input-sm" data-select-search placeholder="Search countries…" aria-label="Search countries" />
</div>
<span class="select-item" role="option" aria-selected="false" value="au">
Australia
<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span>
</span>
<span class="select-item" role="option" aria-selected="false" value="br">
Brazil
<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span>
</span>
<span class="select-item" role="option" aria-selected="false" value="ca">
Canada
<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span>
</span>
<span class="select-item" role="option" aria-selected="false" value="de">
Germany
<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span>
</span>
<span class="select-item" role="option" aria-selected="false" value="jp">
Japan
<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span>
</span>
<span class="select-item" role="option" aria-selected="false" value="mx">
Mexico
<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span>
</span>
<span class="select-item" role="option" aria-selected="true" value="nz">
New Zealand
<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span>
</span>
<span class="select-item" role="option" aria-selected="false" value="pt">
Portugal
<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span>
</span>
<span class="select-item" role="option" aria-selected="false" value="za">
South Africa
<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span>
</span>
<p class="select-empty" data-select-empty hidden>No country matches that.</p>
</div>
</div>
<p class="field-description" id="select-country-help">Where the invoice is billed.</p>
</div>
A select is a dropdown that picks a value: the trigger takes the label's place in
a field, and the chosen option is what the form submits. Reach for it when the
native <select> is not enough — filtering, groups, disabled options, markup inside an option — and for
a plain <select> that only needs styling, keep the .select input
class.
Markup#
- Wrap the trigger and the list in
.selectwithdata-select, like a dropdown. - The trigger is a
buttonwithdata-select-trigger,aria-haspopup="listbox"andaria-expanded="false". Its text goes in an empty.select-valuespan, withdata-placeholderfor the text shown until something is picked. Name the button after the field's label witharia-labelledby. - The list is a
.select-contentwithrole="listbox"that startshidden. Each entry is a.select-itemwithrole="option", avalue, andaria-selected="true"on the picked one. - Add
aria-disabled="true"to an option the user can't pick: it is skipped by the keyboard and can't be clicked. - To submit the value, put a hidden field inside the select:
<input type="hidden" name="country" data-select-input>. A preselected option fills it, and every choice rewrites it. - An option holding more than text (a flag, a badge) can carry
data-label, the plain text the trigger shows.
Keyboard#
| Key | Action |
|---|---|
| ↓ ↑ on the trigger | Open on the picked option, or the first / last |
| ↓ ↑ Home End | Move between options |
| A letter | Move to the next option starting with it (without a search box) |
| Enter Space | Pick the focused option and close |
| Esc | Close and return focus to the trigger |
| Tab, click outside | Close |
Searching#
Put an .input in a .select-search wrapper inside the list with data-select-search.
Typing filters the options (ignoring case and accents, matching every word in an option's text or its
data-keywords; groups left empty hide), ↓ moves into them, and a matching option can carry
aria-selected="true" so it comes first. Add a .select-empty with
data-select-empty for the "no matches" message: it stays hidden until a search finds nothing, and the
filter resets when the list closes.
No timezone matches that.
<div class="select w-56" data-select>
<button type="button" class="select-trigger" data-select-trigger aria-haspopup="listbox" aria-expanded="false" aria-label="Timezone">
<span class="select-value" data-placeholder="Pick a timezone"></span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="select-content" role="listbox" aria-label="Timezone" hidden>
<div class="select-search">
<input class="input input-sm" data-select-search placeholder="Search…" aria-label="Search timezones" />
</div>
<span class="select-item" role="option" aria-selected="true" value="utc">UTC</span>
<span class="select-item" role="option" aria-selected="false" value="cet">Central European Time</span>
<span class="select-item" role="option" aria-selected="false" value="jst">Japan Standard Time</span>
<span class="select-item" role="option" aria-selected="false" value="pst">Pacific Time</span>
<p class="select-empty" data-select-empty hidden>No timezone matches that.</p>
</div>
</div>
Groups#
A .select-label heads a run of options and a .select-separator divides them. They are text, not
options, so the keyboard skips them. Wrap each run in <div role="group" aria-labelledby="…"> (as the
macro does) and a search hides the groups it empties, with their separators.
Americas
North America South AmericaEurope and Africa
Europe Africa Middle East (coming soon)<div class="select w-56" data-select>
<button type="button" class="select-trigger" data-select-trigger aria-haspopup="listbox" aria-expanded="false" aria-label="Region">
<span class="select-value" data-placeholder="All regions"></span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="select-content" role="listbox" aria-label="Region" hidden>
<p class="select-label">Americas</p>
<span class="select-item" role="option" aria-selected="false" value="na">North America</span>
<span class="select-item" role="option" aria-selected="false" value="sa">South America</span>
<div class="select-separator" role="separator"></div>
<p class="select-label">Europe and Africa</p>
<span class="select-item" role="option" aria-selected="false" value="eu">Europe</span>
<span class="select-item" role="option" aria-selected="false" value="af">Africa</span>
<span class="select-item" role="option" aria-selected="false" value="me" aria-disabled="true">Middle East (coming soon)</span>
</div>
</div>
Sizes and states#
.select-sm and .select-lg match .input-sm and .input-lg.
disabled on the trigger, and aria-invalid="true" for an error, work as they do on an input.
<div class="select w-56">
<button type="button" class="select-trigger select-sm" data-select-trigger aria-haspopup="listbox" aria-expanded="false" aria-label="Small">
<span class="select-value" data-placeholder="Small"></span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="select-content" role="listbox" aria-label="Small" hidden>
<span class="select-item" role="option" aria-selected="false" value="a">A</span>
<span class="select-item" role="option" aria-selected="false" value="b">B</span>
</div>
</div>
<div class="select w-56">
<button type="button" class="select-trigger" data-select-trigger aria-haspopup="listbox" aria-expanded="false" aria-invalid="true" aria-label="Invalid">
<span class="select-value" data-placeholder="Invalid"></span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="select-content" role="listbox" aria-label="Invalid" hidden>
<span class="select-item" role="option" aria-selected="false" value="a">A</span>
<span class="select-item" role="option" aria-selected="false" value="b">B</span>
</div>
</div>
<div class="select w-56">
<button type="button" class="select-trigger" data-select-trigger aria-haspopup="listbox" aria-expanded="false" disabled aria-label="Disabled">
<span class="select-value" data-placeholder="Disabled"></span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="select-content" role="listbox" aria-label="Disabled" hidden>
<span class="select-item" role="option" aria-selected="false" value="a">A</span>
</div>
</div>
Alignment#
The list opens below the trigger, as wide as the trigger and aligned to its left edge. Add
.select-content-end to align it to the right edge, or .select-content-up to open it upwards.
<div class="select w-48">
<button type="button" class="select-trigger" data-select-trigger aria-haspopup="listbox" aria-expanded="false" aria-label="Right-aligned">
<span class="select-value" data-placeholder="Right-aligned"></span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="select-content select-content-end" role="listbox" aria-label="Right-aligned" hidden>
<span class="select-item" role="option" aria-selected="false" value="au">Australia</span>
<span class="select-item" role="option" aria-selected="false" value="nz">New Zealand</span>
</div>
</div>
With htmx#
Every choice fires a bubbling select:change with detail.value (the option's
value), detail.label and detail.option. Trigger requests on it rather than on
click, so they run once the value has been set. Inside a form, htmx posts the form's values — the hidden
[data-select-input] among them — so the request carries the choice with no extra wiring:
<form class="select w-56" data-select onsubmit="return false" hx-post="/api/echo" hx-trigger="select:change" hx-target="#select-result">
<button type="button" class="select-trigger" data-select-trigger aria-haspopup="listbox" aria-expanded="false" aria-label="Deploy region">
<span class="select-value" data-placeholder="Deploy region"></span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="select-content" role="listbox" aria-label="Deploy region" hidden>
<span class="select-item" role="option" aria-selected="false" value="eu-west">Europe (Dublin)</span>
<span class="select-item" role="option" aria-selected="false" value="us-east">US East (Virginia)</span>
<span class="select-item" role="option" aria-selected="false" value="ap-south">Mumbai</span>
</div>
<input type="hidden" name="region" data-select-input />
</form>
<p id="select-result" class="muted" aria-live="polite"></p>
Outside a form, pass the value yourself with the event: hx-vals='js:{region: event.detail.value}'.
Loading options with htmx#
Swap the option list in on load, or after a request elsewhere: the behaviour looks options up on every
event, so a list that arrives from the server keeps filtering and navigating. Mark the picked one with
aria-selected="true" and put the value in [data-select-input] in the swapped markup and the
new list starts out in step.
Nunjucks macro#
The select macro writes the trigger, the options and the hidden field from a list. Options are
[{value, label}] pairs, or plain strings when the value is the label; an option can also
carry disabled, icon and keywords (more words the filter matches), and
{label, options} makes a group. The preselected value is written into the trigger
and the hidden field, so the page shows and submits it before any JavaScript runs.
No matches.
Type "root" to find the carrot.
<div class="field w-64">
<label class="field-label" id="macro-food-label" for="macro-food">Ingredient</label>
<div class="select w-full" data-select>
<button type="button" id="macro-food" class="select-trigger" data-select-trigger aria-haspopup="listbox" aria-expanded="false" aria-labelledby="macro-food-label macro-food" aria-describedby="macro-food-help">
<span class="select-value" data-placeholder="Select…">Banana</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="select-content" id="macro-food-listbox" role="listbox" aria-labelledby="macro-food-label" hidden>
<div class="select-search"><input class="input input-sm" data-select-search placeholder="Search…" aria-label="Filter options" /></div>
<div role="group" aria-labelledby="macro-food-group-1">
<div class="select-label" id="macro-food-group-1">Fruit</div>
<span class="select-item" role="option" value="Apple" aria-selected="false">Apple<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span></span>
<span class="select-item" role="option" value="Banana" aria-selected="true">Banana<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span></span>
<span class="select-item" role="option" value="durian" aria-selected="false" aria-disabled="true">Durian<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span></span>
</div>
<div class="select-separator" role="separator"></div>
<div role="group" aria-labelledby="macro-food-group-2">
<div class="select-label" id="macro-food-group-2">Vegetables</div>
<span class="select-item" role="option" value="carrot" aria-selected="false" data-keywords="orange root">Carrot<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span></span>
<span class="select-item" role="option" value="Leek" aria-selected="false">Leek<span class="select-check"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span></span>
</div>
<p class="select-empty" data-select-empty hidden>No matches.</p>
</div>
<input type="hidden" name="food" value="Banana" data-select-input /></div>
<p class="field-description" id="macro-food-help">Type "root" to find the carrot.</p>
</div>
{% from "components/select/select.html" import select %}
<div class="field">
<label class="field-label" id="country-label" for="country">Country</label>
{{ select("country", json("data/countries.json"), value="nz", name="country", labelledby="country-label", searchable=true) }}
</div>
{{ select("plan", ["Free", "Pro"], value="Free", label="Plan", size="sm", invalid=true) }}
| Parameter | Does |
|---|---|
id | The trigger's id, so a <label for> can point at it. The list is <id>-listbox. |
options | Strings, {value, label, disabled?, icon?, keywords?}, or groups {label, options}. |
value, name | The preselected value, and the name of the hidden field that submits the choice. |
label / labelledby | The accessible name as text, or the id of a visible label (the trigger then reads as "Country, New Zealand"). |
describedby | aria-describedby on the trigger, e.g. a .field-description. |
placeholder | Shown until something is picked. Default "Select…". |
searchable | A filter box; search_placeholder and empty set its texts. |
size | "sm" or "lg". |
align, side | align="end" lines the list up with the right edge, side="top" opens it above. |
invalid, required, disabled | aria-invalid, aria-required and disabled on the trigger. |
class | More classes on the .select wrapper, usually a width. |
attrs | More attributes on the trigger, as a dict ({"hx-get": "…"}). |
Reference#
| Class / attribute | Description |
|---|---|
.select | Positioning wrapper (relative, inline-block). |
.select-trigger | The button that opens the list. Looks like an .input. |
.select-sm / .select-lg | Trigger sizes, matching .input-sm / .input-lg. |
.select-value | The picked text. Empty, with data-placeholder, until something is picked. |
.select-content | The listbox. Opens below, as wide as the trigger. |
.select-content-end / .select-content-up | Align to the right edge / open above. |
.select-search | Sticky wrapper for a [data-select-search] filter input. |
.select-item | One option. aria-selected marks the picked one, aria-disabled an unavailable one. |
.select-check | Icon inside an option, shown only while it is picked. |
.select-label / .select-separator | Heading and divider inside the list. |
.select-empty | Shown by the behaviour when a search finds nothing. |
[data-select] / [data-select-trigger] | Behaviour: open/close, keyboard, filtering, and select:change { value, label, option }. |
[data-select-input] | Hidden field mirroring the picked value, so a form can submit it. |