Skip to content

Accordion

component

Sections that open and close, on details and summary: they work without JavaScript, the browser keeping one open at a time; the controller adds the arrows, Home and End.

Preview

Light

Is it free?
Yes.
Where?
In Montreal.
Code
<twig:Accordion id="faq" defaultValue="price">
    <twig:Accordion:Item value="price">
        <twig:Accordion:Trigger>Is it free?</twig:Accordion:Trigger>
        <twig:Accordion:Content>Yes.</twig:Accordion:Content>
    </twig:Accordion:Item>
    <twig:Accordion:Item value="where">
        <twig:Accordion:Trigger>Where?</twig:Accordion:Trigger>
        <twig:Accordion:Content>In Montreal.</twig:Accordion:Content>
    </twig:Accordion:Item>
</twig:Accordion>

Dark

Is it free?
Yes.
Where?
In Montreal.
Code
<twig:Accordion id="faq" defaultValue="price">
    <twig:Accordion:Item value="price">
        <twig:Accordion:Trigger>Is it free?</twig:Accordion:Trigger>
        <twig:Accordion:Content>Yes.</twig:Accordion:Content>
    </twig:Accordion:Item>
    <twig:Accordion:Item value="where">
        <twig:Accordion:Trigger>Where?</twig:Accordion:Trigger>
        <twig:Accordion:Content>In Montreal.</twig:Accordion:Content>
    </twig:Accordion:Item>
</twig:Accordion>

Try it

Change the props; the example is rendered again by the server.

Is it free?
Yes.
Where?
In Montreal.
<twig:Accordion id="faq" defaultValue="price">
    <twig:Accordion:Item value="price">
        <twig:Accordion:Trigger>Is it free?</twig:Accordion:Trigger>
        <twig:Accordion:Content>Yes.</twig:Accordion:Content>
    </twig:Accordion:Item>
    <twig:Accordion:Item value="where">
        <twig:Accordion:Trigger>Where?</twig:Accordion:Trigger>
        <twig:Accordion:Content>In Montreal.</twig:Accordion:Content>
    </twig:Accordion:Item>
</twig:Accordion>

Variants

multiple

Whether multiple items can be opened at once.

Is it free?
Yes.
Where?
In Montreal.
Is it free?
Yes.
Where?
In Montreal.
multiple="false"
Is it free?
Yes.
Where?
In Montreal.
Is it free?
Yes.
Where?
In Montreal.
multiple="true"

orientation

The visual orientation, controls whether keyboard navigation uses up/down or left/right arrow keys.

Is it free?
Yes.
Where?
In Montreal.
Is it free?
Yes.
Where?
In Montreal.
orientation="vertical"
Is it free?
Yes.
Where?
In Montreal.
Is it free?
Yes.
Where?
In Montreal.
orientation="horizontal"

Installation

php bin/console ux:install accordion --kit=github.com/parler-haut-interagir-librement/ux-kit

Props

PropTypeDefaultDescription
multiple bool false Whether multiple items can be opened at once.
defaultValue string|array<string>|null null Value(s) of the item(s) to open by default.
orientation 'vertical'|'horizontal' 'vertical' The visual orientation, controls whether keyboard navigation uses up/down or left/right arrow keys.

Accessibility

summary is focused and toggled by the keyboard natively. A disabled item is inert: it can neither be focused nor opened.

Files to copy

assets/controllers/accordion_controller.js
import { Controller } from '@hotwired/stimulus';

/**
 * The trigger a key moves to from `index` among `count`: along the orientation, the side arrows mirrored when the page
 * reads right to left, Home and End the first and the last; `null` for any other key.
 */
export function moved(key, index, count, { orientation = 'vertical', rtl = false } = {}) {
    const next = (index + 1) % count;
    const previous = (index - 1 + count) % count;
    const horizontal = orientation === 'horizontal';

    switch (key) {
        case 'ArrowDown':
            return horizontal ? null : next;
        case 'ArrowUp':
            return horizontal ? null : previous;
        case 'ArrowRight':
            return horizontal ? (rtl ? previous : next) : null;
        case 'ArrowLeft':
            return horizontal ? (rtl ? next : previous) : null;
        case 'Home':
            return 0;
        case 'End':
            return count - 1;
        default:
            return null;
    }
}

/*
 * The arrows, Home and End between the summaries of an accordion. Opening and closing, and keeping one item open at a
 * time through the `name` of its `details`, are the browser's work.
 */
export default class extends Controller {
    static targets = ['item', 'trigger', 'content'];

    static values = {
        multiple: { type: Boolean, default: false },
        orientation: { type: String, default: 'vertical' },
    };

    toggle(event) {
        const trigger = event.currentTarget;

        if (trigger.tagName === 'SUMMARY') {
            return;
        }

        const item = trigger.closest('details');

        if (item && !item.inert) {
            item.open = !item.open;
        }
    }

    handleKeydown(event) {
        const triggers = this.triggerTargets.filter((trigger) => !trigger.closest('details')?.inert);
        const index = moved(event.key, triggers.indexOf(event.currentTarget), triggers.length, {
            orientation: this.orientationValue,
            rtl: getComputedStyle(this.element).direction === 'rtl',
        });

        if (index !== null) {
            event.preventDefault();
            triggers[index].focus();
        }
    }
}
templates/components/Accordion.html.twig
{%- props
    ## string Unique identifier for the Accordion.
    id,
    ## bool Whether multiple items can be opened at once.
    multiple = false,
    ## string|array<string>|null Value(s) of the item(s) to open by default.
    defaultValue = null,
    ## 'vertical'|'horizontal' The visual orientation, controls whether keyboard navigation uses up/down or left/right arrow keys.
    orientation = 'vertical'
-%}
{%- do provide('accordion.id', id) -%}
{%- do provide('accordion.multiple', multiple) -%}
{%- do provide('accordion.orientation', orientation) -%}
{%- do provide('accordion.defaultValue', defaultValue) -%}
<div id="{{ id }}" data-slot="accordion" data-accordion-multiple-value="{{ multiple ? 'true' : 'false' }}" data-accordion-orientation-value="{{ orientation }}" data-orientation="{{ orientation }}"{{ attributes.defaults({class: 'isAccordion', 'data-controller': 'accordion'}) }}>
{##- The accordion items, typically multiple `Accordion:Item` components. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Accordion/Content.html.twig
<div id="{{ inject('accordionItem.contentId') }}" data-slot="accordion-content" data-accordion-target="content" role="region" aria-labelledby="{{ inject('accordionItem.triggerId') }}"{{ attributes.defaults({class: 'isAccordionContent'}) }}>
{##- The collapsible content of the accordion item. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Accordion/Item.html.twig
{%- props
    ## string Unique value for this accordion item.
    value,
    ## bool Whether the item is open by default.
    open = false,
    ## bool Whether the item is disabled.
    disabled = false
-%}
{%- set _accordion_id = inject('accordion.id') -%}
{%- set _accordion_default = inject('accordion.defaultValue') -%}
{%- set _accordion_open = open or _accordion_default is same as(value) or (_accordion_default is iterable and value in _accordion_default) -%}
{%- set _accordion_item_id = _accordion_id ~ '-' ~ value -%}
{%- do provide('accordionItem.triggerId', _accordion_item_id ~ '-trigger') -%}
{%- do provide('accordionItem.contentId', _accordion_item_id ~ '-content') -%}
<details data-slot="accordion-item" data-accordion-target="item" data-value="{{ value }}" data-disabled="{{ disabled ? 'true' : 'false' }}"{% if not inject('accordion.multiple', false) %} name="{{ _accordion_id }}"{% endif %}{% if _accordion_open %} open{% endif %}{% if disabled %} inert{% endif %}{{ attributes.defaults({class: 'isAccordionItem'}) }}>
{##- The item content, typically an `Accordion:Trigger` and `Accordion:Content`. -#}
{%- block content %}{% endblock -%}
</details>
templates/components/Accordion/Trigger.html.twig
<summary id="{{ inject('accordionItem.triggerId') }}" data-slot="accordion-trigger" data-accordion-target="trigger" data-orientation="{{ inject('accordion.orientation', 'vertical') }}"{{ attributes.defaults({class: 'isAccordionTrigger', 'data-action': 'click->accordion#toggle keydown->accordion#handleKeydown'}) }}>
{##- The clickable trigger label that toggles the accordion item. -#}
{%- block content %}{% endblock -%}
</summary>