Shopify Builds>LUS Brands>Collapsible desktop filter sidebar that reflows the product grid instead of squeezing it

Collapsible Desktop Filter Sidebar That Reflows the Product Grid Instead of Squeezing It

Toggling the vertical filter rail switches the collection grid between four columns and three, keeps that choice for the browsing session, and re-applies it every time the facet pipeline replaces the grid wholesale.

Dawn's vertical filter layout reserves its sidebar column for every shopper, whether or not they ever filter. This build closes the rail by default and, when a shopper opens it, rewrites the grid's desktop column class from 4-up to 3-up instead of squeezing four columns into less room. The choice is kept in sessionStorage, and one delegated listener plus a custom event re-apply it after every facet swap replaces the grid.

The Problem

Dawn's vertical filter layout is a two-column arrangement, and the filter column is always there. Whether a shopper filters or not, the product grid on a collection page gets the narrower share of the width, so every visitor pays for a rail most of them never open.

LUS wanted the opposite default. Filters should be available but out of the way: closed when the page arrives, opened from a button in the collection toolbar, and — the part that decides whether the feature feels finished — the grid should widen back out when the rail is closed again, rather than leaving a blank column where the filters used to be. That means the grid has to change how many products it shows per row as the rail opens and closes, not just how much room each card gets.

The Constraint

Dawn decides the desktop column count in Liquid. The grid carries a class such as grid--4-col-desktop, chosen from the section's columns_desktop setting at render time, and every card width descends from it. Nothing in CSS can follow a client-side toggle to a different column count; either the section re-renders or a script rewrites that class.

The second constraint is the facet pipeline. Every filter or sort change fetches the section through the Section Rendering API and replaces the whole of #ProductGridContainer with the new markup — including the grid and its classes. Anything a script had done to the old grid is gone. The sidebar's close button lives inside the filters aside and the open button lives in a toolbar outside it, and both regions can be re-rendered, so a handler bound to a specific button node can go stale too.

What We Built

main-collection-product-grid.liquid wraps the filters aside and the grid container in a layout element carrying data-facets-sidebar-layout, data-grid-desktop (the section's columns_desktop value) and data-grid-with-sidebar, which Liquid derives as the desktop count minus one, floored at two. The wrapper is emitted with facets-sidebar--collapsed already on it, so a fresh render is closed. Above it, a toolbar button carries data-facets-sidebar-toggle, aria-expanded and aria-controls="main-collection-filters", the id of the aside it opens. Inside the aside, the rail's own close button in facets.liquid carries the same data-facets-sidebar-toggle attribute and nothing else that the script needs.

collection-facets-sidebar.js owns the behavior. It binds one delegated click listener on document and resolves the target through closest('[data-facets-sidebar-toggle]'), so the toolbar button and the rail's close button are the same control from the script's point of view, and a button that was re-rendered since the page loaded is still found. On a click it reads the current state from the layout wrapper's class — collapsed or expanded — flips the pair, syncs aria-expanded on the toolbar toggles, writes the new state to sessionStorage, and rewrites the grid's column class at once rather than waiting for the rail's width transition to finish. The rewrite strips any class matching /^grid--[1-6]-col-desktop$/ and adds grid--3-col-desktop or grid--4-col-desktop from the data attributes.

The re-application is the piece that survives filtering. The theme's facets.js dispatches a facetFilters:productGridUpdated event on document after it has swapped #ProductGridContainer, and the sidebar module listens for it, reads the open state from the layout wrapper — which the swap never touches — and dresses the new grid to match. Both listeners are registered behind window-scoped flags (__collectionFacetsSidebarClickBound, __collectionFacetsSidebarGridListener), so init can run on DOMContentLoaded and again on shopify:section:load without stacking handlers.

STATE · SURVIVES THE SWAPGRID · REPLACED ON EVERY FILTER CHANGE Toggle click closest([data-facets-sidebar-toggle]) Layout wrapper facets-sidebar--collapsed | --expanded · data-grid-desktop=4 · data-grid-with-sidebar=3 sessionStorage open: true | false, for this session Facet AJAX (facets.js) Section Rendering API replaces #ProductGridContainer New #product-grid grid--4-col-desktop · as Liquid rendered it facetFilters:productGridUpdated dispatched on document after the swap flips class · writes storage persists innerHTML swap then dispatches rewrite grid--N-col-desktop re-read wrapper, re-dress grid
State never lives on the grid. The layout wrapper and sessionStorage hold it; the grid is a projection of it. When a facet swap replaces the grid container, the dispatched event is the only thing the module needs to rebuild that projection on the new markup.

The stylesheet does the visual half. component-facets.css collapses the aside through max-width, min-width, opacity and visibility, with pointer-events: none so a closed rail can't be clicked through, and gives the expanded rail a fluid flex basis capped at a fixed width. A prefers-reduced-motion query removes the rail's transition entirely. The module itself is only enqueued when the section's filter_type is vertical and filtering is enabled for that collection.

Why This Way

Delegation is the whole reason the script can be run more than once. A document-level listener that resolves its target on each click doesn't care whether either button has been replaced since the page loaded, and the window-scoped guards mean init is safe to call after every AJAX grid replacement and every theme-editor section load. The naive version — querySelectorAll on the toggles and addEventListener on each — would bind a fresh handler on every one of those events and fire the toggle twice, then three times.

sessionStorage rather than localStorage was a deliberate scope. A shopper who opens the rail keeps it open across collections for that visit, then arrives next week to the closed default again. A sticky setting is worse than a default they can change in one click.

Two things are accepted. The grid rewrite is bound to Dawn's grid--N-col-desktop class naming through a regular expression, so a theme update that renames the grid classes ends the reflow without an error to point at. And the re-application rests on a custom event that facets.js has to keep dispatching after its swap — a contract between two files that nothing enforces except the next person reading both.

Why Not an App

Filter apps come with their own grid renderer. That is the trade you make when you install one: the app indexes the catalog into its own store and draws the results, which is how it can offer layouts the theme doesn't. Here the facets were never the problem. LUS runs Shopify's native storefront filters, configured in the admin from tags and metafields the merchandising team already maintains, and the sidebar work leaves that untouched — every value, count and removal URL is still Shopify's. What changed is the frame around the filters: whether the rail is open, and how many columns the grid draws when it isn't. That is layout, it belongs to the theme, and an app's own grid renderer is the one place it can't be reached from.

Implementation Notes

  • The column-class rewrite is regex-guarded (/^grid--[1-6]-col-desktop$/), so the grid's tablet-down column class, the quick-add bulk class and the row-reveal class all survive a rewrite untouched.
  • The with-sidebar column count is computed in Liquid (columns_desktop minus one, at least two) and passed through data-grid-with-sidebar, so a merchandiser changing grid density in the theme editor changes both states without a code edit.
  • aria-expanded is synced on every toolbar toggle on each state change, including the re-application after an AJAX swap, so the button's announced state can't drift from the rail's.
  • Every sessionStorage read and write is wrapped in try/catch, because Safari private mode and blocked-storage settings throw on the access itself rather than returning null for the missing key.
  • The module is enqueued from the section only when filter_type is vertical and filtering is enabled for the collection, so collections without a rail don't load it.
  • The wrapper carries facets-sidebar--collapsed in its server-rendered class list, so the default state is closed.

Edge Cases

  • A collection whose custom.enable_filter metafield is false has both filtering and sorting turned off for that collection: no toolbar, no layout attributes on the wrapper, and the module is not enqueued.
  • Filtering off with sorting on renders the toolbar with an aria-hidden spacer where the toggle would be, so the sort control keeps its place without a button that opens nothing.
  • An empty collection renders a placeholder that isn't a .product-grid, and syncSidebarState returns before touching classes, so a zero-result filter can't throw.
  • A toggle button removed by an AJAX re-render is still handled — the listener is delegated — and document.body.contains(btn) is re-checked before acting so a detached node is ignored.
  • A sessionStorage read that throws — private browsing, blocked storage — defaults to closed, which is the same state the server already rendered.
  • A layout wrapper that carries no data-grid-desktop attribute short-circuits the sync entirely, so the module never guesses a column count it wasn't given.

Platform Primitives Used

  • Storefront filtering — the rail's contents are Shopify's native filters; this build changes the room around them, not the values in them.
  • Section Rendering API — the transport that replaces the grid on every filter change, and therefore the reason the column state has to live somewhere the swap doesn't reach.
  • Liquid section schema and settingscolumns_desktop and filter_type decide both column counts and whether the module loads.
  • Metafields — a collection-level custom.enable_filter switch turns the whole rail off for a collection that doesn't warrant one.

Where It Runs

One template, the collection page, and only in its vertical-filter configuration. On the live storefront the layout wrapper renders with data-grid-desktop="4" and data-grid-with-sidebar="3", closed on arrival, so the two states a shopper can move between are a four-column grid with the rail hidden and a three-column grid with the rail open. Search results use the same facets snippet but not the sidebar module.

What This Demonstrates

How We Know

The section, the sidebar module and its minified twin were read from the theme, alongside the facets module that dispatches the event and the stylesheet that draws the two states — roughly 90 lines of new JavaScript around a much larger Dawn section. Six client working-session records and two metafield exports cover the filter program: filters were treated as a tagging exercise, an approved tag list preset and products tagged against it, plus a goals-based filter the previous site never offered. Those records establish what the rail had to hold. Keeping it closed by default and reflowing the grid is documented from the code and the section's own settings.

Related Builds

The Buy-vs-Build Question

Keeping the facets native and building only the layout around them is the customize verdict in its cheapest form: the data stays in the admin, and the code you own is the part that is about your grid and nobody else's. Where that line sits, and when an app's own renderer is worth its second index, is set out in the filters and faceted navigation build-or-buy page.

Provenance & Evidence

  • Client: LUS Brands — loveurcurls.com
  • Surface: Product listing page
  • Templates served: one — the collection template
  • Complexity: Medium
  • Scale: roughly 90 lines in the sidebar module, plus the toolbar button and the wrapper's data attributes in the section
  • Attribution: Deploi-authored. The layout wrapper, the toolbar toggle, the sidebar module and the collapse styles are ours; they sit inside a theme built on Dawn 15.4.1, and the grid, the facets pipeline and the section they extend are Dawn's.
  • Status: Live, verified 2026-09-07
  • Evidence: five theme files read from the theme, plus six client working-session records and two metafield exports
  • Confidence: Strong — code and client-side records agree on the filter program the rail carries
  • Primary capability: Filters, faceted navigation and progressive grid loading

Ready for Filters That Get Out of the Way Until Someone Wants Them?

If your collection grid is narrower than it needs to be because a filter column is always there, that's a layout decision your theme made for you. Contact us today and we'll show you what the grid can do with the room back.

More builds