Accordion Rails Components

Collapse and expand sections of content in your app or website. Perfect for FAQs & content-heavy areas.

Installation

1. Stimulus Controller Setup

Start by adding the following controller to your project:

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["item", "trigger", "content", "icon"];
  static values = {
    allowMultiple: { type: Boolean, default: false }, // Allow multiple items to be open at once
  };

  _applyOpenVisuals(index) {
    if (!this.itemTargets[index] || !this.triggerTargets[index] || !this.contentTargets[index]) return;

    const item = this.itemTargets[index];
    const trigger = this.triggerTargets[index];
    const content = this.contentTargets[index];
    const icon = this.hasIconTarget && this.iconTargets[index] ? this.iconTargets[index] : null;

    item.dataset.state = "open";
    trigger.setAttribute("aria-expanded", "true");
    trigger.dataset.state = "open"; // Assuming trigger might have its own state styling
    content.dataset.state = "open";
    content.removeAttribute("hidden");

    if (icon) {
      const isPlusMinus = icon.querySelector('path[d*="M5 12h14"]');
      const isLeftChevron = icon.classList.contains("-rotate-90");

      if (isPlusMinus) {
        // For plus/minus icons, CSS handles the animation
      } else if (isLeftChevron) {
        // For left chevrons
        icon.classList.add("rotate-0");
        icon.classList.remove("-rotate-90");
      } else {
        // For regular chevrons
        icon.classList.add("rotate-180");
      }
    }

    // Set maxHeight to make content visible. scrollHeight should be available after removeAttribute("hidden").
    content.style.maxHeight = `${content.scrollHeight}px`;
  }

  _applyClosedVisuals(index) {
    if (!this.itemTargets[index] || !this.triggerTargets[index] || !this.contentTargets[index]) return;

    const item = this.itemTargets[index];
    const trigger = this.triggerTargets[index];
    const content = this.contentTargets[index];
    const icon = this.hasIconTarget && this.iconTargets[index] ? this.iconTargets[index] : null;

    item.dataset.state = "closed";
    trigger.setAttribute("aria-expanded", "false");
    trigger.dataset.state = "closed";
    content.dataset.state = "closed";
    content.setAttribute("hidden", "");

    if (icon) {
      const isPlusMinus = icon.classList.contains("scale-0");
      const isLeftChevron = icon.classList.contains("rotate-0");

      if (isPlusMinus) {
        // For plus/minus icons, CSS handles the animation
      } else if (isLeftChevron) {
        // For left chevrons
        icon.classList.remove("rotate-0");
        icon.classList.add("-rotate-90");
      } else {
        // For regular chevrons
        icon.classList.remove("rotate-180");
      }
    }

    content.style.maxHeight = "0px";
  }

  connect() {
    this.addKeyboardListeners();
    this.activeIndices = new Set(); // Start with a clean slate for internal tracking

    // Store bound function references for proper cleanup
    this.boundHandleTriggerKeydown = this.handleTriggerKeydown.bind(this);
    this.boundHandleKeydown = this.handleKeydown.bind(this);

    // Ensure all trigger buttons are focusable and have keyboard listeners
    this.triggerTargets.forEach((trigger, index) => {
      // Add tabindex for Safari compatibility
      if (!trigger.hasAttribute("tabindex")) {
        trigger.setAttribute("tabindex", "0");
      }

      // Add individual keydown listener for Safari compatibility
      trigger.addEventListener("keydown", this.boundHandleTriggerKeydown);
    });

    const initiallyOpenIndexesFromDOM = [];
    this.itemTargets.forEach((item, index) => {
      if (this.triggerTargets[index].getAttribute("aria-expanded") === "true") {
        initiallyOpenIndexesFromDOM.push(index);
      } else if (item.dataset.state === "open" && !initiallyOpenIndexesFromDOM.includes(index)) {
        // Fallback or additional check for items where data-state might be set but not aria-expanded
        initiallyOpenIndexesFromDOM.push(index);
      }
    });

    if (!this.allowMultipleValue) {
      if (initiallyOpenIndexesFromDOM.length > 0) {
        const indexToKeepOpen = initiallyOpenIndexesFromDOM[0]; // Keep the first one
        this.activeIndices.add(indexToKeepOpen);
        this._applyOpenVisuals(indexToKeepOpen);

        for (let i = 0; i < this.itemTargets.length; i++) {
          if (i !== indexToKeepOpen) {
            this._applyClosedVisuals(i);
          }
        }
      } else {
        this.itemTargets.forEach((_, index) => this._applyClosedVisuals(index));
      }
    } else {
      // Allow multiple
      initiallyOpenIndexesFromDOM.forEach((index) => {
        this.activeIndices.add(index);
        this._applyOpenVisuals(index);
      });
      // Ensure items not in initiallyOpenIndexesFromDOM are closed
      this.itemTargets.forEach((_, index) => {
        if (!this.activeIndices.has(index)) {
          this._applyClosedVisuals(index);
        }
      });
    }
  }

  disconnect() {
    this.element.removeEventListener("keydown", this.boundHandleKeydown);

    // Remove individual trigger listeners
    this.triggerTargets.forEach((trigger) => {
      trigger.removeEventListener("keydown", this.boundHandleTriggerKeydown);
    });
  }

  addKeyboardListeners() {
    this.element.addEventListener("keydown", this.boundHandleKeydown);
  }

  // Safari-compatible trigger-specific keydown handler
  handleTriggerKeydown(event) {
    const currentTrigger = event.currentTarget;
    const currentIndex = this.triggerTargets.indexOf(currentTrigger);

    if (currentIndex === -1) return;

    switch (event.key) {
      case "ArrowUp":
        event.preventDefault();
        this.focusPreviousItem(currentIndex);
        break;
      case "ArrowDown":
        event.preventDefault();
        this.focusNextItem(currentIndex);
        break;
      case "Home":
        event.preventDefault();
        this.focusFirstItem();
        break;
      case "End":
        event.preventDefault();
        this.focusLastItem();
        break;
      case "Enter":
      case " ": // Space key
        event.preventDefault();
        this.toggle(event);
        break;
    }
  }

  handleKeydown(event) {
    // Fallback handler - try to find the focused trigger
    let currentIndex = -1;

    // Check if any trigger is focused
    this.triggerTargets.forEach((trigger, index) => {
      if (trigger === document.activeElement || trigger.contains(document.activeElement)) {
        currentIndex = index;
      }
    });

    if (currentIndex === -1) return;

    switch (event.key) {
      case "ArrowUp":
        event.preventDefault();
        this.focusPreviousItem(currentIndex);
        break;
      case "ArrowDown":
        event.preventDefault();
        this.focusNextItem(currentIndex);
        break;
      case "Home":
        event.preventDefault();
        this.focusFirstItem();
        break;
      case "End":
        event.preventDefault();
        this.focusLastItem();
        break;
    }
  }

  focusPreviousItem(currentIndex) {
    const previousIndex = (currentIndex - 1 + this.triggerTargets.length) % this.triggerTargets.length;
    this.triggerTargets[previousIndex].focus();
  }

  focusNextItem(currentIndex) {
    const nextIndex = (currentIndex + 1) % this.triggerTargets.length;
    this.triggerTargets[nextIndex].focus();
  }

  focusFirstItem() {
    this.triggerTargets[0].focus();
  }

  focusLastItem() {
    this.triggerTargets[this.triggerTargets.length - 1].focus();
  }

  toggle(event) {
    const index = this.triggerTargets.indexOf(event.currentTarget);

    if (this.activeIndices.has(index)) {
      this.close(index);
    } else {
      if (!this.allowMultipleValue) {
        this.activeIndices.forEach((i) => this.close(i));
      }
      this.open(index);
    }
  }

  open(index) {
    const content = this.contentTargets[index];
    const icon = this.iconTargets[index];

    // Remove hidden and set initial state
    content.removeAttribute("hidden");
    content.style.maxHeight = "0px";

    // Force a reflow to ensure the initial state is applied
    content.offsetHeight;

    // Now set the final state
    requestAnimationFrame(() => {
      this.itemTargets[index].dataset.state = "open";
      this.triggerTargets[index].setAttribute("aria-expanded", "true");
      content.dataset.state = "open";

      // Handle different icon types
      if (icon) {
        const isPlusMinus = icon.querySelector('path[d*="M5 12h14"]');
        const isLeftChevron = icon.classList.contains("-rotate-90");

        if (isPlusMinus) {
          // For plus/minus icons, we don't rotate, the CSS handles it
          this.triggerTargets[index].setAttribute("aria-expanded", "true");
        } else if (isLeftChevron) {
          // For left chevrons, add rotate class to go from -90 to 0
          icon.classList.add("rotate-0");
          icon.classList.remove("-rotate-90");
        } else {
          // For regular chevrons
          icon.classList.add("rotate-180");
        }
      }

      this.activeIndices.add(index);
      content.style.maxHeight = `${content.scrollHeight}px`;
    });
  }

  close(index) {
    const content = this.contentTargets[index];
    const icon = this.iconTargets[index];

    this.itemTargets[index].dataset.state = "closed";
    this.triggerTargets[index].setAttribute("aria-expanded", "false");
    content.dataset.state = "closed";

    // Handle different icon types
    if (icon) {
      const isPlusMinus = icon.querySelector('path[d*="M5 12h14"]');
      const isLeftChevron = icon.classList.contains("rotate-0");

      if (isPlusMinus) {
        // For plus/minus icons, CSS handles it
        this.triggerTargets[index].setAttribute("aria-expanded", "false");
      } else if (isLeftChevron) {
        // For left chevrons, go back to -90
        icon.classList.remove("rotate-0");
        icon.classList.add("-rotate-90");
      } else {
        // For regular chevrons
        icon.classList.remove("rotate-180");
      }
    }

    content.style.maxHeight = "0px";

    // Wait for both opacity and height transitions
    content.addEventListener(
      "transitionend",
      (e) => {
        // Only hide the content after the opacity transition finishes
        if (e.propertyName === "opacity" && content.dataset.state === "closed") {
          content.setAttribute("hidden", "");
        }
      },
      { once: true }
    );

    this.activeIndices.delete(index);
  }
}

Examples

Accordion with chevron

A basic accordion component with chevrons.

<% accordion_items = [
  { question: "Is the accordion accessible?", answer: "Yes, just press the <kbd>↓</kbd> <kbd>↑</kbd> <kbd>Tab</kbd> <kbd>↡</kbd> & <kbd>Space</kbd> keys πŸ˜‰." },
  { question: "Is it styled?", answer: "Yep, it comes Tailwind CSS styles that you can easily customize." },
  { question: "Does it have animations?", answer: "Yes, with smooth animations for opening and closing." }
] %>

<div class="w-full" data-controller="accordion">
  <% accordion_items.each_with_index do |item, index| %>
    <div data-accordion-target="item" data-state="closed" class="border-b pb-2 border-neutral-200 dark:border-neutral-700">
      <h3 data-state="closed" class="flex text-base font-semibold mt-2 mb-0">
        <button
          type="button"
          data-accordion-target="trigger"
          data-action="click->accordion#toggle"
          aria-controls="accordion-content-<%= index %>"
          aria-expanded="false"
          data-state="closed"
          id="accordion-trigger-<%= index %>"
          class="flex flex-1 items-center text-left justify-between py-2 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180 focus:outline-neutral-700 dark:focus:outline-white focus:outline-offset-2 px-2"
        >
          <%= item[:question] %>
          <svg xmlns="http://www.w3.org/2000/svg" data-accordion-target="icon" class="size-4 shrink-0 transition-transform duration-300" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M8.99999 13.5C8.80799 13.5 8.61599 13.4271 8.46999 13.2801L2.21999 7.03005C1.92699 6.73705 1.92699 6.26202 2.21999 5.96902C2.51299 5.67602 2.98799 5.67602 3.28099 5.96902L9.00099 11.689L14.721 5.96902C15.014 5.67602 15.489 5.67602 15.782 5.96902C16.075 6.26202 16.075 6.73705 15.782 7.03005L9.53199 13.2801C9.38599 13.4261 9.19399 13.5 9.00199 13.5H8.99999Z"></path></g></svg>
        </button>
      </h3>
      <div
        data-accordion-target="content"
        data-state="closed"
        id="accordion-content-<%= index %>"
        role="region"
        aria-labelledby="accordion-trigger-<%= index %>"

        class="overflow-hidden text-sm transition-all duration-300 opacity-0 data-[state=open]:opacity-100 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
        hidden
      >
        <div>
          <p class="my-2 px-2">
            <%= item[:answer].html_safe %>
          </p>
        </div>
      </div>
    </div>
  <% end %>
</div>

Accordion with plus/minus

An accordion that uses plus and minus icons instead of chevrons.

<% accordion_items = [
  { question: "Is the accordion accessible?", answer: "Yes, just press the <kbd>↓</kbd> <kbd>↑</kbd> <kbd>Tab</kbd> <kbd>↡</kbd> & <kbd>Space</kbd> keys πŸ˜‰." },
  { question: "Is it styled?", answer: "Yep, it comes Tailwind CSS styles that you can easily customize." },
  { question: "Does it have animations?", answer: "Yes, with smooth animations for opening and closing." }
] %>

<div class="w-full" data-controller="accordion">
  <% accordion_items.each_with_index do |item, index| %>
    <div data-accordion-target="item" data-state="closed" class="border-b pb-2 border-neutral-200 dark:border-neutral-700">
      <h3 data-state="closed" class="flex text-base font-semibold mt-2 mb-0">
        <button
          type="button"
          data-accordion-target="trigger"
          data-action="click->accordion#toggle"
          aria-controls="accordion-content-<%= index %>"
          aria-expanded="false"
          data-state="closed"
          id="accordion-trigger-<%= index %>"
          class="flex flex-1 items-center text-left justify-between py-2 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180 focus:outline-neutral-700 dark:focus:outline-white focus:outline-offset-2 px-2"
        >
          <%= item[:question] %>
          <svg xmlns="http://www.w3.org/2000/svg" data-accordion-target="icon" class="size-4" width="18" height="18" viewBox="0 0 18 18">
            <g fill="currentColor">
              <path d="M14.75,9.75H3.25c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75H14.75c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75Z"></path>
              <path class="origin-center transition-all duration-300 scale-100 [button[aria-expanded=true]_&]:scale-0" d="M9,15.5c-.414,0-.75-.336-.75-.75V3.25c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V14.75c0,.414-.336,.75-.75,.75Z"></path>
            </g>
          </svg>
        </button>
      </h3>
      <div
        data-accordion-target="content"
        data-state="closed"
        id="accordion-content-<%= index %>"
        role="region"
        aria-labelledby="accordion-trigger-<%= index %>"

        class="overflow-hidden text-sm transition-all duration-300 opacity-0 data-[state=open]:opacity-100 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
        hidden
      >
        <div>
          <p class="my-2 px-2">
            <%= item[:answer].html_safe %>
          </p>
        </div>
      </div>
    </div>
  <% end %>
</div>

Accordion with multiple items open

A basic accordion component where you can open multiple items at the same time.

<% accordion_items = [
  { question: "Is the accordion accessible?", answer: "Yes, just press the <kbd>↓</kbd> <kbd>↑</kbd> <kbd>Tab</kbd> <kbd>↡</kbd> & <kbd>Space</kbd> keys πŸ˜‰." },
  { question: "Is it styled?", answer: "Yep, it comes Tailwind CSS styles that you can easily customize." },
  { question: "Does it have animations?", answer: "Yes, with smooth animations for opening and closing." }
] %>

<div class="w-full" data-controller="accordion" data-accordion-allow-multiple-value="true">
  <% accordion_items.each_with_index do |item, index| %>
    <div data-accordion-target="item" data-state="closed" class="border-b pb-2 border-neutral-200 dark:border-neutral-700">
      <h3 data-state="closed" class="flex text-base font-semibold mt-2 mb-0">
        <button
          type="button"
          data-accordion-target="trigger"
          data-action="click->accordion#toggle"
          aria-controls="accordion-content-<%= index %>"
          aria-expanded="false"
          data-state="closed"
          id="accordion-trigger-<%= index %>"
          class="flex flex-1 items-center text-left justify-between py-2 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180 focus:outline-neutral-700 dark:focus:outline-white focus:outline-offset-2 px-2"
        >
          <%= item[:question] %>
          <svg xmlns="http://www.w3.org/2000/svg" data-accordion-target="icon" class="size-4 shrink-0 transition-transform duration-300" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M8.99999 13.5C8.80799 13.5 8.61599 13.4271 8.46999 13.2801L2.21999 7.03005C1.92699 6.73705 1.92699 6.26202 2.21999 5.96902C2.51299 5.67602 2.98799 5.67602 3.28099 5.96902L9.00099 11.689L14.721 5.96902C15.014 5.67602 15.489 5.67602 15.782 5.96902C16.075 6.26202 16.075 6.73705 15.782 7.03005L9.53199 13.2801C9.38599 13.4261 9.19399 13.5 9.00199 13.5H8.99999Z"></path></g></svg>
        </button>
      </h3>
      <div
        data-accordion-target="content"
        data-state="closed"
        id="accordion-content-<%= index %>"
        role="region"
        aria-labelledby="accordion-trigger-<%= index %>"

        class="overflow-hidden text-sm transition-all duration-300 opacity-0 data-[state=open]:opacity-100 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
        hidden
      >
        <div>
          <p class="my-2 px-2">
            <%= item[:answer].html_safe %>
          </p>
        </div>
      </div>
    </div>
  <% end %>
</div>

Accordion with left arrow

Arrow icons positioned on the left side of the accordion items.

<% accordion_items = [
  { question: "Is the accordion accessible?", answer: "Yes, just press the <kbd>↓</kbd> <kbd>↑</kbd> <kbd>Tab</kbd> <kbd>↡</kbd> & <kbd>Space</kbd> keys πŸ˜‰." },
  { question: "Is it styled?", answer: "Yep, it comes Tailwind CSS styles that you can easily customize." },
  { question: "Does it have animations?", answer: "Yes, with smooth animations for opening and closing." }
] %>

<div class="w-full space-y-2" data-controller="accordion">
  <% accordion_items.each_with_index do |item, index| %>
    <div data-accordion-target="item" data-state="closed" class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
      <h3 data-state="closed" class="flex text-base font-semibold mt-0 mb-0">
        <button
          type="button"
          data-accordion-target="trigger"
          data-action="click->accordion#toggle"
          aria-controls="accordion-content-left-<%= index %>"
          aria-expanded="false"
          data-state="closed"
          id="accordion-trigger-left-<%= index %>"
          class="bg-neutral-100/60 dark:bg-neutral-800 gap-2 flex flex-1 items-center text-left px-4 py-3 font-medium transition-all hover:bg-neutral-100 dark:hover:bg-neutral-700/50 rounded-t-md [&[data-state=open]>svg]:rotate-180 focus:outline-neutral-700 dark:focus:outline-white focus:-outline-offset-2"
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            data-accordion-target="icon"
            class="h-4 w-4 shrink-0 transition-transform duration-300 text-neutral-500 dark:text-neutral-400"
            width="18"
            height="18"
            viewBox="0 0 18 18"
          >
            <g fill="currentColor">
              <path d="M8.99999 13.5C8.80799 13.5 8.61599 13.4271 8.46999 13.2801L2.21999 7.03005C1.92699 6.73705 1.92699 6.26202 2.21999 5.96902C2.51299 5.67602 2.98799 5.67602 3.28099 5.96902L9.00099 11.689L14.721 5.96902C15.014 5.67602 15.489 5.67602 15.782 5.96902C16.075 6.26202 16.075 6.73705 15.782 7.03005L9.53199 13.2801C9.38599 13.4261 9.19399 13.5 9.00199 13.5H8.99999Z"></path>
            </g>
          </svg>
          <span class="flex-1 font-medium"><%= item[:question] %></span>
        </button>
      </h3>
      <div
        data-accordion-target="content"
        data-state="closed"
        id="accordion-content-left-<%= index %>"
        role="region"
        aria-labelledby="accordion-trigger-left-<%= index %>"
        class="overflow-hidden text-sm transition-all duration-300 opacity-0 data-[state=open]:opacity-100"
        hidden
      >
        <div>
          <p class="px-4 pb-4 pt-2 text-neutral-600 dark:text-neutral-400">
            <%= item[:answer].html_safe %>
          </p>
        </div>
      </div>
    </div>
  <% end %>
</div>

Styled Accordion with floating answer

The accordion questions don't have a background color and border.

<% accordion_items = [
  { question: "Is the accordion accessible?", answer: "Yes, just press the <kbd>↓</kbd> <kbd>↑</kbd> <kbd>Tab</kbd> <kbd>↡</kbd> & <kbd>Space</kbd> keys πŸ˜‰." },
  { question: "Is it styled?", answer: "Yep, it comes Tailwind CSS styles that you can easily customize." },
  { question: "Does it have animations?", answer: "Yes, with smooth animations for opening and closing." }
] %>

<div class="w-full" data-controller="accordion">
  <% accordion_items.each_with_index do |item, index| %>
    <div data-accordion-target="item" data-state="closed" class="border-neutral-200 dark:border-neutral-700">
      <h3 data-state="closed" class="flex text-base font-semibold mt-2 mb-0">
        <button
          type="button"
          data-accordion-target="trigger"
          data-action="click->accordion#toggle"
          aria-controls="accordion-content-<%= index %>"
          aria-expanded="false"
          data-state="closed"
          id="accordion-trigger-<%= index %>"
          class="bg-neutral-100/60 dark:bg-neutral-800 border border-black/10 dark:border-white/10 flex flex-1 items-center justify-between px-4 py-3 font-medium transition-all hover:bg-neutral-100 dark:hover:bg-neutral-700/50 rounded-lg [&[data-state=open]>svg]:rotate-180 focus:outline-neutral-700 dark:focus:outline-white focus:-outline-offset-1"
        >
          <%= item[:question] %>
          <svg xmlns="http://www.w3.org/2000/svg" data-accordion-target="icon" class="size-4 shrink-0 transition-transform duration-300" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M8.99999 13.5C8.80799 13.5 8.61599 13.4271 8.46999 13.2801L2.21999 7.03005C1.92699 6.73705 1.92699 6.26202 2.21999 5.96902C2.51299 5.67602 2.98799 5.67602 3.28099 5.96902L9.00099 11.689L14.721 5.96902C15.014 5.67602 15.489 5.67602 15.782 5.96902C16.075 6.26202 16.075 6.73705 15.782 7.03005L9.53199 13.2801C9.38599 13.4261 9.19399 13.5 9.00199 13.5H8.99999Z"></path></g></svg>
        </button>
      </h3>
      <div
        data-accordion-target="content"
        data-state="closed"
        id="accordion-content-<%= index %>"
        role="region"
        aria-labelledby="accordion-trigger-<%= index %>"

        class="overflow-hidden text-sm transition-all duration-300 opacity-0 data-[state=open]:opacity-100 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
        hidden
      >
        <div>
          <p class="px-4 py-3">
            <%= item[:answer].html_safe %>
          </p>
        </div>
      </div>
    </div>
  <% end %>
</div>

Styled Accordion with included answer

The accordion questions and answers have a custom background color and border.

<% accordion_items = [
  { question: "Is the accordion accessible?", answer: "Yes, just press the <kbd>↓</kbd> <kbd>↑</kbd> <kbd>Tab</kbd> <kbd>↡</kbd> & <kbd>Space</kbd> keys πŸ˜‰." },
  { question: "Is it styled?", answer: "Yep, it comes Tailwind CSS styles that you can easily customize." },
  { question: "Does it have animations?", answer: "Yes, with smooth animations for opening and closing." }
] %>

<div class="w-full flex flex-col gap-2" data-controller="accordion">
  <% accordion_items.each_with_index do |item, index| %>
    <div data-accordion-target="item" data-state="closed"class="overflow-hidden bg-neutral-100/60 dark:bg-neutral-800 border border-black/10 dark:border-white/10 items-center justify-between font-semibold transition-all hover:bg-neutral-100 dark:hover:bg-neutral-700/50 rounded-lg">
      <h3 data-state="closed" class="flex text-base font-semibold mt-0 mb-0">
        <button
          type="button"
          data-accordion-target="trigger"
          data-action="click->accordion#toggle"
          aria-controls="accordion-content-<%= index %>"
          aria-expanded="false"
          data-state="closed"
          id="accordion-trigger-<%= index %>"
          class="w-full rounded-lg flex flex-1 p-3 items-center justify-between font-medium transition-all [&[data-state=open]>svg]:rotate-180 focus:outline-neutral-700 dark:focus:outline-white focus:-outline-offset-1"
        >
          <%= item[:question] %>
          <svg xmlns="http://www.w3.org/2000/svg" data-accordion-target="icon" class="size-4 shrink-0 transition-transform duration-300" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M8.99999 13.5C8.80799 13.5 8.61599 13.4271 8.46999 13.2801L2.21999 7.03005C1.92699 6.73705 1.92699 6.26202 2.21999 5.96902C2.51299 5.67602 2.98799 5.67602 3.28099 5.96902L9.00099 11.689L14.721 5.96902C15.014 5.67602 15.489 5.67602 15.782 5.96902C16.075 6.26202 16.075 6.73705 15.782 7.03005L9.53199 13.2801C9.38599 13.4261 9.19399 13.5 9.00199 13.5H8.99999Z"></path></g></svg>
        </button>
      </h3>
      <div
        data-accordion-target="content"
        data-state="closed"
        id="accordion-content-<%= index %>"
        role="region"
        aria-labelledby="accordion-trigger-<%= index %>"

        class="overflow-hidden transition-all duration-300 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down opacity-0 data-[state=open]:opacity-100"
        hidden
      >
        <div>
          <p class="px-4 py-3 bg-white dark:bg-neutral-700/50 text-sm font-normal">
            <%= item[:answer].html_safe %>
          </p>
        </div>
      </div>
    </div>
  <% end %>
</div>

Accordion with nesting

An accordion that contains nested accordions within its content areas.

<% accordion_items = [
  {
    question: "Frontend Development",
    answer: "Learn about modern frontend technologies and frameworks.",
    nested: [
      { question: "What is React?", answer: "React is a JavaScript library for building user interfaces." },
      { question: "What is Vue.js?", answer: "Vue.js is a progressive framework for building user interfaces." }
    ]
  },
  {
    question: "Backend Development",
    answer: "Explore server-side technologies and databases.",
    nested: [
      { question: "What is Node.js?", answer: "Node.js is a JavaScript runtime built on Chrome's V8 JavaScript engine." },
      { question: "What is Ruby on Rails?", answer: "Rails is a web application framework written in Ruby." }
    ]
  },
  {
    question: "DevOps",
    answer: "Learn about deployment, monitoring, and infrastructure.",
    nested: []
  }
] %>

<div class="w-full" data-controller="accordion">
  <% accordion_items.each_with_index do |item, index| %>
    <div data-accordion-target="item" data-state="closed" class="border-b pb-2 border-neutral-200 dark:border-neutral-700">
      <h3 data-state="closed" class="flex text-base font-semibold mt-2 mb-0">
        <button
          type="button"
          data-accordion-target="trigger"
          data-action="click->accordion#toggle"
          aria-controls="accordion-content-<%= index %>"
          aria-expanded="false"
          data-state="closed"
          id="accordion-trigger-<%= index %>"
          class="flex flex-1 items-center text-left justify-between py-2 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180 focus:outline-neutral-700 dark:focus:outline-white focus:outline-offset-2 px-2"
        >
          <%= item[:question] %>
          <svg xmlns="http://www.w3.org/2000/svg" data-accordion-target="icon" class="size-4 shrink-0 transition-transform duration-300" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M8.99999 13.5C8.80799 13.5 8.61599 13.4271 8.46999 13.2801L2.21999 7.03005C1.92699 6.73705 1.92699 6.26202 2.21999 5.96902C2.51299 5.67602 2.98799 5.67602 3.28099 5.96902L9.00099 11.689L14.721 5.96902C15.014 5.67602 15.489 5.67602 15.782 5.96902C16.075 6.26202 16.075 6.73705 15.782 7.03005L9.53199 13.2801C9.38599 13.4261 9.19399 13.5 9.00199 13.5H8.99999Z"></path></g></svg>
        </button>
      </h3>
      <div
        data-accordion-target="content"
        data-state="closed"
        id="accordion-content-<%= index %>"
        role="region"
        aria-labelledby="accordion-trigger-<%= index %>"
        class="overflow-hidden text-sm transition-all duration-300 opacity-0 data-[state=open]:opacity-100 data-[state=open]:!max-h-fit data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
        hidden
      >
        <div class="px-2">
          <p class="my-2 text-neutral-600 dark:text-neutral-400">
            <%= item[:answer].html_safe %>
          </p>

          <% if item[:nested].any? %>
            <!-- Nested Accordion -->
            <div class="mt-3 ml-4 border-l-2 border-neutral-200 dark:border-neutral-600 pl-4" data-controller="accordion" data-accordion-allow-multiple-value="true">
              <% item[:nested].each_with_index do |nested_item, nested_index| %>
                <div data-accordion-target="item" data-state="closed" class="border-b border-neutral-100 dark:border-neutral-700 pb-1 mb-2">
                  <h4 data-state="closed" class="flex text-sm font-medium mt-1 mb-0">
                    <button
                      type="button"
                      data-accordion-target="trigger"
                      data-action="click->accordion#toggle"
                      aria-controls="nested-accordion-content-<%= index %>-<%= nested_index %>"
                      aria-expanded="false"
                      data-state="closed"
                      id="nested-accordion-trigger-<%= index %>-<%= nested_index %>"
                      class="flex flex-1 items-center text-left justify-between py-1 text-sm font-medium transition-all hover:text-neutral-700 hover:underline dark:hover:text-neutral-300 [&[data-state=open]>svg]:rotate-180 focus:outline-neutral-700 dark:focus:outline-white focus:outline-offset-2"
                    >
                      <%= nested_item[:question] %>
                      <svg xmlns="http://www.w3.org/2000/svg" data-accordion-target="icon" class="size-3 shrink-0 transition-transform duration-300" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M8.99999 13.5C8.80799 13.5 8.61599 13.4271 8.46999 13.2801L2.21999 7.03005C1.92699 6.73705 1.92699 6.26202 2.21999 5.96902C2.51299 5.67602 2.98799 5.67602 3.28099 5.96902L9.00099 11.689L14.721 5.96902C15.014 5.67602 15.489 5.67602 15.782 5.96902C16.075 6.26202 16.075 6.73705 15.782 7.03005L9.53199 13.2801C9.38599 13.4261 9.19399 13.5 9.00199 13.5H8.99999Z"></path></g></svg>
                    </button>
                  </h4>
                  <div
                    data-accordion-target="content"
                    data-state="closed"
                    id="nested-accordion-content-<%= index %>-<%= nested_index %>"
                    role="region"
                    aria-labelledby="nested-accordion-trigger-<%= index %>-<%= nested_index %>"
                    class="overflow-hidden text-xs transition-all duration-300 opacity-0 data-[state=open]:opacity-100 data-[state=open]:!max-h-fit data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
                    hidden
                  >
                    <div>
                      <p class="py-1 text-neutral-500 dark:text-neutral-400">
                        <%= nested_item[:answer].html_safe %>
                      </p>
                    </div>
                  </div>
                </div>
              <% end %>
            </div>
          <% end %>
        </div>
      </div>
    </div>
  <% end %>
</div>

Accordion with disabled items

An accordion with some items disabled that cannot be clicked or expanded.

Is it styled?

Can items be disabled?

<% accordion_items = [
  { question: "Is the accordion accessible?", answer: "Yes, just press the <kbd>↓</kbd> <kbd>↑</kbd> <kbd>Tab</kbd> <kbd>↡</kbd> & <kbd>Space</kbd> keys πŸ˜‰.", disabled: false },
  { question: "Is it styled?", answer: "Yep, it comes Tailwind CSS styles that you can easily customize.", disabled: true },
  { question: "Does it have animations?", answer: "Yes, with smooth animations for opening and closing.", disabled: false },
  { question: "Can items be disabled?", answer: "Yes, this item demonstrates disabled functionality.", disabled: true }
] %>

<div class="w-full" data-controller="accordion">
  <% accordion_items.each_with_index do |item, index| %>
    <div <% unless item[:disabled] %>data-accordion-target="item"<% end %> data-state="closed" class="border-b pb-2 border-neutral-200 dark:border-neutral-700">
      <h3 data-state="closed" class="flex text-base font-semibold mt-2 mb-0">
        <% if item[:disabled] %>
          <div class="flex flex-1 items-center text-left justify-between py-2 font-medium px-2 opacity-50 cursor-not-allowed">
            <span class="text-neutral-600 dark:text-neutral-300"><%= item[:question] %></span>
            <svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0 text-neutral-300 dark:text-neutral-600" width="18" height="18" viewBox="0 0 18 18">
              <g fill="currentColor">
                <path d="M8.99999 13.5C8.80799 13.5 8.61599 13.4271 8.46999 13.2801L2.21999 7.03005C1.92699 6.73705 1.92699 6.26202 2.21999 5.96902C2.51299 5.67602 2.98799 5.67602 3.28099 5.96902L9.00099 11.689L14.721 5.96902C15.014 5.67602 15.489 5.67602 15.782 5.96902C16.075 6.26202 16.075 6.73705 15.782 7.03005L9.53199 13.2801C9.38599 13.4261 9.19399 13.5 9.00199 13.5H8.99999Z"></path>
              </g>
            </svg>
          </div>
        <% else %>
          <button
            type="button"
            data-accordion-target="trigger"
            data-action="click->accordion#toggle"
            aria-controls="accordion-content-<%= index %>"
            aria-expanded="false"
            data-state="closed"
            id="accordion-trigger-<%= index %>"
            class="flex flex-1 items-center text-left justify-between py-2 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180 focus:outline-neutral-700 dark:focus:outline-white focus:outline-offset-2 px-2"
          >
            <%= item[:question] %>
            <svg xmlns="http://www.w3.org/2000/svg" data-accordion-target="icon" class="size-4 shrink-0 transition-transform duration-300" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M8.99999 13.5C8.80799 13.5 8.61599 13.4271 8.46999 13.2801L2.21999 7.03005C1.92699 6.73705 1.92699 6.26202 2.21999 5.96902C2.51299 5.67602 2.98799 5.67602 3.28099 5.96902L9.00099 11.689L14.721 5.96902C15.014 5.67602 15.489 5.67602 15.782 5.96902C16.075 6.26202 16.075 6.73705 15.782 7.03005L9.53199 13.2801C9.38599 13.4261 9.19399 13.5 9.00199 13.5H8.99999Z"></path></g></svg>
          </button>
        <% end %>
      </h3>
      <% unless item[:disabled] %>
        <div
          data-accordion-target="content"
          data-state="closed"
          id="accordion-content-<%= index %>"
          role="region"
          aria-labelledby="accordion-trigger-<%= index %>"
          class="overflow-hidden text-sm transition-all duration-300 opacity-0 data-[state=open]:opacity-100 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
          hidden
        >
          <div>
            <p class="my-2 px-2">
              <%= item[:answer].html_safe %>
            </p>
          </div>
        </div>
      <% end %>
    </div>
  <% end %>
</div>

Accordion with item open by default

An accordion with the first item expanded by default when the page loads.

Yes, just press the ↓ ↑ Tab ↡ & Space keys πŸ˜‰.

<% accordion_items = [
  { question: "Is the accordion accessible?", answer: "Yes, just press the <kbd>↓</kbd> <kbd>↑</kbd> <kbd>Tab</kbd> <kbd>↡</kbd> & <kbd>Space</kbd> keys πŸ˜‰.", default_open: true },
  { question: "Is it styled?", answer: "Yep, it comes Tailwind CSS styles that you can easily customize.", default_open: false },
  { question: "Does it have animations?", answer: "Yes, with smooth animations for opening and closing.", default_open: false }
] %>

<div class="w-full" data-controller="accordion">
  <% accordion_items.each_with_index do |item, index| %>
    <div data-accordion-target="item" data-state="<%= item[:default_open] ? 'open' : 'closed' %>" class="border-b pb-2 border-neutral-200 dark:border-neutral-700">
      <h3 data-state="<%= item[:default_open] ? 'open' : 'closed' %>" class="flex text-base font-semibold mt-2 mb-0">
        <button
          type="button"
          data-accordion-target="trigger"
          data-action="click->accordion#toggle"
          aria-controls="accordion-content-<%= index %>"
          aria-expanded="<%= item[:default_open] ? 'true' : 'false' %>"
          data-state="<%= item[:default_open] ? 'open' : 'closed' %>"
          id="accordion-trigger-<%= index %>"
          class="flex flex-1 items-center text-left justify-between py-2 font-medium transition-all hover:underline [&[aria-expanded=true]>svg]:rotate-180 focus:outline-neutral-700 dark:focus:outline-white focus:outline-offset-2 px-2"
        >
          <%= item[:question] %>
          <svg xmlns="http://www.w3.org/2000/svg" data-accordion-target="icon" class="size-4 shrink-0 transition-transform duration-300" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M8.99999 13.5C8.80799 13.5 8.61599 13.4271 8.46999 13.2801L2.21999 7.03005C1.92699 6.73705 1.92699 6.26202 2.21999 5.96902C2.51299 5.67602 2.98799 5.67602 3.28099 5.96902L9.00099 11.689L14.721 5.96902C15.014 5.67602 15.489 5.67602 15.782 5.96902C16.075 6.26202 16.075 6.73705 15.782 7.03005L9.53199 13.2801C9.38599 13.4261 9.19399 13.5 9.00199 13.5H8.99999Z"></path></g></svg>
        </button>
      </h3>
      <div
        data-accordion-target="content"
        data-state="<%= item[:default_open] ? 'open' : 'closed' %>"
        id="accordion-content-<%= index %>"
        role="region"
        aria-labelledby="accordion-trigger-<%= index %>"
        class="overflow-hidden text-sm transition-all duration-300 <%= item[:default_open] ? 'opacity-100' : 'opacity-0' %> data-[state=open]:opacity-100 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
        <%= item[:default_open] ? '' : 'hidden' %>
      >
        <div>
          <p class="my-2 px-2">
            <%= item[:answer].html_safe %>
          </p>
        </div>
      </div>
    </div>
  <% end %>
</div>

Zero Dependency Accordion

A pure HTML/CSS accordion using native <details> and <summary> elements - no JavaScript required!

Is this accordion dependency-free?

Yes! This accordion uses only HTML <details> and <summary> elements with CSS styling.

Does it work without JavaScript?

Absolutely! No JavaScript required - it's all native HTML functionality.

Is it accessible?

Yes, the native <details> works with the Tab & ↡ keys.

<% accordion_items = [
  { question: "Is this accordion dependency-free?", answer: "Yes! This accordion uses only HTML <code class='code-inline-text'>&lt;details&gt;</code> and <code class='code-inline-text'>&lt;summary&gt;</code> elements with CSS styling." },
  { question: "Does it work without JavaScript?", answer: "Absolutely! No JavaScript required - it's all native HTML functionality." },
  { question: "Is it accessible?", answer: "Yes, the native <code class='code-inline-text'>&lt;details&gt;</code> works with the <kbd>Tab</kbd> & <kbd>↡</kbd> keys." }
] %>

<div class="w-full space-y-2">
  <% accordion_items.each_with_index do |item, index| %>
    <details class="group border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden select-none">
      <summary class="cursor-pointer flex list-none items-center justify-between px-4 py-3 text-left transition-all hover:bg-neutral-50 group-open:bg-neutral-50 dark:hover:bg-neutral-800/50 dark:group-open:bg-neutral-700/50 [&::-webkit-details-marker]:hidden font-medium focus:outline-neutral-700 dark:focus:outline-white focus:-outline-offset-2">
        <span class="text-base"><%= item[:question] %></span>
        <div class="opacity-50 group-open:rotate-180 transition-transform duration-300">
          <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
            <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"></path>
          </svg>
        </div>
      </summary>
      <div class="px-4 pb-4 pt-2 bg-neutral-100/25 dark:bg-neutral-700/25">
        <p class="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
          <%= item[:answer].html_safe %>
        </p>
      </div>
    </details>
  <% end %>
</div>

Zero Dependency Exclusive Accordion (One Open at a Time)

A zero-dependency accordion where only one item can be open at a time using the native HTML name attribute.

When multiple <details> elements share the same name attribute, only one can be open at a time. This is native HTML behavior.

Is this accordion dependency-free?

Yes! This accordion uses only HTML <details> and <summary> elements with CSS styling.

Does it work without JavaScript?

Absolutely! No JavaScript required - it's all native HTML functionality.

Is it accessible?

Yes, the native <details> works with the Tab & ↡ keys.

<% accordion_items = [
  { question: "Is this accordion dependency-free?", answer: "Yes! This accordion uses only HTML <code class='code-inline-text'>&lt;details&gt;</code> and <code class='code-inline-text'>&lt;summary&gt;</code> elements with CSS styling." },
  { question: "Does it work without JavaScript?", answer: "Absolutely! No JavaScript required - it's all native HTML functionality." },
  { question: "Is it accessible?", answer: "Yes, the native <code class='code-inline-text'>&lt;details&gt;</code> works with the <kbd>Tab</kbd> & <kbd>↡</kbd> keys." }
] %>

<div class="w-full space-y-2">
  <% accordion_items.each_with_index do |item, index| %>
    <details name="exclusive-faq" class="group border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden select-none">
      <summary class="cursor-pointer flex list-none items-center justify-between px-4 py-3 text-left transition-all hover:bg-neutral-50 group-open:bg-neutral-50 dark:hover:bg-neutral-800/50 dark:group-open:bg-neutral-700/50 [&::-webkit-details-marker]:hidden font-medium focus:outline-neutral-700 dark:focus:outline-white focus:-outline-offset-2">
        <span class="text-base"><%= item[:question] %></span>
        <div class="opacity-50 group-open:rotate-180 transition-transform duration-300">
          <svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
            <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"></path>
          </svg>
        </div>
      </summary>
      <div class="px-4 pb-4 pt-2 bg-neutral-100/25 dark:bg-neutral-700/25">
        <p class="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
          <%= item[:answer].html_safe %>
        </p>
      </div>
    </details>
  <% end %>
</div>

Configuration

The accordion component is powered by a Stimulus controller that provides keyboard navigation, accessibility features, and flexible configuration options.

Controller Setup

Basic accordion structure with required data attributes:

<div class="w-full" data-controller="accordion">
  <div data-accordion-target="item">
    <button data-accordion-target="trigger" data-action="click->accordion#toggle">
      Question text
      <div data-accordion-target="icon"><!-- Icon SVG --></div>
    </button>
    <div data-accordion-target="content" class="overflow-hidden" hidden>
      Answer content
    </div>
  </div>
</div>

Configuration Values

Prop Description Type Default
allowMultiple
Controls whether multiple accordion items can be open simultaneously boolean false

Targets

Target Description Required
item
The container for each accordion item (question + answer) Required
trigger
The clickable element that toggles the accordion item Required
content
The collapsible content area that shows/hides Required
icon
Icon element that rotates when item opens/closes Optional

Actions

Action Description Usage
toggle
Toggles accordion item open/closed state click->accordion#toggle

Accessibility Features

  • Keyboard Navigation: Use ↑ ↓ to navigate between items
  • ARIA Support: Automatic aria-expanded attribute management
  • Screen Reader Friendly: ARIA attributes and proper focus management for accessibility

Icon Support

The controller automatically detects and handles different icon types:

  • Chevron Icons: Rotates 180Β° when opened
  • Plus/Minus Icons: Automatically switches between + and - symbols

Table of contents