Collapsible

An interactive component which expands/collapses a panel.

Loading...
<twig:Collapsible class="flex w-[350px] flex-col gap-2 self-start">
    <div class="flex items-center justify-between gap-4 px-4">
        <h4 class="text-sm font-semibold">Order #4189</h4>
        <twig:Collapsible:Trigger>
            <twig:Button variant="ghost" size="icon" class="size-8" {{ ...collapsible_trigger_attrs }}>
                <twig:ux:icon name="lucide:chevrons-up-down" class="size-4" />
                <span class="sr-only">Toggle details</span>
            </twig:Button>
        </twig:Collapsible:Trigger>
    </div>
    <div class="flex items-center justify-between rounded-md border px-4 py-2 text-sm">
        <span class="text-muted-foreground">Status</span>
        <span class="font-medium">Shipped</span>
    </div>
    <twig:Collapsible:Content class="flex flex-col gap-2">
        <div class="rounded-md border px-4 py-2 text-sm">
            <p class="font-medium">Shipping address</p>
            <p class="text-muted-foreground">100 Market St, San Francisco</p>
        </div>
        <div class="rounded-md border px-4 py-2 text-sm">
            <p class="font-medium">Items</p>
            <p class="text-muted-foreground">2x Studio Headphones</p>
        </div>
    </twig:Collapsible:Content>
</twig:Collapsible>

Installation

Note

Available since UX Toolkit 3.0.

bin/console ux:install collapsible --kit shadcn

That's it!

Install the following Composer dependencies:

composer require tales-from-a-dev/twig-tailwind-extra:^1.0.0 symfony/ux-twig-component:^3.1

Copy the following file(s) into your Symfony app:

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

export default class extends Controller {
    static targets = ['trigger', 'content'];

    static values = {
        open: { type: Boolean, default: false },
    };

    connect() {
        this.updateState();
    }

    toggle() {
        this.openValue = !this.openValue;
    }

    openValueChanged() {
        this.updateState();
    }

    updateState() {
        const open = this.openValue;
        const state = open ? 'open' : 'closed';

        this.element.dataset.state = state;

        for (const trigger of this.triggerTargets) {
            trigger.setAttribute('aria-expanded', String(open));
            trigger.dataset.state = state;
        }

        for (const content of this.contentTargets) {
            content.dataset.state = state;
            content.setAttribute('aria-hidden', String(!open));
            if (open) {
                content.removeAttribute('hidden');
            } else {
                content.setAttribute('hidden', '');
            }
        }
    }
}
templates/components/Collapsible.html.twig
{# @prop open boolean Whether the collapsible is open by default. Defaults to `false` #}
{# @block content The collapsible content, typically a `Collapsible:Trigger` and a `Collapsible:Content` #}
{%- props open = false -%}
{%- do provide('collapsible.open', open) -%}
<div
    {{ attributes.defaults({
        'data-slot': 'collapsible',
        'data-controller': 'collapsible',
        'data-collapsible-open-value': open ? 'true' : 'false',
        'data-state': open ? 'open' : 'closed',
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Collapsible/Content.html.twig
{# @block content The content revealed when the collapsible is open #}
{%- set _collapsible_open = inject('collapsible.open', false) -%}
<div{{ attributes.defaults({
        'data-slot': 'collapsible-content',
        'data-collapsible-target': 'content',
        'aria-hidden': not _collapsible_open ? 'true' : 'false',
        'data-state': _collapsible_open ? 'open' : 'closed',
        ...(not _collapsible_open ? {hidden: ''} : {}),
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Collapsible/Trigger.html.twig
{# @block content The clickable element that toggles the collapsible (e.g., a `Button`) #}
{%- set _collapsible_open = inject('collapsible.open', false) -%}
{%- set collapsible_trigger_attrs = {
    'data-slot': 'collapsible-trigger',
    'data-collapsible-target': 'trigger',
    'data-action': 'click->collapsible#toggle'|html_attr_type('sst'),
    'aria-expanded': _collapsible_open ? 'true' : 'false',
    'data-state': _collapsible_open ? 'open' : 'closed',
} -%}
{%- block content %}{% endblock -%}

Happy coding!

Usage

<twig:Collapsible>
    <twig:Collapsible:Trigger>
        <twig:Button variant="ghost" {{ ...collapsible_trigger_attrs }}>Can I use this in my project?</twig:Button>
    </twig:Collapsible:Trigger>
    <twig:Collapsible:Content>
        Yes. Free to use for personal and commercial projects. No attribution required.
    </twig:Collapsible:Content>
</twig:Collapsible>

Examples

Basic

Loading...
<twig:Card class="mx-auto w-full max-w-sm self-start">
    <twig:Card:Content>
        <twig:Collapsible class="rounded-md data-[state=open]:bg-muted">
            <twig:Collapsible:Trigger>
                <twig:Button variant="ghost" class="group w-full" {{ ...collapsible_trigger_attrs }}>
                    Product details
                    <twig:ux:icon name="lucide:chevron-down" class="ml-auto transition-transform group-data-[state=open]:rotate-180" />
                </twig:Button>
            </twig:Collapsible:Trigger>
            <twig:Collapsible:Content class="flex flex-col items-start gap-2 p-2.5 pt-0 text-sm">
                <div>
                    This panel can be expanded or collapsed to reveal additional content.
                </div>
                <twig:Button size="xs">Learn More</twig:Button>
            </twig:Collapsible:Content>
        </twig:Collapsible>
    </twig:Card:Content>
</twig:Card>

Settings Panel

Use a trigger button to reveal additional settings.

Loading...
<twig:Card class="mx-auto w-full max-w-xs self-start" size="sm">
    <twig:Card:Header>
        <twig:Card:Title>Radius</twig:Card:Title>
        <twig:Card:Description>Set the corner radius of the element.</twig:Card:Description>
    </twig:Card:Header>
    <twig:Card:Content>
        <twig:Collapsible class="flex items-start gap-2">
            <div class="grid w-full grid-cols-2 gap-2">
                <twig:Field>
                    <twig:Field:Label for="radius-1" class="sr-only">Radius X</twig:Field:Label>
                    <twig:Input id="radius-1" placeholder="0" value="0" />
                </twig:Field>
                <twig:Field>
                    <twig:Field:Label for="radius-2" class="sr-only">Radius Y</twig:Field:Label>
                    <twig:Input id="radius-2" placeholder="0" value="0" />
                </twig:Field>
                <twig:Collapsible:Content class="col-span-full grid grid-cols-subgrid gap-2">
                    <twig:Field>
                        <twig:Field:Label for="radius-3" class="sr-only">Radius X</twig:Field:Label>
                        <twig:Input id="radius-3" placeholder="0" value="0" />
                    </twig:Field>
                    <twig:Field>
                        <twig:Field:Label for="radius-4" class="sr-only">Radius Y</twig:Field:Label>
                        <twig:Input id="radius-4" placeholder="0" value="0" />
                    </twig:Field>
                </twig:Collapsible:Content>
            </div>
            <twig:Collapsible:Trigger>
                <twig:Button variant="outline" size="icon" class="group" {{ ...collapsible_trigger_attrs }}>
                    <twig:ux:icon name="lucide:maximize" class="size-4 group-data-[state=open]:hidden" />
                    <twig:ux:icon name="lucide:minimize" class="size-4 hidden group-data-[state=open]:block" />
                </twig:Button>
            </twig:Collapsible:Trigger>
        </twig:Collapsible>
    </twig:Card:Content>
</twig:Card>

File Tree

Use nested collapsibles to build a file tree.

Loading...
<twig:Card class="mx-auto w-full max-w-[16rem] gap-2 self-start" size="sm">
    <twig:Card:Header>
        <twig:Tabs defaultValue="explorer">
            <twig:Tabs:List class="w-full">
                <twig:Tabs:Trigger value="explorer">Explorer</twig:Tabs:Trigger>
                <twig:Tabs:Trigger value="outline">Outline</twig:Tabs:Trigger>
            </twig:Tabs:List>
        </twig:Tabs>
    </twig:Card:Header>
    <twig:Card:Content>
        <div class="flex flex-col gap-1">
            {# Folder: components #}
            <twig:Collapsible>
                <twig:Collapsible:Trigger>
                    <twig:Button variant="ghost" size="sm" class="group w-full justify-start transition-none hover:bg-accent hover:text-accent-foreground" {{ ...collapsible_trigger_attrs }}>
                        <twig:ux:icon name="lucide:chevron-right" class="transition-transform group-data-[state=open]:rotate-90" />
                        <twig:ux:icon name="lucide:folder" />
                        components
                    </twig:Button>
                </twig:Collapsible:Trigger>
                <twig:Collapsible:Content class="mt-1 ml-5">
                    <div class="flex flex-col gap-1">
                        {# Nested folder: components/ui #}
                        <twig:Collapsible>
                            <twig:Collapsible:Trigger>
                                <twig:Button variant="ghost" size="sm" class="group w-full justify-start transition-none hover:bg-accent hover:text-accent-foreground" {{ ...collapsible_trigger_attrs }}>
                                    <twig:ux:icon name="lucide:chevron-right" class="transition-transform group-data-[state=open]:rotate-90" />
                                    <twig:ux:icon name="lucide:folder" />
                                    ui
                                </twig:Button>
                            </twig:Collapsible:Trigger>
                            <twig:Collapsible:Content class="mt-1 ml-5">
                                <div class="flex flex-col gap-1">
                                    {% for file in ['button.tsx', 'card.tsx', 'dialog.tsx', 'input.tsx', 'select.tsx', 'table.tsx'] %}
                                        <twig:Button variant="link" size="sm" class="w-full justify-start gap-2 text-foreground">
                                            <twig:ux:icon name="lucide:file" />
                                            <span>{{ file }}</span>
                                        </twig:Button>
                                    {% endfor %}
                                </div>
                            </twig:Collapsible:Content>
                        </twig:Collapsible>
                        {% for file in ['login-form.tsx', 'register-form.tsx'] %}
                            <twig:Button variant="link" size="sm" class="w-full justify-start gap-2 text-foreground">
                                <twig:ux:icon name="lucide:file" />
                                <span>{{ file }}</span>
                            </twig:Button>
                        {% endfor %}
                    </div>
                </twig:Collapsible:Content>
            </twig:Collapsible>

            {# Folder: lib #}
            <twig:Collapsible>
                <twig:Collapsible:Trigger>
                    <twig:Button variant="ghost" size="sm" class="group w-full justify-start transition-none hover:bg-accent hover:text-accent-foreground" {{ ...collapsible_trigger_attrs }}>
                        <twig:ux:icon name="lucide:chevron-right" class="transition-transform group-data-[state=open]:rotate-90" />
                        <twig:ux:icon name="lucide:folder" />
                        lib
                    </twig:Button>
                </twig:Collapsible:Trigger>
                <twig:Collapsible:Content class="mt-1 ml-5">
                    <div class="flex flex-col gap-1">
                        {% for file in ['utils.ts', 'cn.ts', 'api.ts'] %}
                            <twig:Button variant="link" size="sm" class="w-full justify-start gap-2 text-foreground">
                                <twig:ux:icon name="lucide:file" />
                                <span>{{ file }}</span>
                            </twig:Button>
                        {% endfor %}
                    </div>
                </twig:Collapsible:Content>
            </twig:Collapsible>

            {# Folder: hooks #}
            <twig:Collapsible>
                <twig:Collapsible:Trigger>
                    <twig:Button variant="ghost" size="sm" class="group w-full justify-start transition-none hover:bg-accent hover:text-accent-foreground" {{ ...collapsible_trigger_attrs }}>
                        <twig:ux:icon name="lucide:chevron-right" class="transition-transform group-data-[state=open]:rotate-90" />
                        <twig:ux:icon name="lucide:folder" />
                        hooks
                    </twig:Button>
                </twig:Collapsible:Trigger>
                <twig:Collapsible:Content class="mt-1 ml-5">
                    <div class="flex flex-col gap-1">
                        {% for file in ['use-media-query.ts', 'use-debounce.ts', 'use-local-storage.ts'] %}
                            <twig:Button variant="link" size="sm" class="w-full justify-start gap-2 text-foreground">
                                <twig:ux:icon name="lucide:file" />
                                <span>{{ file }}</span>
                            </twig:Button>
                        {% endfor %}
                    </div>
                </twig:Collapsible:Content>
            </twig:Collapsible>

            {# Folder: types #}
            <twig:Collapsible>
                <twig:Collapsible:Trigger>
                    <twig:Button variant="ghost" size="sm" class="group w-full justify-start transition-none hover:bg-accent hover:text-accent-foreground" {{ ...collapsible_trigger_attrs }}>
                        <twig:ux:icon name="lucide:chevron-right" class="transition-transform group-data-[state=open]:rotate-90" />
                        <twig:ux:icon name="lucide:folder" />
                        types
                    </twig:Button>
                </twig:Collapsible:Trigger>
                <twig:Collapsible:Content class="mt-1 ml-5">
                    <div class="flex flex-col gap-1">
                        {% for file in ['index.d.ts', 'api.d.ts'] %}
                            <twig:Button variant="link" size="sm" class="w-full justify-start gap-2 text-foreground">
                                <twig:ux:icon name="lucide:file" />
                                <span>{{ file }}</span>
                            </twig:Button>
                        {% endfor %}
                    </div>
                </twig:Collapsible:Content>
            </twig:Collapsible>

            {# Folder: public #}
            <twig:Collapsible>
                <twig:Collapsible:Trigger>
                    <twig:Button variant="ghost" size="sm" class="group w-full justify-start transition-none hover:bg-accent hover:text-accent-foreground" {{ ...collapsible_trigger_attrs }}>
                        <twig:ux:icon name="lucide:chevron-right" class="transition-transform group-data-[state=open]:rotate-90" />
                        <twig:ux:icon name="lucide:folder" />
                        public
                    </twig:Button>
                </twig:Collapsible:Trigger>
                <twig:Collapsible:Content class="mt-1 ml-5">
                    <div class="flex flex-col gap-1">
                        {% for file in ['favicon.ico', 'logo.svg', 'images'] %}
                            <twig:Button variant="link" size="sm" class="w-full justify-start gap-2 text-foreground">
                                <twig:ux:icon name="lucide:file" />
                                <span>{{ file }}</span>
                            </twig:Button>
                        {% endfor %}
                    </div>
                </twig:Collapsible:Content>
            </twig:Collapsible>

            {# Root files #}
            {% for file in ['app.tsx', 'layout.tsx', 'globals.css', 'package.json', 'tsconfig.json', 'README.md', '.gitignore'] %}
                <twig:Button variant="link" size="sm" class="w-full justify-start gap-2 text-foreground">
                    <twig:ux:icon name="lucide:file" />
                    <span>{{ file }}</span>
                </twig:Button>
            {% endfor %}
        </div>
    </twig:Card:Content>
</twig:Card>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

Loading...
<div class="flex w-[350px] flex-col gap-4 self-start">
    {# Arabic #}
    <twig:Collapsible class="flex flex-col gap-2" dir="rtl">
        <div class="flex items-center justify-between gap-4 px-4">
            <h4 class="text-sm font-semibold">الطلب #4189</h4>
            <twig:Collapsible:Trigger>
                <twig:Button variant="ghost" size="icon" class="size-8" {{ ...collapsible_trigger_attrs }}>
                    <twig:ux:icon name="lucide:chevrons-up-down" class="size-4" />
                    <span class="sr-only">Toggle details</span>
                </twig:Button>
            </twig:Collapsible:Trigger>
        </div>
        <div class="flex items-center justify-between rounded-md border px-4 py-2 text-sm">
            <span class="text-muted-foreground">الحالة</span>
            <span class="font-medium">تم الشحن</span>
        </div>
        <twig:Collapsible:Content class="flex flex-col gap-2">
            <div class="rounded-md border px-4 py-2 text-sm">
                <p class="font-medium">عنوان الشحن</p>
                <p class="text-muted-foreground">100 Market St, San Francisco</p>
            </div>
            <div class="rounded-md border px-4 py-2 text-sm">
                <p class="font-medium">العناصر</p>
                <p class="text-muted-foreground">2x سماعات الاستوديو</p>
            </div>
        </twig:Collapsible:Content>
    </twig:Collapsible>

    {# Hebrew #}
    <twig:Collapsible class="flex flex-col gap-2" dir="rtl">
        <div class="flex items-center justify-between gap-4 px-4">
            <h4 class="text-sm font-semibold">הזמנה #4189</h4>
            <twig:Collapsible:Trigger>
                <twig:Button variant="ghost" size="icon" class="size-8" {{ ...collapsible_trigger_attrs }}>
                    <twig:ux:icon name="lucide:chevrons-up-down" class="size-4" />
                    <span class="sr-only">Toggle details</span>
                </twig:Button>
            </twig:Collapsible:Trigger>
        </div>
        <div class="flex items-center justify-between rounded-md border px-4 py-2 text-sm">
            <span class="text-muted-foreground">סטטוס</span>
            <span class="font-medium">נשלח</span>
        </div>
        <twig:Collapsible:Content class="flex flex-col gap-2">
            <div class="rounded-md border px-4 py-2 text-sm">
                <p class="font-medium">כתובת משלוח</p>
                <p class="text-muted-foreground">100 Market St, San Francisco</p>
            </div>
            <div class="rounded-md border px-4 py-2 text-sm">
                <p class="font-medium">מוצרים</p>
                <p class="text-muted-foreground">2x אוזניות סטודיו</p>
            </div>
        </twig:Collapsible:Content>
    </twig:Collapsible>
</div>

API Reference

Component Collapsible

Prop Type Description
open boolean Whether the collapsible is open by default. Defaults to false
Block Description
content The collapsible content, typically a Collapsible:Trigger and a Collapsible:Content

Component Collapsible:Content

Block Description
content The content revealed when the collapsible is open

Component Collapsible:Trigger

Block Description
content The clickable element that toggles the collapsible (e.g., a Button)