Components
Cascading Select
Two dependent native <select>s: choosing the parent reloads the child's options — and an optional detail panel — from the server. The cascade is htmx's default change trigger plus one hx-swap-oob fragment. No custom JavaScript.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/cascading-select.json2. Use it
import { CascadingSelect, CascadingSelectOption } from "@/components/ui/cascading-select"
// Picking the make GETs /models with the make value, swaps the model
// <option>s into the child, and updates the detail panel out of band.
// No hx-trigger: htmx defaults <select> to "change".
<CascadingSelect
id="vehicle"
endpoint="/models"
parentName="make"
childName="model"
legend="Vehicle"
parentLabel="Make"
childLabel="Model"
detail={<>Pick a make…</>}
>
<CascadingSelectOption value="audi" selected>Audi</CascadingSelectOption>
<CascadingSelectOption value="toyota">Toyota</CascadingSelectOption>
<CascadingSelectOption value="bmw">BMW</CascadingSelectOption>
</CascadingSelect>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { Child, PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Cascading Select — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A pair of dependent native <select>s: picking the parent (e.g. car make)
// reloads the child's <option>s (e.g. model) — and, optionally, a detail
// panel — from the server. One request, two updates: the response swaps the
// child options into the target, and a second fragment carrying
// hx-swap-oob updates the detail panel "out of band".
//
// Built on:
// repos/htmx/www/src/content/patterns/02-forms/04-linked-selects.md
// The canonical linked-selects recipe: parent <select hx-get hx-target>
// swaps a fresh <option> list into the child; a detail card rides along.
// repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md:32-37
// htmx defaults the trigger to `change` for <select>, so NO hx-trigger is
// needed — choosing an option fires the request.
// repos/htmx/www/src/content/reference/01-attributes/13-hx-swap-oob.md
// hx-swap-oob="true" on the detail fragment swaps it into #<id>-detail by
// id, piggybacking a second update onto the same response.
// repos/htmx/www/src/content/reference/01-attributes/07-hx-swap.md
// default swap is innerHTML — the returned <option>s replace the child's
// contents; hx-include carries the parent value with the request.
//
// Native semantics (the whole control is two real <select>s in a <fieldset>):
// repos/mdn/files/en-us/web/html/reference/elements/select/index.md
// repos/mdn/files/en-us/web/html/reference/elements/option/index.md
// repos/mdn/files/en-us/web/html/reference/elements/fieldset/index.md
// The <fieldset> + <legend> groups the related controls for AT. Each
// <select> brings keyboard control, type-to-search, mobile pickers, and
// form submission with zero JS. With htmx off the parent still submits
// its value in a normal form post — progressive enhancement, not emulation.
//
// JS budget: NONE. The cascade is htmx's default `change` trigger + an OOB
// swap; there is no site.js for this component.
//
// Style analogues: registry/ui/select.tsx (the chevron-overlaid native
// <select>, classes mirrored verbatim) and registry/ui/edit-in-place.tsx
// (server-fragment composite returning bare <option>/<div> partials).
// Mirrors registry/ui/select.tsx `triggerBase` verbatim so a cascading
// select is visually identical to a standalone 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"
const fieldsetClass = "grid gap-4"
const legendClass =
"mb-1 text-sm leading-none font-medium text-foreground"
const fieldClass = "grid gap-2"
const fieldLabelClass =
"text-sm leading-none font-medium text-foreground select-none"
const chevronClass =
"pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground peer-disabled:opacity-50"
const detailClass = "text-sm text-muted-foreground"
function Chevron() {
return (
<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={chevronClass}
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
)
}
export type CascadingSelectProps = PropsWithChildren<{
// Base id. The child select is `${id}-child`; the detail panel (if used) is
// `${id}-detail`; the legend is `${id}-legend`.
id: string
// Endpoint the PARENT requests on change. Returns the child's <option>s,
// and (optionally) a detail fragment with hx-swap-oob="true".
endpoint: string
// Form field names. Defaults: parent "parent", child "child".
parentName?: string
childName?: string
// Visible group label rendered as <legend>.
legend?: string
// Per-select field labels (rendered as <label for>).
parentLabel?: string
childLabel?: string
// Parent <option>s. Pass <option> elements (or SelectOption).
children: Child
// Initial child <option>s, shown before the first change fires.
childOptions?: Child
// Initial detail-panel content. Omit to skip the detail panel entirely.
detail?: Child
// Disable the whole group.
disabled?: boolean
// GET keeps the cascade idempotent + the no-JS form post shareable.
method?: "get" | "post"
class?: ClassValue
// Escape hatch: forward arbitrary hx-* / data-* / aria-* onto the parent
// <select> (e.g. hx-indicator). Overrides the defaults below.
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function CascadingSelect(props: CascadingSelectProps) {
const {
id,
endpoint,
parentName = "parent",
childName = "child",
legend,
parentLabel,
childLabel,
children,
childOptions,
detail,
disabled,
method = "get",
class: className,
...rest
} = props
const childId = `${id}-child`
const detailId = `${id}-detail`
const legendId = `${id}-legend`
// The parent's request: GET the endpoint, swap the child's <option>s
// (innerHTML, the default). hx-include pins the parent value to the request
// by name even if the trigger element changes. A detail fragment in the
// response carries hx-swap-oob="true" to update #${id}-detail too.
// No hx-trigger: htmx defaults <select> to `change`.
const hxKey = method === "post" ? "hx-post" : "hx-get"
const parentHx: Record<string, any> = {
[hxKey]: endpoint,
"hx-target": `#${childId}`,
"hx-include": `[name='${parentName}']`,
}
const hx = { ...parentHx, ...rest }
return (
<fieldset
data-slot="cascading-select"
id={id}
disabled={disabled}
class={cn(fieldsetClass, className)}
aria-labelledby={legend ? legendId : undefined}
>
{legend ? (
<legend id={legendId} class={legendClass} data-slot="cascading-select-legend">
{legend}
</legend>
) : null}
<div class={fieldClass}>
{parentLabel ? (
<label for={`${id}-parent`} class={fieldLabelClass}>
{parentLabel}
</label>
) : null}
<span class="relative inline-flex w-full">
<select
id={`${id}-parent`}
name={parentName}
data-slot="cascading-select-parent"
class={triggerBase}
aria-controls={detail !== undefined ? `${childId} ${detailId}` : childId}
{...hx}
>
{children}
</select>
<Chevron />
</span>
</div>
<div class={fieldClass}>
{childLabel ? (
<label for={childId} class={fieldLabelClass}>
{childLabel}
</label>
) : null}
<span class="relative inline-flex w-full">
{/* The cascade target. htmx swaps fresh <option>s in here. */}
<select
id={childId}
name={childName}
data-slot="cascading-select-child"
class={triggerBase}
>
{childOptions}
</select>
<Chevron />
</span>
</div>
{detail !== undefined ? (
// OOB swap target. aria-live so AT announces the detail change that
// accompanies the option swap.
<div
id={detailId}
data-slot="cascading-select-detail"
aria-live="polite"
class={detailClass}
>
{detail}
</div>
) : null}
</fieldset>
)
}
// Re-export the native primitive for ergonomic option authoring (mirrors
// registry/ui/select.tsx). <option> needs no styling beyond the browser's.
export function CascadingSelectOption(
props: PropsWithChildren<{ value: string; selected?: boolean; disabled?: boolean }>,
) {
const { value, selected, disabled, children } = props
return (
<option value={value} selected={selected} disabled={disabled}>
{children}
</option>
)
}
// A detail fragment ready for an out-of-band swap. Return this from the
// endpoint alongside the bare <option>s; htmx swaps it into #${id}-detail.
//
// hx-swap-oob="innerHTML" (not "true"/outerHTML) so the live #${id}-detail
// element stays in the DOM — only its contents are replaced. An outerHTML OOB
// swap would detach and replace the node, breaking the aria-live region's
// identity (and any element reference held to it). The encapsulating <div> is
// stripped by htmx; its children are swapped into the element matched by id.
export function CascadingSelectDetail(
props: PropsWithChildren<{ id: string }>,
) {
return (
<div
id={`${props.id}-detail`}
data-slot="cascading-select-detail"
hx-swap-oob="innerHTML"
aria-live="polite"
class={detailClass}
>
{props.children}
</div>
)
}
1. Save the file
Copy cascading-select.html into templates/components/.
2. Use it
{% from "components/cascading-select.html" import cascading_select_open, cascading_select_close, option %}
{{ cascading_select_open(id="vehicle", endpoint="/models",
parent_name="make", child_name="model",
legend="Vehicle", parent_label="Make", child_label="Model") }}
{{ option("audi", "Audi", selected=true) }}
{{ option("toyota", "Toyota") }}
{{ option("bmw", "BMW") }}
{{ cascading_select_close(id="vehicle", child_name="model", child_label="Model") }}View source
{# Cascading Select macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/cascading-select.tsx. A pair of dependent native
<select>s: picking the parent reloads the child's <option>s (and an
optional detail panel via hx-swap-oob) from the server.
No hx-trigger: htmx defaults <select> to `change`.
See repos/htmx/www/src/content/patterns/02-forms/04-linked-selects.md
Usage (open … parent <option>s … close, like select.html):
{% from "components/cascading-select.html" import cascading_select_open, cascading_select_close, option, cascading_detail %}
{{ cascading_select_open(id="vehicle", endpoint="/models",
parent_name="make", child_name="model",
legend="Vehicle", parent_label="Make", child_label="Model") }}
{{ option("audi", "Audi", selected=true) }}
{{ option("toyota", "Toyota") }}
{{ cascading_select_close(id="vehicle", child_name="model", child_label="Model") }}
The endpoint returns the child <option>s + the OOB detail fragment:
{{ option("a4", "A4", selected=true) }} …
{% call cascading_detail(id="vehicle") %}Audi A4 — Sedan, $39,900{% endcall %} #}
{% macro cascading_select_open(
id,
endpoint,
parent_name="parent",
child_name="child",
legend=none,
parent_label=none,
child_label=none,
detail=true,
disabled=false,
method="get",
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 -%}
{%- set hx_attr = "hx-post" if method == "post" else "hx-get" -%}
<fieldset data-slot="cascading-select" id="{{ id }}" class="grid gap-4 {{ extra_class }}"
{%- if disabled %} disabled{% endif %}
{%- if legend %} aria-labelledby="{{ id }}-legend"{% endif -%}
>
{%- if legend %}
<legend id="{{ id }}-legend" class="mb-1 text-sm leading-none font-medium text-foreground" data-slot="cascading-select-legend">{{ legend }}</legend>
{%- endif %}
<div class="grid gap-2">
{%- if parent_label %}
<label for="{{ id }}-parent" class="text-sm leading-none font-medium text-foreground select-none">{{ parent_label }}</label>
{%- endif %}
<span class="relative inline-flex w-full">
<select id="{{ id }}-parent" name="{{ parent_name }}" data-slot="cascading-select-parent"
class="{{ base }}"
{{ hx_attr }}="{{ endpoint }}"
hx-target="#{{ id }}-child"
hx-include="[name='{{ parent_name }}']"
aria-controls="{% if detail %}{{ id }}-child {{ id }}-detail{% else %}{{ id }}-child{% endif %}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
{%- endmacro %}
{% macro cascading_select_close(
id,
child_name="child",
child_label=none,
detail=true,
child_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>
</div>
<div class="grid gap-2">
{%- if child_label %}
<label for="{{ id }}-child" class="text-sm leading-none font-medium text-foreground select-none">{{ child_label }}</label>
{%- endif %}
<span class="relative inline-flex w-full">
<select id="{{ id }}-child" name="{{ child_name }}" data-slot="cascading-select-child"
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">{{ child_body|safe }}</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>
</div>
{%- if detail %}
<div id="{{ id }}-detail" data-slot="cascading-select-detail" aria-live="polite" class="text-sm text-muted-foreground"></div>
{%- endif %}
</fieldset>
{%- endmacro %}
{% macro option(value, text, selected=false, disabled=false) -%}
<option value="{{ value }}"
{%- if selected %} selected{% endif %}
{%- if disabled %} disabled{% endif -%}
>{{ text }}</option>
{%- endmacro %}
{# Detail fragment for the OOB swap — return alongside the child <option>s.
hx-swap-oob="innerHTML" (not "true"/outerHTML) keeps the live #id-detail node
in the DOM and swaps only its contents, preserving the aria-live region's
identity across updates. #}
{% macro cascading_detail(id) -%}
<div id="{{ id }}-detail" data-slot="cascading-select-detail" hx-swap-oob="innerHTML" aria-live="polite" class="text-sm text-muted-foreground">{{ caller() }}</div>
{%- endmacro %}
1. Save the file
Add cascading-select.tmpl alongside your templates.
2. Use it
{{template "cascading-select" (dict
"ID" "vehicle" "Endpoint" "/models"
"ParentName" "make" "ChildName" "model"
"Legend" "Vehicle" "ParentLabel" "Make" "ChildLabel" "Model"
"Detail" true
"Body" (htmlSafe `
<option value="audi" selected>Audi</option>
<option value="toyota">Toyota</option>
<option value="bmw">BMW</option>
`))}}View source
{{/*
Cascading Select template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/cascading-select.tsx. A pair of dependent native
<select>s: picking the parent reloads the child's <option>s (and an
optional detail panel via hx-swap-oob) from the server.
No hx-trigger: htmx defaults <select> to `change`.
See repos/htmx/www/src/content/patterns/02-forms/04-linked-selects.md
Usage:
type CascadingSelectArgs struct {
ID, Endpoint string
ParentName, ChildName string // default "parent" / "child"
Legend string
ParentLabel, ChildLabel string
Method string // "get" (default) | "post"
Disabled bool
Detail bool // render the OOB detail panel
ExtraClass string
// PARENT <option>s, pre-rendered.
Body template.HTML
// Initial CHILD <option>s for the selected parent, pre-rendered.
ChildBody template.HTML
Attrs map[string]string
}
*/}}
{{define "cascading-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" -}}
{{- $parentName := or .ParentName "parent" -}}
{{- $childName := or .ChildName "child" -}}
{{- $hx := "hx-get" -}}{{- if eq .Method "post" }}{{- $hx = "hx-post" -}}{{- end -}}
<fieldset data-slot="cascading-select" id="{{.ID}}" class="grid gap-4 {{.ExtraClass}}"
{{- if .Disabled}} disabled{{end}}
{{- if .Legend}} aria-labelledby="{{.ID}}-legend"{{end -}}
>
{{- if .Legend}}
<legend id="{{.ID}}-legend" class="mb-1 text-sm leading-none font-medium text-foreground" data-slot="cascading-select-legend">{{.Legend}}</legend>
{{- end}}
<div class="grid gap-2">
{{- if .ParentLabel}}
<label for="{{.ID}}-parent" class="text-sm leading-none font-medium text-foreground select-none">{{.ParentLabel}}</label>
{{- end}}
<span class="relative inline-flex w-full">
<select id="{{.ID}}-parent" name="{{$parentName}}" data-slot="cascading-select-parent"
class="{{$base}}"
{{$hx}}="{{.Endpoint}}"
hx-target="#{{.ID}}-child"
hx-include="[name='{{$parentName}}']"
aria-controls="{{if .Detail}}{{.ID}}-child {{.ID}}-detail{{else}}{{.ID}}-child{{end}}"
{{- 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>
</div>
<div class="grid gap-2">
{{- if .ChildLabel}}
<label for="{{.ID}}-child" class="text-sm leading-none font-medium text-foreground select-none">{{.ChildLabel}}</label>
{{- end}}
<span class="relative inline-flex w-full">
<select id="{{.ID}}-child" name="{{$childName}}" data-slot="cascading-select-child" class="{{$base}}">{{.ChildBody}}</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>
</div>
{{- if .Detail}}
<div id="{{.ID}}-detail" data-slot="cascading-select-detail" aria-live="polite" class="text-sm text-muted-foreground"></div>
{{- end}}
</fieldset>
{{end}}
{{/* Detail fragment for the OOB swap — return alongside the child <option>s.
hx-swap-oob="innerHTML" (not "true"/outerHTML) keeps the live #ID-detail
node in the DOM and swaps only its contents, preserving the aria-live
region's identity across updates.
Args: (dict "ID" "vehicle" "Body" (htmlSafe "…")) */}}
{{define "cascading-select-detail"}}
<div id="{{.ID}}-detail" data-slot="cascading-select-detail" hx-swap-oob="innerHTML" aria-live="polite" class="text-sm text-muted-foreground">{{.Body}}</div>
{{end}}
1. Save the file
Drop cascading_select.ex into lib/my_app_web/components/.
2. Use it
<.cascading_select id="vehicle" endpoint={~p"/models"}
parent_name="make" child_name="model"
legend="Vehicle" parent_label="Make" child_label="Model">
<option value="audi" selected>Audi</option>
<option value="toyota">Toyota</option>
<option value="bmw">BMW</option>
</.cascading_select>View source
defmodule ShadcnHtmx.Components.CascadingSelect do
@moduledoc """
Cascading Select — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/cascading-select.tsx. A pair of dependent native
`<select>`s: picking the parent reloads the child's `<option>`s (and an
optional detail panel via `hx-swap-oob`) from the server.
No `hx-trigger`: htmx defaults `<select>` to `change`.
See repos/htmx/www/src/content/patterns/02-forms/04-linked-selects.md
## Examples
<.cascading_select id="vehicle" endpoint={~p"/models"}
parent_name="make" child_name="model"
legend="Vehicle" parent_label="Make" child_label="Model">
<option value="audi" selected>Audi</option>
<option value="toyota">Toyota</option>
</.cascading_select>
The inner block provides the PARENT `<option>`s; pass the initially-selected
parent's options via the optional `child_options` slot so the child renders
populated before the first change. Return the child `<option>`s plus a
`<.cascading_select_detail>` carrying `hx-swap-oob` from the endpoint.
"""
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 :id, :string, required: true
attr :endpoint, :string, required: true
attr :parent_name, :string, default: "parent"
attr :child_name, :string, default: "child"
attr :legend, :string, default: nil
attr :parent_label, :string, default: nil
attr :child_label, :string, default: nil
attr :method, :string, default: "get", values: ~w(get post)
attr :disabled, :boolean, default: false
attr :detail, :boolean, default: true
attr :class, :string, default: nil
attr :rest, :global,
include: ~w(hx-indicator hx-swap hx-vals hx-sync hx-confirm hx-disabled-elt)
slot :inner_block, required: true
slot :child_options
def cascading_select(assigns) do
assigns =
assigns
|> assign(:base, @base)
|> assign(:hx_attr, if(assigns.method == "post", do: "hx-post", else: "hx-get"))
|> assign(
:controls,
if(assigns.detail,
do: "#{assigns.id}-child #{assigns.id}-detail",
else: "#{assigns.id}-child"
)
)
~H"""
<fieldset
data-slot="cascading-select"
id={@id}
disabled={@disabled}
class={["grid gap-4", @class]}
aria-labelledby={@legend && "#{@id}-legend"}
>
<legend
:if={@legend}
id={"#{@id}-legend"}
class="mb-1 text-sm leading-none font-medium text-foreground"
data-slot="cascading-select-legend"
>
{@legend}
</legend>
<div class="grid gap-2">
<label
:if={@parent_label}
for={"#{@id}-parent"}
class="text-sm leading-none font-medium text-foreground select-none"
>
{@parent_label}
</label>
<span class="relative inline-flex w-full">
<select
id={"#{@id}-parent"}
name={@parent_name}
data-slot="cascading-select-parent"
class={@base}
{%{@hx_attr => @endpoint}}
hx-target={"##{@id}-child"}
hx-include={"[name='#{@parent_name}']"}
aria-controls={@controls}
{@rest}
>
{render_slot(@inner_block)}
</select>
<.chevron />
</span>
</div>
<div class="grid gap-2">
<label
:if={@child_label}
for={"#{@id}-child"}
class="text-sm leading-none font-medium text-foreground select-none"
>
{@child_label}
</label>
<span class="relative inline-flex w-full">
<select
id={"#{@id}-child"}
name={@child_name}
data-slot="cascading-select-child"
class={@base}
>
{render_slot(@child_options)}
</select>
<.chevron />
</span>
</div>
<div
:if={@detail}
id={"#{@id}-detail"}
data-slot="cascading-select-detail"
aria-live="polite"
class="text-sm text-muted-foreground"
>
</div>
</fieldset>
"""
end
attr :id, :string, required: true
slot :inner_block, required: true
@doc """
Detail fragment for the OOB swap — return alongside the child options.
`hx-swap-oob="innerHTML"` (not `"true"`/`outerHTML`) keeps the live
`#id-detail` node in the DOM and swaps only its contents, preserving the
aria-live region's identity across updates.
"""
def cascading_select_detail(assigns) do
~H"""
<div
id={"#{@id}-detail"}
data-slot="cascading-select-detail"
hx-swap-oob="innerHTML"
aria-live="polite"
class="text-sm text-muted-foreground"
>
{render_slot(@inner_block)}
</div>
"""
end
defp chevron(assigns) do
~H"""
<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>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<fieldset data-slot="cascading-select" id="vehicle" class="grid gap-4">
<select id="vehicle-parent" name="make"
hx-get="/models" hx-target="#vehicle-child" hx-include="[name='make']"
class="…"> … </select>
<select id="vehicle-child" name="model" class="…"></select>
<div id="vehicle-detail" aria-live="polite" class="…"></div>
</fieldset>
<!-- Endpoint returns the model <option>s + a <div id="vehicle-detail"
hx-swap-oob="innerHTML"> detail fragment. -->View source
<!--
shadcn-htmx — raw HTML cascading-select snippet.
Mirrors registry/ui/cascading-select.tsx. A pair of dependent native
<select>s in a <fieldset>: picking the parent reloads the child's
<option>s (and an optional detail panel via hx-swap-oob) from the server.
No hx-trigger: htmx defaults <select> to `change`.
See repos/htmx/www/src/content/patterns/02-forms/04-linked-selects.md
WIRING:
- Parent <select> hx-get="/models" hx-target="#vehicle-child"
hx-include="[name='make']" — GETs the endpoint on change, swaps the
returned <option>s into the child (innerHTML, the default).
- The endpoint also returns a <div id="vehicle-detail" hx-swap-oob="innerHTML">
to update the detail panel out of band — one request, two updates. Using
innerHTML (not "true"/outerHTML) keeps the live #vehicle-detail node in the
DOM, swapping only its contents — so the aria-live region's identity is
preserved across updates.
- Relies only on theme tokens; no JS of its own.
-->
<fieldset data-slot="cascading-select" id="vehicle" class="grid gap-4" aria-labelledby="vehicle-legend">
<legend id="vehicle-legend" class="mb-1 text-sm leading-none font-medium text-foreground" data-slot="cascading-select-legend">Vehicle</legend>
<!-- Parent -->
<div class="grid gap-2">
<label for="vehicle-parent" class="text-sm leading-none font-medium text-foreground select-none">Make</label>
<span class="relative inline-flex w-full">
<select id="vehicle-parent" name="make" data-slot="cascading-select-parent"
hx-get="/models" hx-target="#vehicle-child" hx-include="[name='make']"
aria-controls="vehicle-child vehicle-detail"
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 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70">
<option value="audi" selected>Audi</option>
<option value="toyota">Toyota</option>
<option value="bmw">BMW</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" />
</svg>
</span>
</div>
<!-- Child (htmx swaps fresh <option>s in here) -->
<div class="grid gap-2">
<label for="vehicle-child" class="text-sm leading-none font-medium text-foreground select-none">Model</label>
<span class="relative inline-flex w-full">
<select id="vehicle-child" name="model" data-slot="cascading-select-child"
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 md:text-sm dark:bg-input/30 [&.htmx-request]:opacity-70">
<option value="a4" selected>A4</option>
<option value="q5">Q5</option>
<option value="etron-gt">e-tron GT</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" />
</svg>
</span>
</div>
<!-- Detail panel (updated out of band on each parent change) -->
<div id="vehicle-detail" data-slot="cascading-select-detail" aria-live="polite" class="text-sm text-muted-foreground">
Audi A4 — Sedan, $39,900
</div>
</fieldset>
<!--
The server returns, e.g.:
<option value="a4" selected>A4</option>
<option value="q5">Q5</option>
<option value="etron-gt">e-tron GT</option>
<div id="vehicle-detail" hx-swap-oob="innerHTML" aria-live="polite"
class="text-sm text-muted-foreground">Audi A4 — Sedan, $39,900</div>
-->
Examples
Make → model, with a detail panel
Picking a make GETs /models with the make value; the server returns the model <option>s swapped into the child select, plus a detail fragment that updates the price card out of band.
One change on the parent fires one request. The default hx-swap="innerHTML" replaces the child's options, and a second fragment marked hx-swap-oob="true" updates the detail panel by id — two DOM updates, no extra round-trip. hx-include pins the make value to the request. With htmx off, the parent still submits its value in a normal form post.
<CascadingSelect id="vehicle" endpoint="/models"
parentName="make" childName="model"
legend="Vehicle" parentLabel="Make" childLabel="Model"
detail={<>Audi A4 — Sedan, $39,900</>}>
<CascadingSelectOption value="audi" selected>Audi</CascadingSelectOption>
<CascadingSelectOption value="toyota">Toyota</CascadingSelectOption>
<CascadingSelectOption value="bmw">BMW</CascadingSelectOption>
</CascadingSelect>
{/* GET /models returns: model <option>s + a detail fragment */}
{/* <CascadingSelectDetail id="vehicle">…</CascadingSelectDetail> */}{{ cascading_select_open(id="vehicle", endpoint="/models",
parent_name="make", child_name="model",
legend="Vehicle", parent_label="Make", child_label="Model") }}
{{ option("audi", "Audi", selected=true) }}
{{ option("toyota", "Toyota") }}
{{ option("bmw", "BMW") }}
{{ cascading_select_close(id="vehicle", child_name="model", child_label="Model") }}{{template "cascading-select" (dict
"ID" "vehicle" "Endpoint" "/models"
"ParentName" "make" "ChildName" "model"
"Legend" "Vehicle" "ParentLabel" "Make" "ChildLabel" "Model" "Detail" true
"Body" (htmlSafe `<option value="audi" selected>Audi</option>…`))}}<.cascading_select id="vehicle" endpoint={~p"/models"}
parent_name="make" child_name="model"
legend="Vehicle" parent_label="Make" child_label="Model">
<option value="audi" selected>Audi</option>
<option value="toyota">Toyota</option>
<option value="bmw">BMW</option>
</.cascading_select><div class="w-full max-w-md">
<fieldset data-slot="cascading-select" id="ex-cs-vehicle" class="grid gap-4" aria-labelledby="ex-cs-vehicle-legend">
<legend id="ex-cs-vehicle-legend" class="mb-1 text-sm leading-none font-medium text-foreground" data-slot="cascading-select-legend">Vehicle</legend>
<div class="grid gap-2">
<label for="ex-cs-vehicle-parent" class="text-sm leading-none font-medium text-foreground select-none">Make</label>
<span class="relative inline-flex w-full">
<select id="ex-cs-vehicle-parent" name="make" data-slot="cascading-select-parent" 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" aria-controls="ex-cs-vehicle-child ex-cs-vehicle-detail" hx-get="/docs/cascading-select/models" hx-target="#ex-cs-vehicle-child" hx-include="[name='make']">
<option value="audi" selected="">Audi</option>
<option value="toyota">Toyota</option>
<option value="bmw">BMW</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-cs-vehicle-child" class="text-sm leading-none font-medium text-foreground select-none">Model</label>
<span class="relative inline-flex w-full">
<select id="ex-cs-vehicle-child" name="model" data-slot="cascading-select-child" 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">
<option value="a4" selected="">A4</option>
<option value="q5">Q5</option>
<option value="etron-gt">e-tron GT</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 id="ex-cs-vehicle-detail" data-slot="cascading-select-detail" aria-live="polite" class="text-sm text-muted-foreground">Audi A4 — Sedan, $39,900</div>
</fieldset>
</div>Further reading
Country → state (options only)
Omit the detail panel for a plain two-level cascade: the endpoint returns just the child <option>s, swapped in on change.
When you only need dependent options, leave detail off. The component drops the OOB panel entirely and the endpoint returns nothing but the new <option>s. This is the bare linked-selects recipe from the htmx docs.
<CascadingSelect id="region" endpoint="/states"
parentName="country" childName="state"
legend="Region" parentLabel="Country" childLabel="State / Province">
<CascadingSelectOption value="us" selected>United States</CascadingSelectOption>
<CascadingSelectOption value="de">Deutschland</CascadingSelectOption>
<CascadingSelectOption value="tr">Türkiye</CascadingSelectOption>
</CascadingSelect>
{/* GET /states returns just the <option>s for the country. */}{{ cascading_select_open(id="region", endpoint="/states",
parent_name="country", child_name="state",
legend="Region", parent_label="Country", child_label="State / Province",
detail=false) }}
{{ option("us", "United States", selected=true) }}
{{ option("de", "Deutschland") }}
{{ option("tr", "Türkiye") }}
{{ cascading_select_close(id="region", child_name="state",
child_label="State / Province", detail=false) }}{{template "cascading-select" (dict
"ID" "region" "Endpoint" "/states"
"ParentName" "country" "ChildName" "state"
"Legend" "Region" "ParentLabel" "Country" "ChildLabel" "State / Province"
"Detail" false
"Body" (htmlSafe `<option value="us" selected>United States</option>…`))}}<.cascading_select id="region" endpoint={~p"/states"}
parent_name="country" child_name="state"
legend="Region" parent_label="Country" child_label="State / Province"
detail={false}>
<option value="us" selected>United States</option>
<option value="de">Deutschland</option>
<option value="tr">Türkiye</option>
</.cascading_select><div class="w-full max-w-md">
<fieldset data-slot="cascading-select" id="ex-cs-region" class="grid gap-4" aria-labelledby="ex-cs-region-legend">
<legend id="ex-cs-region-legend" class="mb-1 text-sm leading-none font-medium text-foreground" data-slot="cascading-select-legend">Region</legend>
<div class="grid gap-2">
<label for="ex-cs-region-parent" class="text-sm leading-none font-medium text-foreground select-none">Country</label>
<span class="relative inline-flex w-full">
<select id="ex-cs-region-parent" name="country" data-slot="cascading-select-parent" 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" aria-controls="ex-cs-region-child" hx-get="/docs/cascading-select/states" hx-target="#ex-cs-region-child" hx-include="[name='country']">
<option value="us" selected="">United States</option>
<option value="de">Deutschland</option>
<option value="tr">Türkiye</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-cs-region-child" class="text-sm leading-none font-medium text-foreground select-none">State / Province</label>
<span class="relative inline-flex w-full">
<select id="ex-cs-region-child" name="state" data-slot="cascading-select-child" 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">
<option value="california" selected="">California</option>
<option value="new york">New York</option>
<option value="texas">Texas</option>
<option value="washington">Washington</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>
</fieldset>
</div>Further reading
API Reference
Cascading Select
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Base id. The child select is `${id}-child`, the detail panel `${id}-detail`, the legend `${id}-legend`. |
endpoint* | string | — | URL the parent select requests on change. Returns the child <option>s, plus an optional detail fragment with hx-swap-oob.htmxLinked selects |
parentName | string | "parent" | Form field name for the parent select; also pinned to the request via hx-include. |
childName | string | "child" | Form field name for the child (dependent) select. |
legend | string | — | Group label rendered as a <legend>; also wires aria-labelledby on the fieldset. |
parentLabel | string | — | Visible <label> for the parent select. |
childLabel | string | — | Visible <label> for the child select. |
children* | Child | — | The parent <option>s (use CascadingSelectOption). |
childOptions | Child | — | Initial child <option>s shown before the first change fires. |
detail | Child | — | Initial detail-panel content. Omit to drop the detail panel and the OOB swap entirely.htmxhx-swap-oob |
method | "get"|"post" | "get" | Request method for the cascade. GET keeps it idempotent and the no-JS form post shareable. |
disabled | boolean | false | Disable the whole group via the <fieldset disabled> attribute.MDN<fieldset disabled> |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required