View as Markdown

Sheet

Extends the Dialog component to display content that complements the main content of the screen.

100%
Loading...
<div style="min-height: 480px">
    <twig:Sheet id="sheet-demo">
        <twig:Sheet:Trigger>
            <twig:Button variant="outline" {{ ...sheet_trigger_attrs }}>Open</twig:Button>
        </twig:Sheet:Trigger>
        <twig:Sheet:Content>
            <twig:Sheet:Header>
                <twig:Sheet:Title>Edit profile</twig:Sheet:Title>
                <twig:Sheet:Description>Make changes to your profile here. Click save when you're done.</twig:Sheet:Description>
            </twig:Sheet:Header>
            <div class="grid flex-1 auto-rows-min gap-6 px-4">
                <div class="grid gap-3">
                    <twig:Label for="sheet-demo-name">Name</twig:Label>
                    <twig:Input id="sheet-demo-name" value="Pedro Duarte" />
                </div>
                <div class="grid gap-3">
                    <twig:Label for="sheet-demo-username">Username</twig:Label>
                    <twig:Input id="sheet-demo-username" value="@peduarte" />
                </div>
            </div>
            <twig:Sheet:Footer>
                <twig:Button type="submit">Save changes</twig:Button>
                <twig:Sheet:Close>
                    <twig:Button variant="outline" {{ ...sheet_close_attrs }}>Close</twig:Button>
                </twig:Sheet:Close>
            </twig:Sheet:Footer>
        </twig:Sheet:Content>
    </twig:Sheet>
</div>

Installation

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

Install the following Composer dependencies:

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

Copy the following file(s) into your app:

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

<twig:Sheet id="sheet">
    <twig:Sheet:Trigger>
        <twig:Button variant="outline" {{ ...sheet_trigger_attrs }}>Open</twig:Button>
    </twig:Sheet:Trigger>
    <twig:Sheet:Content>
        <twig:Sheet:Header>
            <twig:Sheet:Title>Are you absolutely sure?</twig:Sheet:Title>
            <twig:Sheet:Description>This action cannot be undone.</twig:Sheet:Description>
        </twig:Sheet:Header>
    </twig:Sheet:Content>
</twig:Sheet>

Examples

Sides

Use the side prop to set the edge the sheet slides in from: top, right, bottom or left.

100%
Loading...
<div class="flex flex-wrap gap-2" style="min-height: 480px">
    {% for side in ['top', 'right', 'bottom', 'left'] %}
        <twig:Sheet id="sheet-side-{{ side }}" side="{{ side }}">
            <twig:Sheet:Trigger>
                <twig:Button variant="outline" class="capitalize" {{ ...sheet_trigger_attrs }}>{{ side }}</twig:Button>
            </twig:Sheet:Trigger>
            <twig:Sheet:Content class="data-[side=bottom]:max-h-[50vh] data-[side=top]:max-h-[50vh]">
                <twig:Sheet:Header>
                    <twig:Sheet:Title>Edit profile</twig:Sheet:Title>
                    <twig:Sheet:Description>Make changes to your profile here. Click save when you're done.</twig:Sheet:Description>
                </twig:Sheet:Header>
                <div class="min-h-0 flex-1 overflow-y-auto px-4">
                    {% for i in 1..8 %}
                        <p class="mb-2 leading-relaxed">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
                    {% endfor %}
                </div>
                <twig:Sheet:Footer>
                    <twig:Button type="submit">Save changes</twig:Button>
                    <twig:Sheet:Close>
                        <twig:Button variant="outline" {{ ...sheet_close_attrs }}>Cancel</twig:Button>
                    </twig:Sheet:Close>
                </twig:Sheet:Footer>
            </twig:Sheet:Content>
        </twig:Sheet>
    {% endfor %}
</div>

No Close Button

Set showCloseButton to false on Sheet:Content to hide the close button in the top-right corner.

100%
Loading...
<div style="min-height: 480px">
    <twig:Sheet id="sheet-no-close">
        <twig:Sheet:Trigger>
            <twig:Button variant="outline" {{ ...sheet_trigger_attrs }}>Open Sheet</twig:Button>
        </twig:Sheet:Trigger>
        <twig:Sheet:Content :showCloseButton="false">
            <twig:Sheet:Header>
                <twig:Sheet:Title>No Close Button</twig:Sheet:Title>
                <twig:Sheet:Description>This sheet doesn't have a close button in the top-right corner. Click outside to close.</twig:Sheet:Description>
            </twig:Sheet:Header>
        </twig:Sheet:Content>
    </twig:Sheet>
</div>

RTL

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

100%
Loading...
<div style="min-height: 480px">
    <twig:Sheet id="sheet-rtl" dir="rtl">
        <twig:Sheet:Trigger>
            <twig:Button variant="outline" {{ ...sheet_trigger_attrs }}>فتح</twig:Button>
        </twig:Sheet:Trigger>
        <twig:Sheet:Content>
            <twig:Sheet:Header>
                <twig:Sheet:Title>تعديل الملف الشخصي</twig:Sheet:Title>
                <twig:Sheet:Description>قم بإجراء تغييرات على ملفك الشخصي هنا. انقر على حفظ عند الانتهاء.</twig:Sheet:Description>
            </twig:Sheet:Header>
        </twig:Sheet:Content>
    </twig:Sheet>
</div>

API Reference

<twig:Sheet>

Prop Type Default
id 
string -
side 
'top'|'right'|'bottom'|'left' 'right'
open 
boolean false
Block Description
content The sheet structure, typically includes Sheet:Trigger and Sheet:Content.

<twig:Sheet:Close>

Block Description
content The close trigger element (e.g., a Button) that closes the sheet when clicked.

<twig:Sheet:Content>

Prop Type Default
showCloseButton 
boolean true
Block Description
content The sheet content, typically includes Sheet:Header and optionally Sheet:Footer.

<twig:Sheet:Description>

Block Description
content The descriptive text explaining the sheet purpose.

<twig:Sheet:Footer>

Block Description
content The footer area, typically contains action buttons.

<twig:Sheet:Header>

Block Description
content The header area, typically contains Sheet:Title and Sheet:Description.

<twig:Sheet:Title>

Block Description
content The title text of the sheet.

<twig:Sheet:Trigger>

Block Description
content The trigger element (e.g., a Button) that opens the sheet when clicked.