View as Markdown

Sidebar

A collapsible sidebar layout with a header, body and footer.

100%
Loading...
<twig:Sidebar:Provider id="sidebar-demo" defaultOpen cookieName="" class="">
    <twig:Sidebar collapsible="icon">
        <twig:Sidebar:Header>
            <twig:Sidebar:Menu>
                <twig:Sidebar:MenuItem>
                    <twig:DropdownMenu id="sidebar-team" class="">
                        <twig:DropdownMenu:Trigger>
                            <twig:Sidebar:MenuButton {{ ...dropdown_menu_trigger_attrs }} size="lg" tooltip="Acme Inc">
                                <div class="">
                                    <twig:ux:icon name="lucide:command" class="" aria-hidden="true" />
                                </div>
                                <div class="">
                                    <span class="">Acme Inc</span>
                                    <span class="">Enterprise</span>
                                </div>
                                <twig:ux:icon name="lucide:chevrons-up-down" class="" aria-hidden="true" />
                            </twig:Sidebar:MenuButton>
                        </twig:DropdownMenu:Trigger>
                        <twig:DropdownMenu:Content class="">
                            <twig:DropdownMenu:Label>Teams</twig:DropdownMenu:Label>
                            <twig:DropdownMenu:Item>Acme Inc</twig:DropdownMenu:Item>
                            <twig:DropdownMenu:Item>Acme Corp.</twig:DropdownMenu:Item>
                            <twig:DropdownMenu:Item>Evil Corp.</twig:DropdownMenu:Item>
                            <twig:DropdownMenu:Separator />
                            <twig:DropdownMenu:Item>Add team</twig:DropdownMenu:Item>
                        </twig:DropdownMenu:Content>
                    </twig:DropdownMenu>
                </twig:Sidebar:MenuItem>
            </twig:Sidebar:Menu>
        </twig:Sidebar:Header>
        <twig:Sidebar:Content>
            <twig:Sidebar:Group>
                <twig:Sidebar:GroupLabel>Platform</twig:Sidebar:GroupLabel>
                <twig:Sidebar:GroupContent>
                    <twig:Sidebar:Menu>
                        <twig:Sidebar:MenuItem>
                            <twig:Collapsible open class="">
                                <twig:Collapsible:Trigger>
                                    <twig:Sidebar:MenuButton {{ ...collapsible_trigger_attrs }} tooltip="Playground" active>
                                        <twig:ux:icon name="lucide:square-terminal" aria-hidden="true" />
                                        <span>Playground</span>
                                        <twig:ux:icon name="lucide:chevron-right" class="" aria-hidden="true" />
                                    </twig:Sidebar:MenuButton>
                                </twig:Collapsible:Trigger>
                                <twig:Collapsible:Content>
                                    <twig:Sidebar:MenuSub>
                                        <twig:Sidebar:MenuSubItem>
                                            <twig:Sidebar:MenuSubButton href="#"><span>History</span></twig:Sidebar:MenuSubButton>
                                        </twig:Sidebar:MenuSubItem>
                                        <twig:Sidebar:MenuSubItem>
                                            <twig:Sidebar:MenuSubButton href="#"><span>Starred</span></twig:Sidebar:MenuSubButton>
                                        </twig:Sidebar:MenuSubItem>
                                        <twig:Sidebar:MenuSubItem>
                                            <twig:Sidebar:MenuSubButton href="#"><span>Settings</span></twig:Sidebar:MenuSubButton>
                                        </twig:Sidebar:MenuSubItem>
                                    </twig:Sidebar:MenuSub>
                                </twig:Collapsible:Content>
                            </twig:Collapsible>
                        </twig:Sidebar:MenuItem>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="#" tooltip="Models">
                                <twig:ux:icon name="lucide:bot" aria-hidden="true" />
                                <span>Models</span>
                            </twig:Sidebar:MenuButton>
                        </twig:Sidebar:MenuItem>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="#" tooltip="Documentation">
                                <twig:ux:icon name="lucide:book-open" aria-hidden="true" />
                                <span>Documentation</span>
                            </twig:Sidebar:MenuButton>
                        </twig:Sidebar:MenuItem>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="#" tooltip="Settings">
                                <twig:ux:icon name="lucide:settings-2" aria-hidden="true" />
                                <span>Settings</span>
                            </twig:Sidebar:MenuButton>
                        </twig:Sidebar:MenuItem>
                    </twig:Sidebar:Menu>
                </twig:Sidebar:GroupContent>
            </twig:Sidebar:Group>
            <twig:Sidebar:Group class="">
                <twig:Sidebar:GroupLabel>Projects</twig:Sidebar:GroupLabel>
                <twig:Sidebar:GroupContent>
                    <twig:Sidebar:Menu>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="#">
                                <twig:ux:icon name="lucide:frame" aria-hidden="true" />
                                <span>Design Engineering</span>
                            </twig:Sidebar:MenuButton>
                            <twig:DropdownMenu id="sidebar-project-1" side="right" align="start" class="">
                                <twig:DropdownMenu:Trigger>
                                    <twig:Sidebar:MenuAction class="" {{ ...dropdown_menu_trigger_attrs }}>
                                        <twig:ux:icon name="lucide:more-horizontal" class="" aria-hidden="true" />
                                        <span class="">More</span>
                                    </twig:Sidebar:MenuAction>
                                </twig:DropdownMenu:Trigger>
                                <twig:DropdownMenu:Content class="">
                                    <twig:DropdownMenu:Item>View project</twig:DropdownMenu:Item>
                                    <twig:DropdownMenu:Item>Share project</twig:DropdownMenu:Item>
                                    <twig:DropdownMenu:Separator />
                                    <twig:DropdownMenu:Item>Delete project</twig:DropdownMenu:Item>
                                </twig:DropdownMenu:Content>
                            </twig:DropdownMenu>
                        </twig:Sidebar:MenuItem>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="#">
                                <twig:ux:icon name="lucide:pie-chart" aria-hidden="true" />
                                <span>Sales &amp; Marketing</span>
                            </twig:Sidebar:MenuButton>
                            <twig:DropdownMenu id="sidebar-project-2" side="right" align="start" class="">
                                <twig:DropdownMenu:Trigger>
                                    <twig:Sidebar:MenuAction class="" {{ ...dropdown_menu_trigger_attrs }}>
                                        <twig:ux:icon name="lucide:more-horizontal" class="" aria-hidden="true" />
                                        <span class="">More</span>
                                    </twig:Sidebar:MenuAction>
                                </twig:DropdownMenu:Trigger>
                                <twig:DropdownMenu:Content class="">
                                    <twig:DropdownMenu:Item>View project</twig:DropdownMenu:Item>
                                    <twig:DropdownMenu:Item>Share project</twig:DropdownMenu:Item>
                                    <twig:DropdownMenu:Separator />
                                    <twig:DropdownMenu:Item>Delete project</twig:DropdownMenu:Item>
                                </twig:DropdownMenu:Content>
                            </twig:DropdownMenu>
                        </twig:Sidebar:MenuItem>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="#">
                                <twig:ux:icon name="lucide:map" aria-hidden="true" />
                                <span>Travel</span>
                            </twig:Sidebar:MenuButton>
                        </twig:Sidebar:MenuItem>
                    </twig:Sidebar:Menu>
                </twig:Sidebar:GroupContent>
            </twig:Sidebar:Group>
        </twig:Sidebar:Content>
        <twig:Sidebar:Footer>
            <twig:Sidebar:Menu>
                <twig:Sidebar:MenuItem>
                    <twig:DropdownMenu id="sidebar-account" side="top" class="">
                        <twig:DropdownMenu:Trigger>
                            <twig:Sidebar:MenuButton {{ ...dropdown_menu_trigger_attrs }} size="lg" tooltip="shadcn">
                                <div class="">
                                    <twig:ux:icon name="lucide:user" class="" aria-hidden="true" />
                                </div>
                                <div class="">
                                    <span class="">shadcn</span>
                                    <span class="">m@example.com</span>
                                </div>
                                <twig:ux:icon name="lucide:chevrons-up-down" class="" aria-hidden="true" />
                            </twig:Sidebar:MenuButton>
                        </twig:DropdownMenu:Trigger>
                        <twig:DropdownMenu:Content class="">
                            <twig:DropdownMenu:Label>My Account</twig:DropdownMenu:Label>
                            <twig:DropdownMenu:Item>Upgrade to Pro</twig:DropdownMenu:Item>
                            <twig:DropdownMenu:Separator />
                            <twig:DropdownMenu:Item>Account</twig:DropdownMenu:Item>
                            <twig:DropdownMenu:Item>Billing</twig:DropdownMenu:Item>
                            <twig:DropdownMenu:Item>Notifications</twig:DropdownMenu:Item>
                            <twig:DropdownMenu:Separator />
                            <twig:DropdownMenu:Item>Log out</twig:DropdownMenu:Item>
                        </twig:DropdownMenu:Content>
                    </twig:DropdownMenu>
                </twig:Sidebar:MenuItem>
            </twig:Sidebar:Menu>
        </twig:Sidebar:Footer>
        <twig:Sidebar:Rail />
    </twig:Sidebar>
    <twig:Sidebar:Inset>
        <header class="">
            <twig:Sidebar:Trigger class="">
                <twig:ux:icon name="lucide:panel-left" aria-hidden="true" />
            </twig:Sidebar:Trigger>
            <div class=""></div>
            <span class="">Building Your Application &nbsp;/&nbsp; Data Fetching</span>
        </header>
        <div class="">
            <div class="">
                <div class=""></div>
                <div class=""></div>
                <div class=""></div>
            </div>
            <div class=""></div>
        </div>
    </twig:Sidebar:Inset>
</twig:Sidebar:Provider>

Installation

php bin/console ux:install sidebar --kit shadcn

Install the following Composer dependencies:

composer require twig/extra-bundle twig/html-extra:^3.24.0 symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0 symfony/ux-icons

Copy the following file(s) into your app:

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

const STATE_EXPANDED = 'expanded';
const STATE_COLLAPSED = 'collapsed';

/**
 * Expand/collapse behavior for the `Sidebar` component: cookie persistence,
 * an off-canvas mode on mobile and a keyboard shortcut.
 *
 * Below the breakpoint the sidebar content is relocated into the `Sheet`
 * dialog (which brings the backdrop, slide-in, Escape and click-outside for
 * free) and moved back into the desktop layout above it.
 *
 * @value defaultOpen Initial open state when no cookie is present.
 * @value cookieName Cookie used to persist the sidebar state across reloads (empty disables persistence).
 * @value cookieMaxAge Cookie max-age, in seconds.
 * @value mobileBreakpoint Pixel width below which the sidebar behaves as an off-canvas sheet.
 * @value keyboardShortcut Single key combined with Ctrl/Cmd that toggles the sidebar (empty disables the shortcut).
 * @action toggle Toggle the sidebar between expanded and collapsed (or open/close the mobile sheet).
 * @action open Expand the sidebar (or open the mobile sheet).
 * @action close Collapse the sidebar (or close the mobile sheet).
 * @action handleKeydown Toggle the sidebar on the keyboard shortcut.
 */
export default class extends Controller {
    static values = {
        defaultOpen: { type: Boolean, default: true },
        cookieName: { type: String, default: 'sidebar:state' },
        cookieMaxAge: { type: Number, default: 60 * 60 * 24 * 7 },
        mobileBreakpoint: { type: Number, default: 768 },
        keyboardShortcut: { type: String, default: 'b' },
    };

    connect() {
        this._mobileQuery = window.matchMedia(`(max-width: ${this.mobileBreakpointValue - 1}px)`);
        this._shortcut = this.keyboardShortcutValue ? this.keyboardShortcutValue.toLowerCase() : '';
        this._sidebar = this.element.querySelector('[data-slot="sidebar"]');
        this._desktopSlot = this.element.querySelector('[data-slot="sidebar-inner"]');
        this._mobileSlot = this.element.querySelector('[data-slot="sidebar-mobile-inner"]');
        this._mobileDialog = this._mobileSlot?.closest('dialog') ?? null;

        const cookieValue = this._readCookie();
        if (STATE_EXPANDED === cookieValue || STATE_COLLAPSED === cookieValue) {
            this.element.dataset.state = cookieValue;
        } else {
            this.element.dataset.state = this.defaultOpenValue ? STATE_EXPANDED : STATE_COLLAPSED;
        }

        this._syncPortal();

        this._onMediaChange = () => {
            this._syncPortal();
            this._render();
        };
        this._mobileQuery.addEventListener('change', this._onMediaChange);

        this._onDialogClose = () => this._render();
        this._mobileDialog?.addEventListener('close', this._onDialogClose);

        this._render();
    }

    disconnect() {
        this._mobileQuery.removeEventListener('change', this._onMediaChange);
        this._mobileDialog?.removeEventListener('close', this._onDialogClose);
    }

    toggle(event) {
        event?.preventDefault?.();
        if (this._isOpen()) {
            this.close();
        } else {
            this.open();
        }
    }

    open() {
        if (this._isMobile()) {
            this._mobileDialog?.showModal();
        } else {
            this._setState(STATE_EXPANDED);
        }
        this._render();
    }

    close() {
        if (this._isMobile()) {
            this._mobileDialog?.close();
        } else {
            this._setState(STATE_COLLAPSED);
        }
        this._render();
    }

    handleKeydown(event) {
        if (!this._shortcut || event.key.toLowerCase() !== this._shortcut) {
            return;
        }
        if (!event.ctrlKey && !event.metaKey) {
            return;
        }
        const tag = event.target?.tagName;
        if ('INPUT' === tag || 'TEXTAREA' === tag || event.target?.isContentEditable) {
            return;
        }
        event.preventDefault();
        this.toggle();
    }

    _isExpanded() {
        return STATE_EXPANDED === this.element.dataset.state;
    }

    _isOpen() {
        return this._isMobile() ? (this._mobileDialog?.open ?? false) : this._isExpanded();
    }

    _setState(state) {
        if (this.element.dataset.state === state) {
            return;
        }
        this.element.dataset.state = state;
        this._writeCookie(state);
    }

    _isMobile() {
        return this._mobileQuery.matches;
    }

    _syncPortal() {
        if (!this._desktopSlot || !this._mobileSlot) {
            return;
        }
        if (this._isMobile()) {
            this._moveChildren(this._desktopSlot, this._mobileSlot);
        } else {
            if (this._mobileDialog?.open) {
                this._mobileDialog.close();
            }
            this._moveChildren(this._mobileSlot, this._desktopSlot);
        }
    }

    _moveChildren(from, to) {
        while (from.firstChild) {
            to.appendChild(from.firstChild);
        }
    }

    _render() {
        const state = this.element.dataset.state;

        const sidebar = this._sidebar;
        if (sidebar) {
            sidebar.dataset.state = state;

            // Mirror shadcn React: data-collapsible is the active mode only when collapsed.
            const mode = sidebar.dataset.collapsibleMode || 'icon';
            sidebar.dataset.collapsible = STATE_COLLAPSED === state ? mode : '';
        }

        if (this.element.id) {
            const expanded = this._isOpen();
            const triggers = document.querySelectorAll(`[data-sidebar-target="${this.element.id}"]`);
            for (const trigger of triggers) {
                trigger.setAttribute('aria-expanded', String(expanded));
            }
        }
    }

    _readCookie() {
        if (!this.cookieNameValue || 'undefined' === typeof document) {
            return null;
        }
        const prefix = `${this.cookieNameValue}=`;
        const parts = document.cookie?.split('; ') ?? [];
        for (const part of parts) {
            if (part.startsWith(prefix)) {
                return decodeURIComponent(part.substring(prefix.length));
            }
        }

        return null;
    }

    _writeCookie(value) {
        if (!this.cookieNameValue || 'undefined' === typeof document) {
            return;
        }
        document.cookie = `${this.cookieNameValue}=${encodeURIComponent(value)}; path=/; max-age=${this.cookieMaxAgeValue}`;
    }
}
templates/components/Sidebar.html.twig
{# @prop side 'left'|'right' On which side of the page the sidebar sits. #}
{# @prop variant 'sidebar'|'floating'|'inset' The visual variant. #}
{# @prop collapsible 'icon'|'offcanvas'|'none' How the desktop sidebar collapses. #}
{# @prop label string Accessible name for the sidebar landmark. #}
{# @block content Typically a `Sidebar:Header`, `Sidebar:Content`, `Sidebar:Footer` and an optional `Sidebar:Rail`. #}
{%- props side = 'left', variant = 'sidebar', collapsible = 'icon', label = 'Sidebar' -%}
{%- set _sidebar_initial_open = inject('sidebar.defaultOpen', true) -%}
{%- set _sidebar_content -%}{% block content %}{% endblock %}{%- endset -%}
{%- if 'none' == collapsible -%}
    <aside
        data-slot="sidebar"
        data-side="{{ side }}"
        data-variant="{{ variant }}"
        data-collapsible=""
        data-collapsible-mode="{{ collapsible }}"
        aria-label="{{ label }}"
        {{ attributes.defaults({
            class: 'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground'|tailwind_classes,
        }) }}
    >
        {{ _sidebar_content|raw }}
    </aside>
{%- else -%}
    {%- set _sidebar_id = inject('sidebar.id', 'sidebar') -%}
    <twig:Sheet id="{{ _sidebar_id }}-mobile" :side="side" class="md:hidden">
        <twig:Sheet:Content :showCloseButton="false" class="w-(--sidebar-width-mobile) overflow-x-hidden overflow-y-auto bg-sidebar p-0 text-sidebar-foreground">
            <twig:Sheet:Header class="sr-only">
                <twig:Sheet:Title>{{ label }}</twig:Sheet:Title>
                <twig:Sheet:Description>Displays the mobile sidebar.</twig:Sheet:Description>
            </twig:Sheet:Header>
            <div data-slot="sidebar-mobile-inner" class="flex h-full w-full flex-col"></div>
        </twig:Sheet:Content>
    </twig:Sheet>
    <div
        class="group peer hidden text-sidebar-foreground md:block{{ 'floating' == variant or 'inset' == variant ? ' p-2' }}"
        data-slot="sidebar"
        data-side="{{ side }}"
        data-variant="{{ variant }}"
        data-collapsible="{{ _sidebar_initial_open ? '' : collapsible }}"
        data-collapsible-mode="{{ collapsible }}"
        data-state="{{ _sidebar_initial_open ? 'expanded' : 'collapsed' }}"
    >
        <div
            class="{{ 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear group-data-[collapsible=offcanvas]:w-0'
                ~ ('floating' == variant or 'inset' == variant ? ' group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' : '')
                ~ ('sidebar' == variant ? ' group-data-[collapsible=icon]:w-(--sidebar-width-icon)' : '')
                ~ ('right' == side ? ' rotate-180' : '') }}"
            data-slot="sidebar-gap"
            aria-hidden="true"
        ></div>
        <aside
            data-slot="sidebar-container"
            aria-label="{{ label }}"
            {{ attributes.defaults({
                class: ('fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex'
                    ~ ('left' == side
                        ? ' left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
                        : ' right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]'
                    )
                    ~ ('floating' == variant or 'inset' == variant ? ' p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' : '')
                    ~ ('sidebar' == variant ? ' group-data-[collapsible=icon]:w-(--sidebar-width-icon)' : '')
                    ~ ('sidebar' == variant and 'left' == side ? ' border-r border-sidebar-border' : '')
                    ~ ('sidebar' == variant and 'right' == side ? ' border-l border-sidebar-border' : '')
                )|tailwind_classes,
            }) }}
        >
            <div
                class="{{ 'flex h-full w-full flex-col bg-sidebar group-data-[collapsible=icon]:not-has-[[data-slot=dropdown-menu][data-state=open]]:overflow-hidden'
                    ~ ('floating' == variant ? ' rounded-lg border border-sidebar-border shadow-sm' : '') }}"
                data-slot="sidebar-inner"
            >
                {{ _sidebar_content|raw }}
            </div>
        </aside>
    </div>
{%- endif -%}
templates/components/Sidebar/Content.html.twig
{# @block content The scrollable sidebar body, typically one or more `Sidebar:Group`. #}
<div
    data-slot="sidebar-content"
    {{ attributes.defaults({
        class: 'flex min-h-0 flex-1 flex-col gap-2 group-data-[collapsible=icon]:overflow-hidden'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Sidebar/Footer.html.twig
{# @block content The sidebar footer content. #}
<div
    data-slot="sidebar-footer"
    {{ attributes.defaults({
        class: 'flex flex-col gap-2 p-2'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Sidebar/Group.html.twig
{# @block content The group content, typically a `Sidebar:GroupLabel` and a `Sidebar:GroupContent`. #}
<div
    data-slot="sidebar-group"
    {{ attributes.defaults({
        class: 'relative flex w-full min-w-0 flex-col p-2'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Sidebar/GroupAction.html.twig
{# @block content The action's icon or label. #}
<button
    data-slot="sidebar-group-action"
    {{ attributes.defaults({
        type: 'button',
        class: 'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 group-data-[collapsible=icon]:hidden'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</button>
templates/components/Sidebar/GroupContent.html.twig
{# @block content The group body, typically a `Sidebar:Menu`. #}
<div
    data-slot="sidebar-group-content"
    {{ attributes.defaults({
        class: 'w-full text-sm'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Sidebar/GroupLabel.html.twig
{# @block content The group label text. #}
<div
    data-slot="sidebar-group-label"
    {{ attributes.defaults({
        class: 'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Sidebar/Header.html.twig
{# @block content The sidebar header content. #}
<div
    data-slot="sidebar-header"
    {{ attributes.defaults({
        class: 'flex flex-col gap-2 p-2'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Sidebar/Input.html.twig
<input
    data-slot="sidebar-input"
    {{ attributes.defaults({
        type: 'text',
        class: 'h-8 w-full min-w-0 rounded-md border border-input bg-background px-2.5 py-1 text-base shadow-none transition-colors outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm'|tailwind_classes,
    }) }}
>
templates/components/Sidebar/Inset.html.twig
{# @block content The page content placed next to the sidebar (typically a header with a `Sidebar:Trigger`, then the page body). #}
<main
    data-slot="sidebar-inset"
    {{ attributes.defaults({
        class: 'relative flex w-full min-w-0 flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</main>
templates/components/Sidebar/Menu.html.twig
{# @block content One or more `Sidebar:MenuItem`. #}
<ul
    data-slot="sidebar-menu"
    {{ attributes.defaults({
        class: 'flex w-full min-w-0 flex-col gap-1'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</ul>
templates/components/Sidebar/MenuAction.html.twig
{# @prop showOnHover boolean Show only when the parent `Sidebar:MenuItem` is hovered or focused. #}
{# @block content The action's icon or label. #}
{%- props showOnHover = false -%}
<button
    data-slot="sidebar-menu-action"
    {{ attributes.defaults({
        type: 'button',
        class: ('absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 group-data-[collapsible=icon]:hidden'
            ~ (showOnHover ? ' group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0' : '')
        )|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</button>
templates/components/Sidebar/MenuBadge.html.twig
{# @block content The badge content (typically a count or short label). #}
<div
    data-slot="sidebar-menu-badge"
    {{ attributes.defaults({
        class: 'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 group-data-[collapsible=icon]:hidden'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Sidebar/MenuButton.html.twig
{# @prop as 'button'|'a' The HTML tag to render; use `a` when the menu item is a link. #}
{# @prop active boolean Whether this button represents the active route. #}
{# @prop size 'default'|'sm'|'lg' The visual size. #}
{# @prop variant 'default'|'outline' The visual style variant. #}
{# @prop tooltip string Optional title shown when the sidebar is collapsed to icons. #}
{# @block content The label and/or icon. #}
{%- props as = 'button', active = false, size = 'default', variant = 'default', tooltip = null -%}
{%- set style = html_cva(
    base: 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-none ring-sidebar-ring transition-[width,height,padding] focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-slot=sidebar-menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:[&>:not(:first-child)]:hidden [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
    variants: {
        variant: {
            default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
            outline: 'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]',
        },
        size: {
            default: 'h-8 text-sm',
            sm: 'h-7 text-xs',
            lg: 'h-12 text-sm',
        },
    },
) -%}
<{{ as }}
    data-slot="sidebar-menu-button"
    data-size="{{ size }}"
    data-active="{{ active ? 'true' : 'false' }}"
    {% if 'button' == as %}type="button"{% endif %}
    {% if tooltip %}title="{{ tooltip }}"{% endif %}
    {% if active %}aria-current="page"{% endif %}
    {{ attributes.defaults({
        class: style.apply({variant: variant, size: size})|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</{{ as }}>
templates/components/Sidebar/MenuItem.html.twig
{# @block content A `Sidebar:MenuButton` and optionally a `Sidebar:MenuAction`, `Sidebar:MenuBadge` or `Sidebar:MenuSub`. #}
<li
    data-slot="sidebar-menu-item"
    {{ attributes.defaults({
        class: 'group/menu-item relative'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</li>
templates/components/Sidebar/MenuSkeleton.html.twig
{# @prop showIcon boolean Whether to render a placeholder icon next to the label. #}
{%- props showIcon = false -%}
{%- set _skeleton_width = 50 + random(40) -%}
<div
    data-slot="sidebar-menu-skeleton"
    {{ attributes.defaults({
        class: 'flex h-8 items-center gap-2 rounded-md px-2'|tailwind_classes,
    }) }}
>
    {%- if showIcon -%}
        <div class="size-4 animate-pulse rounded-md bg-sidebar-accent/50"></div>
    {%- endif -%}
    <div class="h-4 max-w-(--skeleton-width) flex-1 animate-pulse rounded-md bg-sidebar-accent/50" style="--skeleton-width: {{ _skeleton_width }}%;"></div>
</div>
templates/components/Sidebar/MenuSub.html.twig
{# @block content One or more `Sidebar:MenuSubItem`. #}
<ul
    data-slot="sidebar-menu-sub"
    {{ attributes.defaults({
        class: 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</ul>
templates/components/Sidebar/MenuSubButton.html.twig
{# @prop as 'a'|'button' The HTML tag to render. #}
{# @prop active boolean Whether this sub menu button represents the active route. #}
{# @prop size 'sm'|'md' The visual size. #}
{# @block content The label and/or icon. #}
{%- props as = 'a', active = false, size = 'md' -%}
{%- set style = html_cva(
    base: 'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden',
    variants: {
        size: {
            sm: 'text-xs',
            md: 'text-sm',
        },
    },
) -%}
<{{ as }}
    data-slot="sidebar-menu-sub-button"
    data-size="{{ size }}"
    data-active="{{ active ? 'true' : 'false' }}"
    {% if 'button' == as %}type="button"{% endif %}
    {% if active %}aria-current="page"{% endif %}
    {{ attributes.defaults({
        class: style.apply({size: size})|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</{{ as }}>
templates/components/Sidebar/MenuSubItem.html.twig
{# @block content A `Sidebar:MenuSubButton`. #}
<li
    data-slot="sidebar-menu-sub-item"
    {{ attributes.defaults({
        class: 'group/menu-sub-item relative'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</li>
templates/components/Sidebar/Provider.html.twig
{# @prop id string Unique id used to wire `Sidebar:Trigger` and `Sidebar:Rail` to the right provider. Auto-generated when omitted. #}
{# @prop defaultOpen boolean Initial open state when no cookie is present. #}
{# @prop cookieName string Cookie used to persist the sidebar state across reloads (empty disables persistence). #}
{# @prop cookieMaxAge int Cookie max-age, in seconds. #}
{# @prop mobileBreakpoint int Pixel width below which the sidebar behaves as an off-canvas sheet. #}
{# @prop keyboardShortcut string Single key combined with Ctrl/Cmd that toggles the sidebar; empty disables the shortcut. #}
{# @block content Typically a `Sidebar` and a `Sidebar:Inset` (with one or more `Sidebar:Trigger` inside). #}
{%- props
    id = null,
    defaultOpen = true,
    cookieName = 'sidebar:state',
    cookieMaxAge = 604800,
    mobileBreakpoint = 768,
    keyboardShortcut = 'b'
-%}
{%- set _sidebar_provider_id = id ?? ('sidebar-provider-' ~ random()) -%}
{%- do provide('sidebar.id', _sidebar_provider_id) -%}
{%- do provide('sidebar.defaultOpen', defaultOpen) -%}
<div
    id="{{ _sidebar_provider_id }}"
    data-slot="sidebar-provider"
    data-sidebar-default-open-value="{{ defaultOpen ? 'true' : 'false' }}"
    data-sidebar-cookie-name-value="{{ cookieName }}"
    data-sidebar-cookie-max-age-value="{{ cookieMaxAge }}"
    data-sidebar-mobile-breakpoint-value="{{ mobileBreakpoint }}"
    data-sidebar-keyboard-shortcut-value="{{ keyboardShortcut }}"
    data-state="{{ defaultOpen ? 'expanded' : 'collapsed' }}"
    {{ attributes.defaults({
        class: 'group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar'|tailwind_classes,
        'data-controller': 'sidebar',
        'data-action': 'keydown@window->sidebar#handleKeydown',
        style: '--sidebar-width: 16rem; --sidebar-width-icon: 3rem; --sidebar-width-mobile: 18rem;',
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Sidebar/Rail.html.twig
{# @prop label string Accessible label announced for the rail toggle. #}
{# @block content Optional custom content for the rail; empty by default. #}
{%- props label = 'Toggle Sidebar' -%}
<button
    data-slot="sidebar-rail"
    aria-label="{{ label }}"
    title="{{ label }}"
    tabindex="-1"
    {{ attributes.defaults({
        type: 'button',
        class: 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear sm:flex after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 [[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize [[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar [[data-side=left][data-collapsible=offcanvas]_&]:-right-2 [[data-side=right][data-collapsible=offcanvas]_&]:-left-2'|tailwind_classes,
        'data-action': 'click->sidebar#toggle',
    }) }}
>
    {%- block content %}{% endblock -%}
</button>
templates/components/Sidebar/Separator.html.twig
<hr
    data-slot="sidebar-separator"
    {{ attributes.defaults({
        class: 'mx-2 w-auto bg-sidebar-border'|tailwind_classes,
    }) }}
>
templates/components/Sidebar/Trigger.html.twig
{# @prop for string Id of the `Sidebar:Provider` to control. Defaults to the id of the surrounding provider. #}
{# @prop label string Accessible label announced for the trigger. #}
{# @block content The trigger's icon (typically a `lucide:panel-left`). #}
{%- props for = null, label = 'Toggle Sidebar' -%}
{%- set _sidebar_for = for ?? inject('sidebar.id', null) -%}
{%- set _sidebar_initial_open = inject('sidebar.defaultOpen', true) -%}
<button
    data-slot="sidebar-trigger"
    aria-label="{{ label }}"
    aria-expanded="{{ _sidebar_initial_open ? 'true' : 'false' }}"
    {% if _sidebar_for %}data-sidebar-target="{{ _sidebar_for }}" aria-controls="{{ _sidebar_for }}"{% endif %}
    {{ attributes.defaults({
        type: 'button',
        class: 'inline-flex size-7 shrink-0 items-center justify-center rounded-md outline-none ring-sidebar-ring transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0'|tailwind_classes,
        'data-action': 'click->sidebar#toggle',
    }) }}
>
    {%- block content %}{% endblock -%}
</button>
templates/components/Sheet.html.twig
{# @prop id string Unique identifier used to generate internal Sheet IDs. #}
{# @prop side 'top'|'right'|'bottom'|'left' Which edge the sheet slides in from. #}
{# @prop open boolean Whether the sheet is open on initial render. #}
{# @block content The sheet structure, typically includes `Sheet:Trigger` and `Sheet:Content`. #}
{%- props id, side = 'right', open = false -%}

{%- set _sheet_id = 'sheet-' ~ id -%}
{%- set _sheet_title_id = _sheet_id ~ '-title' -%}
{%- set _sheet_description_id = _sheet_id ~ '-description' -%}
{%- do provide('sheet.id', _sheet_id) -%}
{%- do provide('sheet.side', side) -%}
{%- do provide('sheet.titleId', _sheet_title_id) -%}
{%- do provide('sheet.descriptionId', _sheet_description_id) -%}
<div
    data-slot="sheet"
    data-dialog-open-value="{{ open ? 'true' : 'false' }}"
    aria-labelledby="{{ _sheet_title_id }}"
    aria-describedby="{{ _sheet_description_id }}"
    {{ attributes.defaults({
        'data-controller': 'dialog',
    }) }}
>
    {% block content %}{% endblock %}
</div>
templates/components/Sheet/Close.html.twig
{# @block content The close trigger element (e.g., a `Button`) that closes the sheet when clicked. #}
{%- set sheet_close_attrs = {
    'data-slot': 'sheet-close',
    'data-action': 'click->dialog#close'|html_attr_type('sst'),
} -%}
{%- block content %}{% endblock -%}
templates/components/Sheet/Content.html.twig
{# @prop showCloseButton boolean Whether to display the close button in the top-right corner. #}
{# @block content The sheet content, typically includes `Sheet:Header` and optionally `Sheet:Footer`. #}
{%- props showCloseButton = true -%}
{%- set _sheet_id = inject('sheet.id') -%}
{%- set _sheet_side = inject('sheet.side', 'right') -%}
{%- set style = html_cva(
    base: 'fixed z-50 m-0 flex max-h-none max-w-none flex-col gap-4 bg-background p-0 shadow-lg outline-none transition duration-300 ease-in-out transition-discrete backdrop:bg-black/50 backdrop:opacity-0 backdrop:transition-[opacity,display,overlay] backdrop:duration-300 backdrop:ease-in-out backdrop:transition-discrete open:backdrop:opacity-100 starting:open:backdrop:opacity-0',
    variants: {
        side: {
            right: 'inset-y-0 right-0 left-auto h-full w-3/4 border-l translate-x-full open:translate-x-0 starting:open:translate-x-full sm:max-w-sm',
            left: 'inset-y-0 left-0 right-auto h-full w-3/4 border-r -translate-x-full open:translate-x-0 starting:open:-translate-x-full sm:max-w-sm',
            top: 'inset-x-0 top-0 bottom-auto h-auto border-b -translate-y-full open:translate-y-0 starting:open:-translate-y-full',
            bottom: 'inset-x-0 bottom-0 top-auto h-auto border-t translate-y-full open:translate-y-0 starting:open:translate-y-full',
        },
    },
) -%}
<dialog
    id="{{ _sheet_id }}"
    data-slot="sheet-content"
    data-dialog-target="dialog"
    data-side="{{ _sheet_side }}"
    {{ attributes.defaults({
        class: style.apply({side: _sheet_side})|tailwind_classes,
        'data-action': 'keydown.esc->dialog#close:prevent click->dialog#closeOnClickOutside',
    }) }}
>
    {%- block content %}{% endblock -%}
    {% if showCloseButton %}
        <twig:Button
            type="button"
            variant="ghost"
            size="icon-sm"
            class="absolute top-4 ltr:right-4 rtl:end-4"
            data-slot="sheet-close"
            data-action="click->dialog#close"
        >
            <twig:ux:icon name="lucide:x" />
            <span class="sr-only">Close</span>
        </twig:Button>
    {% endif %}
</dialog>
templates/components/Sheet/Description.html.twig
{# @block content The descriptive text explaining the sheet purpose. #}
{%- set _sheet_descriptionId = inject('sheet.descriptionId') -%}
<p
    id="{{ _sheet_descriptionId }}"
    data-slot="sheet-description"
    {{ attributes.without('id').defaults({class: 'text-muted-foreground text-sm'|tailwind_classes}) }}
>
    {%- block content %}{% endblock -%}
</p>
templates/components/Sheet/Footer.html.twig
{# @block content The footer area, typically contains action buttons. #}
<div
    data-slot="sheet-footer"
    {{ attributes.defaults({
        class: 'mt-auto flex flex-col gap-2 p-4'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Sheet/Header.html.twig
{# @block content The header area, typically contains `Sheet:Title` and `Sheet:Description`. #}
<div
    data-slot="sheet-header"
    {{ attributes.defaults({
        class: 'flex flex-col gap-1.5 p-4'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Sheet/Title.html.twig
{# @block content The title text of the sheet. #}
{%- set _sheet_titleId = inject('sheet.titleId') -%}
<h2
    id="{{ _sheet_titleId }}"
    data-slot="sheet-title"
    {{ attributes.without('id').defaults({class: 'font-semibold text-foreground'|tailwind_classes}) }}
>
    {%- block content %}{% endblock -%}
</h2>
templates/components/Sheet/Trigger.html.twig
{# @block content The trigger element (e.g., a `Button`) that opens the sheet when clicked. #}
{%- set sheet_trigger_attrs = {
    'data-slot': 'sheet-trigger',
    'data-action': 'click->dialog#open'|html_attr_type('sst'),
    'data-dialog-target': 'trigger',
    'aria-haspopup': 'dialog',
} -%}
{%- block content %}{% endblock -%}
templates/components/Button.html.twig
{# @prop variant 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant. #}
{# @prop size 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size. #}
{# @prop as 'button' The HTML tag to render. #}
{# @block content The button label and/or icon. #}
{%- props variant = 'default', size = 'default', as = 'button' -%}
{%- set style = html_cva(
    base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
    variants: {
        variant: {
            default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
            outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
            secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
            ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
            destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
            link: 'text-primary underline-offset-4 hover:underline',
        },
        size: {
            default: 'h-8 gap-1.5 px-2.5 ltr:has-data-[icon=inline-end]:pr-2 rtl:has-data-[icon=inline-end]:pe-2 ltr:has-data-[icon=inline-start]:pl-2 rtl:has-data-[icon=inline-start]:ps-2',
            xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg ltr:has-data-[icon=inline-end]:pr-1.5 rtl:has-data-[icon=inline-end]:pe-1.5 ltr:has-data-[icon=inline-start]:pl-1.5 rtl:has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
            sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg ltr:has-data-[icon=inline-end]:pr-1.5 rtl:has-data-[icon=inline-end]:pe-1.5 ltr:has-data-[icon=inline-start]:pl-1.5 rtl:has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
            lg: 'h-9 gap-1.5 px-2.5 ltr:has-data-[icon=inline-end]:pr-2 rtl:has-data-[icon=inline-end]:pe-2 ltr:has-data-[icon=inline-start]:pl-2 rtl:has-data-[icon=inline-start]:ps-2',
            icon: 'size-8',
            'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
            'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
            'icon-lg': 'size-9',
        },
    },
) -%}
<{{ as }}
    data-slot="button"
    data-size="{{ size }}"
    data-variant="{{ variant }}"
    {{ attributes.defaults({
        class: style.apply({variant: variant, size: size})|tailwind_classes,
        type: 'button',
    }) }}
>
    {%- block content %}{% endblock -%}
</{{ as }}>
assets/controllers/dialog_controller.js
import { Controller } from '@hotwired/stimulus';

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

    static values = {
        open: Boolean,
    };

    connect() {
        if (this.openValue) {
            this.open();
        }
    }

    open() {
        this.dialogTarget.showModal();
        this._focusInitialElement();

        if (this.hasTriggerTarget) {
            if (this.dialogTarget.getAnimations().length > 0) {
                this.dialogTarget.addEventListener(
                    'transitionend',
                    () => {
                        this.triggerTarget.setAttribute('aria-expanded', 'true');
                    },
                    { once: true }
                );
            } else {
                this.triggerTarget.setAttribute('aria-expanded', 'true');
            }
        }
    }

    closeOnClickOutside({ target }) {
        if (target === this.dialogTarget) {
            this.close();
        }
    }

    _focusInitialElement() {
        // showModal() already focuses an [autofocus] target or the first focusable element;
        // when the author did not opt into autofocus, prefer the first form field instead.
        if (this.dialogTarget.querySelector('[autofocus]')) {
            return;
        }

        const field = this.dialogTarget.querySelector(
            'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
        );
        field?.focus();
    }

    close() {
        this.dialogTarget.close();

        if (this.hasTriggerTarget) {
            if (this.dialogTarget.getAnimations().length > 0) {
                this.dialogTarget.addEventListener('transitionend', () => {
                    this.triggerTarget.setAttribute('aria-expanded', 'false');
                });
            } else {
                this.triggerTarget.setAttribute('aria-expanded', 'false');
            }
        }
    }
}
templates/components/Dialog.html.twig
{# @prop id string Unique identifier used to generate internal Dialog IDs. #}
{# @prop open boolean Whether the dialog is open on initial render. #}
{# @block content The dialog structure, typically includes `Dialog:Trigger` and `Dialog:Content`. #}
{%- props id, open = false -%}

{%- set _dialog_id = 'dialog-' ~ id -%}
{%- set _dialog_title_id = _dialog_id ~ '-title' -%}
{%- set _dialog_description_id = _dialog_id ~ '-description' -%}
{%- do provide('dialog.id', _dialog_id) -%}
{%- do provide('dialog.titleId', _dialog_title_id) -%}
{%- do provide('dialog.descriptionId', _dialog_description_id) -%}
<div
    data-slot="dialog"
    data-dialog-open-value="{{ open ? 'true' : 'false' }}"
    aria-labelledby="{{ _dialog_title_id }}"
    aria-describedby="{{ _dialog_description_id }}"
    {{ attributes.defaults({
        'data-controller': 'dialog',
    }) }}
>
    {% block content %}{% endblock %}
</div>
templates/components/Dialog/Close.html.twig
{# @block content The close trigger element (e.g., a `Button`) that closes the dialog when clicked. #}
{%- set dialog_close_attrs = {
    'data-slot': 'dialog-close',
    'data-action': 'click->dialog#close'|html_attr_type('sst'),
} -%}
{%- block content %}{% endblock -%}
templates/components/Dialog/Content.html.twig
{# @prop showCloseButton boolean Whether to display the close button in the top-right corner. #}
{# @block content The dialog content, typically includes `Dialog:Header` and optionally `Dialog:Footer`. #}
{%- props showCloseButton = true -%}
{%- set _dialog_id = inject('dialog.id') -%}

<dialog
    id="{{ _dialog_id }}"
    data-slot="dialog-content"
    data-dialog-target="dialog"
    {{ attributes.defaults({
        class: 'fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 outline-none sm:max-w-sm opacity-0 scale-95 transition-all transition-discrete duration-100 backdrop:transition-discrete backdrop:duration-100 open:grid open:scale-100 open:opacity-100 open:backdrop:bg-black/10 supports-backdrop-filter:open:backdrop:backdrop-blur-xs starting:open:scale-95 starting:open:opacity-0'|tailwind_classes,
        'data-action': 'keydown.esc->dialog#close:prevent click->dialog#closeOnClickOutside',
    }) }}
>
    {%- block content %}{% endblock -%}
    {% if showCloseButton %}
        <twig:Button
            type="button"
            variant="ghost"
            size="icon-sm"
            class="absolute top-2 ltr:right-2 rtl:end-2"
            data-slot="dialog-close"
            data-action="click->dialog#close"
        >
            <twig:ux:icon name="lucide:x" />
            <span class="sr-only">Close</span>
        </twig:Button>
    {% endif %}
</dialog>
templates/components/Dialog/Description.html.twig
{# @block content The descriptive text explaining the dialog purpose. #}
{%- set _dialog_descriptionId = inject('dialog.descriptionId') -%}
<p
    id="{{ _dialog_descriptionId }}"
    data-slot="dialog-description"
    {{ attributes.without('id').defaults({class: 'text-muted-foreground text-sm *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground'|tailwind_classes}) }}
>
    {%- block content %}{% endblock -%}
</p>
templates/components/Dialog/Footer.html.twig
{# @block content The footer area, typically contains action buttons. #}
<footer
    data-slot="dialog-footer"
    {{ attributes.defaults({
        class: '-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</footer>
templates/components/Dialog/Header.html.twig
{# @block content The header area, typically contains `Dialog:Title` and `Dialog:Description`. #}
<header
    data-slot="dialog-header"
    {{ attributes.defaults({
        class: 'flex flex-col gap-2'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</header>
templates/components/Dialog/Title.html.twig
{# @block content The title text of the dialog. #}
{%- set _dialog_titleId = inject('dialog.titleId') -%}
<h2
    id="{{ _dialog_titleId }}"
    data-slot="dialog-title"
    {{ attributes.without('id').defaults({class: 'cn-font-heading text-base leading-none font-medium'|tailwind_classes}) }}
>
    {%- block content %}{% endblock -%}
</h2>
templates/components/Dialog/Trigger.html.twig
{# @block content The trigger element (e.g., a `Button`) that opens the dialog when clicked. #}
{%- set dialog_trigger_attrs = {
    'data-slot': 'dialog-trigger',
    'data-action': 'click->dialog#open'|html_attr_type('sst'),
    'data-dialog-target': 'trigger',
    'aria-haspopup': 'dialog',
} -%}
{%- block content %}{% endblock -%}

Usage

Wrap your layout in a Sidebar:Provider, place the Sidebar itself next to a Sidebar:Inset holding the page content, and add a Sidebar:Trigger wherever you want to toggle it.

<twig:Sidebar:Provider id="my-sidebar">
    <twig:Sidebar>
        <twig:Sidebar:Header>
            <span class="px-2 font-semibold">My App</span>
        </twig:Sidebar:Header>
        <twig:Sidebar:Content>
            <twig:Sidebar:Group>
                <twig:Sidebar:GroupLabel>Application</twig:Sidebar:GroupLabel>
                <twig:Sidebar:GroupContent>
                    <twig:Sidebar:Menu>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="/dashboard" active>
                                <twig:ux:icon name="lucide:layout-dashboard" aria-hidden="true" />
                                <span>Dashboard</span>
                            </twig:Sidebar:MenuButton>
                        </twig:Sidebar:MenuItem>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="/projects">
                                <twig:ux:icon name="lucide:folder" aria-hidden="true" />
                                <span>Projects</span>
                            </twig:Sidebar:MenuButton>
                        </twig:Sidebar:MenuItem>
                    </twig:Sidebar:Menu>
                </twig:Sidebar:GroupContent>
            </twig:Sidebar:Group>
        </twig:Sidebar:Content>
        <twig:Sidebar:Rail />
    </twig:Sidebar>
    <twig:Sidebar:Inset>
        <header class="flex h-14 items-center gap-2 border-b px-4">
            <twig:Sidebar:Trigger>
                <twig:ux:icon name="lucide:panel-left" aria-hidden="true" />
            </twig:Sidebar:Trigger>
        </header>
        <main class="flex-1 p-4">
            {# your page content #}
        </main>
    </twig:Sidebar:Inset>
</twig:Sidebar:Provider>

Using the sidebar as a page layout

Sidebar:Inset wraps the page content, so you will typically want it in your base layout. Blocks defined inside a component tag belong to that component (and content is already the name of its default block), so a child template cannot override them through {% extends %}. Wrap the sidebar in a small layout component instead: capture its content block in a variable, render it inside Sidebar:Inset, and let your pages fill it:

{# templates/components/AppLayout.html.twig #}
{% set page_content %}{% block content %}{% endblock %}{% endset %}
<twig:Sidebar:Provider>
    <twig:Sidebar>
        {# ... #}
    </twig:Sidebar>
    <twig:Sidebar:Inset>
        <header class="flex h-14 items-center gap-2 border-b px-4">
            <twig:Sidebar:Trigger>
                <twig:ux:icon name="lucide:panel-left" aria-hidden="true" />
            </twig:Sidebar:Trigger>
        </header>
        {{ page_content|raw }}
    </twig:Sidebar:Inset>
</twig:Sidebar:Provider>
{# templates/dashboard.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
    <twig:AppLayout>
        <h1>Dashboard</h1>
    </twig:AppLayout>
{% endblock %}

Examples

Menu badges and actions

Use Sidebar:MenuBadge for counters and Sidebar:MenuAction for a secondary action on a menu item; render menu items as links with as="a" on Sidebar:MenuButton.

100%
Loading...
<twig:Sidebar:Provider id="sidebar-menu-example" cookieName="" class="min-h-[320px] w-full">
    <twig:Sidebar collapsible="none" class="rounded-lg border border-sidebar-border">
        <twig:Sidebar:Content>
            <twig:Sidebar:Group>
                <twig:Sidebar:GroupLabel>Mailbox</twig:Sidebar:GroupLabel>
                <twig:Sidebar:GroupContent>
                    <twig:Sidebar:Menu>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="#" active>
                                <twig:ux:icon name="lucide:inbox" aria-hidden="true" />
                                <span>Inbox</span>
                            </twig:Sidebar:MenuButton>
                            <twig:Sidebar:MenuBadge>24</twig:Sidebar:MenuBadge>
                        </twig:Sidebar:MenuItem>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="#">
                                <twig:ux:icon name="lucide:star" aria-hidden="true" />
                                <span>Starred</span>
                            </twig:Sidebar:MenuButton>
                            <twig:Sidebar:MenuAction showOnHover>
                                <twig:ux:icon name="lucide:plus" aria-hidden="true" />
                                <span class="sr-only">Add</span>
                            </twig:Sidebar:MenuAction>
                        </twig:Sidebar:MenuItem>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="#">
                                <twig:ux:icon name="lucide:mail" aria-hidden="true" />
                                <span>Drafts</span>
                            </twig:Sidebar:MenuButton>
                            <twig:Sidebar:MenuBadge>3</twig:Sidebar:MenuBadge>
                        </twig:Sidebar:MenuItem>
                    </twig:Sidebar:Menu>
                </twig:Sidebar:GroupContent>
            </twig:Sidebar:Group>
        </twig:Sidebar:Content>
    </twig:Sidebar>
</twig:Sidebar:Provider>

Off-canvas collapsing

Use collapsible="offcanvas" to fully hide the sidebar when collapsed. The Sidebar:Trigger automatically targets the surrounding Sidebar:Provider.

100%
Loading...
<twig:Sidebar:Provider id="sidebar-offcanvas-example" cookieName="" class="min-h-[320px] w-full">
    <twig:Sidebar collapsible="offcanvas">
        <twig:Sidebar:Header>
            <span class="px-2 font-semibold">My App</span>
        </twig:Sidebar:Header>
        <twig:Sidebar:Content>
            <twig:Sidebar:Group>
                <twig:Sidebar:GroupContent>
                    <twig:Sidebar:Menu>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="#" active>
                                <twig:ux:icon name="lucide:inbox" aria-hidden="true" />
                                <span>Inbox</span>
                            </twig:Sidebar:MenuButton>
                        </twig:Sidebar:MenuItem>
                        <twig:Sidebar:MenuItem>
                            <twig:Sidebar:MenuButton as="a" href="#">
                                <twig:ux:icon name="lucide:search" aria-hidden="true" />
                                <span>Search</span>
                            </twig:Sidebar:MenuButton>
                        </twig:Sidebar:MenuItem>
                    </twig:Sidebar:Menu>
                </twig:Sidebar:GroupContent>
            </twig:Sidebar:Group>
        </twig:Sidebar:Content>
        <twig:Sidebar:Rail />
    </twig:Sidebar>
    <twig:Sidebar:Inset>
        <header class="flex h-14 items-center gap-2 border-b px-4">
            <twig:Sidebar:Trigger>
                <twig:ux:icon name="lucide:panel-left" aria-hidden="true" />
            </twig:Sidebar:Trigger>
        </header>
    </twig:Sidebar:Inset>
</twig:Sidebar:Provider>

API Reference

<twig:Sidebar>

Prop Type Default
side 
'left'|'right' 'left'
variant 
'sidebar'|'floating'|'inset' 'sidebar'
collapsible 
'icon'|'offcanvas'|'none' 'icon'
label 
string 'Sidebar'
Block Description
content Typically a Sidebar:Header, Sidebar:Content, Sidebar:Footer and an optional Sidebar:Rail.

<twig:Sidebar:Content>

Block Description
content The scrollable sidebar body, typically one or more Sidebar:Group.

<twig:Sidebar:Footer>

Block Description
content The sidebar footer content.

<twig:Sidebar:Group>

Block Description
content The group content, typically a Sidebar:GroupLabel and a Sidebar:GroupContent.

<twig:Sidebar:GroupAction>

Block Description
content The action's icon or label.

<twig:Sidebar:GroupContent>

Block Description
content The group body, typically a Sidebar:Menu.

<twig:Sidebar:GroupLabel>

Block Description
content The group label text.

<twig:Sidebar:Header>

Block Description
content The sidebar header content.

<twig:Sidebar:Inset>

Block Description
content The page content placed next to the sidebar (typically a header with a Sidebar:Trigger, then the page body).

<twig:Sidebar:Menu>

Block Description
content One or more Sidebar:MenuItem.

<twig:Sidebar:MenuAction>

Prop Type Default
showOnHover 
boolean false
Block Description
content The action's icon or label.

<twig:Sidebar:MenuBadge>

Block Description
content The badge content (typically a count or short label).

<twig:Sidebar:MenuButton>

Prop Type Default
as 
'button'|'a' 'button'
active 
boolean false
size 
'default'|'sm'|'lg' 'default'
variant 
'default'|'outline' 'default'
tooltip 
string null
Block Description
content The label and/or icon.

<twig:Sidebar:MenuItem>

Block Description
content A Sidebar:MenuButton and optionally a Sidebar:MenuAction, Sidebar:MenuBadge or Sidebar:MenuSub.

<twig:Sidebar:MenuSkeleton>

Prop Type Default
showIcon 
boolean false

<twig:Sidebar:MenuSub>

Block Description
content One or more Sidebar:MenuSubItem.

<twig:Sidebar:MenuSubButton>

Prop Type Default
as 
'a'|'button' 'a'
active 
boolean false
size 
'sm'|'md' 'md'
Block Description
content The label and/or icon.

<twig:Sidebar:MenuSubItem>

Block Description
content A Sidebar:MenuSubButton.

<twig:Sidebar:Provider>

Prop Type Default
id 
string null
defaultOpen 
boolean true
cookieName 
string 'sidebar:state'
cookieMaxAge 
int 604800
mobileBreakpoint 
int 768
keyboardShortcut 
string 'b'
Block Description
content Typically a Sidebar and a Sidebar:Inset (with one or more Sidebar:Trigger inside).

<twig:Sidebar:Rail>

Prop Type Default
label 
string 'Toggle Sidebar'
Block Description
content Optional custom content for the rail; empty by default.

<twig:Sidebar:Trigger>

Prop Type Default
for 
string null
label 
string 'Toggle Sidebar'
Block Description
content The trigger's icon (typically a lucide:panel-left).

data-controller="sidebar"

Value Type Default
data-sidebar-default-open-value 
Boolean true
data-sidebar-cookie-name-value 
String 'sidebar:state'
data-sidebar-cookie-max-age-value 
Number 60 * 60 * 24 * 7
data-sidebar-mobile-breakpoint-value 
Number 768
data-sidebar-keyboard-shortcut-value 
String 'b'
Action Description
toggle Toggle the sidebar between expanded and collapsed (or open/close the mobile sheet).
open Expand the sidebar (or open the mobile sheet).
close Collapse the sidebar (or close the mobile sheet).
handleKeydown Toggle the sidebar on the keyboard shortcut.