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&apos;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