Shopify Builds>LUS Brands>Vertical filter rail and an in-place mobile filter accordion replacing Dawn's drill-down drawer

Vertical Filter Rail and an In-Place Mobile Filter Accordion Replacing Dawn's Drill-Down Drawer

Dawn's facets snippet was rebuilt into a sidebar rail with color swatches and show-more truncation, an accordion mobile drawer where every group opens in place, and a single sort control that physically relocates between breakpoints.

Dawn's mobile filter drawer is a drill-down, so a shopper filtering on three attributes navigates in and out three times and never sees their combined choices at once. We rebuilt the facets snippet as a desktop rail with swatches, show-more truncation and clear-all pills, plus a mobile accordion — and moved one sort control between breakpoints instead of duplicating it.

Fact Strip

  • Client: LUS Brands
  • Surfaces: Product listing page, search results
  • Templates served: 1 collection template, plus the search results template
  • Complexity: High
  • Attribution: Deploi-authored. Built on top of Dawn 15.4.1 — the facets snippet is our rewrite of Dawn's, and the accordion exemption is a patch to Dawn's own MenuDrawer component, not a component we wrote.
  • Status: Live, verified 2026-09-06
  • Evidence: Four code artifacts plus five client working-session records
  • Platform primitives: 4

The Problem

Dawn's mobile filter experience is a drill-down. Tapping a filter group slides a submenu over the drawer; choosing a value and going back slides it away again. A shopper narrowing on three attributes — say hair type, then concern, then size — makes that trip three times, and at no point does the screen show all three choices together. If you have ever watched someone filter on a phone, you have watched them lose their place doing it. The desktop side had a different gap: filters sat in a horizontal bar with no room for color swatches, no truncation for long value lists, and no persistent view of what was already applied. LUS wanted every group expandable in place on mobile, and a real sidebar rail on desktop with swatches, show-more and clear-all pills.

The Constraint

Dawn implements the mobile drawer as nested <details> elements driven by the MenuDrawer custom element, and MenuDrawer assumes any open nested <details> is a submenu — it force-closes it on interaction. So restyling the markup as an accordion produces filter groups that snap shut the moment another one opens. The behavior is in the component, not the CSS. Sorting is a second, independent problem: Dawn renders two sort controls, one for desktop and one for mobile, sharing the same ids. Duplicating #FacetSortForm breaks label/for pairing and makes the AJAX parameter collector read the same field twice.

What We Built

facets.liquid was rebuilt against Dawn 15.4.1 — roughly 420 lines added and 360 removed, which is less a customization than a replacement that keeps the contract.

The mobile form is now mobile-facets--accordion, with each group a mobile-facets__details element carrying plus and minus toggle icons, so three open groups stay open and a shopper sees the whole combination at once. The desktop side became a vertical rail: a sidebar heading with its own close button, active-filter pills sitting directly under the title, swatch-input rendering for any filter whose presentation is set to swatch, and show-more truncation once a value list passes a threshold.

The sort control is the piece that took the most care. There is exactly one of them, in collection-facet-sort.liquid, and it is physically relocated between the desktop toolbar and a #FacetSortMobile mount by a CollectionFacetMobileSort module in facets.js. The snippet also ships an inline copy of the relocation logic that publishes window.relocateCollectionFacetSort, and the deferred module checks for that function and defers to it rather than binding a second set of matchMedia listeners. One <select>, one id, one form for the AJAX collector to read.

Dawn's MenuDrawer in global.js was patched with two additions — isMobileFacetsAccordionDetails() and syncMobileFacetsAccordion() — so accordion <details> inside the facets form are exempted from the submenu-closing behavior and instead just keep their own aria-expanded in step with their open state. The match is deliberately narrow: only mobile-facets__details elements inside a mobile-facets--accordion container qualify, so the header navigation drawer keeps Dawn's original behavior untouched.

Filter values themselves stay Shopify's. The rail and the accordion both render from filter.values, url_to_remove and price_range, and both route price facets through the shared price-facet snippet with separate id prefixes so nothing collides.

Why This Way

Moving one DOM node between two mounts keeps the sort control a single source of truth. No duplicate ids, no second form for the AJAX collector to double-count, and the selected sort value survives a breakpoint change because it's literally the same element. Duplicating the control and syncing the two copies would have been fewer lines and a permanent class of bug.

Relocation has its own cost. Any script holding a reference to the sort control's parent is holding a stale one the moment a breakpoint changes. Nothing in the theme does that today; anything you install later might.

The MenuDrawer patch is what it cost. We now own a divergence inside a Dawn component that Dawn will keep changing, so every theme update has to be re-checked against those two functions — and if you take this pattern, that re-check is yours forever. We took that over the alternative — forking a private accordion component and leaving Dawn's drawer in place — because a second disclosure implementation on the same page is a second set of keyboard and focus behaviors to keep correct, and the accessibility cost of that outlasts the maintenance cost of a narrow patch.

Why Not an App

Filter and merchandising apps are a standard purchase for exactly this UX, and the reason we did not reach for one is where the filter data lives. Shopify's native storefront filters are configured in the admin, from product data the merchandising team already maintains; a filter app indexes that data into a store of its own. That's a second place to configure, a second place for a value to go stale, and a script tag that has to load before a shopper can narrow anything. Everything here renders from filter.values and url_to_remove, so the filters a merchandiser sees in the admin are the filters on the page. The problem in this build was never the facet data. It was the disclosure pattern around it, and that is theme code either way.

Implementation Notes

  • MenuDrawer event binding was made idempotent using WeakSet collections for summaries, buttons and triggers, because the facets AJAX pipeline calls bindEvents() again on every single render.
  • The component also gained support for a drawer root that is not a <details> element, keyed off a container id and a menu-drawer trigger attribute, including Enter and Space keyboard activation.
  • That same work added a focus-out handler that tolerates a detached panel, because a re-render can remove the node the browser is currently moving focus out of.
  • Desktop and mobile product counts render as separate ids, and the AJAX renderer accepts either as its source, so the count updates whichever one is currently on screen.
  • Show-more truncation applies only to the vertical rail layout; values past the configured threshold are hidden rather than removed, so a filtered-in value is never unreachable.
  • The sidebar close button and the toolbar open button share one data-facets-sidebar-toggle attribute, so a single delegated handler serves both and there is no second listener to keep in sync.
  • Price-range facets render through the shared price-facet snippet in both layouts, with distinct id prefixes for the rail and the accordion so the two never collide in the same document.

Edge Cases

  • An empty filter result hides the sidebar heading but keeps clear-all visible, so a shopper who has filtered to nothing still has one obvious way out.
  • An active price range renders its own removable pill with money-formatted bounds, rather than quietly disappearing from the applied-filters row the way a numeric range usually does.
  • Sorting enabled with filtering disabled renders a distinct no-filters wrapper variant, so a collection configured without facets gets a toolbar rather than an empty rail.
  • The inline relocation script runs before the deferred module, and the module tests for the published function before binding, so breakpoint listeners are never registered twice.
  • Menu-drawer binding runs after a render only when the mobile facets form is actually present, so a search results page without filters does not throw.

Platform Primitives Used

  • Storefront filtering — every value, count, removal URL and price bound comes from Shopify's own filter objects, so the admin stays the source of truth.
  • Section Rendering API — the transport for every filter change; the whole rail and accordion have to survive being re-rendered and re-bound.
  • Custom elements — Dawn's MenuDrawer is the component being patched, and the accordion exemption lives inside its class rather than beside it.
  • Liquid section schema and settings — exposes the show-more threshold and the layout choice, so the rail's truncation point is merchandiser-configurable.

Where It Runs

Two surfaces from one snippet. The rail and accordion mount on the collection template and on search results, which is why the sort relocation had to tolerate a page where filtering is off and sorting is on. Every filter interaction on either surface goes through the same section re-render path.

What This Demonstrates

Primary: Filters, faceted navigation and progressive grid loading. It also carries swatch rendering into the filter rail rather than leaving swatches on the product page, so a shopper narrows by shade in the same place they narrow by everything else.

How We Know

Four code artifacts read directly from the theme — the facets snippet, the sort snippet, the facets module and the theme's global script — running to roughly 1,000 lines. Five client working-session records cover the same ground: the approved tag list the rail renders, and a request for sorting on a small collection where the review concluded filters were not warranted because the catalog was too short to need them. Code and record agree on both the scope and the reasoning.

Related Builds

The Buy-vs-Build Question

This build is the clean case for the customize verdict: the facets stay Shopify's, and what gets built is the interface around them. That is the cheapest place to own code and the most expensive place to rent it. Our full reasoning is in the filters and faceted navigation decision.

Ready to Give Mobile Shoppers a Filter Panel They Can Read?

Filtering is where shoppers decide whether your catalog is worth the effort. Contact us today and we'll tell you what your current facets are costing you.

More builds