svelte-multiselect Svelte MultiSelect

« home

Option Grouping

Group related options together with visual headers. Add a group key to option objects and they’re automatically grouped with section headers.

This addresses GitHub issue #135.

Basic Grouping

Selected: none

<script>
  import MultiSelect from '$lib'

  const options = Object.entries({
    Frontend: [`JavaScript`, `TypeScript`, `React`, `Vue`, `Svelte`, `Angular`],
    Backend: [`Python`, `Go`, `Rust`, `Java`, `Node.js`, `Ruby`],
    Database: [`PostgreSQL`, `MongoDB`, `Redis`, `MySQL`, `SQLite`],
    DevOps: [`Docker`, `Kubernetes`, `Terraform`, `AWS`],
  }).flatMap(([group, options]) =>
    options.map((option) => ({ label: option, group }))
  )

  let selected = $state([])
  let searchMatchesGroups = $state(false)
</script>

<label>
  <input type="checkbox" bind:checked={searchMatchesGroups} />
  <code>searchMatchesGroups</code> — Type "Backend" to match all backend options
</label>

<MultiSelect
  {options}
  bind:selected
  {searchMatchesGroups}
  placeholder="Select technologies..."
/>

<p>Selected: {selected.map((opt) => opt.label).join(`, `) || `none`}</p>

Note: Search filtering works automatically—empty groups are hidden when no options match.

Collapsible Groups

Enable collapsibleGroups to let users collapse/expand groups. Use searchExpandsCollapsedGroups or keyboardExpandsCollapsedGroups for auto-expansion:

Collapsed: Dairy

<script>
  import MultiSelect from '$lib'

  const options = Object.entries({
    Fruits: `🍎 Apple,🍊 Orange,🍌 Banana,🍇 Grapes,🍓 Strawberry,🫐 Blueberry`.split(
      `,`,
    ),
    Vegetables: `🥕 Carrot,🥦 Broccoli,🌽 Corn,🥬 Lettuce,🍅 Tomato,🥒 Cucumber`
      .split(`,`),
    Dairy: `🥛 Milk,🧀 Cheese,🧈 Butter,🍦 Ice Cream,🥚 Eggs`.split(`,`),
    Meat: `🥩 Steak,🍗 Chicken,🥓 Bacon,🌭 Hot Dog,🍖 Ribs`.split(`,`),
  }).flatMap(([group, options]) =>
    options.map((option) => ({ label: option, group }))
  )

  let selected = $state([])
  let collapsedGroups = $state(new Set([`Dairy`])) // Dairy starts collapsed
  let searchExpandsCollapsedGroups = $state(true)
  let keyboardExpandsCollapsedGroups = $state(true)
  let collapseAllGroups = $state()
  let expandAllGroups = $state()
</script>

<div style="display: flex; flex-wrap: wrap; gap: 1em; margin-bottom: 0.5em">
  <label>
    <input type="checkbox" bind:checked={searchExpandsCollapsedGroups} />
    <code>searchExpandsCollapsedGroups</code>
  </label>
  <label>
    <input type="checkbox" bind:checked={keyboardExpandsCollapsedGroups} />
    <code>keyboardExpandsCollapsedGroups</code>
  </label>
</div>

<div style="display: flex; gap: 1em; margin-bottom: 1em">
  <button onclick={() => collapseAllGroups?.()}>Collapse All</button>
  <button onclick={() => expandAllGroups?.()}>Expand All</button>
</div>

<MultiSelect
  {options}
  bind:selected
  collapsibleGroups
  bind:collapsedGroups
  {searchExpandsCollapsedGroups}
  {keyboardExpandsCollapsedGroups}
  bind:collapseAllGroups
  bind:expandAllGroups
  placeholder="Click headers or use arrow keys..."
/>

<p>Collapsed: {[...collapsedGroups].join(`, `) || `none`}</p>

Per-Group Select All

Enable groupSelectAll to add a toggle button to each group header:

Selected: none

<script>
  import MultiSelect from '$lib'

  const options = Object.entries({
    Primary: [`Red`, `Blue`, `Yellow`],
    Secondary: [`Orange`, `Green`, `Purple`],
    Tertiary: [`Vermilion`, `Amber`, `Chartreuse`, `Teal`, `Violet`, `Magenta`],
    Neutrals: [`White`, `Black`, `Gray`, `Silver`, `Beige`],
  }).flatMap(([group, options]) =>
    options.map((option) => ({ label: option, group }))
  )

  let selected = $state([])
</script>

<MultiSelect
  {options}
  bind:selected
  groupSelectAll
  keepSelectedInDropdown="checkboxes"
  placeholder="Select colors..."
/>

<p>Selected: {selected.map((opt) => opt.label).join(`, `) || `none`}</p>

Ungrouped Options & Sorting

Use ungroupedPosition for options without a group key, and groupSortOrder to sort groups:

<script>
  import MultiSelect from '$lib'

  // Ungrouped options (no group key) mixed with grouped options
  const ungrouped = [`⭐ Featured Item`, `🔥 Popular Choice`, `✨ Editor's Pick`].map(
    (label) => ({ label }),
  )
  const grouped = Object.entries({
    'Z Animals': [`Zebra`, `Zorse`, `Zebu`],
    'A Fruits': [`Apple`, `Apricot`, `Avocado`],
    'L Animals': [`Lion`, `Leopard`, `Lemur`],
    'M Fruits': [`Mango`, `Melon`, `Mulberry`],
  }).flatMap(([group, opts]) => opts.map((label) => ({ label, group })))
  const options = [...ungrouped, ...grouped]

  let selected = $state([])
  let ungroupedPosition = $state(`first`)
  let groupSortOrder = $state(`asc`)
</script>

<div style="display: flex; gap: 2em; margin-bottom: 1em">
  <label>
    ungroupedPosition:
    <select bind:value={ungroupedPosition}>
      <option value="first">first</option>
      <option value="last">last</option>
    </select>
  </label>
  <label>
    groupSortOrder:
    <select bind:value={groupSortOrder}>
      <option value="none">none</option>
      <option value="asc">asc</option>
      <option value="desc">desc</option>
    </select>
  </label>
</div>

<MultiSelect {options} bind:selected {ungroupedPosition} {groupSortOrder} />

Sticky Headers & Dynamic Loading

Use stickyGroupHeaders for long lists. Grouping also works with loadOptions:

<script>
  import MultiSelect from '$lib'

  const departments = `Engineering,Design,Marketing,Sales,HR,Finance,Legal,Operations`
    .split(`,`)
  const server_data = departments.flatMap((dept) =>
    Array.from({ length: 8 }, (_, idx) => ({
      label: `${dept.slice(0, 3)}-${String(idx + 1).padStart(3, `0`)}`,
      name: `${dept} Team Member ${idx + 1}`,
      group: dept,
    }))
  )

  async function load_options({ search, offset, limit }) {
    await new Promise((resolve) => setTimeout(resolve, 200))
    const filtered = search
      ? server_data.filter((u) =>
        u.name.toLowerCase().includes(search.toLowerCase()) ||
        u.group.toLowerCase().includes(search.toLowerCase())
      )
      : server_data
    return {
      options: filtered.slice(offset, offset + limit),
      hasMore: offset + limit < filtered.length,
    }
  }

  let selected = $state([])
  let stickyGroupHeaders = $state(true)
</script>

<label style="margin-bottom: 1em; display: block">
  <input type="checkbox" bind:checked={stickyGroupHeaders} />
  <code>stickyGroupHeaders</code>
</label>

<MultiSelect
  loadOptions={load_options}
  bind:selected
  {stickyGroupHeaders}
  collapsibleGroups
  groupSelectAll
  placeholder="Scroll to see sticky headers..."
/>

Custom Group Header

Use the groupHeader snippet for complete control over header rendering:

<script>
  import MultiSelect from '$lib'

  const options = Object.entries({
    USA: [`New York`, `Los Angeles`, `Chicago`, `Houston`, `Phoenix`],
    UK: [`London`, `Manchester`, `Birmingham`, `Leeds`],
    Japan: [`Tokyo`, `Osaka`, `Kyoto`, `Yokohama`],
    France: [`Paris`, `Lyon`, `Marseille`, `Toulouse`],
    Germany: [`Berlin`, `Munich`, `Hamburg`, `Frankfurt`],
  }).flatMap(([group, options]) =>
    options.map((option) => ({ label: option, group }))
  )

  const emojis = { USA: `🇺🇸`, UK: `🇬🇧`, Japan: `🇯🇵`, France: `🇫🇷`, Germany: `🇩🇪` }
  let selected = $state([])
</script>

<MultiSelect
  {options}
  bind:selected
  collapsibleGroups
  groupSelectAll
  placeholder="Select cities..."
>
  {#snippet groupHeader({ group, options, collapsed })}
    <span style="display: flex; align-items: center; gap: 8px; width: 100%">
      <span style="font-size: 1.2em">{emojis[group]}</span>
      <strong>{group}</strong>
      <span style="opacity: 0.6; font-size: 0.85em">({options.length})</span>
      <span style="margin-left: auto">{collapsed ? `` : ``}</span>
    </span>
  {/snippet}
</MultiSelect>

Props Reference

PropTypeDefaultDescription
collapsibleGroupsbooleanfalseEnable click-to-collapse groups
collapsedGroupsSet<string>new SetBindable set of collapsed group names
groupSelectAllbooleanfalseAdd select/deselect all button per group
ungroupedPosition'first' \| 'last''first'Where to render ungrouped options
groupSortOrder'none' \| 'asc' \| 'desc' \| fn'none'Sort groups alphabetically or custom
searchExpandsCollapsedGroupsbooleanfalseAuto-expand when search matches
searchMatchesGroupsbooleanfalseInclude group name in search matching
keyboardExpandsCollapsedGroupsbooleanfalseAuto-expand on arrow key navigation
stickyGroupHeadersbooleanfalseKeep headers visible when scrolling
liGroupHeaderClassstring''CSS class for group header <li>
liGroupHeaderStylestring \| nullnullInline style for group headers
groupHeaderSnippetCustom group header rendering
collapseAllGroups() => voidBindable function to collapse all
expandAllGroups() => voidBindable function to expand all
ongroupTogglefnCallback when group toggled

CSS Variables

VariableDefaultDescription
--sms-group-header-font-weight600Font weight
--sms-group-header-font-size0.85emFont size
--sms-group-header-colorlight-dark(#666, #aaa)Text color
--sms-group-header-bgtransparentBackground
--sms-group-header-padding6pt 1ex 3ptPadding
--sms-group-header-hover-bglight-dark(...)Hover background
--sms-group-item-padding-left1.5exOption indent