shshadcn-htmx

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

2. Use it

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

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

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

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

snippets/cascading-select.html
<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
snippets/cascading-select.html
<!--
  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.

Vehicle
Audi A4 — Sedan, $39,900
<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 [&amp;.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=&#39;make&#39;]">
          <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 [&amp;.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>

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.

Region
<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 [&amp;.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=&#39;country&#39;]">
          <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 [&amp;.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>

API Reference

Cascading Select

PropTypeDefaultDescription
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
parentNamestring"parent"
Form field name for the parent select; also pinned to the request via hx-include.
childNamestring"child"
Form field name for the child (dependent) select.
legendstring
Group label rendered as a <legend>; also wires aria-labelledby on the fieldset.
parentLabelstring
Visible <label> for the parent select.
childLabelstring
Visible <label> for the child select.
children*Child
The parent <option>s (use CascadingSelectOption).
childOptionsChild
Initial child <option>s shown before the first change fires.
detailChild
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.
disabledbooleanfalse
Disable the whole group via the <fieldset disabled> attribute.MDN<fieldset disabled>
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required