Shopify Builds>Three Ships>Tabbed mobile Shop menu with featured banner panel

Tabbed Mobile Shop Menu With Featured Banner Panel

A custom <shop-tabs> element turns the drawer's first-level Shop menu into horizontal tabs with panels of category links, merges a theme-editor banner block into the tab order by position, and leaves every other drawer item on the vendor's accordion.

A mobile drawer accordion puts a store's main Shop links three taps deep. On Three Ships' licensed Palo Alto theme, the first top-level item is rendered instead as a <shop-tabs> strip built from the same link list the accordion uses, with a merchandiser's featured banner inserted as a tab at a chosen position, and the open tab resolved in Liquid so the drawer is correct before any script runs.

The Problem

A mobile navigation drawer is almost always an accordion, and an accordion treats every top-level item the same way: tap to open a list, tap again to open a list inside it. For "About" that's fine. For "Shop" — the item that holds the whole catalog — it means a shopper opens the drawer, expands Shop, expands a category, and only then sees a link they can buy from. Three taps to reach the thing the store exists to sell, with the categories themselves hidden behind the first two.

Merchandising had a second ask. The menu is a surface shoppers open on purpose, and the team wanted a promotable slot inside it — a launch, a bundle, a page — rather than a menu that could only ever be a list of categories.

The Constraint

Only the first top-level link in the mobile menu should behave this way. Every other item in the drawer had to keep the vendor's existing accordion, with its collapsible triggers, three levels and highlight item, untouched. The tabbed menu also had to be built from the same link list the accordion reads — the header's mobile_main_menu setting — so the merchandising team keeps editing one menu in Shopify Navigation rather than maintaining a tab structure somewhere else.

The featured banner is a theme-editor block, and its place in the strip is a position — an index among the menu's children. That puts two sources, a link list and a section block, into one ordered sequence that has to survive a menu whose child count changes.

And the drawer is licensed code. Palo Alto 5.8.0 by Presidio Creative is a paid premium theme, so this was an addition inside the vendor's nav-item-mobile.liquid, not a replacement of it.

What We Built

nav-item-mobile.liquid gained an index == 0 branch: when the drawer renders the first top-level link and that link has children, it renders a <shop-tabs> element in place of the accordion item.

Before any markup, Liquid resolves the featured block and the first active tab. It scans section.blocks for a mobile_featured_image block, reads its position setting as a number, and decides which panel opens first through a fixed chain: the featured panel if its position is one; otherwise the first child link that itself has children; otherwise, if a block exists, the featured panel. That verdict is a handle every tab and panel compares itself against at render time, so the correct tab carries is-active and aria-selected="true" and the correct panel is aria-hidden="false" in the server HTML.

The tab strip is a role="tablist" labelled with the link's title. It walks the Shop link's children in order, and at the child whose one-based index equals the block's position it emits the Featured tab, then the child's own. A child with grandchildren becomes a <button role="tab"> with aria-controls pointing at ShopTab--{handle}; a child with no grandchildren becomes a plain link in the strip, because there's nothing to open. If the loop never reaches the featured position — position eight in a five-item menu — the Featured tab is appended at the end rather than dropped.

SOURCES · SHOPIFY ADMINRENDERED · LIQUID, BEFORE ANY SCRIPT Shop link list · children in order 1 Bestsellers 2 Shop by benefit 3 Bundles & kits 4 Categories has grandchildren → tab · none → plain link Grandchild link images collection/product → featured_image page → custom.featured_image mobile_featured_image block position = 2 · two banner cards spliced in before child #2; appended if past the end role="tablist" [Bestsellers][Featured][Shop by benefit] [Bundles & kits][Categories] buttons · aria-selected · aria-controls First active tab · resolved in Liquid 1 featured, if position is 1 2 else first child with grandchildren 3 else featured, if a block exists shop-tabs.js · after paint click → is-active · aria-selected · aria-hidden in order at position into panels panels aria-hidden to match takes over state
Two admin surfaces become one ordered strip in Liquid: the link list's children in sequence, with the featured block spliced in at its position setting. The open tab is decided server-side by a three-step chain, so the drawer's first paint is already right and shop-tabs.js only has to maintain state from there.

Panels follow the same walk. Each child with grandchildren gets a role="tabpanel" with id ShopTab--{handle} and a list of its grandchild links. Where the header's show_images_mobile setting is on, each grandchild link carries a thumbnail resolved by link type: a collection or product link uses the linked object's featured_image, and a page link reads metafields.custom.featured_image off the page, so a content page gets a menu image without a developer. The image goes through the theme's own image-fill renderer at the header's image aspect ratio. A "Shop All" link to the parent's URL sits under the strip.

The featured panel renders up to two banner cards from the block: image, optional link, an overlay with its opacity from a range setting, a title and description with their own font sizes and a shared text color, and a border radius. An aspect preset — wide, square, portrait — maps to 1.778, 1.0 or 0.8; "adapt" leaves the image at its own ratio.

shop-tabs.js is the whole client side, a little under thirty lines. Registered behind customElements.get('shop-tabs'), its connectedCallback binds a click on each [data-shop-tab] button, and activate(handle) toggles is-active and aria-selected across the tabs and is-active and aria-hidden across the panels, matching on the ShopTab-- id. header.liquid enqueues it deferred.

Why This Way

Resolving the first active tab in Liquid is the decision the rest depends on. A tabs component that decides its open panel in JavaScript shows every panel, or none, until the script runs. Rendering is-active, aria-selected and aria-hidden server-side means the drawer is correct as HTML, and the script only ever changes state, never establishes it.

A custom element keeps that behavior scoped. The component owns its own buttons and panels through this.querySelectorAll, so there's no global click handler in the drawer and nothing for the vendor's accordion triggers to collide with. The registration guard means a second load of the script — a duplicated tag, a theme-editor re-render — can't throw on a name already defined.

The trade is in what the strip is. Tab reaches each tab button in document order, because they are buttons; the element adds no arrow-key movement between them, so this is a row of buttons that behaves like tabs rather than a full tabs widget. And the position setting is an index into a menu the merchandising team edits separately: reorder the Shop menu's children and the Featured tab stays second, whatever is now second. By design — and something to know before reordering.

Why Not an App

The mobile half of a navigation app is a mega-menu module: it stores the menu structure in the app, draws the drawer from that, and injects it after the theme has rendered. That gives up the one thing this build kept. The tabs are the mobile Shop link list — the same one the vendor's accordion reads for every other item in the drawer — so a category added in Shopify Navigation appears in the strip with no second admin to update. The featured banner is a theme block in the theme editor, next to every other block the merchandising team already composes the header from. And the thumbnails come off the linked collection, product or page, so there is no image library to keep in sync with the catalog. An app's drawer would have had to reproduce all three from its own copy of the data.

Implementation Notes

  • The custom element is registered only when customElements.get('shop-tabs') returns nothing, so the script can be enqueued twice or re-run by the theme editor without a redefinition error.
  • Panel ids follow ShopTab--{handle} for link-derived panels and ShopTab--featured for the block, and activate matches on those ids rather than on position, so tab and panel stay paired if either list is reordered.
  • The featured tab's position is a one-based select compared against forloop.index while the children are walked; a featured_tab_rendered flag prevents a second emission, and the fallback append runs only if that flag is still false.
  • Page links pull metafields.custom.featured_image; collection and product links pull featured_image from the linked object, and every image passes through the theme's image-fill renderer with a ratio computed from the header's image aspect setting.
  • The aspect presets map wide, square and portrait to 1.778, 1.0 and 0.8 before the ratio reaches image-fill; "adapt" passes the image's own aspect_ratio through instead.
  • The rest of nav-item-mobile.liquid — the accordion for levels one through three, its collapsible triggers and the highlight item — is the vendor's; the index == 0 branch is the only addition to the file.
  • The block carries fifteen settings across two banner cards: position, aspect ratio, overlay opacity, border radius, two images, two titles, two descriptions, two links, heading and text font sizes, and a text color.

Edge Cases

  • A Shop menu in which no child has grandchildren still gets a Featured tab when a block exists, and the featured panel becomes the first active one, so the strip never renders with nothing open.
  • A featured position beyond the child count appends the Featured tab at the end of the strip instead of silently dropping the block.
  • A child link without grandchildren renders as a plain link in the strip, not as a tab with an empty panel behind it.
  • Grandchild images are optional twice over: gated by the header's show_images_mobile setting, and resolved only for collection, product and page links, so any other link type yields no image object.
  • A banner card with neither an image nor a title is skipped, so a block with one card filled in renders one card rather than an empty second frame.
  • aria-selected and aria-hidden are set in the server markup and then maintained by the element, so the state is expressed in the DOM before and after the script runs.

Platform Primitives Used

  • Custom elements<shop-tabs> owns its buttons and panels through its own subtree, with no global handlers.
  • Metafields — a page's custom.featured_image metafield is how a content page gets a thumbnail in the menu without code.
  • Liquid section schema and settings — the mobile_featured_image block, its position setting, and the header's show_images_mobile and aspect settings are the whole merchandising interface.
  • Theme blocks — the featured banner is a block on the header section, composed in the theme editor like the header's other panels.
  • Section groups — the header lives in the header group, so the tabbed drawer reaches every template at once.

Where It Runs

The header section group, so every template's mobile drawer. On the live storefront the first item of the mobile link list renders as a strip of four link-derived tabs with the Featured tab in second position, the first tab open in the server HTML, and thumbnails on the grandchild links because the header's mobile image setting is on.

What This Demonstrates

  • Mega menu and mobile navigation system — the primary capability: the mobile half of a navigation system, where the Shop menu is restructured into tabs from the same link list, a merchandiser's banner is composed into it as a block, and the open state is right before any script runs.

How We Know

The drawer snippet, the header section that declares the block and enqueues the element, and the element's script were read from the theme. The local branch runs to roughly 180 lines of the vendor snippet's 242; the rest of that file is the vendor's. Three client-side task-register records place the work as the mobile side of a wider menu refresh. The strip, the Featured tab's position and the server-resolved active state were checked in the live storefront's markup. Code and record agree on what was built; the reasoning above is documented from the code.

Related Builds

The Buy-vs-Build Question

A navigation app can draw a tabbed drawer. What it cannot do is draw it from the link list your merchandising team already edits, or take its banner from the theme editor they already work in, without keeping its own copy of both. The case for owning navigation, and the situations where an app's drawer is the right call, is set out in the mega menu and navigation build-or-buy page.

Provenance & Evidence

  • Client: Three Ships — threeshipsbeauty.ca
  • Surface: Global — the header's mobile drawer, on every page
  • Mounted by: one section group
  • Block settings: fifteen, on one mobile_featured_image block
  • Complexity: High
  • Attribution: Built on top of Palo Alto 5.8.0 by Presidio Creative, a paid premium theme the client licenses. The index == 0 tabs branch, the featured banner block and shop-tabs.js are our additions inside the vendor's drawer snippet; the drawer, its accordion and the image-fill renderer are the vendor's.
  • Status: Live, verified 2026-09-07
  • Evidence: three theme files read from the theme, plus three client-side task-register records
  • Confidence: Strong — code and client-side records agree on the scope; the reasoning is documented from the code
  • Primary capability: Mega menu and mobile navigation system

Ready to Bring Your Shop Menu Up to the Surface?

If your mobile drawer hides the catalog behind two taps of accordion, that's the drawer's default doing your merchandising for you. Contact us today and we'll show you what the first tap could open.

More builds