shshadcn-htmx

Components

Collapsible

A single show/hide disclosure built on native <details> + <summary>. Click / Space / Enter toggle, the trigger is focusable, and the browser mirrors aria-expanded — zero JavaScript.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/collapsible.json

2. Use it

components/ui/collapsible.tsx
import { Collapsible, CollapsibleTrigger,
  CollapsibleContent } from "@/components/ui/collapsible"

<Collapsible open>
  <CollapsibleTrigger>Can I use this without JS?</CollapsibleTrigger>
  <CollapsibleContent>
    Yes — it's native &lt;details&gt;/&lt;summary&gt;.
  </CollapsibleContent>
</Collapsible>
Or copy the source manually
components/ui/collapsible.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Collapsible — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn upstream uses Radix Collapsible (a CollapsibleTrigger button + a
// CollapsibleContent region wired together with aria-expanded / aria-controls
// and JS state). We use the native HTML disclosure widget instead:
//   <details><summary>Trigger</summary>...content...</details>
// so the platform gives us, with zero JS:
//   - Click / Space / Enter toggles open (browser default on <summary>).
//   - <summary> is implicitly role="button", focusable, with its text as the
//     accessible name; the browser sets aria-expanded to mirror `open`.
//   - The content is the accessible description of the summary.
//
// This is the WAI-ARIA Disclosure (Show/Hide) pattern: a single button that
// shows/hides one section of content. Distinct from Accordion — Collapsible
// is a standalone, single show/hide, NOT a group, so there is no `name`
// attribute and no exclusive grouping. No public/site.js hook is required:
// the entire keyboard contract (Enter / Space) is native to <summary>.
//
// Refs:
//   repos/aria-practices/content/patterns/disclosure/disclosure-pattern.html
//     (Keyboard: Enter / Space toggle; role=button; aria-expanded true/false)
//   repos/mdn/files/en-us/web/html/reference/elements/details/index.md
//     (`open` boolean; implicit ARIA role=group; toggle event)
//   repos/mdn/files/en-us/web/html/reference/elements/summary/index.md
//     (click/Space toggles parent <details>; display:list-item marker)

type CollapsibleProps = PropsWithChildren<{
  // Pre-open the disclosure on initial render.
  open?: boolean
  disabled?: boolean
  class?: ClassValue
}>

export function Collapsible(props: CollapsibleProps) {
  const { open, disabled, class: className, children, ...rest } = props
  return (
    <details
      data-slot="collapsible"
      data-disabled={disabled ? "true" : undefined}
      open={open}
      class={cn(
        "w-full",
        disabled && "pointer-events-none opacity-50",
        className,
      )}
      {...rest}
    >
      {children}
    </details>
  )
}

type CollapsibleTriggerProps = PropsWithChildren<{ class?: ClassValue }>

export function CollapsibleTrigger(props: CollapsibleTriggerProps) {
  const { class: className, children, ...rest } = props
  return (
    <summary
      data-slot="collapsible-trigger"
      class={cn(
        "flex cursor-pointer items-center justify-between gap-4 rounded-md py-2 text-left text-sm font-medium transition-all outline-none select-none marker:hidden " +
          "hover:underline " +
          "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
          "list-none [&::-webkit-details-marker]:hidden " +
          // Rotate the chevron when the parent <details> is open.
          "[details[open]>&_[data-slot=collapsible-chevron]]:rotate-180",
        className,
      )}
      {...rest}
    >
      {children}
      <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"
        data-slot="collapsible-chevron"
        class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200"
        aria-hidden="true"
      >
        <polyline points="6 9 12 15 18 9" />
      </svg>
    </summary>
  )
}

type CollapsibleContentProps = PropsWithChildren<{ class?: ClassValue }>

export function CollapsibleContent(props: CollapsibleContentProps) {
  const { class: className, children, ...rest } = props
  return (
    <div
      data-slot="collapsible-content"
      class={cn("overflow-hidden pt-2 pb-1 text-sm", className)}
      {...rest}
    >
      {children}
    </div>
  )
}

1. Save the file

Copy collapsible.html into templates/components/.

2. Use it

templates/components/collapsible.html
{% from "components/collapsible.html" import collapsible_open,
   collapsible_close, collapsible_trigger,
   collapsible_content_open, collapsible_content_close %}

{{ collapsible_open(open=true) }}
  {{ collapsible_trigger("Can I use this without JS?") }}
  {{ collapsible_content_open() }}
    Yes — it's native <details>/<summary>.
  {{ collapsible_content_close() }}
{{ collapsible_close() }}
View source
templates/components/collapsible.html
{# Collapsible macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/collapsible.tsx. Native <details>/<summary> single
   disclosure (WAI-ARIA Disclosure pattern). Enter/Space toggle and
   aria-expanded are all native to <summary> — zero JS.

   Distinct from accordion: a standalone show/hide, no group `name`.

   Usage:
     {% from "components/collapsible.html" import collapsible_open, collapsible_close,
        collapsible_trigger, collapsible_content_open, collapsible_content_close %}

     {{ collapsible_open(open=true) }}
       {{ collapsible_trigger("Can I use this without JS?") }}
       {{ collapsible_content_open() }}
         Yes — it is native <details>/<summary>.
       {{ collapsible_content_close() }}
     {{ collapsible_close() }} #}

{% macro collapsible_open(open=false, disabled=false, extra_class="", attrs={}) -%}
<details data-slot="collapsible"
         {%- if disabled %} data-disabled="true"{% endif %}
         {%- if open %} open{% endif %}
         class="w-full {% if disabled %}pointer-events-none opacity-50{% endif %} {{ extra_class }}"
         {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>
{%- endmacro %}

{% macro collapsible_close() %}</details>{% endmacro %}

{% macro collapsible_trigger(text, extra_class="") -%}
<summary data-slot="collapsible-trigger"
  class="flex cursor-pointer items-center justify-between gap-4 rounded-md py-2 text-left text-sm font-medium transition-all outline-none select-none marker:hidden hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 list-none [&::-webkit-details-marker]:hidden [details[open]>&_[data-slot=collapsible-chevron]]:rotate-180 {{ extra_class }}">
  {{ text }}
  <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"
       data-slot="collapsible-chevron"
       class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
    <polyline points="6 9 12 15 18 9" />
  </svg>
</summary>
{%- endmacro %}

{% macro collapsible_content_open(extra_class="") -%}
<div data-slot="collapsible-content" class="overflow-hidden pt-2 pb-1 text-sm {{ extra_class }}">
{%- endmacro %}

{% macro collapsible_content_close() %}</div>{% endmacro %}

1. Save the file

Add collapsible.tmpl alongside your other templates.

2. Use it

components/collapsible.tmpl
{{template "collapsible" (dict
  "Title" "Can I use this without JS?" "Open" true
  "Body" (htmlSafe "Yes — it's native <details>/<summary>.")
)}}
View source
components/collapsible.tmpl
{{/*
  Collapsible templates — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/collapsible.tsx. Native <details>/<summary> single
  disclosure (WAI-ARIA Disclosure pattern). Enter/Space toggle and
  aria-expanded are native to <summary> — zero JS. No group `name`.

  Two named templates compose:
    - "collapsible"          — full <details> with trigger + content
    - "collapsible_trigger"  — just the <summary> (when composing manually)

  Usage:
    {{template "collapsible" (dict
      "Title" "Can I use this without JS?" "Open" true
      "Body" (htmlSafe "Yes — it is native <details>/<summary>.")
    )}}
*/}}

{{/* .Attrs (htmlSafe) forwards hx-* / global attributes onto the <details>
     so the native `toggle` event it fires can drive zero-JS lazy loading:
     hx-trigger="toggle once" hx-get=...  (toggle event: HTMLElement;
     hx-trigger accepts any DOM event). */}}
{{define "collapsible"}}
<details data-slot="collapsible"
         {{if .Disabled}}data-disabled="true"{{end}}
         {{if .Open}}open{{end}}
         class="w-full{{if .Disabled}} pointer-events-none opacity-50{{end}}"{{with .Attrs}} {{.}}{{end}}>
  {{template "collapsible_trigger" (dict "Text" .Title)}}
  <div data-slot="collapsible-content" class="overflow-hidden pt-2 pb-1 text-sm">{{.Body}}</div>
</details>
{{end}}

{{define "collapsible_trigger"}}
<summary data-slot="collapsible-trigger"
  class="flex cursor-pointer items-center justify-between gap-4 rounded-md py-2 text-left text-sm font-medium transition-all outline-none select-none marker:hidden hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 list-none [&::-webkit-details-marker]:hidden [details[open]>&_[data-slot=collapsible-chevron]]:rotate-180">
  {{.Text}}
  <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"
       data-slot="collapsible-chevron"
       class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
    <polyline points="6 9 12 15 18 9" />
  </svg>
</summary>
{{end}}

1. Save the file

Drop collapsible.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/collapsible.ex
<.collapsible open>
  <.collapsible_trigger>Can I use this without JS?</.collapsible_trigger>
  <.collapsible_content>Yes — it's native &lt;details&gt;/&lt;summary&gt;.</.collapsible_content>
</.collapsible>
View source
lib/my_app_web/components/collapsible.ex
defmodule ShadcnHtmx.Components.Collapsible do
  @moduledoc """
  Collapsible — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Native `<details>` + `<summary>` single disclosure (WAI-ARIA Disclosure
  pattern). Enter/Space toggle, focusable summary, and the browser-managed
  aria-expanded are all native — zero JS.

  Distinct from accordion: a standalone show/hide, not a group, so there is
  no `name` attribute and no exclusive grouping.

  ## Examples

      <.collapsible open>
        <.collapsible_trigger>Can I use this without JS?</.collapsible_trigger>
        <.collapsible_content>Yes — it is native &lt;details&gt;/&lt;summary&gt;.</.collapsible_content>
      </.collapsible>
  """

  use Phoenix.Component

  attr :open, :boolean, default: false
  attr :disabled, :boolean, default: false
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def collapsible(assigns) do
    ~H"""
    <details
      data-slot="collapsible"
      data-disabled={@disabled && "true"}
      open={@open}
      class={[
        "w-full",
        @disabled && "pointer-events-none opacity-50",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </details>
    """
  end

  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def collapsible_trigger(assigns) do
    ~H"""
    <summary
      data-slot="collapsible-trigger"
      class={[
        "flex cursor-pointer items-center justify-between gap-4 rounded-md py-2 text-left text-sm font-medium",
        "transition-all outline-none select-none marker:hidden hover:underline",
        "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
        "list-none [&::-webkit-details-marker]:hidden",
        "[details[open]>&_[data-slot=collapsible-chevron]]:rotate-180",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
      <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"
        data-slot="collapsible-chevron"
        class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200"
        aria-hidden="true"
      >
        <polyline points="6 9 12 15 18 9" />
      </svg>
    </summary>
    """
  end

  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def collapsible_content(assigns) do
    ~H"""
    <div data-slot="collapsible-content" class={["overflow-hidden pt-2 pb-1 text-sm", @class]} {@rest}>
      {render_slot(@inner_block)}
    </div>
    """
  end
end

1. Save the file

Paste the markup; it relies only on the theme tokens in styles.css.

2. Use it

snippets/collapsible.html
<details data-slot="collapsible" open class="w-full">
  <summary data-slot="collapsible-trigger" class="…">Can I use this without JS? <svg …chevron/></summary>
  <div data-slot="collapsible-content" class="…">Yes — it's native &lt;details&gt;/&lt;summary&gt;.</div>
</details>
View source
snippets/collapsible.html
<!--
  shadcn-htmx — raw HTML collapsible snippet.

  Native <details> + <summary> single disclosure (WAI-ARIA Disclosure
  pattern). Click / Space / Enter toggle open, the summary is focusable and
  implicitly role="button", and the browser mirrors aria-expanded — all with
  zero JavaScript. Distinct from accordion: a single standalone show/hide,
  with no `name` group attribute.

  Relies only on the theme tokens in styles.css.
-->

<details data-slot="collapsible" open class="w-full">
  <summary data-slot="collapsible-trigger"
    class="flex cursor-pointer items-center justify-between gap-4 rounded-md py-2 text-left text-sm font-medium transition-all outline-none select-none marker:hidden hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 list-none [&::-webkit-details-marker]:hidden [details[open]>&_[data-slot=collapsible-chevron]]:rotate-180">
    Can I use this without JavaScript?
    <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"
         data-slot="collapsible-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
      <polyline points="6 9 12 15 18 9" />
    </svg>
  </summary>
  <div data-slot="collapsible-content" class="overflow-hidden pt-2 pb-1 text-sm">
    Yes. The open/close toggle, keyboard handling (Space / Enter), focus, and
    aria-expanded are all native to &lt;details&gt;/&lt;summary&gt;.
  </div>
</details>

Examples

Basic — click the trigger to reveal

A standalone disclosure. The summary toggles the content; nothing else on the page is affected.

This is the WAI-ARIA Disclosure pattern: one button that shows or hides a single section of content. Because it is native <details>, the toggle, focus, and aria-expanded are handled by the browser — no group, no JS, no state machine. Reach for an Accordion instead when you have several related sections that should expand together or exclusively.

What is a disclosure widget?
A control that shows or hides a single region of content. Native <details> is exactly this — no JavaScript required.
<Collapsible>
  <CollapsibleTrigger>What is a disclosure widget?</CollapsibleTrigger>
  <CollapsibleContent>A control that shows or hides one region.</CollapsibleContent>
</Collapsible>
{{ collapsible_open() }}
  {{ collapsible_trigger("What is a disclosure widget?") }}
  {{ collapsible_content_open() }}A control that shows or hides one region.{{ collapsible_content_close() }}
{{ collapsible_close() }}
{{template "collapsible" (dict "Title" "What is a disclosure widget?" "Body" (htmlSafe "A control that shows or hides one region."))}}
<.collapsible>
  <.collapsible_trigger>What is a disclosure widget?</.collapsible_trigger>
  <.collapsible_content>A control that shows or hides one region.</.collapsible_content>
</.collapsible>
<details data-slot="collapsible" class="w-full max-w-md">
  <summary data-slot="collapsible-trigger" class="flex cursor-pointer items-center justify-between gap-4 rounded-md py-2 text-left text-sm font-medium transition-all outline-none select-none marker:hidden hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 list-none [&amp;::-webkit-details-marker]:hidden [details[open]&gt;&amp;_[data-slot=collapsible-chevron]]:rotate-180">
    What is a disclosure widget?
    <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" data-slot="collapsible-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
      <polyline points="6 9 12 15 18 9">
      </polyline>
    </svg>
  </summary>
  <div data-slot="collapsible-content" class="overflow-hidden pt-2 pb-1 text-sm">
    A control that shows or hides a single region of content. Native
    <code class="rounded bg-muted px-1 py-0.5">&lt;details&gt;</code>
    is exactly this — no JavaScript required.
  </div>
</details>

Open by default

Add the open prop (the native <details open> boolean attribute) to render expanded.

The open prop maps straight to the boolean open attribute on <details>. Remove it (don't set it to false as a string) to start collapsed.

Release notes — v4.0
New native attributes, smaller core, and a faster swap pipeline. Collapse me with Space or Enter.
<Collapsible open>
  <CollapsibleTrigger>Release notes — v4.0</CollapsibleTrigger>
  <CollapsibleContent>New native attributes, smaller core…</CollapsibleContent>
</Collapsible>
{{ collapsible_open(open=true) }}
  {{ collapsible_trigger("Release notes — v4.0") }}
  {{ collapsible_content_open() }}New native attributes, smaller core…{{ collapsible_content_close() }}
{{ collapsible_close() }}
{{template "collapsible" (dict "Title" "Release notes — v4.0" "Open" true "Body" (htmlSafe "New native attributes, smaller core…"))}}
<.collapsible open>
  <.collapsible_trigger>Release notes — v4.0</.collapsible_trigger>
  <.collapsible_content>New native attributes, smaller core…</.collapsible_content>
</.collapsible>
<details data-slot="collapsible" open="" class="w-full max-w-md">
  <summary data-slot="collapsible-trigger" class="flex cursor-pointer items-center justify-between gap-4 rounded-md py-2 text-left text-sm font-medium transition-all outline-none select-none marker:hidden hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 list-none [&amp;::-webkit-details-marker]:hidden [details[open]&gt;&amp;_[data-slot=collapsible-chevron]]:rotate-180">
    Release notes — v4.0
    <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" data-slot="collapsible-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
      <polyline points="6 9 12 15 18 9">
      </polyline>
    </svg>
  </summary>
  <div data-slot="collapsible-content" class="overflow-hidden pt-2 pb-1 text-sm">
    New native attributes, smaller core, and a faster swap pipeline. Collapse me with Space or Enter.
  </div>
</details>

Inside a card — progressive disclosure

Tuck supplementary detail behind a trigger so the primary content stays scannable.

A common use: keep a panel compact, then let the reader expand the extra detail on demand. The chevron rotates via the details[open] attribute selector — pure CSS, in step with the native open state.

Standard plan

$12 / month, billed annually.

What's included?
  • Unlimited projects
  • Priority email support
  • Custom domains
<div class="rounded-lg border bg-card p-4">
  <p class="font-medium">Standard plan</p>
  <Collapsible>
    <CollapsibleTrigger>What's included?</CollapsibleTrigger>
    <CollapsibleContent>…feature list…</CollapsibleContent>
  </Collapsible>
</div>
<div class="rounded-lg border bg-card p-4">
  <p class="font-medium">Standard plan</p>
  {{ collapsible_open() }}
    {{ collapsible_trigger("What's included?") }}
    {{ collapsible_content_open() }}…feature list…{{ collapsible_content_close() }}
  {{ collapsible_close() }}
</div>
<div class="rounded-lg border bg-card p-4">
  <p class="font-medium">Standard plan</p>
  {{template "collapsible" (dict "Title" "What's included?" "Body" (htmlSafe "…feature list…"))}}
</div>
<div class="rounded-lg border bg-card p-4">
  <p class="font-medium">Standard plan</p>
  <.collapsible>
    <.collapsible_trigger>What's included?</.collapsible_trigger>
    <.collapsible_content>…feature list…</.collapsible_content>
  </.collapsible>
</div>
<div class="max-w-md rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
  <p class="mb-1 text-sm font-medium">Standard plan</p>
  <p class="mb-3 text-sm text-muted-foreground">$12 / month, billed annually.</p>
  <details data-slot="collapsible" class="w-full">
    <summary data-slot="collapsible-trigger" class="flex cursor-pointer items-center justify-between gap-4 rounded-md py-2 text-left text-sm font-medium transition-all outline-none select-none marker:hidden hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 list-none [&amp;::-webkit-details-marker]:hidden [details[open]&gt;&amp;_[data-slot=collapsible-chevron]]:rotate-180">
      What&#39;s included?
      <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" data-slot="collapsible-chevron" class="pointer-events-none size-4 shrink-0 text-muted-foreground transition-transform duration-200" aria-hidden="true">
        <polyline points="6 9 12 15 18 9">
        </polyline>
      </svg>
    </summary>
    <div data-slot="collapsible-content" class="overflow-hidden pt-2 pb-1 text-sm">
      <ul class="list-disc space-y-1 pl-5 text-muted-foreground">
        <li>Unlimited projects</li>
        <li>Priority email support</li>
        <li>Custom domains</li>
      </ul>
    </div>
  </details>
</div>

API Reference

<Collapsible>

PropTypeDefaultDescription
hx-trigger="toggle once"string
Lazy-load the disclosure's contents the first time it opens. The root <details> fires a native toggle event whenever its open/closed state changes, and hx-trigger accepts any DOM event — so pairing hx-trigger="toggle once" with hx-get/hx-post on the root fetches heavy panel content on first open with zero JS. Forwarded onto the underlying <details> via the standard hx-* passthrough.MDNdetails: toggle event
openbooleanfalse
Render expanded on initial load. Maps to the native boolean <details open> attribute — omit it (don't pass the string "false") to start collapsed.MDN<details open>
disabledbooleanfalse
Visually mute the disclosure and block pointer interaction (pointer-events-none, reduced opacity).
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference