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
  };

  // Get only the direct child targets (not nested accordion targets)
  get ownItemTargets() {
    return this.itemTargets.filter((item) => item.closest('[data-controller="accordion"]') === this.element);
  }

  get ownTriggerTargets() {
    return this.triggerTargets.filter((trigger) => trigger.closest('[data-controller="accordion"]') === this.element);
  }

  get ownContentTargets() {
    return this.contentTargets.filter((content) => content.closest('[data-controller="accordion"]') === this.element);
  }

  get ownIconTargets() {
    return this.iconTargets.filter((icon) => icon.closest('[data-controller="accordion"]') === this.element);
  }

  _applyOpenVisuals(index) {
    const items = this.ownItemTargets;
    const triggers = this.ownTriggerTargets;
    const contents = this.ownContentTargets;
    const icons = this.ownIconTargets;

    if (!items[index] || !triggers[index] || !contents[index]) return;

    const item = items[index];
    const trigger = triggers[index];
    const content = contents[index];
    const icon = icons[index] || null;

    // Remove hidden first to allow measurement
    content.removeAttribute("hidden");
    // Force reflow to ensure hidden is removed before animation starts
    content.offsetHeight;

    item.dataset.state = "open";
    trigger.setAttribute("aria-expanded", "true");
    trigger.dataset.state = "open";
    content.dataset.state = "open";

    // Update inner wrapper and body states for opacity transition
    this._updateInnerStates(content, "open");

    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 via aria-expanded
      } else if (isLeftChevron) {
        icon.classList.add("rotate-0");
        icon.classList.remove("-rotate-90");
      } else {
        icon.classList.add("rotate-180");
      }
    }
  }

  _applyClosedVisuals(index, skipHidden = false) {
    const items = this.ownItemTargets;
    const triggers = this.ownTriggerTargets;
    const contents = this.ownContentTargets;
    const icons = this.ownIconTargets;

    if (!items[index] || !triggers[index] || !contents[index]) return;

    const item = items[index];
    const trigger = triggers[index];
    const content = contents[index];
    const icon = icons[index] || null;

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

    // Update inner wrapper and body states for opacity transition
    this._updateInnerStates(content, "closed");

    // For initial setup, set hidden immediately; for animations, it's handled in close()
    if (!skipHidden) {
      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) {
        icon.classList.remove("rotate-0");
        icon.classList.add("-rotate-90");
      } else {
        icon.classList.remove("rotate-180");
      }
    }
  }

  connect() {
    this.addKeyboardListeners();
    this.activeIndices = new Set();

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

    const ownTriggers = this.ownTriggerTargets;
    const ownItems = this.ownItemTargets;

    // Ensure all trigger buttons are focusable and have keyboard listeners
    ownTriggers.forEach((trigger) => {
      if (!trigger.hasAttribute("tabindex")) {
        trigger.setAttribute("tabindex", "0");
      }
      trigger.addEventListener("keydown", this.boundHandleTriggerKeydown);
    });

    const initiallyOpenIndexesFromDOM = [];
    ownItems.forEach((item, index) => {
      if (ownTriggers[index]?.getAttribute("aria-expanded") === "true") {
        initiallyOpenIndexesFromDOM.push(index);
      } else if (item.dataset.state === "open" && !initiallyOpenIndexesFromDOM.includes(index)) {
        initiallyOpenIndexesFromDOM.push(index);
      }
    });

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

        for (let i = 0; i < ownItems.length; i++) {
          if (i !== indexToKeepOpen) {
            this._applyClosedVisuals(i);
          }
        }
      } else {
        ownItems.forEach((_, index) => this._applyClosedVisuals(index));
      }
    } else {
      initiallyOpenIndexesFromDOM.forEach((index) => {
        this.activeIndices.add(index);
        this._applyOpenVisuals(index);
      });
      ownItems.forEach((_, index) => {
        if (!this.activeIndices.has(index)) {
          this._applyClosedVisuals(index);
        }
      });
    }
  }

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

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

    // Clean up transition listeners
    this.ownContentTargets.forEach((content) => {
      if (content._onTransitionEnd) {
        content.removeEventListener("transitionend", content._onTransitionEnd);
      }
    });
  }

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

  // Safari-compatible trigger-specific keydown handler
  handleTriggerKeydown(event) {
    const currentTrigger = event.currentTarget;
    const ownTriggers = this.ownTriggerTargets;
    const currentIndex = ownTriggers.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) {
    const ownTriggers = this.ownTriggerTargets;
    let currentIndex = -1;

    ownTriggers.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 ownTriggers = this.ownTriggerTargets;
    const previousIndex = (currentIndex - 1 + ownTriggers.length) % ownTriggers.length;
    ownTriggers[previousIndex].focus();
  }

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

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

  focusLastItem() {
    const ownTriggers = this.ownTriggerTargets;
    ownTriggers[ownTriggers.length - 1]?.focus();
  }

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

    if (index === -1) return;

    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 items = this.ownItemTargets;
    const triggers = this.ownTriggerTargets;
    const contents = this.ownContentTargets;
    const icons = this.ownIconTargets;

    const item = items[index];
    const trigger = triggers[index];
    const content = contents[index];
    const icon = icons[index];

    if (!item || !trigger || !content) return;

    // Remove hidden first to allow CSS Grid animation
    content.removeAttribute("hidden");
    // Force reflow to ensure hidden is removed before animation starts
    content.offsetHeight;

    // Set the open state - CSS Grid handles the animation
    item.dataset.state = "open";
    trigger.setAttribute("aria-expanded", "true");
    trigger.dataset.state = "open";
    content.dataset.state = "open";

    // Update inner wrapper and body states for opacity transition
    this._updateInnerStates(content, "open");

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

      if (isPlusMinus) {
        // CSS handles plus/minus via aria-expanded
      } else if (isLeftChevron) {
        icon.classList.add("rotate-0");
        icon.classList.remove("-rotate-90");
      } else {
        icon.classList.add("rotate-180");
      }
    }

    this.activeIndices.add(index);
  }

  close(index) {
    const items = this.ownItemTargets;
    const triggers = this.ownTriggerTargets;
    const contents = this.ownContentTargets;
    const icons = this.ownIconTargets;

    const item = items[index];
    const trigger = triggers[index];
    const content = contents[index];
    const icon = icons[index];

    if (!item || !trigger || !content) return;

    // Set closed state - CSS Grid handles the animation
    item.dataset.state = "closed";
    trigger.setAttribute("aria-expanded", "false");
    trigger.dataset.state = "closed";
    content.dataset.state = "closed";

    // Update inner wrapper and body states for opacity transition
    this._updateInnerStates(content, "closed");

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

      if (isPlusMinus) {
        // CSS handles plus/minus via aria-expanded
      } else if (isLeftChevron) {
        icon.classList.remove("rotate-0");
        icon.classList.add("-rotate-90");
      } else {
        icon.classList.remove("rotate-180");
      }
    }

    // Remove any existing listener
    if (content._onTransitionEnd) {
      content.removeEventListener("transitionend", content._onTransitionEnd);
    }

    // Add hidden after transition completes for accessibility
    content._onTransitionEnd = (e) => {
      if (e.propertyName === "grid-template-rows" && content.dataset.state === "closed") {
        content.setAttribute("hidden", "");
      }
      content.removeEventListener("transitionend", content._onTransitionEnd);
    };
    content.addEventListener("transitionend", content._onTransitionEnd);

    this.activeIndices.delete(index);
  }

  // Update data-state on inner wrapper elements (for opacity transitions)
  _updateInnerStates(content, state) {
    // Find direct children with data-state attribute and update them
    const innerElements = content.querySelectorAll(":scope > [data-state], :scope > * > [data-state]");
    innerElements.forEach((el) => {
      // Only update if it's not a nested accordion element
      if (
        !el.closest('[data-controller="accordion"]') ||
        el.closest('[data-controller="accordion"]') === this.element
      ) {
        el.dataset.state = state;
      }
    });
  }
}

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="grid transition-[grid-template-rows] duration-300 ease-in-out data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
        hidden
      >
        <div class="overflow-hidden min-h-0">
          <div class="text-sm opacity-0 transition-opacity duration-300 data-[state=open]:opacity-100" data-state="closed">
            <p class="my-2 px-2">
              <%= item[:answer].html_safe %>
            </p>
          </div>
        </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="grid transition-[grid-template-rows] duration-300 ease-in-out data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
        hidden
      >
        <div class="overflow-hidden min-h-0">
          <div class="text-sm opacity-0 transition-opacity duration-300 data-[state=open]:opacity-100" data-state="closed">
            <p class="my-2 px-2">
              <%= item[:answer].html_safe %>
            </p>
          </div>
        </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="grid transition-[grid-template-rows] duration-300 ease-in-out data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
        hidden
      >
        <div class="overflow-hidden min-h-0">
          <div class="text-sm opacity-0 transition-opacity duration-300 data-[state=open]:opacity-100" data-state="closed">
            <p class="my-2 px-2">
              <%= item[:answer].html_safe %>
            </p>
          </div>
        </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="size-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="grid transition-[grid-template-rows] duration-300 ease-in-out data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
        hidden
      >
        <div class="overflow-hidden min-h-0">
          <div class="text-sm opacity-0 transition-opacity duration-300 data-[state=open]:opacity-100" data-state="closed">
            <p class="px-4 pb-4 pt-2 text-neutral-600 dark:text-neutral-400">
              <%= item[:answer].html_safe %>
            </p>
          </div>
        </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="grid transition-[grid-template-rows] duration-300 ease-in-out data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
        hidden
      >
        <div class="overflow-hidden min-h-0">
          <div class="text-sm opacity-0 transition-opacity duration-300 data-[state=open]:opacity-100" data-state="closed">
            <p class="px-4 py-3">
              <%= item[:answer].html_safe %>
            </p>
          </div>
        </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="grid transition-[grid-template-rows] duration-300 ease-in-out data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
        hidden
      >
        <div class="overflow-hidden min-h-0">
          <div class="opacity-0 transition-opacity duration-300 data-[state=open]:opacity-100" data-state="closed">
            <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>
    </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="grid transition-[grid-template-rows] duration-300 ease-in-out data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
        hidden
      >
        <div class="overflow-hidden min-h-0">
          <div class="px-2 text-sm opacity-0 transition-opacity duration-300 data-[state=open]:opacity-100" data-state="closed">
            <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="grid transition-[grid-template-rows] duration-300 ease-in-out data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
                      hidden
                    >
                      <div class="overflow-hidden min-h-0">
                        <div class="text-xs opacity-0 transition-opacity duration-300 data-[state=open]:opacity-100" data-state="closed">
                          <p class="py-1 text-neutral-500 dark:text-neutral-400">
                            <%= nested_item[:answer].html_safe %>
                          </p>
                        </div>
                      </div>
                    </div>
                  </div>
                <% end %>
              </div>
            <% end %>
          </div>
        </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="grid transition-[grid-template-rows] duration-300 ease-in-out data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
          hidden
        >
          <div class="overflow-hidden min-h-0">
            <div class="text-sm opacity-0 transition-opacity duration-300 data-[state=open]:opacity-100" data-state="closed">
              <p class="my-2 px-2">
                <%= item[:answer].html_safe %>
              </p>
            </div>
          </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="grid transition-[grid-template-rows] duration-300 ease-in-out data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
        <%= item[:default_open] ? '' : 'hidden' %>
      >
        <div class="overflow-hidden min-h-0">
          <div class="text-sm opacity-0 transition-opacity duration-300 data-[state=open]:opacity-100" data-state="<%= item[:default_open] ? 'open' : 'closed' %>">
            <p class="my-2 px-2">
              <%= item[:answer].html_safe %>
            </p>
          </div>
        </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/6 text-neutral-600 dark:text-neutral-400">
          <%= 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/6 text-neutral-600 dark:text-neutral-400">
          <%= 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

Get notified when new components come out