Components
Select
The native <select> with a chevron icon. The browser handles the popover, type-to-search, keyboard navigation, and the mobile-native picker. Our styles are just the rounded border + ring; the rest is the platform.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/select.json2. Use it
import { Select, SelectOption } from "@/components/ui/select"
import { Label } from "@/components/ui/label"
<Label htmlFor="role">Role</Label>
<Select id="role" name="role">
<SelectOption value="admin" selected>Administrator</SelectOption>
<SelectOption value="editor">Editor</SelectOption>
<SelectOption value="viewer">Viewer</SelectOption>
</Select>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Select — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Upstream shadcn uses a Radix Popover with custom items (rich content
// support, virtualization, etc.). For an SSR-friendly htmx setup, the
// native <select> is hard to beat: it brings full keyboard control,
// type-to-search, mobile-native pickers, accessible name handling, and
// form submission with zero JS. We restyle it with `appearance-none` and
// layer a chevron icon on top — the native dropdown rendering still pops
// from inside (browser-controlled).
//
// MDN: repos/mdn/files/en-us/web/html/reference/elements/select/
const triggerBase =
"peer flex h-9 w-full min-w-0 cursor-pointer appearance-none items-center rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none " +
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
"disabled:cursor-not-allowed disabled:opacity-50 " +
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " +
"md:text-sm dark:bg-input/30 " +
"[&.htmx-request]:opacity-70"
export function selectTriggerClasses(opts?: { class?: ClassValue }): string {
return cn(triggerBase, opts?.class)
}
type SelectProps = PropsWithChildren<{
id?: string
name?: string
required?: boolean
disabled?: boolean
multiple?: boolean
// Renders as a list box if >=2 (default 1 = dropdown).
size?: number
form?: string
autocomplete?: string
// Focus this select on initial page load (one per document).
autofocus?: boolean
class?: ClassValue
// ARIA
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
ariaInvalid?: boolean
// Id of the visible error message element; pair with ariaInvalid="true".
// See repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-errormessage/index.md
ariaErrormessage?: string
ariaRequired?: boolean
// htmx — fire when the user picks a new option.
"hx-get"?: string
"hx-post"?: string
"hx-put"?: string
"hx-patch"?: string
"hx-target"?: string
"hx-swap"?: string
"hx-trigger"?: string
"hx-vals"?: string
"hx-include"?: string
}>
export function Select(props: SelectProps) {
const {
class: className,
children,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
ariaInvalid,
ariaErrormessage,
ariaRequired,
...rest
} = props
return (
<span class="relative inline-flex w-full">
<select
class={selectTriggerClasses({ class: className })}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
aria-invalid={ariaInvalid === undefined ? undefined : String(ariaInvalid)}
aria-errormessage={ariaErrormessage}
aria-required={ariaRequired === undefined ? undefined : String(ariaRequired)}
data-slot="select"
{...rest}
>
{children}
</select>
{/* Chevron — hidden on multi-line listbox (size > 1) where native UI
doesn't render a chevron. */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground peer-disabled:opacity-50"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
)
}
// Re-exports for ergonomic composition. <option> and <optgroup> are the
// native primitives — no special styling needed beyond what the browser does.
export function SelectOption(
props: PropsWithChildren<{
value: string
disabled?: boolean
selected?: boolean
label?: string
}>,
) {
const { value, disabled, selected, label, children } = props
return (
<option value={value} disabled={disabled} selected={selected} label={label}>
{children}
</option>
)
}
export function SelectGroup(
props: PropsWithChildren<{ label: string; disabled?: boolean }>,
) {
return (
<optgroup label={props.label} disabled={props.disabled}>
{props.children}
</optgroup>
)
}
1. Save the file
Copy select.html into templates/components/.
2. Use it
{% from "components/select.html" import select_open, select_close, option %}
{% from "components/label.html" import label %}
{{ label("Role", for_="role") }}
{{ select_open(id="role", name="role") }}
{{ option("admin", "Administrator", selected=true) }}
{{ option("editor", "Editor") }}
{{ option("viewer", "Viewer") }}
{{ select_close() }}View source
{# Select macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/select.tsx. The native <select> + a chevron icon
layered on top via absolute positioning.
Usage:
{% from "components/select.html" import select_open, select_close, option, optgroup %}
{{ select_open(id="role", name="role", aria_label="Role") }}
{{ option(value="admin", text="Administrator", selected=true) }}
{{ option(value="editor", text="Editor") }}
{{ option(value="viewer", text="Viewer") }}
{{ select_close() }} #}
{% macro select_open(
id=none,
name=none,
required=false,
disabled=false,
multiple=false,
size=none,
form=none,
autocomplete=none,
aria_label=none,
aria_labelledby=none,
aria_describedby=none,
aria_invalid=none,
aria_errormessage=none,
aria_required=none,
extra_class="",
**attrs
) -%}
{%- set base -%}
peer flex h-9 w-full min-w-0 cursor-pointer appearance-none items-center rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70
{%- endset -%}
<span class="relative inline-flex w-full">
<select class="{{ base }} {{ extra_class }}"
{%- if id %} id="{{ id }}"{% endif %}
{%- if name %} name="{{ name }}"{% endif %}
{%- if required %} required{% endif %}
{%- if disabled %} disabled{% endif %}
{%- if multiple %} multiple{% endif %}
{%- if size is not none %} size="{{ size }}"{% endif %}
{%- if form %} form="{{ form }}"{% endif %}
{%- if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
{%- if aria_invalid is not none %} aria-invalid="{{ aria_invalid|string|lower }}"{% endif %}
{#- Id of the visible error message; pair with aria_invalid=true. aria-errormessage spec #}
{%- if aria_errormessage %} aria-errormessage="{{ aria_errormessage }}"{% endif %}
{%- if aria_required is not none %} aria-required="{{ aria_required|string|lower }}"{% endif %}
data-slot="select"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
{%- endmacro %}
{% macro select_close() %}
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground peer-disabled:opacity-50" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
{% endmacro %}
{% macro option(value, text, selected=false, disabled=false) -%}
<option value="{{ value }}"
{%- if selected %} selected{% endif %}
{%- if disabled %} disabled{% endif -%}
>{{ text }}</option>
{%- endmacro %}
{% macro optgroup_open(label, disabled=false) -%}
<optgroup label="{{ label }}"{% if disabled %} disabled{% endif %}>
{%- endmacro %}
{% macro optgroup_close() -%}
</optgroup>
{%- endmacro %}
1. Save the file
Add select.tmpl alongside button.tmpl.
2. Use it
{{template "label" (dict "For" "role" "Text" "Role")}}
{{template "select" (dict
"ID" "role" "Name" "role"
"Body" (htmlSafe `
<option value="admin" selected>Administrator</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
`)
)}}View source
{{/*
Select template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/select.tsx. Native <select> + a chevron overlay.
Usage:
type SelectArgs struct {
ID, Name, Form, Autocomplete string
Required, Disabled, Multiple bool
Size int
AriaLabel, AriaLabelledby, AriaDescribedby string
AriaInvalid, AriaErrormessage, AriaRequired string
// The <option>s, pre-rendered.
Body template.HTML
Attrs map[string]string
}
*/}}
{{define "select"}}
{{- $base := "peer flex h-9 w-full min-w-0 cursor-pointer appearance-none items-center rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70" -}}
<span class="relative inline-flex w-full">
<select class="{{$base}}"
{{- if .ID}} id="{{.ID}}"{{end}}
{{- if .Name}} name="{{.Name}}"{{end}}
{{- if .Required}} required{{end}}
{{- if .Disabled}} disabled{{end}}
{{- if .Multiple}} multiple{{end}}
{{- if .Size}} size="{{.Size}}"{{end}}
{{- if .Form}} form="{{.Form}}"{{end}}
{{- if .Autocomplete}} autocomplete="{{.Autocomplete}}"{{end}}
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
{{- if .AriaInvalid}} aria-invalid="{{.AriaInvalid}}"{{end}}
{{- /* Id of the visible error message; pair with AriaInvalid="true". aria-errormessage spec */}}
{{- if .AriaErrormessage}} aria-errormessage="{{.AriaErrormessage}}"{{end}}
{{- if .AriaRequired}} aria-required="{{.AriaRequired}}"{{end}}
data-slot="select"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Body}}</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground peer-disabled:opacity-50" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
{{end}}
1. Save the file
Drop select.ex into lib/my_app_web/components/.
2. Use it
<.label for="role">Role</.label>
<.select id="role" name="role">
<option value="admin" selected>Administrator</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</.select>View source
defmodule ShadcnHtmx.Components.Select do
@moduledoc """
Select — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/select.tsx. Native `<select>` + a chevron icon overlay.
Full keyboard, mobile-native picker, form submission for free.
## Examples
<.select id="role" name="role" aria-label="Role">
<option value="admin" selected>Administrator</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</.select>
<.select name="country"
hx-get={~p"/cities"} hx-target="#cities" hx-trigger="change">
<option value="tr">Türkiye</option>
<option value="de">Deutschland</option>
</.select>
"""
use Phoenix.Component
@base "peer flex h-9 w-full min-w-0 cursor-pointer appearance-none items-center rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs " <>
"transition-[color,box-shadow] outline-none " <>
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
"disabled:cursor-not-allowed disabled:opacity-50 " <>
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " <>
"md:text-sm dark:bg-input/30 " <>
"[&.htmx-request]:opacity-70"
attr :class, :string, default: nil
attr :rest, :global,
include:
~w(hx-get hx-post hx-put hx-patch hx-target hx-swap hx-trigger hx-vals hx-include
id name required disabled multiple size form autocomplete
aria-label aria-labelledby aria-describedby aria-invalid
aria-errormessage aria-required)
slot :inner_block, required: true
def select(assigns) do
assigns = assign(assigns, :base, @base)
~H"""
<span class="relative inline-flex w-full">
<select class={[@base, @class]} data-slot="select" {@rest}>
{render_slot(@inner_block)}
</select>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground peer-disabled:opacity-50"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
"""
end
end
1. Save the file
Tailwind utilities only; no JS.
2. Use it
<span class="relative inline-flex w-full">
<select id="role" name="role" class="peer flex h-9 w-full appearance-none …">
<option value="admin" selected>Administrator</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<svg class="absolute right-3 top-1/2 size-4 -translate-y-1/2 …">…chevron…</svg>
</span>View source
<!--
shadcn-htmx — raw HTML select snippets.
Mirrors registry/ui/select.tsx. Native <select> styled with appearance-none
+ a chevron icon positioned absolutely on top. The browser still renders
its native dropdown menu (no JS popover).
BASE (on the <select>):
peer flex h-9 w-full min-w-0 cursor-pointer appearance-none items-center
rounded-md border border-input bg-background px-3 pr-8 py-1 text-base
shadow-xs transition-[color,box-shadow] outline-none
focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50
disabled:cursor-not-allowed disabled:opacity-50
aria-invalid:border-destructive aria-invalid:ring-destructive/20
dark:aria-invalid:ring-destructive/40
md:text-sm dark:bg-input/30
[&.htmx-request]:opacity-70
-->
<!-- Basic -->
<span class="relative inline-flex w-full">
<select id="role" name="role" data-slot="select"
class="peer flex h-9 w-full min-w-0 cursor-pointer appearance-none items-center rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30">
<option value="admin" selected>Administrator</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<!-- With groups + disabled item -->
<span class="relative inline-flex w-full">
<select name="timezone"
class="peer flex h-9 w-full appearance-none rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs md:text-sm dark:bg-input/30">
<optgroup label="Europe">
<option value="Europe/Istanbul">Istanbul</option>
<option value="Europe/Berlin">Berlin</option>
<option value="Europe/London">London</option>
</optgroup>
<optgroup label="Americas">
<option value="America/New_York">New York</option>
<option value="America/Sao_Paulo" disabled>São Paulo (coming soon)</option>
</optgroup>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<!-- htmx — load dependent options on change -->
<span class="relative inline-flex w-full">
<select name="country"
hx-get="/cities" hx-target="#cities" hx-trigger="change"
class="peer flex h-9 w-full appearance-none rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs md:text-sm [&.htmx-request]:opacity-70">
<option value="">Pick a country…</option>
<option value="tr">Türkiye</option>
<option value="de">Deutschland</option>
</select>
<svg class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<!-- Invalid state — point AT at the visible error message.
Set aria-invalid="true", then aria-errormessage to the id of the
visible message element. See aria-errormessage spec:
repos/mdn/.../aria/reference/attributes/aria-errormessage/index.md -->
<span class="relative inline-flex w-full">
<select name="plan" required aria-invalid="true" aria-errormessage="plan-error"
class="peer flex h-9 w-full appearance-none rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30">
<option value="">Select a plan…</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<p id="plan-error" class="mt-1 text-sm text-destructive">Please choose a plan.</p>
Examples
Basic — type-to-search comes free
Open the dropdown and start typing — the browser jumps to the first matching option. ↑/↓ moves between options, Enter confirms.
Native <select> is the workhorse — desktop keyboards, mobile pickers, screen reader announcement of "combobox … listbox … 3 of 5" all work without code. We restyle with appearance-none and overlay a chevron; the browser still renders its own dropdown when the user clicks.
<Select id="role" name="role">
<SelectOption value="admin" selected>Administrator</SelectOption>
<SelectOption value="editor">Editor</SelectOption>
<SelectOption value="viewer">Viewer</SelectOption>
</Select>{{ select_open(id="role", name="role") }}
{{ option("admin", "Administrator", selected=true) }}
{{ option("editor", "Editor") }}
{{ option("viewer", "Viewer") }}
{{ select_close() }}{{template "select" (dict "ID" "role" "Name" "role" "Body" (htmlSafe `
<option value="admin" selected>Administrator</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
`))}}<.select id="role" name="role">
<option value="admin" selected>Administrator</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</.select><div class="grid w-full max-w-md gap-2">
<label for="ex-sel-role" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Role</label>
<span class="relative inline-flex w-full">
<select class="peer flex h-9 w-full min-w-0 cursor-pointer appearance-none items-center rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70" data-slot="select" id="ex-sel-role" name="role">
<option value="admin" selected="">Administrator</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground peer-disabled:opacity-50" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</span>
</div>Further reading
Groups + disabled items
<optgroup> renders a section header (non-selectable). Disabled <option>s are skipped by keyboard navigation.
Use <optgroup> to group related options. The browser renders the group label in bold and indents the children — works on every platform. Disabled options stay visible but the browser prevents selection and screen readers announce "dimmed".
<Select id="tz" name="timezone">
<SelectGroup label="Europe">
<SelectOption value="Europe/Istanbul" selected>Istanbul</SelectOption>
<SelectOption value="Europe/Berlin">Berlin</SelectOption>
</SelectGroup>
<SelectGroup label="Americas">
<SelectOption value="America/New_York">New York</SelectOption>
<SelectOption value="America/Sao_Paulo" disabled>São Paulo</SelectOption>
</SelectGroup>
</Select>{{ select_open(id="tz", name="timezone") }}
{{ optgroup_open("Europe") }}
{{ option("Europe/Istanbul", "Istanbul", selected=true) }}
{{ option("Europe/Berlin", "Berlin") }}
{{ optgroup_close() }}
{{ select_close() }}{{template "select" (dict "ID" "tz" "Name" "timezone" "Body" (htmlSafe `
<optgroup label="Europe">…</optgroup>
<optgroup label="Americas">…</optgroup>
`))}}<.select id="tz" name="timezone">
<optgroup label="Europe">
<option value="Europe/Istanbul" selected>Istanbul</option>
<option value="Europe/Berlin">Berlin</option>
</optgroup>
<optgroup label="Americas">
<option value="America/New_York">New York</option>
<option value="America/Sao_Paulo" disabled>São Paulo</option>
</optgroup>
</.select><div class="grid w-full max-w-md gap-2">
<label for="ex-sel-tz" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Timezone</label>
<span class="relative inline-flex w-full">
<select class="peer flex h-9 w-full min-w-0 cursor-pointer appearance-none items-center rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70" data-slot="select" id="ex-sel-tz" name="timezone">
<optgroup label="Europe">
<option value="Europe/Istanbul" selected="">Istanbul (GMT+3)</option>
<option value="Europe/Berlin">Berlin (GMT+1)</option>
<option value="Europe/London">London (GMT+0)</option>
</optgroup>
<optgroup label="Americas">
<option value="America/New_York">New York (GMT-5)</option>
<option value="America/Sao_Paulo" disabled="">São Paulo (coming soon)</option>
</optgroup>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground peer-disabled:opacity-50" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</span>
</div>Further reading
htmx — dependent selects
Pick a country; htmx GETs /select/cities, the second <select> swaps its options to match. Classic cascading dropdown without a single line of JS.
Pair hx-get with hx-target to refresh the dependent select on every change. The server returns the inner HTML of the new <option> list, htmx swaps it in, hx-include ensures the country value rides along with the request.
<Select name="country"
hx-get="/api/cities" hx-target="#city"
hx-trigger="change" hx-swap="innerHTML">…</Select>
<Select id="city" name="city">
<SelectOption value="">Pick a country first…</SelectOption>
</Select>{{ select_open(name="country",
hx_get="/api/cities", hx_target="#city",
hx_trigger="change", hx_swap="innerHTML") }}
…
{{ select_close() }}
{{ select_open(id="city", name="city") }}
{{ option("", "Pick a country first…") }}
{{ select_close() }}{{template "select" (dict "Name" "country"
"Attrs" (dict
"hx-get" "/api/cities"
"hx-target" "#city"
"hx-trigger" "change"
"hx-swap" "innerHTML"
)
"Body" (htmlSafe `…`)
)}}<.select name="country"
hx-get={~p"/api/cities"} hx-target="#city"
hx-trigger="change" hx-swap="innerHTML">
…
</.select>
<.select id="city" name="city">
<option value="">Pick a country first…</option>
</.select><div class="grid w-full max-w-md gap-3">
<div class="grid gap-2">
<label for="ex-sel-country" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Country</label>
<span class="relative inline-flex w-full">
<select class="peer flex h-9 w-full min-w-0 cursor-pointer appearance-none items-center rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70" data-slot="select" id="ex-sel-country" name="country" hx-get="/select/cities" hx-target="#ex-sel-city" hx-trigger="change" hx-swap="innerHTML">
<option value="">Pick a country…</option>
<option value="tr">Türkiye</option>
<option value="de">Deutschland</option>
<option value="us">United States</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground peer-disabled:opacity-50" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</span>
</div>
<div class="grid gap-2">
<label for="ex-sel-city" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">City</label>
<span class="relative inline-flex w-full">
<select class="peer flex h-9 w-full min-w-0 cursor-pointer appearance-none items-center rounded-md border border-input bg-background px-3 pr-8 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70" data-slot="select" id="ex-sel-city" name="city">
<option value="">Pick a country first…</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground peer-disabled:opacity-50" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</span>
</div>
</div>Further reading
API Reference
<Select>
| Prop | Type | Default | Description |
|---|---|---|---|
ariaErrormessage | string | — | Id of a visible error message element. Pair with ariaInvalid="true" so assistive tech announces why the field is invalid. |
id | string | — | Pairs the input with a <label for>. |
name | string | — | Form field name on submit. |
value | string | — | Initial value. |
required | boolean | false | Native HTML required for form validation. |
disabled | boolean | false | Disable — unfocusable, not submitted. |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
multiple | boolean | false | Allow multi-select (renders as a listbox). |
size | number | 1 | Number of visible rows (>= 2 forces listbox). |
autofocus | boolean | — | Focus on initial page load. |