Add Dropdown Menus to Ghost Navigation
Group pages under a parent menu item that opens on hover or tap, on any Ghost theme, from Ghost Admin alone. Label children with a dash, paste the code, done.
Ghost’s navigation menu is a flat list. You can add links, but you cannot group them: there is no way to put “About” and “Contact” under a single “Company” heading from the admin. People have asked for this for years, and the usual answers mean editing your theme or running a script that depends on another library.
This snippet adds grouping without touching your theme. You mark each sub-page by starting its label with a dash, paste one block of code into Ghost’s code injection settings, and the item above the dashed ones becomes a dropdown. On a laptop the menu opens when you hover. On a phone it opens when you tap, and inside a mobile menu the sub-pages simply appear indented under their heading.
It works on any theme that uses Ghost’s standard navigation markup, which includes every official theme and most third-party ones.
Step 1: Label your menu
In Settings → Navigation, arrange the primary navigation like this:
| Label | URL | Renders as |
|---|---|---|
Home | / | top-level link |
Company | # | dropdown parent |
- About | /about/ | first item in the dropdown |
- Contact | /contact/ | second item |
Blog | /blog/ | top-level link |
Three rules. Sub-pages go directly below their heading. Each sub-page label starts with a dash, with or without a space after it; en and em dashes work too. The heading’s URL is # if it is only a heading. A heading can also be a real page: give it a normal URL and it still works as a link on a laptop, while on a phone the first tap opens the menu and a second tap follows the link.
Step 2: Paste the code
Paste this into Settings → Code injection → Site footer:
<style>.nav-has-dropdown { position: relative; }.nav-has-dropdown > a::after { content: ""; display: inline-block; width: .4em; height: .4em; margin-left: .4em; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: translateY(-.15em) rotate(45deg);}.nav-dropdown { position: absolute; top: 100%; left: 0; z-index: 1000; min-width: 12rem; margin: 0; padding: .5rem 0; list-style: none; background: #fff; color: #15171a; border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,.14), 0 0 0 1px rgba(0,0,0,.05); opacity: 0; visibility: hidden; transform: translateY(6px); transition: opacity .15s ease, transform .15s ease, visibility .15s;}.nav-dropdown li { display: block; margin: 0; padding: 0; }.nav-dropdown a { display: block; padding: .45rem 1rem; white-space: nowrap; color: inherit; }.nav-dropdown a:hover { background: rgba(0,0,0,.05); }.nav-has-dropdown:hover > .nav-dropdown,.nav-has-dropdown:focus-within > .nav-dropdown,.nav-has-dropdown.is-open > .nav-dropdown { opacity: 1; visibility: visible; transform: none; }@media (max-width: 767px) { .nav-dropdown { position: static; min-width: 0; padding: 0 0 0 1rem; background: transparent; color: inherit; box-shadow: none; opacity: 1; visibility: visible; transform: none; } .nav-has-dropdown > a::after { display: none; }}</style><script>(function () { var CHILD = /^[-–—]\s*/; function build() { document.querySelectorAll('ul.nav').forEach(function (nav) { var parent = null, submenu = null; Array.prototype.slice.call(nav.querySelectorAll(':scope > li')).forEach(function (li) { var a = li.querySelector('a'); if (!a) return; var labelEl = a.querySelector('.nav-label') || a; var label = labelEl.textContent.trim(); if (CHILD.test(label) && parent) { if (!submenu) { submenu = document.createElement('ul'); submenu.className = 'nav-dropdown'; parent.classList.add('nav-has-dropdown'); parent.querySelector('a').setAttribute('aria-haspopup', 'true'); parent.querySelector('a').setAttribute('aria-expanded', 'false'); parent.appendChild(submenu); } labelEl.textContent = label.replace(CHILD, ''); submenu.appendChild(li); } else { parent = li; submenu = null; } }); }); } build(); var resizeTimer; window.addEventListener('resize', function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(build, 150); }); document.addEventListener('click', function (e) { var link = e.target.closest('.nav-has-dropdown > a'); var openItems = document.querySelectorAll('.nav-has-dropdown.is-open'); if (link) { var li = link.parentNode; var href = link.getAttribute('href') || ''; var isPlaceholder = /#$/.test(href); var isTouch = window.matchMedia('(hover: none)').matches; if (isPlaceholder || (isTouch && !li.classList.contains('is-open'))) { e.preventDefault(); openItems.forEach(function (o) { if (o !== li) { o.classList.remove('is-open'); o.querySelector('a').setAttribute('aria-expanded', 'false'); } }); var open = li.classList.toggle('is-open'); link.setAttribute('aria-expanded', String(open)); return; } } openItems.forEach(function (o) { o.classList.remove('is-open'); o.querySelector('a').setAttribute('aria-expanded', 'false'); }); });})();</script>Save, reload your site, and the dashed items have moved under their heading.
How it works
Ghost renders every menu as a list, and the script reads that list from top to bottom. When it meets a label that starts with a dash and there is an item above it, it creates a nested list inside that item, removes the dash from the label, and moves the sub-page into the nested list. Nothing here is specific to one theme. The script only relies on the list structure Ghost itself generates, which is why the same code works on Casper, Source, and the other official themes. It also runs again after the window is resized, because several official themes rebuild their menu on resize from a copy taken before the snippet ran; without that, rotating a phone would flatten the menu again.
The styles place the nested list under its heading and keep it hidden until the heading is hovered, focused by someone using the keyboard, or opened by a tap. They also add a small chevron to headings. On screens narrower than 768 pixels the nested list stops floating and appears as an indented block, because mobile menus in almost every theme are a vertical list where a hover panel makes no sense.
The click handling does three things. A heading whose URL is # opens its menu instead of jumping to the top of the page. On touch screens, the first tap on any heading opens the menu and a second tap follows the link. A click anywhere else closes any open menu. The two accessibility attributes that tell screen readers a menu exists and whether it is open are kept in sync.
Adjusting it
Match your theme’s colors. The panel is white with dark text so that it reads on both light and dark headers. On a dark site, change background: #fff; color: #15171a; in .nav-dropdown to your own values, for example background: #1f1f1f; color: #fff;, and change the hover tint rgba(0,0,0,.05) to rgba(255,255,255,.08).
Open on click only. Remove the line .nav-has-dropdown:hover > .nav-dropdown, from the styles. Headings with a # URL will still open on click; give every heading a # URL for a click-only menu.
Footer menus. Ghost uses the same list markup for the secondary navigation in the footer. The script only groups items whose labels start with a dash, so your footer is untouched unless you dash labels there too, which also works if you want grouped footer links.
Wider or right-aligned panels. Adjust min-width: 12rem, or replace left: 0 with right: 0 for the last item in a right-aligned menu so the panel does not run off the screen.
Where it works
Any theme that renders its navigation with Ghost’s standard markup: Casper, Source, Headline, Edition, Solo, Dawn, and most themes in the marketplace. We verified hovering, clicking, closing by clicking elsewhere, and keyboard focus on the official Casper and Source demos; we verified the resize behavior against Source’s own theme script; and we checked the mobile styles. The exact indentation inside your theme’s mobile menu may want a small adjustment. Themes with fully custom header markup are not affected by the snippet at all; it simply does nothing there.
Maintaining your own theme files? The same code works from inside the theme. Put the styles in your theme’s stylesheet and the script in default.hbs just above {{ghost_foot}}, then upload the theme. Ghost’s navigation data is flat, so there is no template-only way to nest items; a script is the honest answer either way. Code injection remains the safer home, because it survives theme updates, while edits to theme files are overwritten the next time you upload a new version.
Luxe themes do not need it. Luxe themes with dropdown navigation built in read the same dash convention natively: label sub-pages -About, give the heading # as its URL, and the theme renders a dropdown in its own design, with a “More” overflow menu on narrow headers in several themes. Each theme’s documentation has the details, for example Luno’s navigation section and Tribune’s.
Related
- Hide the floating Portal button, if your new menu has its own Subscribe link
- Add a table of contents to posts
- The Ghost code injection guide explains where header and footer injection run and how to keep snippets safe across theme updates