View as Markdown

Navigation Menu

A collection of navigation links with optional hover-triggered submenus.

100%
Loading...
<div class="flex items-start justify-center" style="min-height: 320px">
    <twig:NavigationMenu>
        <twig:NavigationMenu:List>
            <twig:NavigationMenu:Item>
                <twig:NavigationMenu:Trigger>Getting started</twig:NavigationMenu:Trigger>
                <twig:NavigationMenu:Content>
                    <ul class="grid w-96 gap-1">
                        <li>
                            <a class="block rounded-md p-3 hover:bg-accent" href="/docs">
                                <div class="text-sm font-medium leading-none">Introduction</div>
                                <p class="line-clamp-2 text-sm text-muted-foreground">Reusable components built with Tailwind CSS.</p>
                            </a>
                        </li>
                        <li>
                            <a class="block rounded-md p-3 hover:bg-accent" href="/docs/installation">
                                <div class="text-sm font-medium leading-none">Installation</div>
                                <p class="line-clamp-2 text-sm text-muted-foreground">How to install dependencies and structure your app.</p>
                            </a>
                        </li>
                        <li>
                            <a class="block rounded-md p-3 hover:bg-accent" href="/docs/primitives/typography">
                                <div class="text-sm font-medium leading-none">Typography</div>
                                <p class="line-clamp-2 text-sm text-muted-foreground">Styles for headings, paragraphs, lists, and more.</p>
                            </a>
                        </li>
                    </ul>
                </twig:NavigationMenu:Content>
            </twig:NavigationMenu:Item>
            <twig:NavigationMenu:Item>
                <twig:NavigationMenu:Trigger>Components</twig:NavigationMenu:Trigger>
                <twig:NavigationMenu:Content>
                    <ul class="grid w-[500px] grid-cols-2 gap-2">
                        {% set components = [
                            {title: 'Alert Dialog', href: '/docs/primitives/alert-dialog', description: 'A modal dialog that interrupts the user with important content and expects a response.'},
                            {title: 'Hover Card', href: '/docs/primitives/hover-card', description: 'For sighted users to preview content available behind a link.'},
                            {title: 'Progress', href: '/docs/primitives/progress', description: 'Displays an indicator showing the completion progress of a task.'},
                            {title: 'Scroll Area', href: '/docs/primitives/scroll-area', description: 'Visually or semantically separates content.'},
                            {title: 'Tabs', href: '/docs/primitives/tabs', description: 'Layered sections of content displayed one panel at a time.'},
                            {title: 'Tooltip', href: '/docs/primitives/tooltip', description: 'A popup that displays information related to an element on hover or focus.'},
                        ] %}
                        {% for component in components %}
                            <li>
                                <a class="block rounded-md p-3 hover:bg-accent" href="{{ component.href }}">
                                    <div class="text-sm font-medium leading-none">{{ component.title }}</div>
                                    <p class="line-clamp-2 text-sm text-muted-foreground">{{ component.description }}</p>
                                </a>
                            </li>
                        {% endfor %}
                    </ul>
                </twig:NavigationMenu:Content>
            </twig:NavigationMenu:Item>
            <twig:NavigationMenu:Item>
                <twig:NavigationMenu:Link href="/docs">Docs</twig:NavigationMenu:Link>
            </twig:NavigationMenu:Item>
            <twig:NavigationMenu:Item>
                <twig:NavigationMenu:Trigger>Resources</twig:NavigationMenu:Trigger>
                <twig:NavigationMenu:Content>
                    <ul class="grid w-[500px] grid-cols-2 gap-2">
                        {% set resources = [
                            {title: 'Blog', href: '/blog', description: 'Read the latest news and articles from the team.'},
                            {title: 'Changelog', href: '/changelog', description: 'See what shipped in every release.'},
                            {title: 'Support', href: '/support', description: 'Get help from the community and the maintainers.'},
                            {title: 'Roadmap', href: '/roadmap', description: 'Discover what we are planning to build next.'},
                        ] %}
                        {% for resource in resources %}
                            <li>
                                <a class="block rounded-md p-3 hover:bg-accent" href="{{ resource.href }}">
                                    <div class="text-sm font-medium leading-none">{{ resource.title }}</div>
                                    <p class="line-clamp-2 text-sm text-muted-foreground">{{ resource.description }}</p>
                                </a>
                            </li>
                        {% endfor %}
                    </ul>
                </twig:NavigationMenu:Content>
            </twig:NavigationMenu:Item>
        </twig:NavigationMenu:List>
    </twig:NavigationMenu>
</div>

Installation

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

Install the following Composer dependencies:

composer require 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

Copy the following file(s) into your app:

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

export default class extends Controller {
    static targets = ['item', 'trigger', 'content', 'viewport', 'viewportPositioner'];
    static values = {
        openDelay: { type: Number, default: 200 },
        closeDelay: { type: Number, default: 300 },
    };

    connect() {
        this.activeIndex = null;
        this.openTimeout = null;
        this.closeTimeout = null;
        this.contentByItem = new Map();
        this.triggerByItem = new Map();

        this.triggerTargets.forEach((trigger) => {
            const item = trigger.closest('[data-navigation-menu-target="item"]');
            if (item) {
                this.triggerByItem.set(item, trigger);
            }
        });

        this.contentTargets.forEach((content) => {
            const item = content.closest('[data-navigation-menu-target="item"]');
            if (item) {
                this.contentByItem.set(item, content);
            }
            this.viewportTarget.appendChild(content);
        });
    }

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

    open(event) {
        const item = event.currentTarget;
        const content = this.contentByItem.get(item);
        this.#clearTimeouts();

        if (!content) {
            this.scheduleClose();

            return;
        }

        const delay = this.activeIndex === null ? this.openDelayValue : 0;
        this.openTimeout = setTimeout(() => {
            this.#activate(item, content);
            this.openTimeout = null;
        }, delay);
    }

    scheduleClose() {
        this.#clearTimeouts();
        this.closeTimeout = setTimeout(() => {
            this.#setOpen(false);
            this.closeTimeout = null;
        }, this.closeDelayValue);
    }

    cancelClose() {
        this.#clearCloseTimeout();
    }

    onFocusIn(event) {
        const trigger = event.target.closest('[data-navigation-menu-target="trigger"]');
        if (!trigger) {
            return;
        }

        const item = trigger.closest('[data-navigation-menu-target="item"]');
        const content = item && this.contentByItem.get(item);
        this.#clearTimeouts();

        if (content) {
            this.#activate(item, content);
        }
    }

    onFocusOut(event) {
        if (!this.element.contains(event.relatedTarget)) {
            this.#setOpen(false);
        }
    }

    #activate(item, content) {
        const newIndex = this.itemTargets.indexOf(item);
        const motion =
            this.activeIndex === null || newIndex === this.activeIndex
                ? null
                : newIndex > this.activeIndex
                  ? 'from-end'
                  : 'from-start';

        this.contentTargets.forEach((candidate) => {
            if (candidate !== content) {
                delete candidate.dataset.active;
                delete candidate.dataset.motion;
            }
        });

        // Read layout before writing styles so the writes below don't force extra reflows.
        const width = content.offsetWidth;
        const height = content.offsetHeight;
        const navRect = this.element.getBoundingClientRect();
        const itemRect = item.getBoundingClientRect();
        const rtl = getComputedStyle(this.element).direction === 'rtl';
        const inlineStart = this.#viewportInlineStart(navRect, itemRect, width, rtl);

        this.viewportTarget.style.setProperty('--navigation-menu-viewport-width', `${width}px`);
        this.viewportTarget.style.setProperty('--navigation-menu-viewport-height', `${height}px`);
        this.viewportPositionerTarget.style.insetInlineStart = `${inlineStart}px`;

        if (motion) {
            content.dataset.motion = motion;
            // Commit the off-screen start position before activating, so the switch transitions.
            void content.offsetWidth;
            delete content.dataset.motion;
        }
        content.dataset.active = '';

        this.activeIndex = newIndex;
        this.#setOpen(true, item);
    }

    // Anchor to the trigger's inline-start, but shift so the content never overflows the viewport.
    #viewportInlineStart(navRect, itemRect, width, rtl) {
        const margin = 8;
        if (rtl) {
            const right = Math.max(width + margin, Math.min(itemRect.right, window.innerWidth - margin));

            return navRect.right - right;
        }

        const left = Math.max(margin, Math.min(itemRect.left, window.innerWidth - margin - width));

        return left - navRect.left;
    }

    #setOpen(open, activeItem = null) {
        this.viewportTarget.dataset.state = open ? 'open' : 'closed';

        if (!open) {
            this.activeIndex = null;
            this.contentTargets.forEach((content) => {
                delete content.dataset.active;
                delete content.dataset.motion;
            });
        }

        this.itemTargets.forEach((item) => {
            const isActive = open && item === activeItem;
            item.dataset.state = isActive ? 'open' : 'closed';
            const trigger = this.triggerByItem.get(item);
            if (trigger) {
                trigger.setAttribute('aria-expanded', isActive ? 'true' : 'false');
            }
        });
    }

    #clearTimeouts() {
        this.#clearOpenTimeout();
        this.#clearCloseTimeout();
    }

    #clearOpenTimeout() {
        if (this.openTimeout) {
            clearTimeout(this.openTimeout);
            this.openTimeout = null;
        }
    }

    #clearCloseTimeout() {
        if (this.closeTimeout) {
            clearTimeout(this.closeTimeout);
            this.closeTimeout = null;
        }
    }
}
templates/components/NavigationMenu.html.twig
{# @prop ariaLabel string The accessible name for the navigation landmark. #}
{# @prop openDelay number Delay in milliseconds before opening a menu on hover. #}
{# @prop closeDelay number Delay in milliseconds before closing the menu once the pointer leaves. #}
{# @block content A `NavigationMenu:List` containing `NavigationMenu:Item` children. #}
{%- props ariaLabel = 'Main', openDelay = 200, closeDelay = 300 -%}
<nav
    data-slot="navigation-menu"
    aria-label="{{ ariaLabel }}"
    data-navigation-menu-open-delay-value="{{ openDelay }}"
    data-navigation-menu-close-delay-value="{{ closeDelay }}"
    {{ attributes.defaults({
        class: 'relative flex max-w-max flex-1 items-center justify-center'|tailwind_classes,
        'data-controller': 'navigation-menu',
        'data-action': 'mouseleave->navigation-menu#scheduleClose focusin->navigation-menu#onFocusIn focusout->navigation-menu#onFocusOut',
    }) }}
>
    {%- block content %}{% endblock -%}
    <div
        data-navigation-menu-target="viewportPositioner"
        class="absolute start-0 top-full isolate z-50 transition-[inset-inline-start] duration-150 ease-out"
        data-action="mouseenter->navigation-menu#cancelClose mouseleave->navigation-menu#scheduleClose"
    >
        <div
            data-slot="navigation-menu-viewport"
            data-navigation-menu-target="viewport"
            data-state="closed"
            class="invisible relative mt-1.5 h-[var(--navigation-menu-viewport-height)] w-[var(--navigation-menu-viewport-width)] max-w-[calc(100vw-2rem)] origin-top scale-95 overflow-hidden rounded-md border bg-popover text-popover-foreground opacity-0 shadow-md transition-[opacity,scale,width,height,visibility] duration-150 ease-out data-[state=open]:visible data-[state=open]:scale-100 data-[state=open]:opacity-100"
        ></div>
    </div>
</nav>
templates/components/NavigationMenu/Content.html.twig
{# @block content The submenu revealed inside the shared viewport on hover or focus. #}
<div
    data-slot="navigation-menu-content"
    data-navigation-menu-target="content"
    {{ attributes.defaults({
        class: 'invisible absolute start-0 top-0 w-max p-2 opacity-0 transition-[opacity,translate,visibility] duration-200 ease-out data-[active]:visible data-[active]:translate-x-0 data-[active]:opacity-100 data-[motion=from-end]:translate-x-8 data-[motion=from-start]:-translate-x-8'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/NavigationMenu/Item.html.twig
{# @block content The `NavigationMenu:Trigger` and its optional `NavigationMenu:Content`. #}
<li
    data-slot="navigation-menu-item"
    data-navigation-menu-target="item"
    {{ attributes.defaults({
        class: 'group/navigation-menu-item relative'|tailwind_classes,
        'data-action': 'mouseenter->navigation-menu#open',
    }) }}
>
    {%- block content %}{% endblock -%}
</li>
templates/components/NavigationMenu/Link.html.twig
{# @prop href string The target URL. #}
{# @block content The link label. #}
{%- props href -%}
<a
    href="{{ href }}"
    data-slot="navigation-menu-link"
    {{ attributes.defaults({
        class: 'inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</a>
templates/components/NavigationMenu/List.html.twig
{# @block content The `NavigationMenu:Item` children. #}
<ul
    data-slot="navigation-menu-list"
    {{ attributes.defaults({
        class: 'flex flex-1 list-none items-center justify-center gap-1'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</ul>
templates/components/NavigationMenu/Trigger.html.twig
{# @block content The trigger label. #}
<button
    type="button"
    data-slot="navigation-menu-trigger"
    data-navigation-menu-target="trigger"
    aria-haspopup="menu"
    aria-expanded="false"
    {{ attributes.defaults({
        class: 'inline-flex h-9 w-max items-center justify-center gap-1 rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
    <twig:ux:icon name="lucide:chevron-down" class="relative top-px size-3 transition-transform duration-200 group-data-[state=open]/navigation-menu-item:rotate-180" aria-hidden="true" />
</button>

Usage

<twig:NavigationMenu>
    <twig:NavigationMenu:List>
        <twig:NavigationMenu:Item>
            <twig:NavigationMenu:Link href="/">Home</twig:NavigationMenu:Link>
        </twig:NavigationMenu:Item>
        <twig:NavigationMenu:Item>
            <twig:NavigationMenu:Link href="/docs">Docs</twig:NavigationMenu:Link>
        </twig:NavigationMenu:Item>
    </twig:NavigationMenu:List>
</twig:NavigationMenu>

Examples

Simple

A menu of plain links, without any submenu.

100%
Loading...
<div class="flex items-start justify-center" style="min-height: 100px">
    <twig:NavigationMenu>
        <twig:NavigationMenu:List>
            <twig:NavigationMenu:Item>
                <twig:NavigationMenu:Link href="/">Home</twig:NavigationMenu:Link>
            </twig:NavigationMenu:Item>
            <twig:NavigationMenu:Item>
                <twig:NavigationMenu:Link href="/docs">Docs</twig:NavigationMenu:Link>
            </twig:NavigationMenu:Item>
            <twig:NavigationMenu:Item>
                <twig:NavigationMenu:Link href="/pricing">Pricing</twig:NavigationMenu:Link>
            </twig:NavigationMenu:Item>
        </twig:NavigationMenu:List>
    </twig:NavigationMenu>
</div>

RTL

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

100%
Loading...
<div class="flex flex-col items-center gap-16 py-8" style="min-height: 320px">
    {# Arabic #}
    <div dir="rtl">
        <twig:NavigationMenu>
            <twig:NavigationMenu:List>
                <twig:NavigationMenu:Item>
                    <twig:NavigationMenu:Trigger>ابدأ</twig:NavigationMenu:Trigger>
                    <twig:NavigationMenu:Content>
                        <ul class="grid w-96 gap-1">
                            <li>
                                <a class="block rounded-md p-3 hover:bg-accent" href="/docs">
                                    <div class="text-sm font-medium leading-none">مقدمة</div>
                                    <p class="line-clamp-2 text-sm text-muted-foreground">مكونات قابلة لإعادة الاستخدام مبنية باستخدام Tailwind CSS.</p>
                                </a>
                            </li>
                            <li>
                                <a class="block rounded-md p-3 hover:bg-accent" href="/docs/installation">
                                    <div class="text-sm font-medium leading-none">التثبيت</div>
                                    <p class="line-clamp-2 text-sm text-muted-foreground">كيفية تثبيت التبعيات وتنظيم تطبيقك.</p>
                                </a>
                            </li>
                        </ul>
                    </twig:NavigationMenu:Content>
                </twig:NavigationMenu:Item>
                <twig:NavigationMenu:Item>
                    <twig:NavigationMenu:Link href="/docs">التوثيق</twig:NavigationMenu:Link>
                </twig:NavigationMenu:Item>
            </twig:NavigationMenu:List>
        </twig:NavigationMenu>
    </div>

    {# Hebrew #}
    <div dir="rtl">
        <twig:NavigationMenu>
            <twig:NavigationMenu:List>
                <twig:NavigationMenu:Item>
                    <twig:NavigationMenu:Trigger>תחילת העבודה</twig:NavigationMenu:Trigger>
                    <twig:NavigationMenu:Content>
                        <ul class="grid w-96 gap-1">
                            <li>
                                <a class="block rounded-md p-3 hover:bg-accent" href="/docs">
                                    <div class="text-sm font-medium leading-none">מבוא</div>
                                    <p class="line-clamp-2 text-sm text-muted-foreground">רכיבים לשימוש חוזר הבנויים עם Tailwind CSS.</p>
                                </a>
                            </li>
                            <li>
                                <a class="block rounded-md p-3 hover:bg-accent" href="/docs/installation">
                                    <div class="text-sm font-medium leading-none">התקנה</div>
                                    <p class="line-clamp-2 text-sm text-muted-foreground">כיצד להתקין תלויות ולבנות את האפליקציה שלך.</p>
                                </a>
                            </li>
                        </ul>
                    </twig:NavigationMenu:Content>
                </twig:NavigationMenu:Item>
                <twig:NavigationMenu:Item>
                    <twig:NavigationMenu:Link href="/docs">תיעוד</twig:NavigationMenu:Link>
                </twig:NavigationMenu:Item>
            </twig:NavigationMenu:List>
        </twig:NavigationMenu>
    </div>
</div>

API Reference

<twig:NavigationMenu>

Prop Type Default
ariaLabel 
string 'Main'
openDelay 
number 200
closeDelay 
number 300
Block Description
content A NavigationMenu:List containing NavigationMenu:Item children.

<twig:NavigationMenu:Content>

Block Description
content The submenu revealed inside the shared viewport on hover or focus.

<twig:NavigationMenu:Item>

Block Description
content The NavigationMenu:Trigger and its optional NavigationMenu:Content.
Prop Type Default
href 
string -
Block Description
content The link label.

<twig:NavigationMenu:List>

Block Description
content The NavigationMenu:Item children.

<twig:NavigationMenu:Trigger>

Block Description
content The trigger label.