A four-level navbar built on top of Bootstrap 5.3 with a split tap-target mobile pattern (each section heading is two hit-targets: a navigation link and a separate expand caret) and out-of-the-box Bootstrap dropdowns on desktop.
The implementation has three deliberate constraints:
- Minimal CSS and JS. Every rule earns its place. ~70 lines of CSS, ~90 of JS.
- Scoped. Every CSS selector and JS query is anchored to
#mainNavso it cannot bleed into other Bootstrap components on the page. - Separated. All behaviour lives in
nav.js, not inline.
Files: index.html, styles.css,
nav.js.
The HTML is the standard navbar-expand-lg + offcanvas-lg responsive
offcanvas pattern, with three deliberate additions:
Bootstrap 5 doesn't ship nested dropdowns. We use the community
convention: each nested level wraps its <ul class="dropdown-menu"> in a
parent <li class="dropdown-submenu">. The corresponding CSS (three
rules in styles.css) provides the desktop flyout.
After every section heading link (Products, Software, Web Apps, …) and
before its <ul class="dropdown-menu">, we add a <button>:
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="productsLink"
data-bs-toggle="dropdown" aria-expanded="false">Products</a>
<button class="nav-expand dropdown-toggle" type="button"
aria-expanded="false" aria-label="Expand Products section"></button>
<ul class="dropdown-menu" aria-labelledby="productsLink">…</ul>
</li>That gives mobile users two hit-targets per row in the same accessible
DOM order: tap the text to navigate to the section page, tap the caret
button to expand the submenu. CSS hides the button at the lg breakpoint
so desktop sees only the standard Bootstrap dropdown toggle.
Placing the Bootstrap data attribute on the link means desktop clicks on
the link itself open the dropdown (OOB behaviour, with built-in
preventDefault and auto-close). On mobile we intercept the link click in
JS and stopPropagation() so Bootstrap's delegated listener never sees
it — we navigate the prototype's page instead.
The whole file is around 70 lines of substantive CSS (the rest is
whitespace + commentary). All rules are scoped to #mainNav.
Using the ID #mainNav as the prefix achieves two goals at once:
- Isolation. Nothing leaks into other Bootstrap components on the
page. A
<nav>or<aside>outside#mainNavkeeps every default. - Specificity.
#mainNav .nav-linkis1,1,0which beats Bootstrap's.navbar-dark .navbar-nav .nav-linkcascade (0,3,0) without needing!importantor longer selectors.
#mainNav .dropdown-submenu { position: relative; }
#mainNav .dropdown-submenu > .dropdown-menu {
top: 0; left: 100%; margin-top: -1px;
}These two rules add positioning context for Bootstrap's missing nested-
dropdown pattern. They apply at all viewports — the desktop flyout uses
this positioning, and on mobile our position: static reset overrides
the left: 100% so the nested menu expands inline.
The hover-to-open rule lives in the desktop
@mediablock — see Close-on-second-tap fix below.
Full-screen offcanvas. Bootstrap's default is 400 px wide; one CSS variable assignment overrides that.
#mainNav { --bs-offcanvas-width: 100%; }Split-row flex layout + 1 rem indent per level. The <li> becomes
a 3-child flex container: link grows, button takes content width, menu
wraps to its own row beneath. flex-basis: calc(100% - 1rem) plus
margin-left: 1rem gives a clean indent without overflow.
#mainNav .nav-item.dropdown,
#mainNav .dropdown-submenu {
display: flex;
flex-wrap: wrap;
}
#mainNav .nav-item.dropdown > .nav-link,
#mainNav .dropdown-submenu > .dropdown-item {
flex: 1 0 0;
width: auto; /* override Bootstrap's .dropdown-item { width: 100% } */
}
#mainNav .nav-item.dropdown > .dropdown-menu,
#mainNav .dropdown-submenu > .dropdown-menu {
flex-basis: calc(100% - 1rem);
margin-left: 1rem;
}Inline submenu positioning. Reset the desktop flyout so nested menus expand below their parent instead of flying right.
#mainNav .dropdown-submenu > .dropdown-menu {
position: static;
left: auto;
}Flat dropdown panels. Strip Bootstrap's panel styling so the menu reads as part of the inline accordion.
#mainNav .dropdown-menu {
padding: 0; margin: 0; border: 0; background-color: transparent;
}Consistent item padding. 0.6 rem vertical for a comfortable tap target, 0.5 rem left to leave room for the active vertical bar.
#mainNav .nav-link,
#mainNav .dropdown-item {
padding: 0.6rem 1rem 0.6rem 0.5rem;
}The expand button. Transparent visual, 44 px min-width for WCAG tap targets, focus-visible outline for keyboard users.
#mainNav .nav-expand {
background: transparent; border: 0; color: inherit; cursor: pointer;
min-width: 44px; padding: 0.6rem 1rem;
display: inline-flex; align-items: center; justify-content: center;
}
#mainNav .nav-expand:focus-visible {
outline: 2px solid var(--bs-primary); outline-offset: -2px;
}Caret rotation driven by aria-expanded. No :has() dependency —
the JS already keeps aria-expanded in sync, so we can use it as the
state selector. ▸ closed, ▼ open.
#mainNav .nav-expand::after {
transform: rotate(-90deg);
transition: transform 0.2s ease;
}
#mainNav .nav-expand[aria-expanded="true"]::after {
transform: rotate(0deg);
}Hide the link's Bootstrap caret on mobile — the .nav-expand
button carries the caret instead.
#mainNav .nav-link.dropdown-toggle::after { display: none; }Nav-link colours. Bootstrap's .navbar-dark .navbar-nav .nav-link
cascade would force white-on-white text on the offcanvas. The #mainNav
specificity wins so source order doesn't matter.
#mainNav .nav-link { color: var(--bs-body-color); }
#mainNav .nav-link:hover,
#mainNav .nav-link:focus { color: var(--bs-primary); }
#mainNav .show > .nav-link { color: var(--bs-body-color); }The third rule beats Bootstrap's .navbar-dark .navbar-nav .show > .nav-link { color: rgb(255,255,255,1) } which would otherwise make a
section heading vanish (pure white on white) the moment its dropdown
opens.
Active indicator. A 5 px primary bar drawn via inset box-shadow
(zero layout impact) plus primary text colour for the selected leaf.
#mainNav .nav-link.active,
#mainNav .dropdown-item.active {
box-shadow: inset 5px 0 0 var(--bs-primary);
color: var(--bs-primary);
background-color: transparent;
}Ancestor path. Every section heading on the path to the selected
leaf gets primary text (added by markParents() in the JS).
#mainNav .nav-link.parent-active,
#mainNav .dropdown-item.parent-active {
color: var(--bs-primary);
}Tap-state suppression. Bootstrap's :active on .dropdown-item
forces color: #fff + blue bg, which makes the text vanish on every
tap against the white offcanvas. Override.
#mainNav .dropdown-item:active {
background-color: transparent;
color: var(--bs-body-color);
}#mainNav .nav-expand { display: none; }
#mainNav .dropdown-submenu:hover > .dropdown-menu { display: block; }Two rules. The first hides the mobile expand button so the desktop navbar
shows just the standard Bootstrap dropdown-toggle. The second is the
nested-submenu hover-open — see Close-on-second-tap fix
for why it lives in the desktop @media block specifically.
Everything else falls back to Bootstrap's OOB navbar behaviour:
horizontal layout, click-to-open dropdowns, the built-in ▼ caret,
single-open auto-close, click-outside to dismiss.
The hover-to-open rule for nested submenus —
#mainNav .dropdown-submenu:hover > .dropdown-menu {
display: block;
}— is the natural way to wire up Bootstrap's missing nested dropdowns at
desktop. The temptation is to put it at the top of the file, outside
any media query, so it applies everywhere. Don't. On a touch device
:hover persists on the element after a tap until a different element
is tapped. Sequence on a mobile L2 caret:
- Tap Software's caret → JS adds
.show→ menu visible (display: block) - The same tap activated
:hoveron the parent.dropdown-submenu - Tap the caret again → JS removes
.show - Bug: Bootstrap's default
.dropdown-menu { display: none }(specificity0,1,0) is now overridden by the still-active:hoverrule (specificity1,3,0) → menu stays visible
The fix is one move, not a new rule: scope the :hover rule to
@media (min-width: 992px) only. Mobile then has zero :hover
selectors against .dropdown-submenu, so the JS-managed .show /
no-.show state is the sole source of truth, and second-tap-to-close
works.
Verified at mobile: zero matching :hover rules; tapping Software's
caret twice removes .show and computes display: none. Verified at
desktop: the rule matches, hovering Software opens the L2 submenu
positioned at left: 100%.
Around 90 lines of substantive code (the rest is whitespace + comments).
Three responsibilities, all scoped to a single nav reference.
nav.querySelectorAll('.nav-expand').forEach(function (btn) {
btn.addEventListener('click', function (e) {
if (!isMobile()) return; // hidden at desktop, never fires there anyway
e.preventDefault();
e.stopPropagation(); // keep Bootstrap's listeners out of this
var menu = btn.nextElementSibling;
var open = menu.classList.toggle('show');
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
// Mirror onto the section link if it has aria-expanded
var sectionLink = btn.previousElementSibling;
if (sectionLink && sectionLink.hasAttribute('aria-expanded')) {
sectionLink.setAttribute('aria-expanded', open ? 'true' : 'false');
}
});
});nav.querySelectorAll('a').forEach(function (link) {
link.addEventListener('click', function (e) {
e.preventDefault();
// Desktop, a Bootstrap dropdown-toggle link → return early.
// The event bubbles, Bootstrap's delegated listener opens the dropdown.
if (!isMobile() && link.matches('[data-bs-toggle="dropdown"]')) return;
// Mobile → stop the event so Bootstrap doesn't also try to toggle.
if (isMobile()) e.stopPropagation();
// Move .active, mark ancestors .parent-active, update the page content
nav.querySelectorAll('.nav-link.active, .dropdown-item.active').forEach(...);
link.classList.add('active');
link.setAttribute('aria-current', 'page');
markParents(link);
// ... pageTitle / pageBreadcrumb / pageBody assignments ...
closeAll();
var oc = bootstrap.Offcanvas.getInstance(nav);
if (oc && nav.classList.contains('show')) oc.hide();
});
});function closeAll() {
// Bootstrap-managed dropdowns (desktop): use the API so Popper + ARIA
// are torn down cleanly. On mobile no instances exist (our handler
// stops the event from reaching Bootstrap's listener), so this loop
// is a no-op there.
nav.querySelectorAll('[data-bs-toggle="dropdown"]').forEach(function (t) {
var inst = bootstrap.Dropdown.getInstance(t);
if (inst) inst.hide();
});
// Sweep up the mobile-JS-managed menus + reset every aria-expanded.
nav.querySelectorAll('.dropdown-menu.show').forEach(...);
nav.querySelectorAll('.nav-expand, .nav-link[aria-expanded]').forEach(...);
}Bootstrap 5.3 adds .show to the toggle (the link in our HTML) and
the menu, but not to the parent <li>. The natural-looking
.nav-item.dropdown.show selector would never match a Bootstrap-opened
dropdown — querying the toggles directly is the reliable approach. (E6
hit this bug during testing; E7 starts from the right answer.)
Same test plan as E6:
Mobile (375 × 812). Offcanvas slides in full-screen, white panel, header + close button; section heading rows have split tap-targets; tap text navigates (offcanvas closes, breadcrumb updates); tap caret expands the submenu without closing the offcanvas; multi-open works at every level; carets rotate ▸/▼; 1 rem indent per nesting level; selected leaf shows the 5 px primary bar; every ancestor of the selected leaf is primary text; section heading text stays visible when expanded (no white-on-white); no items vanish on tap; the link's Bootstrap caret is hidden so only the button caret shows.
M-fix (close-on-second-tap). Tap a section's expand caret twice;
the second tap must close the submenu — both .show removed AND
computed display is none. Verified at L2 (Software, Hardware) and
L3 (Web Apps).
Desktop (1280 × 800). No hamburger, no expand buttons; horizontal
navbar with ▼ carets on Products and Solutions; clicking a top-level
toggle opens the dropdown absolutely-positioned below; hover opens L2
flyouts and L3 sub-flyouts; clicking a leaf or section heading link
navigates and closes the dropdown chain; single-open auto-close (OOB);
click-outside dismisses.
All checks verified visually + via getComputedStyle /
getBoundingClientRect / aria-expanded inspection during the build.
- ✅ Minimal. ~70 lines of substantive CSS, ~90 of JS.
- ✅ Scoped. Every CSS selector starts with
#mainNav. The JS only reaches outside that root to read/write#page-title,#page-breadcrumb,#page-body— the prototype's three demo elements. - ✅ Separated. All JS lives in
nav.js.index.htmlhas only a single<script src>line.