View as Markdown

Command

Command menu for search and quick actions.

100%
Loading...
<div class="flex w-full max-w-sm flex-col" style="min-height: 392px">
    <twig:Command class="max-w-sm rounded-lg border">
        <twig:Command:Input placeholder="Type a command or search..." />
        <twig:Command:List>
            <twig:Command:Empty>No results found.</twig:Command:Empty>
            <twig:Command:Group heading="Suggestions">
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:calendar" />
                    <span>Calendar</span>
                </twig:Command:Item>
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:smile" />
                    <span>Search Emoji</span>
                </twig:Command:Item>
                <twig:Command:Item disabled>
                    <twig:ux:icon name="lucide:calculator" />
                    <span>Calculator</span>
                </twig:Command:Item>
            </twig:Command:Group>
            <twig:Command:Separator />
            <twig:Command:Group heading="Settings">
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:user" />
                    <span>Profile</span>
                    <twig:Command:Shortcut>⌘P</twig:Command:Shortcut>
                </twig:Command:Item>
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:credit-card" />
                    <span>Billing</span>
                    <twig:Command:Shortcut>⌘B</twig:Command:Shortcut>
                </twig:Command:Item>
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:settings" />
                    <span>Settings</span>
                    <twig:Command:Shortcut>⌘S</twig:Command:Shortcut>
                </twig:Command:Item>
            </twig:Command:Group>
        </twig:Command:List>
    </twig:Command>
</div>

Installation

Note

Available since UX Toolkit 3.6.

php bin/console ux:install command --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

Install the following front-end dependencies:

npm install shadcn tw-animate-css
# ...or with Symfony AssetMapper
php bin/console importmap:require tw-animate-css/dist/tw-animate.css shadcn/dist/tailwind.css

Copy the following file(s) into your app:

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

let instances = 0;

export default class extends Controller {
    static targets = ['input', 'list', 'item', 'group', 'separator', 'empty'];

    #prefix = `command-$+instances}`;
    #nextItemId = 0;
    #dialog = null;
    #scrollPending = false;

    // A command in a dialog starts over each time the dialog is closed.
    // The closed dialog is not rendered, so its list cannot scroll yet: that waits until the reopened dialog focuses the input.
    #reset = () => {
        for (const input of this.inputTargets) {
            input.value = '';
        }

        this.#scrollPending = true;
        this.filter();
    };

    #scrollToTop = () => {
        if (!this.#scrollPending) {
            return;
        }

        this.#scrollPending = false;
        for (const list of this.listTargets) {
            list.scrollTop = 0;
        }
    };

    connect() {
        this.#identify();
        this.filter();

        this.#dialog = this.element.closest('dialog');
        this.#dialog?.addEventListener('close', this.#reset);
        this.element.addEventListener('focusin', this.#scrollToTop);
    }

    disconnect() {
        this.#dialog?.removeEventListener('close', this.#reset);
        this.#dialog = null;
        this.element.removeEventListener('focusin', this.#scrollToTop);
    }

    // Items are addressed by `aria-activedescendant`, which needs an id.
    // The ids are generated here, so the markup needs no id prop and they stay unique when a page renders several commands.
    itemTargetConnected(item) {
        item.id ||= `${this.#prefix}-item-${this.#nextItemId+`;
    }

    filter() {
        const query = (this.inputTargets[0]?.value ?? '').trim().toLowerCase();
        let matched = 0;

        for (const item of this.itemTargets) {
            const matches = '' === query || item.textContent.toLowerCase().includes(query);
            item.hidden = !matches;
            matched += matches ? 1 : 0;
        }

        for (const group of this.groupTargets) {
            group.hidden = !this.itemTargets.some((item) => !item.hidden && group.contains(item));
        }

        for (const separator of this.separatorTargets) {
            separator.hidden = '' !== query;
        }

        for (const empty of this.emptyTargets) {
            empty.hidden = matched > 0;
        }

        this.#select(this.#items()[0]);
    }

    highlight(event) {
        const item = event.currentTarget;
        if ('true' === item.dataset.disabled || item === this.#selected) {
            return;
        }

        this.#select(item, { scroll: false });
    }

    navigate(event) {
        if (event.defaultPrevented || event.isComposing || 229 === event.keyCode) {
            return;
        }

        switch (event.key) {
            case 'ArrowDown':
                this.#move(event, 1);
                break;
            case 'ArrowUp':
                this.#move(event, -1);
                break;
            case 'n':
            case 'j':
                if (event.ctrlKey) {
                    this.#move(event, 1);
                }
                break;
            case 'p':
            case 'k':
                if (event.ctrlKey) {
                    this.#move(event, -1);
                }
                break;
            case 'Home':
                event.preventDefault();
                this.#select(this.#items()[0]);
                break;
            case 'End':
                event.preventDefault();
                this.#select(this.#items().at(-1));
                break;
            case 'Enter':
                event.preventDefault();
                this.#selected?.click();
                break;
        }
    }

    get #selected() {
        return this.itemTargets.find((item) => 'true' === item.dataset.selected) ?? null;
    }

    #items() {
        return this.itemTargets.filter((item) => !item.hidden && 'true' !== item.dataset.disabled);
    }

    // The Meta key jumps to the first or last item, the Alt key to the next or previous group.
    #move(event, step) {
        event.preventDefault();

        const items = this.#items();
        if (event.metaKey) {
            this.#select(step > 0 ? items.at(-1) : items[0]);

            return;
        }

        if (event.altKey) {
            const target = this.#itemInSiblingGroup(step);
            if (null !== target) {
                this.#select(target);

                return;
            }
        }

        const next = items[items.indexOf(this.#selected) + step];
        if (undefined !== next) {
            this.#select(next);
        }
    }

    #itemInSiblingGroup(step) {
        const groups = this.groupTargets.filter((group) => !group.hidden);
        const current = this.#selected?.closest('[data-slot="command-group"]');

        for (let index = groups.indexOf(current) + step; index >= 0 && index < groups.length; index += step) {
            const item = this.#items().find((candidate) => groups[index].contains(candidate));
            if (undefined !== item) {
                return item;
            }
        }

        return null;
    }

    #select(item, { scroll = true } = {}) {
        for (const other of this.itemTargets) {
            other.dataset.selected = String(other === item);
            other.setAttribute('aria-selected', String(other === item));
        }

        for (const input of this.inputTargets) {
            if (undefined === item) {
                input.removeAttribute('aria-activedescendant');
            } else {
                input.setAttribute('aria-activedescendant', item.id);
            }
        }

        if (scroll && undefined !== item) {
            this.#scrollIntoList(item);
        }
    }

    // Unlike `scrollIntoView()`, this only scrolls the list: the page must not jump to a command selecting its first item.
    #scrollIntoList(item) {
        const list = item.closest('[data-slot="command-list"]');
        if (null === list || 0 === list.offsetHeight) {
            return;
        }

        // The boxes are scaled while a dialog plays its opening transition: bring the offset back to the layout size.
        const listBox = list.getBoundingClientRect();
        const scale = listBox.height / list.offsetHeight;
        const top = list.scrollTop + (item.getBoundingClientRect().top - listBox.top) / scale - list.clientTop;
        const bottom = top + item.offsetHeight;
        if (top < list.scrollTop) {
            list.scrollTop = top;
        } else if (bottom > list.scrollTop + list.clientHeight) {
            list.scrollTop = bottom - list.clientHeight;
        }
    }

    #identify() {
        for (const list of this.listTargets) {
            list.id ||= `${this.#prefix}-list`;
        }

        if (this.hasListTarget) {
            for (const input of this.inputTargets) {
                input.setAttribute('aria-controls', this.listTarget.id);
            }
        }
    }
}
templates/components/Command.html.twig
<div
    data-slot="command"
    tabindex="-1"
    {{ attributes.defaults({
        class: 'flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground'|tailwind_classes,
        'data-controller': 'command',
        'data-action': 'keydown->command#navigate',
    }) }}
>
    {##- The command structure, typically includes `Command:Input` and `Command:List`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Command/Dialog.html.twig
{%- props
    ## string The title of the dialog, only announced to assistive technologies.
    title = 'Command Palette',
    ## string The description of the dialog, only announced to assistive technologies.
    description = 'Search for a command to run...',
    ## boolean Whether to display the close button in the top-right corner.
    showCloseButton = false
-%}
<twig:Dialog:Content
    :showCloseButton="showCloseButton"
    {{ ...attributes.defaults({class: 'top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0'|tailwind_classes}) }}
>
    <twig:Dialog:Header class="sr-only">
        <twig:Dialog:Title>{{ title }}</twig:Dialog:Title>
        <twig:Dialog:Description>{{ description }}</twig:Dialog:Description>
    </twig:Dialog:Header>
    {##- The dialog content, typically a `Command`. -#}
    {{- block(outerBlocks.content) -}}
</twig:Dialog:Content>
templates/components/Command/Empty.html.twig
<div
    role="presentation"
    hidden
    data-slot="command-empty"
    data-command-target="empty"
    {{ attributes.defaults({
        class: 'py-6 text-center text-sm'|tailwind_classes,
    }) }}
>
    {##- The message shown when the search matches no item. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Command/Group.html.twig
{%- props
    ## string|null The heading displayed above the items of the group.
    heading = null
-%}
<div
    role="presentation"
    data-slot="command-group"
    data-command-target="group"
    {{ attributes.defaults({
        class: 'overflow-hidden p-1 text-foreground'|tailwind_classes,
    }) }}
>
    {%- if heading is not null -%}
        <div data-slot="command-group-heading" aria-hidden="true" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">{{ heading }}</div>
    {%- endif -%}
    <div role="group" data-slot="command-group-items"{% if heading is not null %} aria-label="{{ heading }}"{% endif %}>
        {##- The items of the group, typically `Command:Item` elements. -#}
        {%- block content %}{% endblock -%}
    </div>
</div>
templates/components/Command/Input.html.twig
{%- set _command_input_attrs = attributes.defaults({
    class: 'w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50'|tailwind_classes,
    'data-action': 'input->command#filter',
}) -%}
<div data-slot="command-input-wrapper" class="p-1 pb-0">
    <twig:InputGroup class="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:ps-2!">
        <input
            type="text"
            role="combobox"
            autocomplete="off"
            autocorrect="off"
            spellcheck="false"
            aria-autocomplete="list"
            aria-expanded="true"
            data-slot="command-input"
            data-command-target="input"
            {{ _command_input_attrs }}
        >
        <twig:InputGroup:Addon>
            <twig:ux:icon name="lucide:search" class="size-4 shrink-0 opacity-50" />
        </twig:InputGroup:Addon>
    </twig:InputGroup>
</div>
templates/components/Command/Item.html.twig
{%- props
    ## 'div'|'a' The HTML tag to render; use `a` when the item is a link.
    as = 'div',
    ## boolean Whether the item is disabled.
    disabled = false
-%}
<{{ as }}
    role="option"
    {% if as == 'a' %}tabindex="-1"{% endif %}
    data-slot="command-item"
    data-command-target="item"
    data-selected="false"
    data-disabled="{{ disabled ? 'true' : 'false' }}"
    aria-selected="false"
    aria-disabled="{{ disabled ? 'true' : 'false' }}"
    {{ attributes.defaults({
        class: "group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-muted data-[selected=true]:text-foreground data-[selected=true]:*:[svg]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"|tailwind_classes,
        'data-action': 'pointermove->command#highlight'|html_attr_type('sst'),
    }) }}
>
    {##- The item content, typically an icon, a label and an optional `Command:Shortcut`. -#}
    {%- block content %}{% endblock -%}
    <twig:ux:icon name="lucide:check" class="ms-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</{{ as }}>
templates/components/Command/List.html.twig
<div
    role="listbox"
    tabindex="-1"
    data-slot="command-list"
    data-command-target="list"
    {{ attributes.defaults({
        class: 'no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none'|tailwind_classes,
    }) }}
>
    {##- The list content, typically includes `Command:Empty`, `Command:Group` and `Command:Separator`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Command/Separator.html.twig
<div
    role="separator"
    data-slot="command-separator"
    data-command-target="separator"
    {{ attributes.defaults({
        class: '-mx-1 h-px bg-border'|tailwind_classes,
    }) }}
></div>
templates/components/Command/Shortcut.html.twig
<span
    data-slot="command-shortcut"
    {{ attributes.defaults({
        class: 'ms-auto text-xs tracking-widest text-muted-foreground group-data-[selected=true]/command-item:text-foreground'|tailwind_classes,
    }) }}
>
    {##- The keyboard shortcut text (e.g., "⌘P"). -#}
    {%- block content %}{% endblock -%}
</span>
assets/controllers/input_group_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    focusInput(event) {
        if (event.target.closest('button')) {
            return;
        }

        event.currentTarget.parentElement?.querySelector('input')?.focus();
    }
}
assets/controllers/input_group_display_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['copyIcon', 'copiedIcon', 'favoriteIcon'];
    static values = { copiedDuration: { type: Number, default: 2000 } };

    #copiedTimeout = null;

    async copy(event) {
        await navigator.clipboard.writeText(event.params.text);

        this.copyIconTarget.toggleAttribute('hidden', true);
        this.copiedIconTarget.toggleAttribute('hidden', false);

        clearTimeout(this.#copiedTimeout);
        this.#copiedTimeout = setTimeout(() => {
            this.copyIconTarget.toggleAttribute('hidden', false);
            this.copiedIconTarget.toggleAttribute('hidden', true);
        }, this.copiedDurationValue);
    }

    toggleFavorite() {
        const icon = this.favoriteIconTarget;
        icon.dataset.favorite = String(icon.dataset.favorite !== 'true');
    }

    disconnect() {
        clearTimeout(this.#copiedTimeout);
    }
}
templates/components/InputGroup.html.twig
<div
    data-slot="input-group"
    role="group"
    {{ attributes.defaults({
        class: 'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pe-1.5 has-[>[data-align=inline-start]]:[&>input]:ps-1.5'|tailwind_classes,
        'data-controller': 'input-group',
    }) }}
>
    {##- The input group elements, typically includes input, `InputGroup:Addon`, and/or `InputGroup:Button`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/InputGroup/Addon.html.twig
{%- props
    ## 'inline-start'|'inline-end'|'block-start'|'block-end' The addon position relative to the input.
    align = 'inline-start'
-%}
{%- set style = html_cva(
    base: "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
    variants: {
        align: {
            'inline-start': 'order-first ps-2 has-[>button]:ms-[-0.3rem] has-[>kbd]:ms-[-0.15rem]',
            'inline-end': 'order-last pe-2 has-[>button]:me-[-0.3rem] has-[>kbd]:me-[-0.15rem]',
            'block-start': 'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
            'block-end': 'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
        },
    },
) -%}
<div
    data-slot="input-group-addon"
    data-align="{{ align }}"
    role="group"
    {{ attributes.defaults({
        class: style.apply({align: align})|tailwind_classes,
        'data-action': 'click->input-group#focusInput'|html_attr_type('sst'),
    }) }}
>
    {##- The addon content, typically an icon or text. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/InputGroup/Button.html.twig
{%- props
    ## 'button'|'submit' The button type.
    type = 'button',
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'ghost',
    ## 'xs'|'sm'|'icon-xs'|'icon-sm' The button size.
    size = 'xs'
-%}
{%- set style = html_cva(
    base: 'flex items-center gap-2 text-sm shadow-none',
    variants: {
        size: {
            xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
            sm: '',
            'icon-xs': 'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
            'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
        },
    },
) %}
<twig:Button
    type="{{ type }}"
    variant="{{ variant }}"
    {{ ...attributes.defaults({class: style.apply({size: size})|tailwind_classes}) }}
>
    {##- The button label and/or icon. -#}
    {{- block(outerBlocks.content) -}}
</twig:Button>
templates/components/InputGroup/Input.html.twig
<twig:Input
    data-slot="input-group-control"
    {{ ...attributes.defaults({class: 'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent'|tailwind_classes}) }}
/>
templates/components/InputGroup/Text.html.twig
<span
    data-slot="input-group-text"
    {{ attributes.defaults({
        class: 'text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*=size-])]:size-4'|tailwind_classes,
    }) }}
>
    {##- The text content displayed in the input group. -#}
    {%- block content %}{% endblock -%}
</span>
templates/components/InputGroup/Textarea.html.twig
<twig:Textarea
    data-slot="input-group-control"
    {{ ...attributes.defaults({class: 'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent'|tailwind_classes}) }}
>
    {##- The initial textarea value. -#}
    {{- block(outerBlocks.content) -}}
</twig:Textarea>
templates/components/Button.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'default',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default',
    ## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
    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 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-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] 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 has-data-[icon=inline-end]:pe-2 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 has-data-[icon=inline-end]:pe-1.5 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 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
            lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 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="{{ attributes.render('data-slot')|default('button') }}"
    data-variant="{{ variant }}"
    data-size="{{ size }}"
    {{ attributes.defaults({
        class: style.apply({variant: variant, size: size})|tailwind_classes,
        type: as == 'button' ? 'button' : false,
    }) }}
>
    {##- The button label and/or icon. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>
templates/components/Input.html.twig
<input
    data-slot="{{ attributes.render('data-slot')|default('input') }}"
    {{ attributes.defaults({
        class: 'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40'|tailwind_classes,
    }) }}
>
templates/components/Textarea.html.twig
<textarea
    data-slot="{{ attributes.render('data-slot')|default('textarea') }}"
    {{ attributes.defaults({
        class: 'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40'|tailwind_classes,
    }) }}
>
    {##- The initial textarea value. -#}
    {%- block content %}{% endblock -%}
</textarea>
assets/controllers/dialog_controller.js
import { Controller } from '@hotwired/stimulus';

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

    static values = {
        open: Boolean,
    };

    #wasOpen = null;
    #closing = null;

    connect() {
        if (this.#wasOpen ?? this.openValue) {
            this.open();
        }
    }

    disconnect() {
        // A <dialog> taken out of the DOM comes back open but no longer modal, so reopen it on reconnect.
        this.#wasOpen = this.dialogTarget.open && !this.dialogTarget.hasAttribute('data-closing');
        this.#closing = null;
        this.dialogTarget.removeAttribute('data-closing');
        if (this.dialogTarget.open) {
            this.dialogTarget.close();
        }
    }

    open() {
        this.#closing = null;
        // Reopened before the exit transition ended: transition back instead.
        const reopened = this.dialogTarget.open && this.dialogTarget.hasAttribute('data-closing');
        this.dialogTarget.removeAttribute('data-closing');
        if (!reopened) {
            this.dialogTarget.showModal();
        }
        this._focusInitialElement();

        if (this.hasTriggerTarget) {
            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() {
        const dialog = this.dialogTarget;
        if (!dialog.open || dialog.hasAttribute('data-closing')) {
            return;
        }

        if (this.hasTriggerTarget) {
            this.triggerTarget.setAttribute('aria-expanded', 'false');
        }

        // Only Chromium can transition a <dialog> out of the top layer, so play the exit
        // transition while it is still open, through [data-closing], then close it.
        dialog.setAttribute('data-closing', '');
        const closing = {};
        this.#closing = closing;
        const finish = () => this._finishClosing(dialog, closing);
        // The <dialog> and its ::backdrop only: an endless animation in the content would never let it close.
        const animations = dialog
            .getAnimations({ subtree: true })
            .filter(
                (animation) =>
                    animation.effect?.target === dialog && Number.isFinite(animation.effect.getComputedTiming().endTime)
            );
        if (animations.length === 0) {
            finish();

            return;
        }

        Promise.allSettled(animations.map((animation) => animation.finished)).then(finish);
        // A paused animation never finishes: close anyway once the longest one should have ended.
        setTimeout(
            finish,
            Math.max(...animations.map((animation) => animation.effect.getComputedTiming().endTime)) + 100
        );
    }

    _finishClosing(dialog, closing) {
        // open() or disconnect() ran in the meantime and cancelled this closing.
        if (this.#closing !== closing) {
            return;
        }

        this.#closing = null;
        dialog.removeAttribute('data-closing');
        dialog.close();
    }
}
templates/components/Dialog.html.twig
{%- props
    ## string Unique identifier used to generate internal Dialog IDs.
    id,
    ## boolean Whether the dialog is open on initial render.
    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' }}"
    {{ attributes.defaults({
        'data-controller': 'dialog',
    }) }}
>
    {## The dialog structure, typically includes `Dialog:Trigger` and `Dialog:Content`. -#}
    {% block content %}{% endblock %}
</div>
templates/components/Dialog/Close.html.twig
{%- set dialog_close_attrs = {
    'data-slot': 'dialog-close',
    'data-action': 'click->dialog#close'|html_attr_type('sst'),
} -%}
{##- The close trigger element (e.g., a `Button`) that closes the dialog when clicked. -#}
{%- block content %}{% endblock -%}
templates/components/Dialog/Content.html.twig
{%- props
    ## boolean Whether to display the close button in the top-right corner.
    showCloseButton = true
-%}
{%- set _dialog_id = inject('dialog.id') -%}
{%- set _dialog_titleId = inject('dialog.titleId') -%}
{%- set _dialog_descriptionId = inject('dialog.descriptionId') -%}

<dialog
    id="{{ _dialog_id }}"
    aria-labelledby="{{ _dialog_titleId }}"
    aria-describedby="{{ _dialog_descriptionId }}"
    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 duration-100 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 data-closing:scale-95 data-closing:opacity-0 data-closing:backdrop:bg-transparent supports-backdrop-filter:data-closing:backdrop:backdrop-blur-none'|tailwind_classes,
        'data-action': 'keydown.esc->dialog#close:prevent click->dialog#closeOnClickOutside',
    }) }}
>
    {##- The dialog content, typically includes `Dialog:Header` and optionally `Dialog:Footer`. -#}
    {%- block content %}{% endblock -%}
    {% if showCloseButton %}
        <twig:Button
            type="button"
            variant="ghost"
            size="icon-sm"
            class="absolute top-2 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
{%- 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}) }}
>
    {##- The descriptive text explaining the dialog purpose. -#}
    {%- block content %}{% endblock -%}
</p>
templates/components/Dialog/Footer.html.twig
<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,
    }) }}
>
    {##- The footer area, typically contains action buttons. -#}
    {%- block content %}{% endblock -%}
</footer>
templates/components/Dialog/Header.html.twig
<header
    data-slot="dialog-header"
    {{ attributes.defaults({
        class: 'flex flex-col gap-2'|tailwind_classes,
    }) }}
>
    {##- The header area, typically contains `Dialog:Title` and `Dialog:Description`. -#}
    {%- block content %}{% endblock -%}
</header>
templates/components/Dialog/Title.html.twig
{%- 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}) }}
>
    {##- The title text of the dialog. -#}
    {%- block content %}{% endblock -%}
</h2>
templates/components/Dialog/Trigger.html.twig
{%- set dialog_trigger_attrs = {
    'data-slot': 'dialog-trigger',
    'data-action': 'click->dialog#open'|html_attr_type('sst'),
    'data-dialog-target': 'trigger',
    'aria-haspopup': 'dialog',
} -%}
{##- The trigger element (e.g., a `Button`) that opens the dialog when clicked. -#}
{%- block content %}{% endblock -%}

Usage

<twig:Command class="max-w-sm rounded-lg border">
    <twig:Command:Input placeholder="Type a command or search..." />
    <twig:Command:List>
        <twig:Command:Empty>No results found.</twig:Command:Empty>
        <twig:Command:Group heading="Suggestions">
            <twig:Command:Item>Calendar</twig:Command:Item>
            <twig:Command:Item>Search Emoji</twig:Command:Item>
            <twig:Command:Item>Calculator</twig:Command:Item>
        </twig:Command:Group>
        <twig:Command:Separator />
        <twig:Command:Group heading="Settings">
            <twig:Command:Item>Profile</twig:Command:Item>
            <twig:Command:Item>Billing</twig:Command:Item>
            <twig:Command:Item>Settings</twig:Command:Item>
        </twig:Command:Group>
    </twig:Command:List>
</twig:Command>

To run an action when an item is chosen, with a click or with Enter, listen to the click event of the item, or render the item as a link with as="a".

Examples

Basic

A simple command menu in a dialog.

100%
Loading...
<div class="flex flex-col gap-4" style="min-height: 300px">
    <twig:Dialog id="command-basic">
        <twig:Dialog:Trigger>
            <twig:Button variant="outline" class="w-fit" {{ ...dialog_trigger_attrs }}>Open Menu</twig:Button>
        </twig:Dialog:Trigger>
        <twig:Command:Dialog>
            <twig:Command>
                <twig:Command:Input placeholder="Type a command or search..." />
                <twig:Command:List>
                    <twig:Command:Empty>No results found.</twig:Command:Empty>
                    <twig:Command:Group heading="Suggestions">
                        <twig:Command:Item>Calendar</twig:Command:Item>
                        <twig:Command:Item>Search Emoji</twig:Command:Item>
                        <twig:Command:Item>Calculator</twig:Command:Item>
                    </twig:Command:Group>
                </twig:Command:List>
            </twig:Command>
        </twig:Command:Dialog>
    </twig:Dialog>
</div>

Shortcuts

100%
Loading...
<div class="flex flex-col gap-4" style="min-height: 300px">
    <twig:Dialog id="command-shortcuts">
        <twig:Dialog:Trigger>
            <twig:Button variant="outline" class="w-fit" {{ ...dialog_trigger_attrs }}>Open Menu</twig:Button>
        </twig:Dialog:Trigger>
        <twig:Command:Dialog>
            <twig:Command>
                <twig:Command:Input placeholder="Type a command or search..." />
                <twig:Command:List>
                    <twig:Command:Empty>No results found.</twig:Command:Empty>
                    <twig:Command:Group heading="Settings">
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:user" />
                            <span>Profile</span>
                            <twig:Command:Shortcut>⌘P</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:credit-card" />
                            <span>Billing</span>
                            <twig:Command:Shortcut>⌘B</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:settings" />
                            <span>Settings</span>
                            <twig:Command:Shortcut>⌘S</twig:Command:Shortcut>
                        </twig:Command:Item>
                    </twig:Command:Group>
                </twig:Command:List>
            </twig:Command>
        </twig:Command:Dialog>
    </twig:Dialog>
</div>

Groups

A command menu with groups, icons and separators.

100%
Loading...
<div class="flex flex-col gap-4" style="min-height: 470px">
    <twig:Dialog id="command-groups">
        <twig:Dialog:Trigger>
            <twig:Button variant="outline" class="w-fit" {{ ...dialog_trigger_attrs }}>Open Menu</twig:Button>
        </twig:Dialog:Trigger>
        <twig:Command:Dialog>
            <twig:Command>
                <twig:Command:Input placeholder="Type a command or search..." />
                <twig:Command:List>
                    <twig:Command:Empty>No results found.</twig:Command:Empty>
                    <twig:Command:Group heading="Suggestions">
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:calendar" />
                            <span>Calendar</span>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:smile" />
                            <span>Search Emoji</span>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:calculator" />
                            <span>Calculator</span>
                        </twig:Command:Item>
                    </twig:Command:Group>
                    <twig:Command:Separator />
                    <twig:Command:Group heading="Settings">
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:user" />
                            <span>Profile</span>
                            <twig:Command:Shortcut>⌘P</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:credit-card" />
                            <span>Billing</span>
                            <twig:Command:Shortcut>⌘B</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:settings" />
                            <span>Settings</span>
                            <twig:Command:Shortcut>⌘S</twig:Command:Shortcut>
                        </twig:Command:Item>
                    </twig:Command:Group>
                </twig:Command:List>
            </twig:Command>
        </twig:Command:Dialog>
    </twig:Dialog>
</div>

Scrollable

Scrollable command menu with multiple items.

100%
Loading...
<div class="flex flex-col gap-4" style="min-height: 520px">
    <twig:Dialog id="command-scrollable">
        <twig:Dialog:Trigger>
            <twig:Button variant="outline" class="w-fit" {{ ...dialog_trigger_attrs }}>Open Menu</twig:Button>
        </twig:Dialog:Trigger>
        <twig:Command:Dialog>
            <twig:Command>
                <twig:Command:Input placeholder="Type a command or search..." />
                <twig:Command:List>
                    <twig:Command:Empty>No results found.</twig:Command:Empty>
                    <twig:Command:Group heading="Navigation">
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:house" />
                            <span>Home</span>
                            <twig:Command:Shortcut>⌘H</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:inbox" />
                            <span>Inbox</span>
                            <twig:Command:Shortcut>⌘I</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:file-text" />
                            <span>Documents</span>
                            <twig:Command:Shortcut>⌘D</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:folder" />
                            <span>Folders</span>
                            <twig:Command:Shortcut>⌘F</twig:Command:Shortcut>
                        </twig:Command:Item>
                    </twig:Command:Group>
                    <twig:Command:Separator />
                    <twig:Command:Group heading="Actions">
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:plus" />
                            <span>New File</span>
                            <twig:Command:Shortcut>⌘N</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:folder-plus" />
                            <span>New Folder</span>
                            <twig:Command:Shortcut>⇧⌘N</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:copy" />
                            <span>Copy</span>
                            <twig:Command:Shortcut>⌘C</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:scissors" />
                            <span>Cut</span>
                            <twig:Command:Shortcut>⌘X</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:clipboard-paste" />
                            <span>Paste</span>
                            <twig:Command:Shortcut>⌘V</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:trash" />
                            <span>Delete</span>
                            <twig:Command:Shortcut>⌫</twig:Command:Shortcut>
                        </twig:Command:Item>
                    </twig:Command:Group>
                    <twig:Command:Separator />
                    <twig:Command:Group heading="View">
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:layout-grid" />
                            <span>Grid View</span>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:list" />
                            <span>List View</span>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:zoom-in" />
                            <span>Zoom In</span>
                            <twig:Command:Shortcut>⌘+</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:zoom-out" />
                            <span>Zoom Out</span>
                            <twig:Command:Shortcut>⌘-</twig:Command:Shortcut>
                        </twig:Command:Item>
                    </twig:Command:Group>
                    <twig:Command:Separator />
                    <twig:Command:Group heading="Account">
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:user" />
                            <span>Profile</span>
                            <twig:Command:Shortcut>⌘P</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:credit-card" />
                            <span>Billing</span>
                            <twig:Command:Shortcut>⌘B</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:settings" />
                            <span>Settings</span>
                            <twig:Command:Shortcut>⌘S</twig:Command:Shortcut>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:bell" />
                            <span>Notifications</span>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:circle-help" />
                            <span>Help & Support</span>
                        </twig:Command:Item>
                    </twig:Command:Group>
                    <twig:Command:Separator />
                    <twig:Command:Group heading="Tools">
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:calculator" />
                            <span>Calculator</span>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:calendar" />
                            <span>Calendar</span>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:image" />
                            <span>Image Editor</span>
                        </twig:Command:Item>
                        <twig:Command:Item>
                            <twig:ux:icon name="lucide:code" />
                            <span>Code Editor</span>
                        </twig:Command:Item>
                    </twig:Command:Group>
                </twig:Command:List>
            </twig:Command>
        </twig:Command:Dialog>
    </twig:Dialog>
</div>

Render an item as a link with as="a": a click and Enter follow it.

100%
Loading...
<div class="flex w-full max-w-sm flex-col">
    <twig:Command class="max-w-sm rounded-lg border">
        <twig:Command:Input placeholder="Search a page..." />
        <twig:Command:List>
            <twig:Command:Empty>No results found.</twig:Command:Empty>
            <twig:Command:Group heading="Pages">
                <twig:Command:Item as="a" href="#home">
                    <twig:ux:icon name="lucide:house" />
                    <span>Home</span>
                </twig:Command:Item>
                <twig:Command:Item as="a" href="#inbox">
                    <twig:ux:icon name="lucide:inbox" />
                    <span>Inbox</span>
                </twig:Command:Item>
                <twig:Command:Item as="a" href="#settings">
                    <twig:ux:icon name="lucide:settings" />
                    <span>Settings</span>
                </twig:Command:Item>
            </twig:Command:Group>
        </twig:Command:List>
    </twig:Command>
</div>

RTL

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

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-4">
    {# Arabic #}
    <twig:Command class="max-w-sm rounded-lg border" dir="rtl">
        <twig:Command:Input placeholder="اكتب أمرًا أو ابحث..." />
        <twig:Command:List>
            <twig:Command:Empty>لم يتم العثور على نتائج.</twig:Command:Empty>
            <twig:Command:Group heading="اقتراحات">
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:calendar" />
                    <span>التقويم</span>
                </twig:Command:Item>
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:smile" />
                    <span>البحث عن الرموز التعبيرية</span>
                </twig:Command:Item>
                <twig:Command:Item disabled>
                    <twig:ux:icon name="lucide:calculator" />
                    <span>الآلة الحاسبة</span>
                </twig:Command:Item>
            </twig:Command:Group>
            <twig:Command:Separator />
            <twig:Command:Group heading="الإعدادات">
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:user" />
                    <span>الملف الشخصي</span>
                    <twig:Command:Shortcut>⌘P</twig:Command:Shortcut>
                </twig:Command:Item>
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:credit-card" />
                    <span>الفوترة</span>
                    <twig:Command:Shortcut>⌘B</twig:Command:Shortcut>
                </twig:Command:Item>
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:settings" />
                    <span>الإعدادات</span>
                    <twig:Command:Shortcut>⌘S</twig:Command:Shortcut>
                </twig:Command:Item>
            </twig:Command:Group>
        </twig:Command:List>
    </twig:Command>

    {# Hebrew #}
    <twig:Command class="max-w-sm rounded-lg border" dir="rtl">
        <twig:Command:Input placeholder="הקלד פקודה או חפש..." />
        <twig:Command:List>
            <twig:Command:Empty>לא נמצאו תוצאות.</twig:Command:Empty>
            <twig:Command:Group heading="הצעות">
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:calendar" />
                    <span>לוח שנה</span>
                </twig:Command:Item>
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:smile" />
                    <span>חפש אמוג'י</span>
                </twig:Command:Item>
                <twig:Command:Item disabled>
                    <twig:ux:icon name="lucide:calculator" />
                    <span>מחשבון</span>
                </twig:Command:Item>
            </twig:Command:Group>
            <twig:Command:Separator />
            <twig:Command:Group heading="הגדרות">
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:user" />
                    <span>פרופיל</span>
                    <twig:Command:Shortcut>⌘P</twig:Command:Shortcut>
                </twig:Command:Item>
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:credit-card" />
                    <span>חיוב</span>
                    <twig:Command:Shortcut>⌘B</twig:Command:Shortcut>
                </twig:Command:Item>
                <twig:Command:Item>
                    <twig:ux:icon name="lucide:settings" />
                    <span>הגדרות</span>
                    <twig:Command:Shortcut>⌘S</twig:Command:Shortcut>
                </twig:Command:Item>
            </twig:Command:Group>
        </twig:Command:List>
    </twig:Command>
</div>

Accessibility

  • Command:Input is a combobox that controls the Command:List listbox. The selected item is announced through aria-activedescendant, so the focus stays in the input while the selection moves.
  • ArrowDown and ArrowUp move the selection, Home and End jump to the first and last item, and Alt with an arrow jumps to the next or previous group. Enter clicks the selected item. Disabled items are skipped.
  • Command:Dialog renders a Dialog:Content labelled by a visually hidden title and description. Set the title and description props to translate them.

API Reference

<twig:Command>

Block Description
content The command structure, typically includes Command:Input and Command:List.

<twig:Command:Dialog>

Prop Type Default
title 
string 'Command Palette'
description 
string 'Search for a command to run...'
showCloseButton 
boolean false
Block Description
content The dialog content, typically a Command.

<twig:Command:Empty>

Block Description
content The message shown when the search matches no item.

<twig:Command:Group>

Prop Type Default
heading 
string|null null
Block Description
content The items of the group, typically Command:Item elements.

<twig:Command:Item>

Prop Type Default
as 
'div'|'a' 'div'
disabled 
boolean false
Block Description
content The item content, typically an icon, a label and an optional Command:Shortcut.

<twig:Command:List>

Block Description
content The list content, typically includes Command:Empty, Command:Group and Command:Separator.

<twig:Command:Shortcut>

Block Description
content The keyboard shortcut text (e.g., "⌘P").