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.
Yes, just press the β β Tab β΅ & Space keys π.
Yep, it comes Tailwind CSS styles that you can easily customize.
Yes, with smooth animations for opening and closing.
<% 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.
Yes, just press the β β Tab β΅ & Space keys π.
Yep, it comes Tailwind CSS styles that you can easily customize.
Yes, with smooth animations for opening and closing.
<% 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.
Yes, just press the β β Tab β΅ & Space keys π.
Yep, it comes Tailwind CSS styles that you can easily customize.
Yes, with smooth animations for opening and closing.
<% 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.
Yes, just press the β β Tab β΅ & Space keys π.
Yep, it comes Tailwind CSS styles that you can easily customize.
Yes, with smooth animations for opening and closing.
<% 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.
Yes, just press the β β Tab β΅ & Space keys π.
Yep, it comes Tailwind CSS styles that you can easily customize.
Yes, with smooth animations for opening and closing.
<% 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.
Yes, just press the β β Tab β΅ & Space keys π.
Yep, it comes Tailwind CSS styles that you can easily customize.
Yes, with smooth animations for opening and closing.
<% 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.
Learn about modern frontend technologies and frameworks.
React is a JavaScript library for building user interfaces.
Vue.js is a progressive framework for building user interfaces.
Explore server-side technologies and databases.
Node.js is a JavaScript runtime built on Chrome's V8 JavaScript engine.
Rails is a web application framework written in Ruby.
Learn about deployment, monitoring, and infrastructure.
<% 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.
Yes, just press the β β Tab β΅ & Space keys π.
Is it styled?
Yes, with smooth animations for opening and closing.
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 π.
Yep, it comes Tailwind CSS styles that you can easily customize.
Yes, with smooth animations for opening and closing.
<% 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'><details></code> and <code class='code-inline-text'><summary></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'><details></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'><details></code> and <code class='code-inline-text'><summary></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'><details></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