This pattern shows how to build a responsive, adaptive, and accessible, multiselect component for sort and filter user experiences.
Full article · Video on YouTube · Source on Github
HTML
<main> <header> <h1>Lighting</h1> <small>Find your perfect light</small> </header> <aside> <form> <select multiple="true" title="Filter results by category"> <optgroup label="New"> <option value="last 30 days">Last 30 Days</option> <option value="last 6 months">Last 6 Months</option> </optgroup> <optgroup label="Lamps"> <option value="table lamps">Table Lamps</option> <option value="desk lamps">Desk Lamps</option> <option value="floor lamps">Floor Lamps</option> </optgroup> <optgroup label="Ceiling"> <option value="chandeliers">Chandeliers</option> <option value="pendant">Pendant</option> <option value="flush">Flush</option> <option value="fans">Fans</option> </optgroup> <optgroup label="By Room"> <option value="bedroom">Bedroom</option> <option value="dining room">Dining Room</option> <option value="kitchen">Kitchen</option> <option value="living room">Living Room</option> <option value="bathroom">Bathroom</option> <option value="entryway">Entryway</option> <option value="outdoor">Outdoor</option> </optgroup> <optgroup label="Kids"> <option value="lamps">Lamps</option> <option value="night lights">Night Lights</option> <option value="ceiling">Ceiling</option> </optgroup> </select> <fieldset> <legend>New</legend> <div> <input type="checkbox" id="last-30-days" name="new" value="last 30 days"> <label for="last-30-days">Last 30 Days</label> </div> <div> <input type="checkbox" id="last-6-months" name="new" value="last 6 months"> <label for="last-6-months">Last 6 Months</label> </div> </fieldset> <fieldset> <legend>Lamps</legend> <div> <input type="checkbox" id="table-lamps" name="lamps" value="table lamps"> <label for="table-lamps">Table Lamps</label> </div> <div> <input type="checkbox" id="desk-lamps" name="lamps" value="desk lamps"> <label for="desk-lamps">Desk Lamps</label> </div> <div> <input type="checkbox" id="floor-lamps" name="lamps" value="floor lamps"> <label for="floor-lamps">Floor Lamps</label> </div> </fieldset> <fieldset> <legend>Ceiling</legend> <div> <input type="checkbox" id="chandeliers" name="ceiling" value="chandeliers"> <label for="chandeliers">Chandeliers</label> </div> <div> <input type="checkbox" id="pendant" name="ceiling" value="pendant"> <label for="pendant">Pendant</label> </div> <div> <input type="checkbox" id="flush" name="ceiling" value="flush"> <label for="flush">Flush</label> </div> <div> <input type="checkbox" id="fans" name="ceiling" value="fans"> <label for="fans">Fans</label> </div> </fieldset> <fieldset> <legend>By Room</legend> <div> <input type="checkbox" id="bedroom" name="by room" value="bedroom"> <label for="bedroom">Bedroom</label> </div> <div> <input type="checkbox" id="dining-room" name="by room" value="dining room"> <label for="dining-room">Dining Room</label> </div> <div> <input type="checkbox" id="kitchen" name="by room" value="kitchen"> <label for="kitchen">Kitchen</label> </div> <div> <input type="checkbox" id="living-room" name="by room" value="living room"> <label for="living-room">Living Room</label> </div> <div> <input type="checkbox" id="bathroom" name="by room" value="bathroom"> <label for="bathroom">Bathroom</label> </div> <div> <input type="checkbox" id="entryway" name="by room" value="entryway"> <label for="entryway">Entryway</label> </div> <div> <input type="checkbox" id="outdoor" name="by room" value="outdoor"> <label for="outdoor">Outdoor</label> </div> </fieldset> <fieldset> <legend>Kids</legend> <div> <input type="checkbox" id="lamps" name="kids" value="lamps"> <label for="lamps">Lamps</label> </div> <div> <input type="checkbox" id="night-lights" name="kids" value="night lights"> <label for="night-lights">Night Lights</label> </div> <div> <input type="checkbox" id="ceiling" name="kids" value="ceiling"> <label for="ceiling">Ceiling</label> </div> </fieldset> </form> <div role="status" class="sr-only" id="applied-filters"></div> </aside> <article> <span class="last-30-days table-lamps"></span> <span class="last-6-months desk-lamps"></span> <span class="floor-lamps"></span> <span class="last-6-months chandeliers"></span> <span class="pendant last-6-months"></span> <span class="flush fans"></span> <span class="fans pendant table-lamps"></span> <span class="bedroom"></span> <span class="dining-room last-30-days chandeliers"></span> <span class="kitchen lamps"></span> <span class="living-room"></span> <span class="bathroom living-room chandeliers desk-lamps"></span> <span class="bathroom table-lamps desk-lamps"></span> <span class="entryway last-30-days"></span> <span class="outdoor desk-lamps"></span> <span class="lamps last-30-days"></span> <span class="night-lights table-lamps"></span> <span class="ceiling last-30-days"></span> <span class="floor-lamps table-lamps"></span> <span class="floor-lamps last-6-months"></span> <span class="dining-room last-30-days chandeliers"></span> <span class="kitchen lamps"></span> <span class="living-room"></span> <span class="bathroom living-room chandeliers desk-lamps"></span> </article> </main>
CSS
main { display: grid; grid-template-columns: max-content 1fr; gap: 5vmin; align-items: flex-start; & > header { grid-column: 1 / -1; } @media (orientation: portrait) { grid-template-columns: 1fr; } @media (--useSelect) { & > article { grid-row: 3; grid-column: 1 / -1; } } } article { --size: min(300px, calc(25% - 2ch)); margin: -1ch; & > span { will-change: transform; background: hsl(0 0% 50% / 25%); border-radius: 10px; inline-size: var(--size); block-size: 15ch; margin: 1ch; @media (orientation: portrait) { --size: calc(50% - 2ch); } @supports (aspect-ratio: 1) { block-size: auto; aspect-ratio: 1; } } } header { display: grid; gap: 1ch; } aside { counter-reset: filters; & :checked { counter-increment: filters; } & #applied-filters::before { content: counter(filters) " filters "; } } fieldset:first-of-type { margin-block-start: -5px; } [role="status"] { @media (--useSelect) { display: none; } } .sr-only { inline-size: 0; block-size: 0; overflow: hidden; }
JS
import 'https://unpkg.com/[email protected]/dist/isotope.pkgd.min.js' const IsotopeGrid = new Isotope( 'article', { itemSelector: 'span', layoutMode: 'fitRows', percentPosition: true }) const filterGrid = query => { const { matches:motionOK } = window.matchMedia( '(prefers-reduced-motion: no-preference)' ) IsotopeGrid.arrange({ filter: query, stagger: 25, transitionDuration: motionOK ? '0.4s' : 0, }) } // takes a const prepareSelectOptions = element => Array.from(element.selectedOptions).reduce((data, opt) => { data.push([opt.parentElement.label.toLowerCase(), opt.value]) return data }, []) // document.querySelector('select').addEventListener('input', e => { let selectData = prepareSelectOptions(e.target) console.warn('Multiselect', selectData) // DEMO // isotope query assembly from checkbox selections let query = selectData.reduce((query, val) => { query.push('.' + val[1].split(' ').join('-')) return query }, []).join(',') filterGrid(query) // update for assistive technology let statusRoleElement = document.querySelector('#applied-filters') let filterResults = IsotopeGrid.getFilteredItemElements().length statusRoleElement.style.counterSet = selectData.length statusRoleElement.textContent = " giving " + filterResults + " results" }) document .querySelector('aside form') .addEventListener('input', e => { if (e.target.nodeName === 'SELECT') return const formData = new FormData(document.querySelector('form')) console.warn('Checkboxes', Array.from(formData.entries())) // DEMO // isotope query assembly from checkbox selections let query = Array.from(formData.values()).reduce((query, val) => { query.push('.' + val.split(' ').join('-')) return query }, []).join(',') filterGrid(query) document.querySelector('#applied-filters').textContent = " giving " + IsotopeGrid.getFilteredItemElements().length + " results" })