- Installation
- Usage
- Using the sidebar as a page layout
- Examples
- Menu badges and actions
- Off-canvas collapsing
- API Reference
-
Sidebar -
Sidebar:Content -
Sidebar:Footer -
Sidebar:Group -
Sidebar:GroupAction -
Sidebar:GroupContent -
Sidebar:GroupLabel -
Sidebar:Header -
Sidebar:Inset -
Sidebar:Menu -
Sidebar:MenuAction -
Sidebar:MenuBadge -
Sidebar:MenuButton -
Sidebar:MenuItem -
Sidebar:MenuSkeleton -
Sidebar:MenuSub -
Sidebar:MenuSubButton -
Sidebar:MenuSubItem -
Sidebar:Provider -
Sidebar:Rail -
Sidebar:Trigger -
sidebar
Sidebar
A collapsible sidebar layout with a header, body and footer.
<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 & 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 / 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:
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}`;
}
}
{# @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 -%}
{# @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>
{# @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>
{# @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>
{# @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>
{# @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>
{# @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>
{# @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>
<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,
}) }}
>
{# @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>
{# @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>
{# @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>
{# @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>
{# @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 }}>
{# @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>
{# @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>
{# @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>
{# @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 }}>
{# @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>
{# @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>
{# @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>
<hr
data-slot="sidebar-separator"
{{ attributes.defaults({
class: 'mx-2 w-auto bg-sidebar-border'|tailwind_classes,
}) }}
>
{# @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>
{# @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>
{# @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 -%}
{# @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>
{# @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>
{# @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>
{# @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>
{# @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>
{# @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 -%}
{# @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 }}>
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');
}
}
}
}
{# @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>
{# @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 -%}
{# @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>
{# @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>
{# @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>
{# @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>
{# @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>
{# @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.
<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.
<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 On which side of the page the sidebar sits.
|
'left'|'right' |
'left' |
variant The visual variant.
|
'sidebar'|'floating'|'inset' |
'sidebar' |
collapsible How the desktop sidebar collapses.
|
'icon'|'offcanvas'|'none' |
'icon' |
label Accessible name for the sidebar landmark.
|
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 Show only when the parent
Sidebar:MenuItem is hovered or focused. |
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 The HTML tag to render; use
a when the menu item is a link. |
'button'|'a' |
'button' |
active Whether this button represents the active route.
|
boolean |
false |
size The visual size.
|
'default'|'sm'|'lg' |
'default' |
variant The visual style variant.
|
'default'|'outline' |
'default' |
tooltip Optional title shown when the sidebar is collapsed to icons.
|
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 Whether to render a placeholder icon next to the label.
|
boolean |
false |
<twig:Sidebar:MenuSub>
| Block | Description |
|---|---|
content |
One or more Sidebar:MenuSubItem. |
<twig:Sidebar:MenuSubButton>
| Prop | Type | Default |
|---|---|---|
as The HTML tag to render.
|
'a'|'button' |
'a' |
active Whether this sub menu button represents the active route.
|
boolean |
false |
size The visual 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 Unique id used to wire
Sidebar:Trigger and Sidebar:Rail to the right provider. Auto-generated when omitted. |
string |
null |
defaultOpen Initial open state when no cookie is present.
|
boolean |
true |
cookieName Cookie used to persist the sidebar state across reloads (empty disables persistence).
|
string |
'sidebar:state' |
cookieMaxAge Cookie max-age, in seconds.
|
int |
604800 |
mobileBreakpoint Pixel width below which the sidebar behaves as an off-canvas sheet.
|
int |
768 |
keyboardShortcut Single key combined with Ctrl/Cmd that toggles the sidebar; empty disables the shortcut.
|
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 Accessible label announced for the rail toggle.
|
string |
'Toggle Sidebar' |
| Block | Description |
|---|---|
content |
Optional custom content for the rail; empty by default. |
<twig:Sidebar:Trigger>
| Prop | Type | Default |
|---|---|---|
for Id of the
Sidebar:Provider to control. Defaults to the id of the surrounding provider. |
string |
null |
label Accessible label announced for the trigger.
|
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 Initial open state when no cookie is present.
|
Boolean |
true |
data-sidebar-cookie-name-value Cookie used to persist the sidebar state across reloads (empty disables persistence).
|
String |
'sidebar:state' |
data-sidebar-cookie-max-age-value Cookie max-age, in seconds.
|
Number |
60 * 60 * 24 * 7 |
data-sidebar-mobile-breakpoint-value Pixel width below which the sidebar behaves as an off-canvas sheet.
|
Number |
768 |
data-sidebar-keyboard-shortcut-value Single key combined with Ctrl/Cmd that toggles the sidebar (empty disables the shortcut).
|
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. |