Accordion Tags

A collapsible content component built on native <details>/<summary>. Supports single-open and multi-open modes. Follows the WAI-ARIA Accordion Pattern with aria-controls, aria-labelledby, role="region", and arrow key navigation between triggers.

Where are accordion tags best used?
Accordion tags are best used for content that is collapsible and expandable. They are a good way to show a lot of content in a small space.
When should I avoid the usage of accordion tags
Accordion tags should be avoided when the content is not collapsible or expandable. They are not a good way to show a lot of content in a small space.
Can other content types be embedded inside an accordion item?
Yes, other content types can be embedded inside an accordion item. For example, you can embed a table, a list, a form, or even another accordion inside an accordion item.

For example, here's a mermaid diagram of how the accordian component works:

Accordion Accordion Item Trigger Content

Accordion Item

Each collapsible section. Pass a title prop for the trigger text; children become the expandable body.

<details
  data-acc="item"
  class="acc-item border-b border-gray-200 dark:border-gray-700"
>
  <summary
    class="acc-trigger flex w-full cursor-pointer items-center justify-between py-4 text-left font-medium transition-colors hover:underline"
  >
    <span>{{ title }}</span>
    <svg
      class="acc-chevron h-4 w-4 shrink-0 text-gray-500 dark:text-gray-400"
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      stroke="currentColor"
      stroke-width="2"
    >
      <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
    </svg>
  </summary>
  <div class="acc-body">
    <div class="acc-content pb-4 text-gray-600 dark:text-gray-300">
      <yield />
    </div>
  </div>
</details>
.acc-item summary {
  list-style: none;
}

.acc-item summary::-webkit-details-marker {
  display: none;
}

.acc-item summary::marker {
  display: none;
  content: "";
}

.acc-chevron {
  transition: transform 200ms ease;
}

details[open] > summary .acc-chevron {
  transform: rotate(180deg);
}

.acc-body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 200ms ease-out;
}

details[open] > .acc-body {
  grid-template-rows: 1fr;
}

.acc-content {
  overflow: hidden;
}

Accordion

Wrapper for accordion items. Set type="multiple" to allow several items open at once; defaults to single (opening one closes the others).

<div data-acc-type="{{ type: single }}">
  <yield />
</div>
document.addEventListener("DOMContentLoaded", function () {
  document
    .querySelectorAll('[data-tag="accordion"]:not([data-acc-init])')
    .forEach(function (wrapper) {
      wrapper.setAttribute("data-acc-init", "")
      var type = wrapper.getAttribute("data-acc-type") || "single"
      var items = wrapper.querySelectorAll("details[data-acc='item']")
      var triggers = []

      items.forEach(function (detail) {
        var uid = "acc-" + Math.random().toString(36).substr(2, 8)
        var summary = detail.querySelector("summary")
        var body = detail.querySelector(".acc-body")
        if (!summary || !body) return

        summary.setAttribute("id", uid + "-t")
        summary.setAttribute("aria-controls", uid + "-p")
        body.setAttribute("id", uid + "-p")
        body.setAttribute("role", "region")
        body.setAttribute("aria-labelledby", uid + "-t")

        triggers.push(summary)
      })

      if (type === "single") {
        items.forEach(function (detail) {
          detail.addEventListener("toggle", function () {
            if (!detail.open) return
            items.forEach(function (other) {
              if (other !== detail && other.open) {
                other.open = false
              }
            })
          })
        })
      }

      wrapper.addEventListener("keydown", function (e) {
        var idx = triggers.indexOf(document.activeElement)
        if (idx === -1) return
        var next = -1
        if (e.key === "ArrowDown") next = (idx + 1) % triggers.length
        else if (e.key === "ArrowUp")
          next = (idx - 1 + triggers.length) % triggers.length
        else if (e.key === "Home") next = 0
        else if (e.key === "End") next = triggers.length - 1
        if (next !== -1) {
          e.preventDefault()
          triggers[next].focus()
        }
      })
    })
})