Components
Accordion
Stacked, collapsible sections built on native <details> + <summary>. The new HTML name attribute makes single-expand mode zero-JS; arrow-key navigation is wired up in public/site.js per APG.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/accordion.json2. Use it
import { Accordion, AccordionItem, AccordionTrigger,
AccordionContent } from "@/components/ui/accordion"
<Accordion id="faq" type="single">
<AccordionItem value="q1" open>
<AccordionTrigger>What's htmx?</AccordionTrigger>
<AccordionContent>Hypermedia-driven HTML extensions.</AccordionContent>
</AccordionItem>
<AccordionItem value="q2">
<AccordionTrigger>Why Tailwind v4?</AccordionTrigger>
<AccordionContent>Utility-first, small bundles.</AccordionContent>
</AccordionItem>
</Accordion>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Accordion — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn upstream uses Radix Accordion. We use the native HTML
// <details><summary>...</summary>...</details>
// so the platform gives us:
// - Click / Space / Enter toggles open (browser default).
// - aria-expanded mirrors the open attribute (browser-set on <summary>).
// - <summary> is implicitly role="button" with accessible name from text.
//
// Native `<details name="...">` attribute (HTML Living Standard) makes the
// group exclusive — opening one item closes the others, no JS required.
// We use that for `type="single"`. For `type="multiple"` (the default) we
// omit the name attribute and every item can be expanded independently.
//
// Public/site.js adds the APG keyboard contract on top:
// - Down/Up Arrow: move focus between summaries in the same accordion.
// - Home / End: focus first / last summary.
//
// Refs:
// repos/mdn/files/en-us/web/html/reference/elements/details/index.md
// repos/mdn/files/en-us/web/html/reference/elements/summary/index.md
// repos/aria-practices/content/patterns/accordion/
export type AccordionType = "single" | "multiple"
type AccordionProps = PropsWithChildren<{
// Required so the exclusive-accordion `name` attribute can scope items.
id: string
type?: AccordionType
class?: ClassValue
}>
export function Accordion(props: AccordionProps) {
const { id, type = "multiple", class: className, children } = props
return (
<div
id={id}
data-slot="accordion"
data-accordion
data-type={type}
data-group-name={type === "single" ? id : undefined}
class={cn("w-full", className)}
>
{children}
</div>
)
}
type AccordionItemProps = PropsWithChildren<{
// Distinct value per item. Emitted as the `data-value` attribute so each
// item is individually identifiable; the boot script does not derive any
// aria-controls wiring from it. (Native <details>/<summary> is a disclosure
// widget where aria-controls is optional per the WAI-ARIA Disclosure
// pattern — repos/aria-practices/content/patterns/disclosure/.)
value: string
// Pre-open this item on initial render.
open?: boolean
disabled?: boolean
class?: ClassValue
}>
export function AccordionItem(props: AccordionItemProps) {
const { value, open, disabled, class: className, children, ...rest } = props
return (
<details
data-slot="accordion-item"
data-value={value}
data-disabled={disabled ? "true" : undefined}
// The boot script in registry/ui/accordion's Accordion wrapper assigns
// the `name` attribute at render time for type="single" so it groups
// exclusively. (We can't compute it here without context.)
open={open}
class={cn(
"border-b last:border-b-0",
disabled && "pointer-events-none opacity-50",
className,
)}
// Forward hx-* / global attributes so the native `toggle` event (fired
// by <details> just after open/close) can drive zero-JS lazy loading,
// e.g. hx-trigger="toggle once" hx-get=...
// repos/mdn/files/en-us/web/api/htmlelement/toggle_event/index.md
// repos/htmx hx-trigger: accepts any DOM event.
{...rest}
>
{children}
</details>
)
}
type AccordionTriggerProps = PropsWithChildren<{ class?: ClassValue }>
export function AccordionTrigger(props: AccordionTriggerProps) {
const { class: className, children, ...rest } = props
return (
<summary
data-slot="accordion-trigger"
class={cn(
"flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180",
className,
)}
// Forward hx-* / global attributes onto the <summary> control.
{...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="accordion-chevron"
class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
)
}
type AccordionContentProps = PropsWithChildren<{ class?: ClassValue }>
export function AccordionContent(props: AccordionContentProps) {
const { class: className, children, ...rest } = props
return (
<div
data-slot="accordion-content"
class={cn("overflow-hidden pt-0 pb-4 text-sm", className)}
// Forward hx-* / global attributes so a panel can lazy-load on first
// open: hx-trigger="toggle once" hx-get=... on the enclosing <details>,
// or hx-* directly here. repos/htmx hx-trigger accepts any DOM event.
{...rest}
>
{children}
</div>
)
}
1. Save the file
Copy accordion.html into templates/components/.
2. Use it
{% from "components/accordion.html" import accordion_open, accordion_close,
accordion_item_open, accordion_item_close, accordion_trigger,
accordion_content_open, accordion_content_close %}
{{ accordion_open(id="faq", type="single") }}
{{ accordion_item_open(value="q1", open=true) }}
{{ accordion_trigger("What's htmx?") }}
{{ accordion_content_open() }}
Hypermedia-driven HTML extensions.
{{ accordion_content_close() }}
{{ accordion_item_close() }}
{{ accordion_close() }}View source
{# Accordion macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/accordion.tsx. Native <details>/<summary>; the boot
script in public/site.js wires the `name` attribute for type="single"
(browser-native exclusive accordion) and the APG keyboard contract.
Usage:
{% from "components/accordion.html" import accordion_open, accordion_close,
accordion_item_open, accordion_item_close,
accordion_trigger, accordion_content_open, accordion_content_close %}
{{ accordion_open(id="faq", type="single") }}
{{ accordion_item_open(value="q1", open=true) }}
{{ accordion_trigger("What's htmx?") }}
{{ accordion_content_open() }}
Hypermedia-driven HTML extensions.
{{ accordion_content_close() }}
{{ accordion_item_close() }}
{{ accordion_close() }} #}
{% macro accordion_open(id, type="multiple", extra_class="") -%}
<div id="{{ id }}"
data-slot="accordion" data-accordion data-type="{{ type }}"
{%- if type == "single" %} data-group-name="{{ id }}"{% endif %}
class="w-full {{ extra_class }}">
{%- endmacro %}
{% macro accordion_close() %}</div>{% endmacro %}
{# attrs forwards hx-* / global attributes so the native <details> `toggle`
event can drive zero-JS lazy loading (hx-trigger="toggle once" hx-get=...);
toggle event fires just after open/close; hx-trigger accepts any DOM event. #}
{% macro accordion_item_open(value, open=false, disabled=false, extra_class="", attrs={}) -%}
<details data-slot="accordion-item" data-value="{{ value }}"
{%- if disabled %} data-disabled="true"{% endif %}
{%- if open %} open{% endif %}
class="border-b last:border-b-0 {% if disabled %}pointer-events-none opacity-50{% endif %} {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>
{%- endmacro %}
{% macro accordion_item_close() %}</details>{% endmacro %}
{% macro accordion_trigger(text, extra_class="", attrs={}) -%}
<summary data-slot="accordion-trigger"
class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180 {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>
{{ 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="accordion-chevron"
class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
{%- endmacro %}
{% macro accordion_content_open(extra_class="", attrs={}) -%}
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>
{%- endmacro %}
{% macro accordion_content_close() %}</div>{% endmacro %}
1. Save the file
Add accordion.tmpl alongside button.tmpl.
2. Use it
{{template "accordion" (dict
"ID" "faq" "Type" "single"
"Body" (htmlSafe `
{{template "accordion_item" (dict
"Value" "q1" "Title" "What's htmx?" "Open" true
"Body" (htmlSafe "Hypermedia-driven HTML extensions.")
)}}`)
)}}View source
{{/*
Accordion templates — shadcn-htmx, htmx v4 + Tailwind v4.
Three named templates compose:
- "accordion" — wrapper open/close (call with Body)
- "accordion_item" — single <details> with summary + content
- "accordion_trigger" — just the <summary> (when composing manually)
*/}}
{{define "accordion"}}
{{- $type := or .Type "multiple" -}}
<div id="{{.ID}}"
data-slot="accordion" data-accordion data-type="{{$type}}"
{{if eq $type "single"}}data-group-name="{{.ID}}"{{end}}
class="w-full">
{{.Body}}
</div>
{{end}}
{{/* .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 "accordion_item"}}
<details data-slot="accordion-item" data-value="{{.Value}}"
{{if .Disabled}}data-disabled="true"{{end}}
{{if .Open}}open{{end}}
class="border-b last:border-b-0"{{with .Attrs}} {{.}}{{end}}>
{{template "accordion_trigger" (dict "Text" .Title)}}
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">{{.Body}}</div>
</details>
{{end}}
{{define "accordion_trigger"}}
<summary data-slot="accordion-trigger"
class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180"{{with .Attrs}} {{.}}{{end}}>
{{.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="accordion-chevron"
class="pointer-events-none size-4 shrink-0 translate-y-0.5 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 accordion.ex into lib/my_app_web/components/.
2. Use it
<.accordion id="faq" type="single">
<.accordion_item value="q1" open>
<.accordion_trigger>What's htmx?</.accordion_trigger>
<.accordion_content>Hypermedia-driven HTML extensions.</.accordion_content>
</.accordion_item>
</.accordion>View source
defmodule ShadcnHtmx.Components.Accordion do
@moduledoc """
Accordion — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Native `<details>` + `<summary>`. Single-expand mode (`type="single"`)
uses the new HTML `name` attribute which makes the group exclusive
with zero JS. Multi-expand mode just omits the name.
Keyboard nav (arrows, Home/End) lives in public/site.js.
## Examples
<.accordion id="faq" type="single">
<.accordion_item value="q1" open>
<.accordion_trigger>What's htmx?</.accordion_trigger>
<.accordion_content>Hypermedia-driven HTML extensions.</.accordion_content>
</.accordion_item>
</.accordion>
"""
use Phoenix.Component
attr :id, :string, required: true
attr :type, :string, default: "multiple", values: ~w(single multiple)
attr :class, :string, default: nil
slot :inner_block, required: true
def accordion(assigns) do
~H"""
<div
id={@id}
data-slot="accordion"
data-accordion
data-type={@type}
data-group-name={if @type == "single", do: @id}
class={["w-full", @class]}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :value, :string, required: true
attr :open, :boolean, default: false
attr :disabled, :boolean, default: false
attr :class, :string, default: nil
# Forward hx-* / global attributes so the native `toggle` event <details>
# fires can drive zero-JS lazy loading: hx-trigger="toggle once" hx-get=...
# (toggle event: HTMLElement; hx-trigger accepts any DOM event).
attr :rest, :global
slot :inner_block, required: true
def accordion_item(assigns) do
~H"""
<details
data-slot="accordion-item"
data-value={@value}
data-disabled={@disabled && "true"}
open={@open}
class={[
"border-b last:border-b-0",
@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 accordion_trigger(assigns) do
~H"""
<summary
data-slot="accordion-trigger"
class={[
"flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-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="accordion-chevron"
class="pointer-events-none size-4 shrink-0 translate-y-0.5 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 accordion_content(assigns) do
~H"""
<div data-slot="accordion-content" class={["overflow-hidden pt-0 pb-4 text-sm", @class]} {@rest}>
{render_slot(@inner_block)}
</div>
"""
end
end
1. Save the file
Includes the arrow-key navigation script. Copy once per page.
2. Use it
<div id="faq" data-accordion data-type="single" class="w-full">
<details name="faq" data-slot="accordion-item" data-value="q1" open class="border-b last:border-b-0">
<summary data-slot="accordion-trigger" class="…">What's htmx? <svg …chevron/></summary>
<div data-slot="accordion-content" class="…">Hypermedia-driven HTML extensions.</div>
</details>
</div>View source
<!--
shadcn-htmx — raw HTML accordion snippet.
Native <details> + <summary>. For exclusive (single-expand) accordions
use the new HTML `name` attribute that groups items — browser handles
the "close the others" behaviour with zero JS.
For arrow-key navigation between summaries and Home/End shortcuts copy
the inline JS at the bottom.
Zero-JS lazy loading: <details> fires a native `toggle` event just after it
opens/closes, and htmx hx-trigger accepts any DOM event — so adding
hx-trigger="toggle once" hx-get="/panel" to a <details> (or its content
<div>) fetches the panel only on first open. Add hx-* / aria-* inline.
-->
<!-- Single-expand: items share data-group-name=name attribute -->
<div id="faq" data-slot="accordion" data-accordion data-type="single" data-group-name="faq" class="w-full">
<details name="faq" data-slot="accordion-item" data-value="q1" open class="border-b last:border-b-0">
<summary data-slot="accordion-trigger"
class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180">
What's htmx?
<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="accordion-chevron" class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">
Hypermedia-driven HTML extensions. AJAX, WebSockets, server-sent events with attributes.
</div>
</details>
<details name="faq" data-slot="accordion-item" data-value="q2" class="border-b last:border-b-0">
<summary data-slot="accordion-trigger" class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180">
Why pair it with Tailwind v4?
<svg data-slot="accordion-chevron" 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 size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">
Utility-first CSS keeps the markup self-explanatory and the bundle small.
</div>
</details>
</div>
<!-- Arrow-key navigation across summaries -->
<script>
document.addEventListener('keydown', function (e) {
var t = e.target.closest && e.target.closest('[data-slot="accordion-trigger"]')
if (!t) return
var group = t.closest('[data-accordion]'); if (!group) return
var keys = { ArrowDown: 1, ArrowUp: -1, Home: 'h', End: 'e' }
if (!(e.key in keys)) return
e.preventDefault()
var list = [].slice.call(group.querySelectorAll('details:not([data-disabled="true"]) > [data-slot="accordion-trigger"]'))
var i = list.indexOf(t), d = keys[e.key], to
if (d === 1) to = list[(i + 1) % list.length]
if (d === -1) to = list[(i - 1 + list.length) % list.length]
if (d === 'h') to = list[0]
if (d === 'e') to = list[list.length - 1]
if (to) to.focus()
})
</script>
Examples
Single-expand — only one open at a time
Set type="single". The boot script applies the same name attribute to every <details> in the group; the browser handles closing-others natively.
The HTML Living Standard added the name attribute on <details> for exactly this pattern. All items sharing the same name are mutually exclusive — opening one auto-closes the rest. No state machine, no JS framework.
What's htmx?
Why pair it with Tailwind v4?
Does it work without JavaScript?
<details> behaviour. Arrow-key navigation needs the boot script, but the accordion still works without it.<Accordion id="faq" type="single">
<AccordionItem value="q1" open>…</AccordionItem>
<AccordionItem value="q2">…</AccordionItem>
</Accordion>{{ accordion_open(id="faq", type="single") }}
{{ accordion_item_open(value="q1", open=true) }}…{{ accordion_item_close() }}
{{ accordion_item_open(value="q2") }}…{{ accordion_item_close() }}
{{ accordion_close() }}{{template "accordion" (dict "ID" "faq" "Type" "single" "Body" (htmlSafe `…`))}}<.accordion id="faq" type="single">
<.accordion_item value="q1" open>…</.accordion_item>
<.accordion_item value="q2">…</.accordion_item>
</.accordion><div id="ex-acc-single" data-slot="accordion" data-accordion="true" data-type="single" data-group-name="ex-acc-single" class="w-full max-w-md">
<details data-slot="accordion-item" data-value="q1" open="" class="border-b last:border-b-0">
<summary data-slot="accordion-trigger" class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180">
What's htmx?
<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="accordion-chevron" class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</summary>
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">
A small library that turns any HTML attribute into an AJAX trigger — no JSON, no client framework needed.
</div>
</details>
<details data-slot="accordion-item" data-value="q2" class="border-b last:border-b-0">
<summary data-slot="accordion-trigger" class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180">
Why pair it with Tailwind v4?
<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="accordion-chevron" class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</summary>
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">Utility-first CSS keeps the markup self-explanatory and the bundle small.</div>
</details>
<details data-slot="accordion-item" data-value="q3" class="border-b last:border-b-0">
<summary data-slot="accordion-trigger" class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180">
Does it work 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="accordion-chevron" class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</summary>
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">
Yes — the open/close toggle is the native
<code class="rounded bg-muted px-1 py-0.5"><details></code>
behaviour. Arrow-key navigation needs the boot script, but the accordion still works without it.
</div>
</details>
</div>Further reading
Multi-expand — any number open at once
Default mode. Omit the name attribute; each <details> is independent.
Use multi-expand for documents where the user is comparing sections or where forced exclusivity would frustrate (FAQ pages with cross-referenced answers, settings panels with multiple sub-sections).
Account
Notifications
Privacy
<Accordion id="settings" type="multiple">
<AccordionItem value="a" open>…</AccordionItem>
<AccordionItem value="b" open>…</AccordionItem>
</Accordion>{{ accordion_open(id="settings", type="multiple") }}
{{ accordion_item_open(value="a", open=true) }}…{{ accordion_item_close() }}
{{ accordion_close() }}{{template "accordion" (dict "ID" "settings" "Type" "multiple" "Body" (htmlSafe `…`))}}<.accordion id="settings" type="multiple">
<.accordion_item value="a" open>…</.accordion_item>
</.accordion><div id="ex-acc-multi" data-slot="accordion" data-accordion="true" data-type="multiple" class="w-full max-w-md">
<details data-slot="accordion-item" data-value="a" open="" class="border-b last:border-b-0">
<summary data-slot="accordion-trigger" class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180">
Account
<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="accordion-chevron" class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</summary>
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">Email, password, 2FA.</div>
</details>
<details data-slot="accordion-item" data-value="b" open="" class="border-b last:border-b-0">
<summary data-slot="accordion-trigger" class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180">
Notifications
<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="accordion-chevron" class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</summary>
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">Email, push, in-app preferences.</div>
</details>
<details data-slot="accordion-item" data-value="c" class="border-b last:border-b-0">
<summary data-slot="accordion-trigger" class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180">
Privacy
<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="accordion-chevron" class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</summary>
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">Data retention, third-party sharing.</div>
</details>
</div>Further reading
Keyboard nav — arrow keys, Home, End
Tab focuses one summary; then ↑/↓ cycles, Home/End jump to ends. Space/Enter toggles (native).
APG's keyboard contract differs from generic Tab behaviour: inside the accordion, Tab moves focus once into the group (to the active or first summary), then arrows cycle within. This matches what users expect from native widgets like radio groups. Our handler in site.js delegates on data-accordion containers, so newly htmx-swapped accordions pick it up for free.
Tab here, then press Down
Second item
Third item
// keyboard contract is handled by site.js — no extra props.
<Accordion id="kb">…</Accordion>{# keyboard nav is wired by site.js #}
{{ accordion_open(id="kb") }}…{{ accordion_close() }}{{template "accordion" (dict "ID" "kb" "Body" (htmlSafe `…`))}}<.accordion id="kb">…</.accordion><div id="ex-acc-kb" data-slot="accordion" data-accordion="true" data-type="single" data-group-name="ex-acc-kb" class="w-full max-w-md">
<details data-slot="accordion-item" data-value="x" class="border-b last:border-b-0">
<summary data-slot="accordion-trigger" class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180">
Tab here, then press Down
<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="accordion-chevron" class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</summary>
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">Focus stays inside the group; arrows cycle, Home/End jump to first/last.</div>
</details>
<details data-slot="accordion-item" data-value="y" class="border-b last:border-b-0">
<summary data-slot="accordion-trigger" class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180">
Second item
<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="accordion-chevron" class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</summary>
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">Press Space to toggle. ESC isn't needed — there's no modal to dismiss.</div>
</details>
<details data-slot="accordion-item" data-value="z" class="border-b last:border-b-0">
<summary data-slot="accordion-trigger" class="flex cursor-pointer items-start justify-between gap-4 rounded-md py-4 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=accordion-chevron]]:rotate-180">
Third item
<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="accordion-chevron" class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</summary>
<div data-slot="accordion-content" class="overflow-hidden pt-0 pb-4 text-sm">Home jumps back here, End comes to the last item.</div>
</details>
</div>Further reading
API Reference
<Accordion>
| Prop | Type | Default | Description |
|---|---|---|---|
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element via the ...rest spread on AccordionItem (<details>), AccordionTrigger (<summary>), and AccordionContent (<div>).htmxAttribute reference |
...rest (toggle lazy-load) | any | — | Arbitrary global / hx-* attributes pass through to the rendered element. Because <details> fires a native toggle event just after open/close and hx-trigger accepts any DOM event, hx-trigger="toggle once" hx-get="/panel" on an item fetches its content only on first open — the canonical zero-JS lazy-load pattern.MDNtoggle event |
id* | string | — | Used as the exclusive-group name for single-expand. |
type | "single"|"multiple" | "multiple" | single uses the new HTML <details name> for exclusive expansion.MDN<details name> |
class | string | — | Extra Tailwind classes appended to the root element. |
* required