Collapsible

An interactive component which expands and collapses a section of content.

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

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

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 -%}
{%- set _collapsible_open = open -%}
<div
    class="{{ ('group ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ 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 #}
<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_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 }}>Show details</twig:Button>
    </twig:Collapsible:Trigger>
    <twig:Collapsible:Content>
        The content is now visible.
    </twig:Collapsible:Content>
</twig:Collapsible>

Examples

Basic

Loading...
<twig:Card class="mx-auto w-full max-w-sm self-start">
    <twig:Card:Content class="p-3">
        <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 size-4 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">
    <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="w-full space-y-2">
                <div class="grid 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>
                </div>
                <twig:Collapsible:Content>
                    <div class="grid grid-cols-2 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>
                    </div>
                </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-sm">
    <twig:Card:Header class="p-2.5">
        <div class="flex items-center gap-1.5 text-sm font-medium">
            <twig:ux:icon name="lucide:panel-left" class="size-4" />
            Explorer
        </div>
    </twig:Card:Header>
    <twig:Card:Content class="p-2.5 pt-0">
        <div class="flex flex-col gap-0.5">
            {# Folder: components #}
            <twig:Collapsible>
                <twig:Collapsible:Trigger>
                    <twig:Button variant="ghost" class="group w-full justify-start gap-1.5 h-7 px-2 text-sm" {{ ...collapsible_trigger_attrs }}>
                        <twig:ux:icon name="lucide:chevron-right" class="size-3.5 transition-transform group-data-[state=open]:rotate-90" />
                        <twig:ux:icon name="lucide:folder" class="size-3.5 text-muted-foreground" />
                        components
                    </twig:Button>
                </twig:Collapsible:Trigger>
                <twig:Collapsible:Content>
                    <div class="ml-4 flex flex-col gap-0.5">
                        {# Nested folder: components/ui #}
                        <twig:Collapsible>
                            <twig:Collapsible:Trigger>
                                <twig:Button variant="ghost" class="group w-full justify-start gap-1.5 h-7 px-2 text-sm" {{ ...collapsible_trigger_attrs }}>
                                    <twig:ux:icon name="lucide:chevron-right" class="size-3.5 transition-transform group-data-[state=open]:rotate-90" />
                                    <twig:ux:icon name="lucide:folder" class="size-3.5 text-muted-foreground" />
                                    ui
                                </twig:Button>
                            </twig:Collapsible:Trigger>
                            <twig:Collapsible:Content>
                                <div class="ml-4 flex flex-col gap-0.5">
                                    <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                                        <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                                        button.tsx
                                    </twig:Button>
                                    <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                                        <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                                        card.tsx
                                    </twig:Button>
                                    <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                                        <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                                        dialog.tsx
                                    </twig:Button>
                                    <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                                        <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                                        input.tsx
                                    </twig:Button>
                                    <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                                        <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                                        select.tsx
                                    </twig:Button>
                                    <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                                        <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                                        table.tsx
                                    </twig:Button>
                                </div>
                            </twig:Collapsible:Content>
                        </twig:Collapsible>
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            login-form.tsx
                        </twig:Button>
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            register-form.tsx
                        </twig:Button>
                    </div>
                </twig:Collapsible:Content>
            </twig:Collapsible>

            {# Folder: lib #}
            <twig:Collapsible>
                <twig:Collapsible:Trigger>
                    <twig:Button variant="ghost" class="group w-full justify-start gap-1.5 h-7 px-2 text-sm" {{ ...collapsible_trigger_attrs }}>
                        <twig:ux:icon name="lucide:chevron-right" class="size-3.5 transition-transform group-data-[state=open]:rotate-90" />
                        <twig:ux:icon name="lucide:folder" class="size-3.5 text-muted-foreground" />
                        lib
                    </twig:Button>
                </twig:Collapsible:Trigger>
                <twig:Collapsible:Content>
                    <div class="ml-4 flex flex-col gap-0.5">
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            utils.ts
                        </twig:Button>
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            cn.ts
                        </twig:Button>
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            api.ts
                        </twig:Button>
                    </div>
                </twig:Collapsible:Content>
            </twig:Collapsible>

            {# Folder: hooks #}
            <twig:Collapsible>
                <twig:Collapsible:Trigger>
                    <twig:Button variant="ghost" class="group w-full justify-start gap-1.5 h-7 px-2 text-sm" {{ ...collapsible_trigger_attrs }}>
                        <twig:ux:icon name="lucide:chevron-right" class="size-3.5 transition-transform group-data-[state=open]:rotate-90" />
                        <twig:ux:icon name="lucide:folder" class="size-3.5 text-muted-foreground" />
                        hooks
                    </twig:Button>
                </twig:Collapsible:Trigger>
                <twig:Collapsible:Content>
                    <div class="ml-4 flex flex-col gap-0.5">
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            use-media-query.ts
                        </twig:Button>
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            use-debounce.ts
                        </twig:Button>
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            use-local-storage.ts
                        </twig:Button>
                    </div>
                </twig:Collapsible:Content>
            </twig:Collapsible>

            {# Folder: types #}
            <twig:Collapsible>
                <twig:Collapsible:Trigger>
                    <twig:Button variant="ghost" class="group w-full justify-start gap-1.5 h-7 px-2 text-sm" {{ ...collapsible_trigger_attrs }}>
                        <twig:ux:icon name="lucide:chevron-right" class="size-3.5 transition-transform group-data-[state=open]:rotate-90" />
                        <twig:ux:icon name="lucide:folder" class="size-3.5 text-muted-foreground" />
                        types
                    </twig:Button>
                </twig:Collapsible:Trigger>
                <twig:Collapsible:Content>
                    <div class="ml-4 flex flex-col gap-0.5">
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            index.d.ts
                        </twig:Button>
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            api.d.ts
                        </twig:Button>
                    </div>
                </twig:Collapsible:Content>
            </twig:Collapsible>

            {# Folder: public #}
            <twig:Collapsible>
                <twig:Collapsible:Trigger>
                    <twig:Button variant="ghost" class="group w-full justify-start gap-1.5 h-7 px-2 text-sm" {{ ...collapsible_trigger_attrs }}>
                        <twig:ux:icon name="lucide:chevron-right" class="size-3.5 transition-transform group-data-[state=open]:rotate-90" />
                        <twig:ux:icon name="lucide:folder" class="size-3.5 text-muted-foreground" />
                        public
                    </twig:Button>
                </twig:Collapsible:Trigger>
                <twig:Collapsible:Content>
                    <div class="ml-4 flex flex-col gap-0.5">
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            favicon.ico
                        </twig:Button>
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            logo.svg
                        </twig:Button>
                        <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                            <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                            images
                        </twig:Button>
                    </div>
                </twig:Collapsible:Content>
            </twig:Collapsible>

            {# Root files #}
            <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                app.tsx
            </twig:Button>
            <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                layout.tsx
            </twig:Button>
            <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                globals.css
            </twig:Button>
            <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                package.json
            </twig:Button>
            <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                tsconfig.json
            </twig:Button>
            <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                README.md
            </twig:Button>
            <twig:Button variant="ghost" class="w-full justify-start gap-1.5 h-7 px-2 text-sm">
                <twig:ux:icon name="lucide:file" class="size-3.5 text-muted-foreground" />
                .gitignore
            </twig:Button>
        </div>
    </twig:Card:Content>
</twig:Card>

RTL

Loading...
<div class="flex w-[350px] flex-col gap-4">
    {# 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>

    {# English #}
    <twig:Collapsible class="flex flex-col gap-2" dir="ltr">
        <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>

    {# 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

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

Collapsible:Content

Block Description
content The content revealed when the collapsible is open

Collapsible:Trigger

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