Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
Loading...
<twig:Accordion id="accordion-demo" defaultValue="{{ ['shipping'] }}" class="max-w-lg">
<twig:Accordion:Item value="shipping">
<twig:Accordion:Trigger>What are your shipping options?</twig:Accordion:Trigger>
<twig:Accordion:Content>
We offer standard (5-7 days), express (2-3 days), and overnight
shipping. Free shipping on international orders.
</twig:Accordion:Content>
</twig:Accordion:Item>
<twig:Accordion:Item value="returns">
<twig:Accordion:Trigger>What is your return policy?</twig:Accordion:Trigger>
<twig:Accordion:Content>
Returns accepted within 30 days. Items must be unused and in original
packaging. Refunds processed within 5-7 business days.
</twig:Accordion:Content>
</twig:Accordion:Item>
<twig:Accordion:Item value="support">
<twig:Accordion:Trigger>How can I contact customer support?</twig:Accordion:Trigger>
<twig:Accordion:Content>
Reach us via email, live chat, or phone. We respond within 24 hours
during business days.
</twig:Accordion:Content>
</twig:Accordion:Item>
</twig:Accordion>
Installation
bin/console ux:install accordion --kit shadcn
That's it!
Install the following Composer dependencies:
composer require symfony/ux-icons twig/extra-bundle twig/html-extra:^3.12.0 tales-from-a-dev/twig-tailwind-extra:^1.0.0
Copy the following file(s) into your Symfony app:
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['item', 'trigger', 'content'];
static values = {
multiple: { type: Boolean, default: false },
orientation: { type: String, default: 'vertical' },
};
/**
* Toggle an accordion item when its trigger is clicked.
* @param {Event} event
*/
toggle(event) {
const trigger = event.currentTarget;
const item = trigger.closest('[data-accordion-target="item"]');
if (!item || this.#isDisabled(item)) {
return;
}
const isOpen = item.dataset.open === 'true';
if (isOpen) {
this.#closeItem(item);
} else {
this.#openItem(item);
}
}
/**
* Handle keyboard navigation for accessibility.
* @param {KeyboardEvent} event
*/
handleKeydown(event) {
const trigger = event.currentTarget;
const enabledTriggers = this.#getEnabledTriggers();
const currentIndex = enabledTriggers.indexOf(trigger);
if (currentIndex === -1) {
return;
}
const isVertical = this.orientationValue === 'vertical';
const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';
let newIndex = null;
switch (event.key) {
case prevKey:
event.preventDefault();
newIndex = currentIndex > 0 ? currentIndex - 1 : enabledTriggers.length - 1;
break;
case nextKey:
event.preventDefault();
newIndex = currentIndex < enabledTriggers.length - 1 ? currentIndex + 1 : 0;
break;
case 'Home':
event.preventDefault();
newIndex = 0;
break;
case 'End':
event.preventDefault();
newIndex = enabledTriggers.length - 1;
break;
}
if (newIndex !== null) {
enabledTriggers[newIndex].focus();
}
}
/**
* Open an accordion item.
* @param {HTMLElement} item
*/
#openItem(item) {
// If not multiple, close other open items first
if (!this.multipleValue) {
for (const otherItem of this.itemTargets) {
if (otherItem !== item && otherItem.dataset.open === 'true') {
this.#closeItem(otherItem);
}
}
}
const trigger = item.querySelector('[data-accordion-target="trigger"]');
const content = item.querySelector('[data-accordion-target="content"]');
if (!trigger || !content) {
return;
}
// Update item state
item.dataset.open = 'true';
delete item.dataset.closed;
// Update trigger ARIA
trigger.setAttribute('aria-expanded', 'true');
// Remove hidden class and prepare for animation
content.classList.remove('hidden');
content.setAttribute('aria-hidden', 'false');
content.dataset.open = 'true';
delete content.dataset.closed;
// Force reflow to ensure the transition starts from 0fr
content.offsetHeight;
// Apply open state with CSS Grid
content.style.gridTemplateRows = '1fr';
}
/**
* Close an accordion item.
* @param {HTMLElement} item
*/
#closeItem(item) {
const trigger = item.querySelector('[data-accordion-target="trigger"]');
const content = item.querySelector('[data-accordion-target="content"]');
if (!trigger || !content) {
return;
}
// Update item state
delete item.dataset.open;
item.dataset.closed = 'true';
// Update trigger ARIA
trigger.setAttribute('aria-expanded', 'false');
// Update content state
content.setAttribute('aria-hidden', 'true');
delete content.dataset.open;
content.dataset.closed = 'true';
// Apply closed state with CSS Grid
content.style.gridTemplateRows = '0fr';
// Hide after transition completes
const onTransitionEnd = (event) => {
// Only react to grid-template-rows transition
if (event.propertyName === 'grid-template-rows') {
if (item.dataset.closed === 'true') {
content.classList.add('hidden');
}
content.removeEventListener('transitionend', onTransitionEnd);
}
};
content.addEventListener('transitionend', onTransitionEnd);
}
/**
* Check if an item is disabled.
* @param {HTMLElement} item
* @returns {boolean}
*/
#isDisabled(item) {
return item.hasAttribute('disabled');
}
/**
* Get all enabled (non-disabled) triggers.
* @returns {HTMLElement[]}
*/
#getEnabledTriggers() {
return this.triggerTargets.filter((trigger) => {
const item = trigger.closest('[data-accordion-target="item"]');
return item && !this.#isDisabled(item);
});
}
}
{# @prop id string Unique identifier for the Accordion #}
{# @prop multiple boolean Whether multiple items can be opened at once, default to `false` #}
{# @prop defaultValue string|array<string>|null Value(s) of the item(s) to open by default #}
{# @prop orientation 'vertical'|'horizontal'The visual orientation of the accordion. Controls whether roving focus uses left/right or up/down arrow keys, default to `vertical` #}
{# @block content The default block #}
{%- props id, multiple = false, defaultValue = null, orientation = 'vertical' -%}
{%- set _accordion_id = id -%}
{%- set _accordion_multiple = multiple -%}
{%- set _accordion_default_value = defaultValue -%}
{%- set _accordion_orientationn = orientation -%}
<div
class="{{ 'flex w-full flex-col ' ~ attributes.render('class')|tailwind_merge }}"
{{ attributes.defaults({
id: _accordion_id,
'data-slot': 'accordion',
'data-controller': 'accordion',
'data-accordion-multiple-value': _accordion_multiple ? 'true' : 'false',
'data-accordion-orientation-value': _accordion_orientationn,
'data-orientation': _accordion_orientationn,
}) }}
>
{%- block content %}{% endblock -%}
</div>
{# @block content The default block #}
<div
class="grid text-sm overflow-hidden transition-[grid-template-rows] duration-300 ease-out {{ not _accordion_item_is_open ? 'hidden' }}"
{{ attributes.defaults({
id: _accordion_item_content_id,
'data-slot': 'accordion-content',
'data-accordion-target': 'content',
role: 'region',
'aria-labelledby': _accordion_item_trigger_id,
'aria-hidden': not _accordion_item_is_open ? 'true' : 'false',
'data-open': _accordion_item_is_open ? 'true' : false,
'data-closed': not _accordion_item_is_open ? 'true' : false,
style: _accordion_item_is_open ? 'grid-template-rows: 1fr;' : 'grid-template-rows: 0fr;',
}).without('class') }}
>
<div class="min-h-0 min-w-0 overflow-hidden">
<div class="{{ 'pt-0 pb-2.5 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4 ' ~ attributes.render('class')|tailwind_merge }}">
{%- block content %}{% endblock -%}
</div>
</div>
</div>
{# @prop value string Unique value for this accordion item #}
{# @prop open boolean Whether the item is open by default, default to `false` #}
{# @prop disabled boolean Whether the item is disabled, default to `false` #}
{# @block content The default block #}
{%- props value, open = false, disabled = false -%}
{%- set _accordion_item_is_open = open or (_accordion_default_value == value or (_accordion_default_value is iterable and value in defaultValue)) -%}
{%- set _accordion_item_id = _accordion_id ~ '-' ~ value -%}
{%- set _accordion_item_content_id = _accordion_item_id ~ '-content' -%}
{%- set _accordion_item_trigger_id = _accordion_item_id ~ '-trigger' -%}
{%- set _accordion_item_disabled = disabled -%}
<div
class="{{ 'not-last:border-b group ' ~ attributes.render('class')|tailwind_merge }}"
{{ attributes.defaults({
'data-slot': 'accordion-item',
'data-accordion-target': 'item',
'data-value': value,
'data-open': _accordion_item_is_open ? 'true' : false,
'data-closed': not _accordion_item_is_open ? 'true' : false,
'aria-disabled': _accordion_item_disabled ? 'true' : false,
}) }}
>
{%- block content %}{% endblock -%}
</div>
{# @block content The default block #}
<h3 class="flex" data-orientation="{{ _accordion_orientationn }}">
<button
class="{{ 'focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 ' ~ attributes.render('class')|tailwind_merge }}"
{{ attributes.defaults({
type: 'button',
id: _accordion_item_trigger_id,
'data-slot': 'accordion-trigger',
'data-accordion-target': 'trigger',
'data-action': 'click->accordion#toggle keydown->accordion#handleKeydown',
disabled: _accordion_item_disabled,
'data-orientation': _accordion_orientationn,
'aria-expanded': _accordion_item_is_open ? 'true' : 'false',
'aria-controls': _accordion_item_content_id,
}) }}
>
{%- block content %}{% endblock -%}
<twig:ux:icon name="lucide:chevron-down" data-slot="accordion-trigger-icon" class="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<twig:ux:icon name="lucide:chevron-up" data-slot="accordion-trigger-icon" class="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</button>
</h3>
Happy coding!
Usage
<twig:Accordion id="accordion-usage" defaultValue="{{ ['item-1'] }}">
<twig:Accordion:Item value="item-1">
<twig:Accordion:Trigger>Is it accessible?</twig:Accordion:Trigger>
<twig:Accordion:Content>
Yes. It adheres to the WAI-ARIA design pattern.
</twig:Accordion:Content>
</twig:Accordion:Item>
</twig:Accordion>
Examples
Basic
A basic accordion that shows one item at a time. The first item is open by default.
Loading...
{% set items = [
{
value: 'item-1',
trigger: 'How do I reset my password?',
content:
"Click on 'Forgot Password' on the login page, enter your email address, and we'll send you a link to reset your password. The link will expire in 24 hours.",
},
{
value: 'item-2',
trigger: 'Can I change my subscription plan?',
content:
'Yes, you can upgrade or downgrade your plan at any time from your account settings. Changes will be reflected in your next billing cycle.',
},
{
value: 'item-3',
trigger: 'What payment methods do you accept?',
content:
'We accept all major credit cards, PayPal, and bank transfers. All payments are processed securely through our payment partners.',
},
] %}
<twig:Accordion id="accordion-basic" defaultValue="{{ ['item-1'] }}" class="max-w-lg">
{% for item in items %}
<twig:Accordion:Item key="{{ item.value }}" value="{{ item.value }}">
<twig:Accordion:Trigger>{{ item.trigger }}</twig:Accordion:Trigger>
<twig:Accordion:Content>{{ item.content }}</twig:Accordion:Content>
</twig:Accordion:Item>
{% endfor %}
</twig:Accordion>
Multiple
Use the multiple prop to allow multiple items to be open at the same time.
Loading...
{% set items = [
{
value: 'notifications',
trigger: 'Notification Settings',
content:
'Manage how you receive notifications. You can enable email alerts for updates or push notifications for mobile devices.',
},
{
value: 'privacy',
trigger: 'Privacy & Security',
content:
'Control your privacy settings and security preferences. Enable two-factor authentication, manage connected devices, review active sessions, and configure data sharing preferences. You can also download your data or delete your account.',
},
{
value: 'billing',
trigger: 'Billing & Subscription',
content:
'View your current plan, payment history, and upcoming invoices. Update your payment method, change your subscription tier, or cancel your subscription.',
},
] %}
<twig:Accordion id="accordion-multiple" multiple class="max-w-lg" defaultValue="{{ ['notifications', 'billing'] }}">
{% for item in items %}
<twig:Accordion:Item key="{{ item.value }}" value="{{ item.value }}">
<twig:Accordion:Trigger>{{ item.trigger }}</twig:Accordion:Trigger>
<twig:Accordion:Content>{{ item.content }}</twig:Accordion:Content>
</twig:Accordion:Item>
{% endfor %}
</twig:Accordion>
Disabled
Use the disabled prop on Accordion:Item to disable individual items.
Loading...
<twig:Accordion id="accordion-disabled" class="max-w-lg">
<twig:Accordion:Item value="item-1">
<twig:Accordion:Trigger>Can I access my account history?</twig:Accordion:Trigger>
<twig:Accordion:Content>
Yes, you can view your complete account history including all
transactions, plan changes, and support tickets in the Account History
section of your dashboard.
</twig:Accordion:Content>
</twig:Accordion:Item>
<twig:Accordion:Item value="item-2" disabled>
<twig:Accordion:Trigger>Premium feature information</twig:Accordion:Trigger>
<twig:Accordion:Content>
This section contains information about premium features. Upgrade your
plan to access this content.
</twig:Accordion:Content>
</twig:Accordion:Item>
<twig:Accordion:Item value="item-3">
<twig:Accordion:Trigger>How do I update my email address?</twig:Accordion:Trigger>
<twig:Accordion:Content>
You can update your email address in your account settings.
You'll receive a verification email at your new address to
confirm the change.
</twig:Accordion:Content>
</twig:Accordion:Item>
</twig:Accordion>
Borders
Add border to the Accordion and border-b last:border-b-0 to the Accordion:Item to add borders to the items.
Loading...
{% set items = [
{
value: 'billing',
trigger: 'How does billing work?',
content:
'We offer monthly and annual subscription plans. Billing is charged at the beginning of each cycle, and you can cancel anytime. All plans include automatic backups, 24/7 support, and unlimited team members.',
},
{
value: 'security',
trigger: 'Is my data secure?',
content:
'Yes. We use end-to-end encryption, SOC 2 Type II compliance, and regular third-party security audits. All data is encrypted at rest and in transit using industry-standard protocols.',
},
{
value: 'integration',
trigger: 'What integrations do you support?',
content:
'We integrate with 500+ popular tools including Slack, Zapier, Salesforce, HubSpot, and more. You can also build custom integrations using our REST API and webhooks.',
},
] %}
<twig:Accordion
id="accordion-borders"
class="max-w-lg rounded-lg border"
defaultValue="{{ ['billing'] }}"
>
{% for item in items %}
<twig:Accordion:Item
key="{{ item.value }}"
value="{{ item.value }}"
class="border-b px-4 last:border-b-0"
>
<twig:Accordion:Trigger>{{ item.trigger }}</twig:Accordion:Trigger>
<twig:Accordion:Content>{{ item.content }}</twig:Accordion:Content>
</twig:Accordion:Item>
{% endfor %}
</twig:Accordion>
Card
Wrap the Accordion in a Card component.
Loading...
<twig:Card class="w-full max-w-md">
<twig:Card:Header>
<twig:Card:Title>FAQ</twig:Card:Title>
<twig:Card:Description>Frequently asked questions</twig:Card:Description>
</twig:Card:Header>
<twig:Card:Content>
<twig:Accordion id="accordion-faq">
<twig:Accordion:Item value="q1">
<twig:Accordion:Trigger>What is your return policy?</twig:Accordion:Trigger>
<twig:Accordion:Content>
You can return any item within 30 days of purchase for a full refund.
</twig:Accordion:Content>
</twig:Accordion:Item>
<twig:Accordion:Item value="q2">
<twig:Accordion:Trigger>How long does shipping take?</twig:Accordion:Trigger>
<twig:Accordion:Content>
Standard shipping takes 5-7 business days. Express shipping is available for 2-3 days delivery.
</twig:Accordion:Content>
</twig:Accordion:Item>
<twig:Accordion:Item value="q3">
<twig:Accordion:Trigger>Do you ship internationally?</twig:Accordion:Trigger>
<twig:Accordion:Content>
Yes, we ship to over 50 countries worldwide. Shipping rates vary by location.
</twig:Accordion:Content>
</twig:Accordion:Item>
</twig:Accordion>
</twig:Card:Content>
</twig:Card>
API Reference
Accordion
| Prop | Type | Description |
|---|---|---|
id |
string |
Unique identifier for the Accordion |
multiple |
boolean |
Whether multiple items can be opened at once, default to false |
defaultValue |
string|array<string>|null |
Value(s) of the item(s) to open by default |
orientation |
'vertical'|'horizontal'The |
visual orientation of the accordion. Controls whether roving focus uses left/right or up/down arrow keys, default to vertical |
| Block | Description |
|---|---|
content |
The default block |
Accordion:Content
| Block | Description |
|---|---|
content |
The default block |
Accordion:Item
| Prop | Type | Description |
|---|---|---|
value |
string |
Unique value for this accordion item |
open |
boolean |
Whether the item is open by default, default to false |
disabled |
boolean |
Whether the item is disabled, default to false |
| Block | Description |
|---|---|
content |
The default block |
Accordion:Trigger
| Block | Description |
|---|---|
content |
The default block |