Detached Mobile Drawer With Three-Level Native-Details Navigation
The mobile menu panel lives outside the element that opens it. The theme's own drawer class was taught that shape, and the panel it opens is a native <details> accordion three levels deep that expands without JavaScript.
Dawn's menu drawer assumes the panel is a child of the element that opens it, so moving the panel elsewhere in the header breaks open state, overlay and scroll lock at once. Both halves changed. The theme's drawer class was extended to accept a root that isn't a <details> and a trigger that isn't a <summary>, and the panel it opens holds a three-level native <details> accordion.
Fact Strip
- Client: LUS Brands
- Surface: Global — the header section group, on every page
- Mounted by: one section group,
header-group.json - Navigation depth: three levels
- Complexity: Medium
- Attribution: Deploi-authored, inside a Dawn 15.4.1 theme. The drawer class is Dawn's, and our work on it is an extension of that class rather than a replacement for it. The detached panel, the sync script and the accordion markup are ours.
- Status: Live, verified 2026-09-06
- Evidence: Strong — theme code plus client working-session records
The Problem
Dawn's menu drawer assumes one shape. The panel is a child of the <details> element whose <summary> opens it, open state is an attribute on that element, and the CSS that reveals the panel, dims the page behind it and locks body scroll all descends from that one relationship.
This design asked for something else. The trigger belongs in one part of the header layout and the panel in another, and the navigation had to go three levels deep, with a brand bar at the top of the panel and a quiz link pinned at the bottom of it. Dawn's nested menus move between levels with slide transforms sized for two levels, not three. So both requirements break the same assumption from different directions: one moves the panel out of the subtree the CSS depends on, the other outgrows the transition that animates it.
The Constraint
Dawn's drawer JavaScript — the MenuDrawer class in global.js — binds to a <details>/<summary> pair. It listens for the element's toggle event, and everything downstream — the overlay, the body scroll lock, the focus handling — hangs off that element's open state. Move the panel out of the details subtree and the CSS relationship that expresses "open" simply stops existing.
So something had to give, and the only real question was where. A second drawer implementation living beside Dawn's would have put two classes with opinions about the same header, the same overlay and the same body scroll lock into one page. Changing the class the theme already ships keeps one owner for that behavior, and costs a theme file that is no longer stock. Navigation data had to stay in Shopify's link lists either way, because the same menus feed the desktop header and are edited by people who never open the theme editor.
What We Built
Three files, plus a change to a fourth that belongs to the theme.
header-drawer.liquid renders the trigger as a div carrying role="button", a tabindex and a data-menu-drawer-trigger hook — not a <summary> — inside a container element that holds the open attribute. The panel it controls sits elsewhere in the header markup entirely.
Dawn's MenuDrawer class, in global.js, was extended to work in that arrangement. It accepts a root that isn't a <details> element, falling back to the drawer's own container when there isn't one. Where there is no <summary> to bind to, it binds its summary handler to the trigger's data attribute or the header's menu icon instead, and activates on Enter and Space, because a div gets none of that for free. In that mode it sets and clears the open attribute itself rather than leaving it to the browser. onFocusOut was reimplemented so that focus landing inside the detached panel counts as focus still inside the drawer. Opening on a small screen writes a --viewport-height custom property onto the document element, so the panel can size against the viewport a phone actually gives it rather than against 100vh. Event binding was made idempotent through WeakSets, so the class can be asked to bind again after a section re-render without stacking a second set of listeners on the same elements.
header-drawer-detached-panel.js is the small piece between the two. It applies the open state to a panel that isn't inside the trigger: toggle .menu-drawer--open, show or hide the overlay, and save and restore document.body.style.overflow. Clicking the overlay resolves the trigger element and synthesizes a click on it rather than closing the panel where it stands.
header-mobile-accordion-menu.liquid renders the navigation itself as nested native <details> elements, three levels deep, with no transforms in play. Liquid decides which branches are already open before the page is sent: link.current and link.child_active are tested at levels one and two, so a shopper who opens the drawer on a product page finds the branch containing that page expanded rather than collapsed. Parent rows are split in two — the label is a link, the caret is a separate control — so tapping a category name navigates to the category and tapping the arrow next to it expands the category. Around the menu sits the drawer's own furniture: a brand bar with the shop logo, a close control, and an authorable footer call to action that currently sends shoppers into the curl quiz.
Why This Way
Extending the theme's own class rather than standing a second one beside it is the decision the rest of this rests on. What "open" means, what closes it and what happens to focus stay in one place, and the new branches are entered only where the detached markup is present, so a stock Dawn drawer elsewhere in the theme runs the path it always ran.
Native <details> for the accordion is the second decision, and it's the one that pays off quietly. Expand and collapse come from the browser rather than from us, the levels nest as deeply as the menu does, and the accordion still opens with JavaScript unavailable — which a transform-driven slide menu cannot do at all.
The cost is a diff against a vendor file. global.js is Dawn's, it changes with every Dawn release, and the part of it that understands a detached drawer is ours — so an upgrade here is a read of both versions before shipping rather than taking the vendor's file whole. We took that over a second drawer class because two implementations of "open" is the same cost paid twice, forever, in a component that renders on every page.
Why Not an App
Mobile-menu apps exist and they solve the layout problem. They also move the navigation somewhere else. The menus stop being Shopify link lists and become records in a vendor's database, which means the merchandising team edits navigation in one admin and everything else in another, and the theme no longer holds the answer to what the menu says.
There's a rendering difference too, and here it's structural rather than a matter of milliseconds. The menu is server-rendered from linklists and it expands with <details>, so every level of the navigation is usable in the HTML the browser receives — before any script runs, and whether or not one ever does. An app-injected menu is the opposite arrangement: nothing until the script lands, on the connection type where mobile shoppers are least forgiving of a wait.
Implementation Notes
- The script exits immediately unless the panel carries
.menu-drawer--detached, so it's inert on any drawer that hasn't opted in — including Dawn's own, elsewhere in the theme. - The extended
MenuDrawerkeeps a<summary>binding where one exists and falls back to the trigger's data attribute only where one doesn't, so the branches are additive rather than a replacement of Dawn's own path. - Ancestor auto-open is computed in Liquid from
link.currentandlink.child_activeat levels one and two, so the correct branch is already expanded in the initial HTML rather than opened by a script after paint. - Parent rows carry two hooks,
data-menu-parent-linkanddata-menu-parent-toggle, which is what separates "go to this category" from "show me what's under it" on a touch target the size of a thumb. - Initial state is applied once on load, in case the drawer is already open at the moment the script runs — a theme-editor preview reaches the page in exactly that condition.
- The drawer's account and localization block is present in the markup but ships with the
hidden hideclasses applied in the live theme, so the panel currently opens on navigation alone. - The drawer's footer call to action is a theme setting rather than hard-coded copy, so the campaign at the bottom of the menu changes without a deploy; it currently points at the curl quiz.
Edge Cases
- An overlay click resolves the trigger through three selectors in order — the data attribute, the class, then a
summaryelement — before it clicks anything. - Body overflow is captured before the lock and restored to whatever it was afterwards, rather than cleared — the panel gives the page back exactly what it took.
- Third-level links render as plain links with no further nesting, so the accordion has a floor and a fourth menu level added in Shopify simply doesn't appear.
- When the resolved link list is empty the menu renders nothing at all — no empty accordion, no stray chevrons.
Platform Primitives Used
- Liquid section schema and settings — the drawer's logo, close control and footer call to action are settings, so the panel's furniture is merchandiser-editable.
- Section groups — the drawer renders from the header section group, so one change reaches every template at once.
Where It Runs
One section group, every page of the storefront, every mobile viewport. There is no template on this store that doesn't mount it. That's the reason the sync approach mattered more than it looks: a bug in a header component isn't a bug on a page, it's a bug on the site.
What This Demonstrates
- Vendor widget remediation and cross-app cart sync — the same work we do on third-party widgets, pointed at the theme itself. Making somebody else's component accept a shape it was never written for is the pattern; whether the component belongs to an app vendor or to Dawn changes the file, not the discipline.
- Mega menu and mobile navigation system — the mobile half of a navigation system whose desktop half is a mega menu.
How We Know
Four files read from the live theme — two Liquid snippets, the sync script and the theme's own drawer class — plus three client working-session records. Those records are unusually clear about intent: mobile navigation was specified as a plain hamburger with no merchandising imagery, in deliberate contrast to the desktop menu. The three-level accordion is that requirement — hierarchy without pictures — and the working-session record is why we can say so rather than infer it.
The change to the drawer class is documented from the code; no client-side record of the decision survives.
Related Builds
- Adults/Kids audience-switching header — the same header file, a different problem: which menu this drawer renders is decided by an audience flag documented there.
- Mega-menu header with separate mobile menu and image blocks — Three Ships answered the desktop-versus-mobile split by giving the drawer its own link list instead of restructuring the panel.
The Buy-vs-Build Question
Navigation is the component most often bought and most expensive to buy badly, because it renders on every pageview and it holds the site's internal link structure. The decision we work from — what a menu app can actually do for you, and what stays yours either way — is set out in the mega menu and navigation build-or-buy page.
Ready to Move a Menu Your Theme Won't Let You Move?
Most drawer problems are a layout the theme didn't anticipate, not a theme that needs replacing. Contact us today and we'll tell you which one you have.