Multi-Select

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/isotope-layout@3.0.6/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  watcher
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"
  })