Tutorials 5 min read

How to create an accordion with Tailwind CSS: JavaScript and Alpine.js

Build an expand-and-collapse accordion with Tailwind CSS, two ways: vanilla JavaScript with class toggles or Alpine.js with x-show.

An accordion, yeah that’s right. Today we are building one twice: first with vanilla JavaScript, then with Alpine.js, so you can pick whichever fits your stack.

What is an accordion?

An accordion is a way to display a list of items that can be expanded and collapsed. It saves space by not showing everything at once, which makes longer content easier to organize and read.

Use cases

  • FAQs: the classic accordion, one question per row.
  • Product and service lists: showcase items without overwhelming the page.
  • Documentation: collapse instructions into steps that readers open as needed.
  • News and blogs: headlines up front, the full blurb on demand.

The markup

  • id="accordion": the accordion container.
  • id="accordionBtn1" with class="accordion-btn": the button that opens the first item; the class is what the script hooks into.
  • id="accordionIcon1" with class="accordion-icon": the SVG icon inside the button that rotates when the item opens.
  • id="accordionContent1": the content displayed when the first item is opened.
  • The second item repeats the pattern with accordionBtn2, accordionIcon2, and accordionContent2.

Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.

html
<div id="accordion">
  <!-- Accordion Item 1 -->
  <div>
    <button id="accordionBtn1" class="accordion-btn">
      <span>What time is it?</span>
      <svg class="accordion-icon ..." id="accordionIcon1">
        <!---- SVG path goes here --->
      </svg>
    </button>
    <div id="accordionContent1" class="accordion-content ... hidden">
      I don't know what time it is.
    </div>
  </div>
  <!-- Accordion Item 2 -->
  <div>
    <button id="accordionBtn2" class="accordion-btn ...">
      <span>Why not?</span>
      <svg class="accordion-icon ..." id="accordionIcon2">
        <!---- SVG path goes here --->
      </svg>
    </button>
    <div id="accordionContent2" class="accordion-content ... hidden">
      Because I have lost the notion of time.
    </div>
  </div>
  <!-- Add more Items here -->
</div>

The script

  • document.addEventListener("DOMContentLoaded", function () {: runs once the page is loaded.
  • document.querySelectorAll(".accordion-btn"), and the same for .accordion-icon and .accordion-content: grab every button, icon, and content panel.
  • Each button gets a click listener that calls toggleAccordion(index) with its position.
  • Inside toggleAccordion, the matching icon toggles rotate-45 while every other icon has it removed.
  • The matching content toggles hidden while every other panel gets hidden added, so only one item stays open at a time.

The full script:

js
document.addEventListener("DOMContentLoaded", function () {
  const accordionBtns = document.querySelectorAll(".accordion-btn");
  const accordionIcons = document.querySelectorAll(".accordion-icon");
  const accordionContents = document.querySelectorAll(".accordion-content");
  accordionBtns.forEach((btn, index) => {
    btn.addEventListener("click", () => {
      toggleAccordion(index);
    });
  });

  function toggleAccordion(index) {
    accordionIcons.forEach((icon, i) => {
      if (i === index) {
        icon.classList.toggle("rotate-45");
      } else {
        icon.classList.remove("rotate-45");
      }
    });
    accordionContents.forEach((content, i) => {
      if (i === index) {
        content.classList.toggle("hidden");
      } else {
        content.classList.add("hidden");
      }
    });
  }
});

The Alpine.js version

Same component, no separate script: the state lives in x-data.

  • x-data="{ openIndex: null }": tracks which item is open; null means everything is closed.
  • @click="openIndex === 0 ? openIndex = null : openIndex = 0": clicking an open item closes it, clicking a closed one opens it.
  • x-show="openIndex === 0": shows the content that belongs to the open item.
  • :class="{ 'rotate-45': openIndex === 0 }": rotates the icon of the open item.

Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.

html
<div x-data="{ openIndex: null }">
  <div class="overflow-hidden border rounded-md">
    <!-- Accordion Item 1 -->
    <div class="border-b">
      <button
        @click="openIndex === 0 ? openIndex = null : openIndex = 0"
        class="flex items-center justify-between w-full p-4 focus:outline-none text-zinc-500 focus:text-orange-500"
      >
        <span>What time is it?</span>
        <svg
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke-width="1.5"
          stroke="currentColor"
          class="w-6 h-6 transform transition-transform"
          :class="{ 'rotate-45': openIndex === 0 }"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            d="M12 4.5v15m7.5-7.5h-15"
          ></path>
        </svg>
      </button>
      <div
        x-show="openIndex === 0"
        class="p-4 text-sm border-t bg-zinc-50 text-zinc-500"
      >
        I don't know what time it is.
      </div>
    </div>

    <!-- Accordion Item 2 -->
    <div class="border-b">
      <button
        @click="openIndex === 1 ? openIndex = null : openIndex = 1"
        class="flex items-center justify-between w-full p-4 focus:outline-none text-zinc-500 focus:text-orange-500"
      >
        <span>Why not?</span>
        <svg
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke-width="1.5"
          stroke="currentColor"
          class="w-6 h-6 transform transition-transform"
          :class="{ 'rotate-45': openIndex === 1 }"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            d="M12 4.5v15m7.5-7.5h-15"
          ></path>
        </svg>
      </button>
      <div
        x-show="openIndex === 1"
        class="p-4 text-sm border-t text-zinc-500 bg-zinc-50"
      >
        Because I have lost the notion of time.
      </div>
    </div>
  </div>
</div>

You can try the Alpine version separately: live demo and source code.

Which one should you use?

With Alpine, one openIndex variable replaces the whole toggle function, so if it’s already loaded, that’s the leaner path. The vanilla version does the same job with a few class toggles and no dependency.

Conclusion

A couple of buttons, one piece of state, and Tailwind’s hidden and rotate-45 classes: that’s an accordion. Do not forget to make it fully accessible for all users, and to test your code on different devices and browsers to ensure that it works correctly.

Hope you enjoyed this tutorial and have a great day!

/Michael Andreuzza