shshadcn-htmx

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.json

2. Use it

components/ui/select.tsx
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
components/ui/select.tsx
/** @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

templates/components/select.html
{% 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
templates/components/select.html
{# 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

templates/components/select.tmpl
{{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
templates/components/select.tmpl
{{/*
  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

lib/my_app_web/components/select.ex
<.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
lib/my_app_web/components/select.ex
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

index.html
<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
index.html
<!--
  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 [&amp;.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>

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 [&amp;.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 [&amp;.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 [&amp;.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>

API Reference

<Select>

PropTypeDefaultDescription
ariaErrormessagestring
Id of a visible error message element. Pair with ariaInvalid="true" so assistive tech announces why the field is invalid.
idstring
Pairs the input with a <label for>.
namestring
Form field name on submit.
valuestring
Initial value.
requiredbooleanfalse
Native HTML required for form validation.
disabledbooleanfalse
Disable — unfocusable, not submitted.
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
ariaDescribedbystring
Id of an element describing this control (announced after the name).MDNaria-describedby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference
multiplebooleanfalse
Allow multi-select (renders as a listbox).
sizenumber1
Number of visible rows (>= 2 forces listbox).
autofocusboolean
Focus on initial page load.