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 The accessible name for the navigation landmark.
|
string |
'Main' |
openDelay Delay in milliseconds before opening a menu on hover.
|
number |
200 |
closeDelay Delay in milliseconds before closing the menu once the pointer leaves.
|
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. |
<twig:NavigationMenu:Link>
| Prop | Type | Default |
|---|---|---|
href The target URL.
|
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. |