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.json2. Use it
import { Collapsible, CollapsibleTrigger,
CollapsibleContent } from "@/components/ui/collapsible"
<Collapsible open>
<CollapsibleTrigger>Can I use this without JS?</CollapsibleTrigger>
<CollapsibleContent>
Yes — it's native <details>/<summary>.
</CollapsibleContent>
</Collapsible>Or copy the source manually
/** @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
{% 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
{# 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
{{template "collapsible" (dict
"Title" "Can I use this without JS?" "Open" true
"Body" (htmlSafe "Yes — it's native <details>/<summary>.")
)}}View source
{{/*
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
<.collapsible open>
<.collapsible_trigger>Can I use this without JS?</.collapsible_trigger>
<.collapsible_content>Yes — it's native <details>/<summary>.</.collapsible_content>
</.collapsible>View source
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 <details>/<summary>.</.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
<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 <details>/<summary>.</div>
</details>View source
<!--
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 <details>/<summary>.
</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?
<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 [&::-webkit-details-marker]:hidden [details[open]>&_[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"><details></code>
is exactly this — no JavaScript required.
</div>
</details>Further reading
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
<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 [&::-webkit-details-marker]:hidden [details[open]>&_[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>Further reading
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 [&::-webkit-details-marker]:hidden [details[open]>&_[data-slot=collapsible-chevron]]:rotate-180">
What'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>Further reading
API Reference
<Collapsible>
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
open | boolean | false | 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> |
disabled | boolean | false | Visually mute the disclosure and block pointer interaction (pointer-events-none, reduced opacity). |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |