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.
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="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.
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="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.
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="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.
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="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.
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="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.
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="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.
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="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.
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="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 😉.
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="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'><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/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'><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/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
Targets
Actions
Accessibility Features
- Keyboard Navigation: Use ↑ ↓ to navigate between items
-
ARIA Support: Automatic
aria-expandedattribute 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