View as Markdown

Context Menu

Displays a menu of actions triggered by a right click.

100%
Loading...
<div class="w-full max-w-xs" style="min-height: 330px">
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click here</span>
            <span class="hidden pointer-coarse:inline-block">Long press here</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content class="w-48">
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>
                    Back
                    <twig:ContextMenu:Shortcut>⌘[</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item disabled>
                    Forward
                    <twig:ContextMenu:Shortcut>⌘]</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    Reload
                    <twig:ContextMenu:Shortcut>⌘R</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Sub>
                    <twig:ContextMenu:SubTrigger>More Tools</twig:ContextMenu:SubTrigger>
                    <twig:ContextMenu:SubContent class="w-44">
                        <twig:ContextMenu:Group>
                            <twig:ContextMenu:Item>Save Page...</twig:ContextMenu:Item>
                            <twig:ContextMenu:Item>Create Shortcut...</twig:ContextMenu:Item>
                            <twig:ContextMenu:Item>Name Window...</twig:ContextMenu:Item>
                        </twig:ContextMenu:Group>
                        <twig:ContextMenu:Separator />
                        <twig:ContextMenu:Group>
                            <twig:ContextMenu:Item>Developer Tools</twig:ContextMenu:Item>
                        </twig:ContextMenu:Group>
                        <twig:ContextMenu:Separator />
                        <twig:ContextMenu:Group>
                            <twig:ContextMenu:Item variant="destructive">Delete</twig:ContextMenu:Item>
                        </twig:ContextMenu:Group>
                    </twig:ContextMenu:SubContent>
                </twig:ContextMenu:Sub>
            </twig:ContextMenu:Group>
            <twig:ContextMenu:Separator />
            <twig:ContextMenu:Group>
                <twig:ContextMenu:CheckboxItem checked>Show Bookmarks</twig:ContextMenu:CheckboxItem>
                <twig:ContextMenu:CheckboxItem>Show Full URLs</twig:ContextMenu:CheckboxItem>
            </twig:ContextMenu:Group>
            <twig:ContextMenu:Separator />
            <twig:ContextMenu:Group>
                <twig:ContextMenu:RadioGroup value="pedro">
                    <twig:ContextMenu:Label>People</twig:ContextMenu:Label>
                    <twig:ContextMenu:RadioItem value="pedro" checked>Pedro Duarte</twig:ContextMenu:RadioItem>
                    <twig:ContextMenu:RadioItem value="colm">Colm Tuite</twig:ContextMenu:RadioItem>
                </twig:ContextMenu:RadioGroup>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
</div>

Installation

Note

Available since UX Toolkit 3.6.

php bin/console ux:install context-menu --kit shadcn

Install the following Composer dependencies:

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

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/context_menu_controller.js
import { Controller } from '@hotwired/stimulus';

const LONG_PRESS_DELAY = 700;
const LONG_PRESS_TOLERANCE = 10;
const VIEWPORT_MARGIN = 8;

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

    connect() {
        this._onOutsidePointerDown = this._onOutsidePointerDown.bind(this);
        this._onViewportChange = this._onViewportChange.bind(this);
        this._longPress = null;
        this._setState(false);
    }

    disconnect() {
        this.cancelLongPress();
        this._removeListeners();
    }

    open(event) {
        event.preventDefault();
        this.cancelLongPress();

        const hasPointer = event.clientX !== 0 || event.clientY !== 0;
        const anchor = hasPointer ? { x: event.clientX, y: event.clientY } : this._triggerOrigin();

        this._openAt(anchor);
    }

    close() {
        this._setState(false);
        this._removeListeners();
        this.dispatch('close');
    }

    startLongPress(event) {
        if (event.pointerType === 'mouse') {
            return;
        }

        this.cancelLongPress();
        const anchor = { x: event.clientX, y: event.clientY };
        this._longPress = {
            anchor,
            timeout: setTimeout(() => {
                this._longPress = null;
                this._openAt(anchor);
            }, LONG_PRESS_DELAY),
        };
    }

    moveLongPress(event) {
        if (!this._longPress) {
            return;
        }

        const { anchor } = this._longPress;
        if (Math.hypot(event.clientX - anchor.x, event.clientY - anchor.y) > LONG_PRESS_TOLERANCE) {
            this.cancelLongPress();
        }
    }

    cancelLongPress() {
        if (this._longPress) {
            clearTimeout(this._longPress.timeout);
            this._longPress = null;
        }
    }

    preventContextMenu(event) {
        event.preventDefault();
    }

    closeFromItem(event) {
        const item = event.currentTarget;
        if (this._isItemDisabled(item)) {
            event.preventDefault();
            return;
        }

        if (item.dataset.closeOnSelect !== 'false') {
            this.close();
        }
    }

    toggleCheckbox(event) {
        const item = event.currentTarget;
        if (this._isItemDisabled(item)) {
            event.preventDefault();
            return;
        }

        const checked = item.getAttribute('aria-checked') !== 'true';
        item.setAttribute('aria-checked', String(checked));

        const indicator = item.querySelector('[data-checkbox-indicator]');
        if (indicator) {
            indicator.hidden = !checked;
        }

        if (item.dataset.closeOnSelect === 'true') {
            this.close();
        }
    }

    selectRadio(event) {
        const item = event.currentTarget;
        if (this._isItemDisabled(item)) {
            event.preventDefault();
            return;
        }

        const group = item.closest('[data-slot="context-menu-radio-group"]');
        if (!group) {
            return;
        }

        group.dataset.value = item.dataset.value;

        for (const radio of group.querySelectorAll('[role="menuitemradio"]')) {
            const selected = radio.dataset.value === item.dataset.value;
            radio.setAttribute('aria-checked', String(selected));

            const indicator = radio.querySelector('[data-radio-indicator]');
            if (indicator) {
                indicator.hidden = !selected;
            }
        }

        if (item.dataset.closeOnSelect === 'true') {
            this.close();
        }
    }

    onContentKeydown(event) {
        switch (event.key) {
            case 'Escape':
                event.preventDefault();
                this.close();
                break;
            case 'ArrowDown':
                event.preventDefault();
                this._focusNextItem();
                break;
            case 'ArrowUp':
                event.preventDefault();
                this._focusPrevItem();
                break;
            case 'Home':
                event.preventDefault();
                this._focusFirstItem();
                break;
            case 'End':
                event.preventDefault();
                this._focusLastItem();
                break;
            case 'Tab':
                event.preventDefault();
                this.close();
                break;
        }
    }

    _openAt(anchor) {
        this._setState(true);
        this._position(anchor);

        document.addEventListener('pointerdown', this._onOutsidePointerDown, true);
        window.addEventListener('resize', this._onViewportChange);
        window.addEventListener('scroll', this._onViewportChange, true);

        this.contentTarget.focus({ preventScroll: true });
    }

    _removeListeners() {
        document.removeEventListener('pointerdown', this._onOutsidePointerDown, true);
        window.removeEventListener('resize', this._onViewportChange);
        window.removeEventListener('scroll', this._onViewportChange, true);
    }

    _triggerOrigin() {
        const rect = this.triggerTarget.getBoundingClientRect();

        return { x: rect.left, y: rect.top };
    }

    _position({ x, y }) {
        const content = this.contentTarget;
        const side = content.dataset.side;
        const width = content.offsetWidth;
        const height = content.offsetHeight;
        const viewportWidth = document.documentElement.clientWidth;
        const viewportHeight = document.documentElement.clientHeight;

        let left = side === 'left' ? x - width : x;
        let top = side === 'top' ? y - height : y;
        let originX = side === 'left' ? 'right' : 'left';
        let originY = side === 'top' ? 'bottom' : 'top';

        if (side === 'left' ? left < VIEWPORT_MARGIN : left + width > viewportWidth - VIEWPORT_MARGIN) {
            left = side === 'left' ? x : x - width;
            originX = side === 'left' ? 'left' : 'right';
        }
        if (side === 'top' ? top < VIEWPORT_MARGIN : top + height > viewportHeight - VIEWPORT_MARGIN) {
            top = side === 'top' ? y : y - height;
            originY = side === 'top' ? 'top' : 'bottom';
        }

        left = Math.max(VIEWPORT_MARGIN, Math.min(left, viewportWidth - width - VIEWPORT_MARGIN));
        top = Math.max(VIEWPORT_MARGIN, Math.min(top, viewportHeight - height - VIEWPORT_MARGIN));

        content.style.left = `${left}px`;
        content.style.top = `${top}px`;
        content.style.transformOrigin = `${originX} ${originY}`;
    }

    _setState(isOpen) {
        const state = isOpen ? 'open' : 'closed';
        this.element.dataset.state = state;

        if (this.hasTriggerTarget) {
            this.triggerTarget.dataset.state = state;
        }
        if (this.hasContentTarget) {
            this.contentTarget.dataset.state = state;
        }
    }

    _onOutsidePointerDown(event) {
        if (!this.contentTarget.contains(event.target)) {
            this.close();
        }
    }

    _onViewportChange(event) {
        if (event.type === 'scroll' && this.contentTarget.contains(event.target)) {
            return;
        }

        this.close();
    }

    _isItemDisabled(item) {
        return (
            !item ||
            item.getAttribute('aria-disabled') === 'true' ||
            item.dataset.disabled === 'true' ||
            item.hasAttribute('disabled')
        );
    }

    _enabledItems() {
        // Exclude items nested in a sub-menu: they are not part of this menu's roving focus.
        return this.itemTargets.filter(
            (item) => !this._isItemDisabled(item) && !item.closest('[data-slot="context-menu-sub-content"]')
        );
    }

    _focusFirstItem() {
        this._enabledItems()[0]?.focus();
    }

    _focusLastItem() {
        const items = this._enabledItems();
        items[items.length - 1]?.focus();
    }

    _focusNextItem() {
        const items = this._enabledItems();
        if (items.length === 0) return;
        const index = items.indexOf(document.activeElement);
        items[index === -1 ? 0 : (index + 1) % items.length].focus();
    }

    _focusPrevItem() {
        const items = this._enabledItems();
        if (items.length === 0) return;
        const index = items.indexOf(document.activeElement);
        items[index === -1 ? items.length - 1 : (index - 1 + items.length) % items.length].focus();
    }
}
assets/controllers/context_menu_sub_controller.js
import { Controller } from '@hotwired/stimulus';

const VIEWPORT_MARGIN = 8;

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

    static values = {
        openDelay: { type: Number, default: 0 },
        closeDelay: { type: Number, default: 0 },
    };

    connect() {
        this.openTimeout = null;
        this.closeTimeout = null;
        this.#setState('closed');
    }

    disconnect() {
        this.#clearTimeouts();
    }

    show() {
        this.#clearTimeouts();
        this.openTimeout = setTimeout(() => {
            this.#open();
            this.openTimeout = null;
        }, this.openDelayValue);
    }

    hide() {
        this.#clearTimeouts();
        this.closeTimeout = setTimeout(() => {
            this.#setState('closed');
            this.closeTimeout = null;
        }, this.closeDelayValue);
    }

    close(event) {
        if (!event.target.contains(this.element)) {
            return;
        }

        this.#clearTimeouts();
        this.#setState('closed');
    }

    focusOut(event) {
        if (!this.element.contains(event.relatedTarget)) {
            this.hide();
        }
    }

    onKeydown(event) {
        if (!this.hasTriggerTarget || !this.hasContentTarget) {
            return;
        }

        const rtl = getComputedStyle(this.element).direction === 'rtl';
        const enterKey = rtl ? 'ArrowLeft' : 'ArrowRight';
        const exitKey = rtl ? 'ArrowRight' : 'ArrowLeft';

        if (event.target === this.triggerTarget) {
            if ([enterKey, 'Enter', ' '].includes(event.key)) {
                event.preventDefault();
                event.stopPropagation();
                this.#clearTimeouts();
                this.#open();
                this.#items()[0]?.focus();
            }

            return;
        }

        if (event.target.closest('[data-slot="context-menu-sub-content"]') !== this.contentTarget) {
            return;
        }

        const items = this.#items();
        const index = items.indexOf(event.target);
        let next;
        switch (event.key) {
            case exitKey:
                next = this.triggerTarget;
                break;
            case 'ArrowDown':
                next = items[index === -1 ? 0 : (index + 1) % items.length];
                break;
            case 'ArrowUp':
                next = items[index === -1 ? items.length - 1 : (index - 1 + items.length) % items.length];
                break;
            case 'Home':
                next = items[0];
                break;
            case 'End':
                next = items[items.length - 1];
                break;
            default:
                return;
        }

        event.preventDefault();
        event.stopPropagation();
        next?.focus();
    }

    #open() {
        this.#place();
        this.#setState('open');
    }

    #items() {
        return [...this.contentTarget.querySelectorAll('[data-context-menu-target="item"]')].filter(
            (item) =>
                item.closest('[data-slot="context-menu-sub-content"]') === this.contentTarget &&
                item.getAttribute('aria-disabled') !== 'true'
        );
    }

    // State lives on the trigger and content directly (not a parent group) so nested
    // sub-menus each react only to their own state.
    #setState(state) {
        this.element.dataset.state = state;
        if (this.hasTriggerTarget) {
            this.triggerTarget.dataset.state = state;
        }
        if (this.hasContentTarget) {
            this.contentTarget.dataset.state = state;
        }
    }

    #place() {
        if (!this.hasContentTarget) {
            return;
        }

        const content = this.contentTarget;
        const anchor = this.element.getBoundingClientRect();
        const viewportWidth = document.documentElement.clientWidth;
        const viewportHeight = document.documentElement.clientHeight;
        const rtl = getComputedStyle(this.element).direction === 'rtl';
        const needed = content.offsetWidth + parseFloat(getComputedStyle(content).marginInlineStart);
        const endSpace = rtl ? anchor.left - VIEWPORT_MARGIN : viewportWidth - VIEWPORT_MARGIN - anchor.right;
        const startSpace = rtl ? viewportWidth - VIEWPORT_MARGIN - anchor.right : anchor.left - VIEWPORT_MARGIN;
        const side = needed <= endSpace || endSpace >= startSpace ? 'inline-end' : 'inline-start';

        if (content.dataset.side !== side) {
            content.style.transition = 'none';
            content.dataset.side = side;
            // Commits the new side's closed offset, so the open transition slides from that side.
            content.getBoundingClientRect();
            content.style.transition = '';
        }

        const overflow = anchor.top + content.offsetHeight - (viewportHeight - VIEWPORT_MARGIN);
        const shift = Math.min(Math.max(overflow, 0), Math.max(anchor.top - VIEWPORT_MARGIN, 0));
        content.style.top = shift > 0 ? `${-shift}px` : '';
    }

    #clearTimeouts() {
        if (this.openTimeout) {
            clearTimeout(this.openTimeout);
            this.openTimeout = null;
        }
        if (this.closeTimeout) {
            clearTimeout(this.closeTimeout);
            this.closeTimeout = null;
        }
    }
}
templates/components/ContextMenu.html.twig
<div
    data-slot="context-menu"
    {{ attributes.defaults({
        class: 'contents'|tailwind_classes,
        'data-controller': 'context-menu',
    }) }}
>
    {##- A `ContextMenu:Trigger` and a `ContextMenu:Content`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/ContextMenu/CheckboxItem.html.twig
{%- props
    ## boolean Whether the checkbox is initially checked.
    checked = false,
    ## boolean Whether the item is disabled.
    disabled = false,
    ## boolean Whether selecting the item closes the menu.
    closeOnSelect = false
-%}
<button
    type="button"
    role="menuitemcheckbox"
    tabindex="-1"
    data-slot="context-menu-checkbox-item"
    data-context-menu-target="item"
    data-close-on-select="{{ closeOnSelect ? 'true' : 'false' }}"
    data-disabled="{{ disabled ? 'true' : 'false' }}"
    aria-checked="{{ checked ? 'true' : 'false' }}"
    aria-disabled="{{ disabled ? 'true' : 'false' }}"
    {%- if disabled %} disabled{% endif %}
    {{ attributes.defaults({
        class: 'relative flex w-full cursor-default select-none items-center gap-1.5 rounded-md py-1 ps-1.5 pe-8 text-sm outline-hidden transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*=size-])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0'|tailwind_classes,
        'data-action': 'click->context-menu#toggleCheckbox',
    }) }}
>
    <span class="pointer-events-none absolute end-2 flex items-center justify-center" data-checkbox-indicator {% if not checked %}hidden{% endif %}>
        <twig:ux:icon name="lucide:check" class="size-4" aria-hidden="true" />
    </span>
    {##- The label text. -#}
    {%- block content %}{% endblock -%}
</button>
templates/components/ContextMenu/Content.html.twig
{%- props
    ## 'top'|'right'|'bottom'|'left' Which side of the pointer the menu appears on.
    side = 'bottom'
-%}
<div
    role="menu"
    tabindex="-1"
    aria-orientation="vertical"
    data-slot="context-menu-content"
    data-context-menu-target="content"
    data-state="closed"
    data-side="{{ side }}"
    {{ attributes.defaults({
        class: 'invisible fixed left-0 top-0 z-50 min-w-36 scale-95 rounded-lg bg-popover p-1 text-popover-foreground opacity-0 shadow-md ring-1 ring-foreground/10 outline-hidden transition-[opacity,scale,visibility] duration-100 ease-out data-[state=open]:transition-[opacity,scale] data-[state=open]:visible data-[state=open]:scale-100 data-[state=open]:opacity-100'|tailwind_classes,
        'data-action': 'keydown->context-menu#onContentKeydown contextmenu->context-menu#preventContextMenu',
    }) }}
>
    {##- The menu items, e.g. `ContextMenu:Item`, `ContextMenu:Separator`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/ContextMenu/Group.html.twig
<div role="group" data-slot="context-menu-group" {{ attributes }}>
    {##- One or more `ContextMenu:Item` components. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/ContextMenu/Item.html.twig
{%- props
    ## 'default'|'destructive' The visual style variant.
    variant = 'default',
    ## boolean Whether the item is disabled.
    disabled = false,
    ## boolean Whether the item is inset with extra start padding.
    inset = false,
    ## boolean Whether selecting the item closes the menu.
    closeOnSelect = true
-%}
{%- set style = html_cva(
    base: 'group/context-menu-item relative flex w-full cursor-default select-none items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*=size-])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0',
    variants: {
        variant: {
            default: 'hover:**:text-accent-foreground focus:**:text-accent-foreground',
            destructive: 'text-destructive hover:bg-destructive/10 hover:text-destructive focus:bg-destructive/10 focus:text-destructive dark:hover:bg-destructive/20 dark:focus:bg-destructive/20 *:[svg]:text-destructive',
        },
        inset: {
            true: 'ps-7',
        },
    },
) -%}
<button
    type="button"
    role="menuitem"
    tabindex="-1"
    data-slot="context-menu-item"
    data-context-menu-target="item"
    data-variant="{{ variant }}"
    data-close-on-select="{{ closeOnSelect ? 'true' : 'false' }}"
    data-disabled="{{ disabled ? 'true' : 'false' }}"
    aria-disabled="{{ disabled ? 'true' : 'false' }}"
    {%- if disabled %} disabled{% endif %}
    {{ attributes.defaults({
        class: style.apply({variant: variant, inset: inset ? 'true' : 'false'})|tailwind_classes,
        'data-action': 'click->context-menu#closeFromItem',
    }) }}
>
    {##- The item label. -#}
    {%- block content %}{% endblock -%}
</button>
templates/components/ContextMenu/Label.html.twig
{%- props
    ## boolean Whether the label is inset with extra start padding.
    inset = false
-%}
{%- set style = html_cva(
    base: 'px-1.5 py-1 text-xs font-medium text-muted-foreground',
    variants: {
        inset: {
            true: 'ps-7',
        },
    },
) -%}
<div
    data-slot="context-menu-label"
    {{ attributes.defaults({
        class: style.apply({inset: inset ? 'true' : 'false'})|tailwind_classes,
    }) }}
>
    {##- The label text. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/ContextMenu/RadioGroup.html.twig
{%- props
    ## string|null The currently selected value.
    value = null
-%}
<div
    role="group"
    data-slot="context-menu-radio-group"
    data-value="{{ value }}"
    {{ attributes }}
>
    {##- One or more `ContextMenu:RadioItem` components. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/ContextMenu/RadioItem.html.twig
{%- props
    ## string The value submitted when this radio is selected.
    value,
    ## boolean Whether this radio is initially checked.
    checked = false,
    ## boolean Whether the item is disabled.
    disabled = false,
    ## boolean Whether selecting the item closes the menu.
    closeOnSelect = false
-%}
<button
    type="button"
    role="menuitemradio"
    tabindex="-1"
    data-slot="context-menu-radio-item"
    data-context-menu-target="item"
    data-value="{{ value }}"
    data-close-on-select="{{ closeOnSelect ? 'true' : 'false' }}"
    data-disabled="{{ disabled ? 'true' : 'false' }}"
    aria-checked="{{ checked ? 'true' : 'false' }}"
    aria-disabled="{{ disabled ? 'true' : 'false' }}"
    {%- if disabled %} disabled{% endif %}
    {{ attributes.defaults({
        class: 'relative flex w-full cursor-default select-none items-center gap-1.5 rounded-md py-1 ps-1.5 pe-8 text-sm outline-hidden transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*=size-])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0'|tailwind_classes,
        'data-action': 'click->context-menu#selectRadio',
    }) }}
>
    <span class="pointer-events-none absolute end-2 flex items-center justify-center" data-radio-indicator {% if not checked %}hidden{% endif %}>
        <twig:ux:icon name="lucide:check" class="size-4" aria-hidden="true" />
    </span>
    {##- The label text. -#}
    {%- block content %}{% endblock -%}
</button>
templates/components/ContextMenu/Separator.html.twig
<div
    role="separator"
    aria-orientation="horizontal"
    data-slot="context-menu-separator"
    {{ attributes.defaults({
        class: '-mx-1 my-1 h-px bg-border'|tailwind_classes,
    }) }}
></div>
templates/components/ContextMenu/Shortcut.html.twig
<span
    data-slot="context-menu-shortcut"
    {{ attributes.defaults({
        class: 'ms-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground'|tailwind_classes,
    }) }}
>
    {##- The shortcut label, e.g. "⌘K". -#}
    {%- block content %}{% endblock -%}
</span>
templates/components/ContextMenu/Sub.html.twig
{%- props
    ## number Delay in milliseconds before showing the sub-menu.
    openDelay = 0,
    ## number Delay in milliseconds before hiding the sub-menu.
    closeDelay = 0
-%}
<div
    data-slot="context-menu-sub"
    data-context-menu-sub-open-delay-value="{{ openDelay }}"
    data-context-menu-sub-close-delay-value="{{ closeDelay }}"
    {{ attributes.defaults({
        class: 'relative'|tailwind_classes,
        'data-controller': 'context-menu-sub',
        'data-action': 'mouseenter->context-menu-sub#show mouseleave->context-menu-sub#hide focusout->context-menu-sub#focusOut keydown->context-menu-sub#onKeydown context-menu:close@window->context-menu-sub#close',
    }) }}
>
    {##- A `ContextMenu:SubTrigger` and a `ContextMenu:SubContent`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/ContextMenu/SubContent.html.twig
<div
    role="menu"
    data-slot="context-menu-sub-content"
    data-context-menu-sub-target="content"
    data-state="closed"
    data-side="inline-end"
    {{ attributes.defaults({
        class: 'invisible opacity-0 transition-[opacity,translate,visibility] duration-100 ease-out data-[state=open]:transition-[opacity,translate] data-[state=open]:visible data-[state=open]:opacity-100 data-[state=open]:translate-x-0 data-[state=closed]:ltr:-translate-x-2 data-[state=closed]:rtl:translate-x-2 data-[side=inline-start]:data-[state=closed]:ltr:translate-x-2 data-[side=inline-start]:data-[state=closed]:rtl:-translate-x-2 absolute data-[side=inline-end]:start-full data-[side=inline-start]:end-full top-0 z-50 mx-1 w-max min-w-32 rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg outline-hidden'|tailwind_classes,
    }) }}
>
    {##- The sub-menu items, e.g. `ContextMenu:Item`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/ContextMenu/SubTrigger.html.twig
{%- props
    ## boolean Whether the trigger is inset with extra start padding.
    inset = false
-%}
{%- set style = html_cva(
    base: 'relative flex w-full cursor-default select-none items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*=size-])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0',
    variants: {
        inset: {
            true: 'ps-7',
        },
    },
) -%}
<button
    type="button"
    role="menuitem"
    tabindex="-1"
    aria-haspopup="menu"
    data-slot="context-menu-sub-trigger"
    data-context-menu-target="item"
    data-context-menu-sub-target="trigger"
    data-state="closed"
    {{ attributes.defaults({
        class: style.apply({inset: inset ? 'true' : 'false'})|tailwind_classes,
        'data-action': 'mouseenter->context-menu-sub#show focus->context-menu-sub#show',
    }) }}
>
    {##- The sub-menu trigger label. -#}
    {%- block content %}{% endblock -%}
    <twig:ux:icon name="lucide:chevron-right" class="ms-auto size-4 rtl:rotate-180" aria-hidden="true" />
</button>
templates/components/ContextMenu/Trigger.html.twig
<div
    data-slot="context-menu-trigger"
    data-context-menu-target="trigger"
    {{ attributes.defaults({
        class: 'select-none [-webkit-touch-callout:none]'|tailwind_classes,
        'data-action': 'contextmenu->context-menu#open pointerdown->context-menu#startLongPress pointermove->context-menu#moveLongPress pointerup->context-menu#cancelLongPress pointercancel->context-menu#cancelLongPress',
    }) }}
>
    {##- The region that opens the menu on right click or long press. -#}
    {%- block content %}{% endblock -%}
</div>

Usage

<twig:ContextMenu>
    <twig:ContextMenu:Trigger class="flex h-40 items-center justify-center rounded-xl border border-dashed">
        Right click here
    </twig:ContextMenu:Trigger>
    <twig:ContextMenu:Content side="bottom">
        <twig:ContextMenu:Item>Back</twig:ContextMenu:Item>
        <twig:ContextMenu:Item>Reload</twig:ContextMenu:Item>
        <twig:ContextMenu:Separator />
        <twig:ContextMenu:Item variant="destructive">Delete</twig:ContextMenu:Item>
    </twig:ContextMenu:Content>
</twig:ContextMenu>

Examples

Basic

100%
Loading...
<div class="w-full max-w-xs">
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click here</span>
            <span class="hidden pointer-coarse:inline-block">Long press here</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content>
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>Back</twig:ContextMenu:Item>
                <twig:ContextMenu:Item disabled>Forward</twig:ContextMenu:Item>
                <twig:ContextMenu:Item>Reload</twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
</div>

Submenu

Use ContextMenu:Sub to nest secondary actions.

100%
Loading...
<div class="w-full max-w-xs" style="min-height: 280px">
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click here</span>
            <span class="hidden pointer-coarse:inline-block">Long press here</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content>
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>
                    Copy
                    <twig:ContextMenu:Shortcut>⌘C</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    Cut
                    <twig:ContextMenu:Shortcut>⌘X</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
            <twig:ContextMenu:Sub>
                <twig:ContextMenu:SubTrigger>More Tools</twig:ContextMenu:SubTrigger>
                <twig:ContextMenu:SubContent>
                    <twig:ContextMenu:Group>
                        <twig:ContextMenu:Item>Save Page...</twig:ContextMenu:Item>
                        <twig:ContextMenu:Item>Create Shortcut...</twig:ContextMenu:Item>
                        <twig:ContextMenu:Item>Name Window...</twig:ContextMenu:Item>
                    </twig:ContextMenu:Group>
                    <twig:ContextMenu:Separator />
                    <twig:ContextMenu:Group>
                        <twig:ContextMenu:Item>Developer Tools</twig:ContextMenu:Item>
                    </twig:ContextMenu:Group>
                    <twig:ContextMenu:Separator />
                    <twig:ContextMenu:Group>
                        <twig:ContextMenu:Item variant="destructive">Delete</twig:ContextMenu:Item>
                    </twig:ContextMenu:Group>
                </twig:ContextMenu:SubContent>
            </twig:ContextMenu:Sub>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
</div>

Shortcuts

Add ContextMenu:Shortcut to show keyboard hints.

100%
Loading...
<div class="w-full max-w-xs" style="min-height: 210px">
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click here</span>
            <span class="hidden pointer-coarse:inline-block">Long press here</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content>
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>
                    Back
                    <twig:ContextMenu:Shortcut>⌘[</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item disabled>
                    Forward
                    <twig:ContextMenu:Shortcut>⌘]</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    Reload
                    <twig:ContextMenu:Shortcut>⌘R</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
            <twig:ContextMenu:Separator />
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>
                    Save
                    <twig:ContextMenu:Shortcut>⌘S</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    Save As...
                    <twig:ContextMenu:Shortcut>⇧⌘S</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
</div>

Groups

100%
Loading...
<div class="w-full max-w-xs" style="min-height: 390px">
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click here</span>
            <span class="hidden pointer-coarse:inline-block">Long press here</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content>
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Label>File</twig:ContextMenu:Label>
                <twig:ContextMenu:Item>
                    New File
                    <twig:ContextMenu:Shortcut>⌘N</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    Open File
                    <twig:ContextMenu:Shortcut>⌘O</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    Save
                    <twig:ContextMenu:Shortcut>⌘S</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
            <twig:ContextMenu:Separator />
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Label>Edit</twig:ContextMenu:Label>
                <twig:ContextMenu:Item>
                    Undo
                    <twig:ContextMenu:Shortcut>⌘Z</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    Redo
                    <twig:ContextMenu:Shortcut>⇧⌘Z</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
            <twig:ContextMenu:Separator />
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>
                    Cut
                    <twig:ContextMenu:Shortcut>⌘X</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    Copy
                    <twig:ContextMenu:Shortcut>⌘C</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    Paste
                    <twig:ContextMenu:Shortcut>⌘V</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
            <twig:ContextMenu:Separator />
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item variant="destructive">
                    Delete
                    <twig:ContextMenu:Shortcut>⌫</twig:ContextMenu:Shortcut>
                </twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
</div>

Icons

100%
Loading...
<div class="w-full max-w-xs">
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click here</span>
            <span class="hidden pointer-coarse:inline-block">Long press here</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content>
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>
                    <twig:ux:icon name="lucide:copy" />
                    Copy
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    <twig:ux:icon name="lucide:scissors" />
                    Cut
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    <twig:ux:icon name="lucide:clipboard-paste" />
                    Paste
                </twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
            <twig:ContextMenu:Separator />
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item variant="destructive">
                    <twig:ux:icon name="lucide:trash" />
                    Delete
                </twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
</div>

Checkboxes

Use ContextMenu:CheckboxItem for toggles.

100%
Loading...
<div class="w-full max-w-xs">
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click here</span>
            <span class="hidden pointer-coarse:inline-block">Long press here</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content>
            <twig:ContextMenu:Group>
                <twig:ContextMenu:CheckboxItem checked>Show Bookmarks Bar</twig:ContextMenu:CheckboxItem>
                <twig:ContextMenu:CheckboxItem>Show Full URLs</twig:ContextMenu:CheckboxItem>
                <twig:ContextMenu:CheckboxItem checked>Show Developer Tools</twig:ContextMenu:CheckboxItem>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
</div>

Radio

Use ContextMenu:RadioItem for exclusive choices.

100%
Loading...
<div class="w-full max-w-xs" style="min-height: 260px">
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click here</span>
            <span class="hidden pointer-coarse:inline-block">Long press here</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content>
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Label>People</twig:ContextMenu:Label>
                <twig:ContextMenu:RadioGroup value="pedro">
                    <twig:ContextMenu:RadioItem value="pedro" checked>Pedro Duarte</twig:ContextMenu:RadioItem>
                    <twig:ContextMenu:RadioItem value="colm">Colm Tuite</twig:ContextMenu:RadioItem>
                </twig:ContextMenu:RadioGroup>
            </twig:ContextMenu:Group>
            <twig:ContextMenu:Separator />
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Label>Theme</twig:ContextMenu:Label>
                <twig:ContextMenu:RadioGroup value="light">
                    <twig:ContextMenu:RadioItem value="light" checked>Light</twig:ContextMenu:RadioItem>
                    <twig:ContextMenu:RadioItem value="dark">Dark</twig:ContextMenu:RadioItem>
                    <twig:ContextMenu:RadioItem value="system">System</twig:ContextMenu:RadioItem>
                </twig:ContextMenu:RadioGroup>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
</div>

Destructive

Use variant="destructive" to style the menu item as destructive.

100%
Loading...
<div class="w-full max-w-xs">
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click here</span>
            <span class="hidden pointer-coarse:inline-block">Long press here</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content>
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>
                    <twig:ux:icon name="lucide:pencil" />
                    Edit
                </twig:ContextMenu:Item>
                <twig:ContextMenu:Item>
                    <twig:ux:icon name="lucide:share" />
                    Share
                </twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
            <twig:ContextMenu:Separator />
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item variant="destructive">
                    <twig:ux:icon name="lucide:trash" />
                    Delete
                </twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
</div>

Sides

Use the side prop on ContextMenu:Content to choose which side of the pointer the menu appears on.

100%
Loading...
<div class="grid w-full max-w-sm grid-cols-2 gap-4">
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click (top)</span>
            <span class="hidden pointer-coarse:inline-block">Long press (top)</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content side="top">
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>Back</twig:ContextMenu:Item>
                <twig:ContextMenu:Item>Forward</twig:ContextMenu:Item>
                <twig:ContextMenu:Item>Reload</twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click (right)</span>
            <span class="hidden pointer-coarse:inline-block">Long press (right)</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content side="right">
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>Back</twig:ContextMenu:Item>
                <twig:ContextMenu:Item>Forward</twig:ContextMenu:Item>
                <twig:ContextMenu:Item>Reload</twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click (bottom)</span>
            <span class="hidden pointer-coarse:inline-block">Long press (bottom)</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content side="bottom">
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>Back</twig:ContextMenu:Item>
                <twig:ContextMenu:Item>Forward</twig:ContextMenu:Item>
                <twig:ContextMenu:Item>Reload</twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
    <twig:ContextMenu>
        <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
            <span class="hidden pointer-fine:inline-block">Right click (left)</span>
            <span class="hidden pointer-coarse:inline-block">Long press (left)</span>
        </twig:ContextMenu:Trigger>
        <twig:ContextMenu:Content side="left">
            <twig:ContextMenu:Group>
                <twig:ContextMenu:Item>Back</twig:ContextMenu:Item>
                <twig:ContextMenu:Item>Forward</twig:ContextMenu:Item>
                <twig:ContextMenu:Item>Reload</twig:ContextMenu:Item>
            </twig:ContextMenu:Group>
        </twig:ContextMenu:Content>
    </twig:ContextMenu>
</div>

RTL

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

100%
Loading...
<div class="flex w-full flex-col gap-6">
    <div dir="rtl">
        <twig:ContextMenu>
            <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
                <span class="hidden pointer-fine:inline-block">انقر بزر الماوس الأيمن هنا</span>
                <span class="hidden pointer-coarse:inline-block">اضغط مطولاً هنا</span>
            </twig:ContextMenu:Trigger>
            <twig:ContextMenu:Content class="w-48">
                <twig:ContextMenu:Group>
                    <twig:ContextMenu:Sub>
                        <twig:ContextMenu:SubTrigger>التنقل</twig:ContextMenu:SubTrigger>
                        <twig:ContextMenu:SubContent class="w-44">
                            <twig:ContextMenu:Group>
                                <twig:ContextMenu:Item>
                                    <twig:ux:icon name="lucide:arrow-left" />
                                    رجوع
                                    <twig:ContextMenu:Shortcut>⌘[</twig:ContextMenu:Shortcut>
                                </twig:ContextMenu:Item>
                                <twig:ContextMenu:Item disabled>
                                    <twig:ux:icon name="lucide:arrow-right" />
                                    تقدم
                                    <twig:ContextMenu:Shortcut>⌘]</twig:ContextMenu:Shortcut>
                                </twig:ContextMenu:Item>
                                <twig:ContextMenu:Item>
                                    <twig:ux:icon name="lucide:rotate-cw" />
                                    إعادة تحميل
                                    <twig:ContextMenu:Shortcut>⌘R</twig:ContextMenu:Shortcut>
                                </twig:ContextMenu:Item>
                            </twig:ContextMenu:Group>
                        </twig:ContextMenu:SubContent>
                    </twig:ContextMenu:Sub>
                    <twig:ContextMenu:Sub>
                        <twig:ContextMenu:SubTrigger>المزيد من الأدوات</twig:ContextMenu:SubTrigger>
                        <twig:ContextMenu:SubContent class="w-44">
                            <twig:ContextMenu:Group>
                                <twig:ContextMenu:Item>حفظ الصفحة...</twig:ContextMenu:Item>
                                <twig:ContextMenu:Item>إنشاء اختصار...</twig:ContextMenu:Item>
                                <twig:ContextMenu:Item>تسمية النافذة...</twig:ContextMenu:Item>
                            </twig:ContextMenu:Group>
                            <twig:ContextMenu:Separator />
                            <twig:ContextMenu:Group>
                                <twig:ContextMenu:Item>أدوات المطور</twig:ContextMenu:Item>
                            </twig:ContextMenu:Group>
                            <twig:ContextMenu:Separator />
                            <twig:ContextMenu:Group>
                                <twig:ContextMenu:Item variant="destructive">حذف</twig:ContextMenu:Item>
                            </twig:ContextMenu:Group>
                        </twig:ContextMenu:SubContent>
                    </twig:ContextMenu:Sub>
                </twig:ContextMenu:Group>
                <twig:ContextMenu:Separator />
                <twig:ContextMenu:Group>
                    <twig:ContextMenu:CheckboxItem checked>إظهار الإشارات المرجعية</twig:ContextMenu:CheckboxItem>
                    <twig:ContextMenu:CheckboxItem>إظهار عناوين URL الكاملة</twig:ContextMenu:CheckboxItem>
                </twig:ContextMenu:Group>
                <twig:ContextMenu:Separator />
                <twig:ContextMenu:Group>
                    <twig:ContextMenu:RadioGroup value="pedro">
                        <twig:ContextMenu:Label>الأشخاص</twig:ContextMenu:Label>
                        <twig:ContextMenu:RadioItem value="pedro" checked>Pedro Duarte</twig:ContextMenu:RadioItem>
                        <twig:ContextMenu:RadioItem value="colm">Colm Tuite</twig:ContextMenu:RadioItem>
                    </twig:ContextMenu:RadioGroup>
                </twig:ContextMenu:Group>
            </twig:ContextMenu:Content>
        </twig:ContextMenu>
    </div>
    <div dir="rtl">
        <twig:ContextMenu>
            <twig:ContextMenu:Trigger class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm">
                <span class="hidden pointer-fine:inline-block">לחץ לחיצה ימנית כאן</span>
                <span class="hidden pointer-coarse:inline-block">לחץ לחיצה ארוכה כאן</span>
            </twig:ContextMenu:Trigger>
            <twig:ContextMenu:Content class="w-48">
                <twig:ContextMenu:Group>
                    <twig:ContextMenu:Sub>
                        <twig:ContextMenu:SubTrigger>ניווט</twig:ContextMenu:SubTrigger>
                        <twig:ContextMenu:SubContent class="w-44">
                            <twig:ContextMenu:Group>
                                <twig:ContextMenu:Item>
                                    <twig:ux:icon name="lucide:arrow-left" />
                                    חזור
                                    <twig:ContextMenu:Shortcut>⌘[</twig:ContextMenu:Shortcut>
                                </twig:ContextMenu:Item>
                                <twig:ContextMenu:Item disabled>
                                    <twig:ux:icon name="lucide:arrow-right" />
                                    קדימה
                                    <twig:ContextMenu:Shortcut>⌘]</twig:ContextMenu:Shortcut>
                                </twig:ContextMenu:Item>
                                <twig:ContextMenu:Item>
                                    <twig:ux:icon name="lucide:rotate-cw" />
                                    רענן
                                    <twig:ContextMenu:Shortcut>⌘R</twig:ContextMenu:Shortcut>
                                </twig:ContextMenu:Item>
                            </twig:ContextMenu:Group>
                        </twig:ContextMenu:SubContent>
                    </twig:ContextMenu:Sub>
                    <twig:ContextMenu:Sub>
                        <twig:ContextMenu:SubTrigger>כלים נוספים</twig:ContextMenu:SubTrigger>
                        <twig:ContextMenu:SubContent class="w-44">
                            <twig:ContextMenu:Group>
                                <twig:ContextMenu:Item>שמור עמוד...</twig:ContextMenu:Item>
                                <twig:ContextMenu:Item>צור קיצור דרך...</twig:ContextMenu:Item>
                                <twig:ContextMenu:Item>שם חלון...</twig:ContextMenu:Item>
                            </twig:ContextMenu:Group>
                            <twig:ContextMenu:Separator />
                            <twig:ContextMenu:Group>
                                <twig:ContextMenu:Item>כלי מפתח</twig:ContextMenu:Item>
                            </twig:ContextMenu:Group>
                            <twig:ContextMenu:Separator />
                            <twig:ContextMenu:Group>
                                <twig:ContextMenu:Item variant="destructive">מחק</twig:ContextMenu:Item>
                            </twig:ContextMenu:Group>
                        </twig:ContextMenu:SubContent>
                    </twig:ContextMenu:Sub>
                </twig:ContextMenu:Group>
                <twig:ContextMenu:Separator />
                <twig:ContextMenu:Group>
                    <twig:ContextMenu:CheckboxItem checked>הצג סימניות</twig:ContextMenu:CheckboxItem>
                    <twig:ContextMenu:CheckboxItem>הצג כתובות URL מלאות</twig:ContextMenu:CheckboxItem>
                </twig:ContextMenu:Group>
                <twig:ContextMenu:Separator />
                <twig:ContextMenu:Group>
                    <twig:ContextMenu:RadioGroup value="pedro">
                        <twig:ContextMenu:Label>אנשים</twig:ContextMenu:Label>
                        <twig:ContextMenu:RadioItem value="pedro" checked>Pedro Duarte</twig:ContextMenu:RadioItem>
                        <twig:ContextMenu:RadioItem value="colm">Colm Tuite</twig:ContextMenu:RadioItem>
                    </twig:ContextMenu:RadioGroup>
                </twig:ContextMenu:Group>
            </twig:ContextMenu:Content>
        </twig:ContextMenu>
    </div>
</div>

Accessibility

  • ContextMenu:Content renders role="menu" and its entries role="menuitem", role="menuitemcheckbox" or role="menuitemradio", with aria-checked kept in sync by the controller.
  • The menu opens on right click, on the Menu key or Shift+F10 when the trigger has focus, and on a long press on touch screens.
  • ArrowDown and ArrowUp move between items and wrap around, Home and End jump to the first and last, and Escape closes the menu. Disabled items are skipped.
  • On a submenu trigger, ArrowRight, Enter or Space moves the focus to the first item of the submenu (ArrowLeft instead of ArrowRight in right-to-left layouts). Inside a submenu, ArrowLeft (ArrowRight in right-to-left layouts) moves the focus back to its trigger, and the arrow keys, Home and End stay within the submenu's own items.
  • ContextMenu:Separator renders role="separator", and ContextMenu:Group renders role="group".
  • A disabled item renders aria-disabled="true" rather than being removed, so its presence stays discoverable.
  • The trigger is not a focusable element by default. Give it tabindex="0" when keyboard users must be able to open the menu, and give the menu an accessible name with aria-label on ContextMenu:Content.

API Reference

<twig:ContextMenu>

Block Description
content A ContextMenu:Trigger and a ContextMenu:Content.

<twig:ContextMenu:CheckboxItem>

Prop Type Default
checked 
boolean false
disabled 
boolean false
closeOnSelect 
boolean false
Block Description
content The label text.

<twig:ContextMenu:Content>

Prop Type Default
side 
'top'|'right'|'bottom'|'left' 'bottom'
Block Description
content The menu items, e.g. ContextMenu:Item, ContextMenu:Separator.

<twig:ContextMenu:Group>

Block Description
content One or more ContextMenu:Item components.

<twig:ContextMenu:Item>

Prop Type Default
variant 
'default'|'destructive' 'default'
disabled 
boolean false
inset 
boolean false
closeOnSelect 
boolean true
Block Description
content The item label.

<twig:ContextMenu:Label>

Prop Type Default
inset 
boolean false
Block Description
content The label text.

<twig:ContextMenu:RadioGroup>

Prop Type Default
value 
string|null null
Block Description
content One or more ContextMenu:RadioItem components.

<twig:ContextMenu:RadioItem>

Prop Type Default
value 
string -
checked 
boolean false
disabled 
boolean false
closeOnSelect 
boolean false
Block Description
content The label text.

<twig:ContextMenu:Shortcut>

Block Description
content The shortcut label, e.g. "⌘K".

<twig:ContextMenu:Sub>

Prop Type Default
openDelay 
number 0
closeDelay 
number 0
Block Description
content A ContextMenu:SubTrigger and a ContextMenu:SubContent.

<twig:ContextMenu:SubContent>

Block Description
content The sub-menu items, e.g. ContextMenu:Item.

<twig:ContextMenu:SubTrigger>

Prop Type Default
inset 
boolean false
Block Description
content The sub-menu trigger label.

<twig:ContextMenu:Trigger>

Block Description
content The region that opens the menu on right click or long press.