Quick start

<script>
  import { MultiSelect } from 'svelte-widgets'

  const ui_libs = [`Svelte`, `React`, `Vue`, `Angular`, `...`]

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

Favorite Frontend Tools?

<code>selected = {JSON.stringify(selected)}</code>

<MultiSelect bind:value={selected} options={ui_libs} />

Mental model

PropPurposeValue
optionsAvailable choicesStrings, numbers, or objects with a label
modeSelection shapemultiple (default) or single
bind:valueSelected choicesAn array in multiple mode; an option or null in single mode

Common Patterns

<!-- Multi-select -->
<MultiSelect bind:value={selected} options={['A', 'B', 'C']} />

<!-- Single-select -->
<MultiSelect bind:value options={colors} mode="single" />

<!-- Object options (need 'label' property, can have arbitrary other keys, some like `value`, `disabled`, `style` have special meaning, see type ObjectOption) -->
<MultiSelect
  bind:value={selected}
  options={[
    { label: 'Red', value: '#ff0000' },
    { label: 'Blue', value: '#0000ff' },
  ]}
/>

Troubleshooting

  • Object options not working? → Add label property
  • Dropdown not showing? → Check you have options and not disabled={true}
  • Want single item not array? → Use bind:value with mode="single"
  • Types confusing? → Component auto-infers type of value from your options array

Props

Props ordered by how often you’ll reach for them.

💡 Tip: The Option type is automatically inferred from your options array, or you can import it: import { type Option } from 'svelte-widgets'

Essential Props

  1. options?: Option[]  // required unless load_options is provided
    

    Array of strings, numbers, or objects that users can select from. Objects must have a label property that will be displayed in the dropdown. Optional when load_options supplies the dropdown contents; if you pass both, these options are filtered client-side and listed ahead of each loaded batch (see load_options).

    <!-- Simple options -->
    <MultiSelect options={['Red', 'Green', 'Blue']} />
    
    <!-- Object options -->
    <MultiSelect
      options={[
        { label: 'Red', value: '#ff0000', hex: true },
        { label: 'Green', value: '#00ff00', hex: true },
      ]}
    />
    
  2. mode: 'multiple' | 'single' = 'multiple' and bindable value

    Multiple mode uses an array, initially []. Single mode uses one option or null. Pass initial selections through value; option metadata never initializes state.

    <MultiSelect options={colors} bind:value={selected_colors} />
    <MultiSelect options={colors} mode="single" bind:value={selected_color} />
    
  3. max_select: number | null = null
    

    Limits multiple selection. null means unlimited; positive integers cap the number of selected options. Use mode="single" for a scalar value; single mode does not accept max_select.

    <!-- Unlimited selection -->
    <MultiSelect options={colors} />
    
    <!-- Single selection -->
    <MultiSelect options={colors} mode="single" />
    
    <!-- Max 3 selections -->
    <MultiSelect options={colors} max_select={3} />
    
  4. placeholder: string | { text: string; persistent?: boolean } | null = null
    

    Text shown when no options are selected. Can be a simple string or an object with extended options:

    <!-- Simple string -->
    <MultiSelect placeholder="Choose..." />
    
    <!-- Object with persistent option (stays visible even when options selected) -->
    <MultiSelect placeholder={{ text: 'Add items...', persistent: true }} />
    
  5. disabled: boolean = false
    

    Disables the component. Users can’t interact with it, but it’s still rendered.

  6. required: boolean | number = false
    

    For form validation. true means at least 1 option required, numbers specify exact minimum.

Commonly Used Props

  1. search_text: string = `` // bindable
    

    The text user entered to filter options. Bindable for external control.

  2. open: boolean = false // bindable
    

    Whether the dropdown is visible. Bindable for external control.

  3. allow_user_options: boolean | `append` = false
    

    Whether users can create new options by typing. true = add to selected only, 'append' = add to both options and selected.

  4. allow_empty: boolean = false
    

    Whether to allow the component to exist with no options. If false, shows console error when no options provided (unless loading, disabled, or allow_user_options is true).

  5. loading: boolean = false
    

    Shows a loading spinner. Useful when fetching options asynchronously.

  6. invalid: boolean = false // bindable
    

    Marks the component as invalid (adds CSS class). Automatically set during form validation.

Advanced Props

  1. load_options: LoadOptionsFn | LoadOptionsConfig = undefined
    

    Dynamic loading for large datasets. Enables lazy loading / infinite scroll instead of passing static options. Pass either a function or an object with config:

    <!-- Function shorthand -->
    <MultiSelect load_options={myFetchFn} />
    
    <!-- With config -->
    <MultiSelect load_options={{ fetch: myFetchFn, debounce_ms: 500, batch_size: 20 }} />
    

    Failed requests show an error and a Retry button. Retry preserves previously loaded options and requests the same page again. Bind load_error (Error | null) to inspect the failure; it clears on retry or a new search. Customize the messages through labels.loading_failed and labels.retry.

    The function receives { search, offset, limit, signal } and returns { options, has_more, replace?, error? }. Return error to display a partial batch alongside Retry; replace: true replaces previously loaded options with an ordered snapshot, useful when retrying missing results. signal is an AbortSignal that fires when the request is superseded by a newer search or when the component closes or unmounts. Forward it to fetch to cancel work in flight:

    import type { LoadOptionsParams, LoadOptionsResult } from 'svelte-widgets'
    
    async function load_options({
      search,
      offset,
      limit,
      signal,
    }: LoadOptionsParams): Promise<LoadOptionsResult<string>> {
      const query = new URLSearchParams({
        q: search,
        skip: String(offset),
        take: String(limit),
      })
      const response = await fetch(`/api/items?${query}`, { signal })
      if (!response.ok) throw new Error(`Loading items failed: HTTP ${response.status}`)
      const { items, total }: { items: string[]; total: number } = await response.json()
      return { options: items, has_more: offset + items.length < total }
    }
    

    The endpoint must return { items: string[], total: number }. HTTP failures throw so the component can offer Retry; a successful empty response { items: [], total: 0 } shows the normal no-matches state. Do not catch failures and turn them into empty results.

    Config options (when passing an object):

    KeyTypeDefaultDescription
    fetchfnAsync function to load options (required)
    debounce_msnumber300Debounce delay for search queries
    batch_sizenumber50Number of options to load per batch
    on_openbooleantrueWhether to load options when dropdown opens

    Features automatic state management, debounced search, infinite scroll pagination, and loading indicators. See the infinite-scroll demo for live examples.

    Passing options alongside load_options combines both sources: the static options are filtered client-side with filter_func and rendered above the loaded batches. Since they need neither the debounce nor a request, they appear on the first keystroke, which is what lets PageSearch match known routes instantly while its Pagefind index is still loading.

  2. active_index: number | null = null  // bindable
    

    Zero-based index of currently active option in the filtered list.

  3. active_option: Option | null = null  // bindable
    

    Currently active option (hovered or navigated to with arrow keys).

  4. create_option_msg: string | ((state: { search_text: string; selected: Option[]; options: Option[]; matching_options: Option[] }) => string) | null = `Create this option...`
    

    Message shown when allow_user_options is enabled and user can create a new option. Can be a static string or a function that receives component state and returns a dynamic message.

  5. duplicates: boolean | 'case-insensitive' = false
    

    Controls duplicate detection. false (default) blocks exact duplicates. true allows selecting the same option multiple times. 'case-insensitive' blocks case variants (e.g. “Apple” blocks “apple”).

  6. expand_icon_position: 'left' | 'right' | 'none' = 'left'
    

    Which side of the input to render the expand icon on, or 'none' to hide it entirely (applies to both the default chevron and a custom expand_icon snippet). Clicking the icon toggles the dropdown.

  7. filter_func: (opt: Option, search_text: string) => boolean
    

    Custom function to filter options based on search text. Default filters by label.

  8. key: (opt: Option) => unknown
    

    Generates the identity key for an option. Default: an object’s value if it defines one, else its label; primitives are their own key. No case folding — use duplicates="case-insensitive" for that.

  9. close_dropdown_on_select: boolean | 'if-mobile' | 'retain-focus' = false
    

    Whether to close dropdown after selection. false (default) keeps dropdown open for rapid multi-selection. true closes after each selection. 'if-mobile' closes on mobile devices only (screen width below breakpoint). 'retain-focus' closes dropdown but keeps input focused for rapid typing to create custom options from text input (see allow_user_options).

  10. reset_filter_on_add: boolean = true
    

    Whether to clear search text when an option is selected.

  11. sort_selected: boolean | ((a: Option, b: Option) => number) = false
    

    Whether/how to sort selected options. true uses default sort, function enables custom sorting.

  12. portal: { target_node?: HTMLElement; active?: boolean; placement?: 'auto' | 'bottom' | 'top' } = {}
    

    Configuration for portal rendering. When active: true, the dropdown is rendered at document.body level with fixed positioning. Useful for avoiding z-index and overflow issues. active is honored at runtime, so toggling it portals/un-portals in place. placement controls which side of the input the dropdown opens on: 'auto' (default) opens below and flips above when the dropdown would overflow the viewport bottom with more space available above, 'bottom'/'top' force a side.

Grouping Props

Group related options together with visual headers. Add a group key to your option objects:

<script>
  const options = [
    { label: `JavaScript`, group: `Frontend` },
    { label: `TypeScript`, group: `Frontend` },
    { label: `Python`, group: `Backend` },
    { label: `Go`, group: `Backend` },
  ]
</script>

<MultiSelect {options} collapsible_groups group_select_all />

See the grouping demo for live examples.

  1. collapsible_groups: boolean = false
    

    Enable click-to-collapse groups. When true, users can click group headers to hide/show options in that group.

  2. collapsed_groups: Set<string> = new Set()
    

    Bindable set of collapsed group names. Use bind:collapsed_groups to control which groups are collapsed externally or to persist collapse state.

  3. group_select_all: boolean = false
    

    Add a “Select all” button to each group header, allowing users to select all options in a specific group at once.

  4. ungrouped_position: 'first' | 'last' = 'first'
    

    Where to render options that don’t have a group key. 'first' places them at the top, 'last' at the bottom.

  5. group_sort_order: 'none' | 'asc' | 'desc' | ((a: string, b: string) => number) = 'none'
    

    Sort groups alphabetically ('asc' or 'desc') or with a custom comparator function. Default 'none' preserves order of first occurrence.

  6. search_expands_collapsed_groups: boolean = false
    

    When true, collapsed groups automatically expand when the search query matches options within them.

  7. search_matches_groups: boolean = false
    

    When true, the search query also matches against group names, not just option labels. If a group name matches, all options in that group are shown.

  8. keyboard_expands_collapsed_groups: boolean = false
    

    When true, collapsed groups automatically expand when the user navigates into them with arrow keys.

  9. sticky_group_headers: boolean = false
    

    When true, group headers stick to the top of the dropdown while scrolling through their options.

  10. collapse_all_groups: () => void  // bindable
    

    Programmatically collapse all groups. Use with bind:collapse_all_groups to get a callable function.

  11. expand_all_groups: () => void  // bindable
    

    Programmatically expand all groups. Use with bind:expand_all_groups to get a callable function.

  12. li_group_header_class: string = ''
    

    CSS class applied to group header <li> elements.

  13. li_group_header_style: string | null = null
    

    Inline style for group header elements.

  14. group_header: Snippet<[{ group: string; options: T[]; collapsed: boolean }]>
    

    Custom snippet for rendering group headers. Receives the group name, array of options in that group, and whether the group is collapsed.

  15. on_group_toggle: (data: { group: string; collapsed: boolean }) => void
    

    Callback fired when a group is collapsed or expanded. Receives the group name and its new collapsed state.

Form & Accessibility Props

  1. id: string | null = null
    

    Applied to the <input> for associating with <label> elements.

  2. name: string | null = null
    

    Form field name for form submission. When selected options are displayed as chips (the default display mode), they submit as JSON.stringify(selected). Prefer stable object value fields for server processing, or customize serialization with form_serialize.

  3. form_serialize: (selected: Option[]) => string | null = JSON.stringify
    

    Customizes the submitted value in chip mode. For object options, use form_serialize={(selected) => selected.map(({ value }) => value).join(',')}. For primitive options, use form_serialize={(selected) => selected.join(',')}.

  4. autocomplete: string = 'off'
    

    Browser autocomplete behavior. Usually 'on' or 'off'.

  5. inputmode: string | null = null
    

    Hint for mobile keyboard type ('numeric', 'tel', 'email', etc.). Set to 'none' to hide keyboard.

  6. pattern: string | null = null
    

    Regex pattern for input validation.

UI and Behavior Props

  1. max_options: number | undefined = undefined
    

    Limit number of options shown in dropdown. undefined = no limit.

  2. max_visible_chips: number | null = null
    

    Max number of selected chips to render before collapsing the rest into a +N more toggle chip (click to expand/collapse). null renders all chips. Keyboard chip navigation auto-expands so hidden chips can’t be highlighted invisibly. Ignored in selected_display="input" mode.

  3. selected_display: 'chips' | 'input' = 'chips'
    

    How selected options are shown. 'chips' renders them as removable tags inside the input. 'input' writes the selected label straight into the text input (combobox/datalist style) and requires mode="single"; other values throw a configuration error. See the input-dropdown demo.

  4. virtual_list: boolean | { item_height?: number; overscan?: number } = false
    

    Virtualized dropdown rendering for large option lists: only rows near the scroll viewport are rendered as DOM nodes. Pass true for defaults or an object to tune item_height (px per row, default 30, also applies to group headers) and overscan (extra rows rendered above/below the visible window, default 10). Grouped options are supported, but combining them with sticky_group_headers throws a configuration error.

  5. min_select: number | null = null
    

    Minimum selections required before remove buttons appear.

  6. auto_scroll: boolean = true
    

    Whether to keep active option in view when navigating with arrow keys.

  7. breakpoint: number = 800
    

    Screen width (px) that separates ‘mobile’ from ‘desktop’ behavior.

  8. fuzzy: boolean = true
    

    Whether to use fuzzy matching for filtering options. When true (default), matches non-consecutive characters (e.g., “ga” matches “Grapes” and “Green Apple”). When false, uses substring matching only.

  9. highlight_matches: boolean = true
    

    Whether to highlight matching text in dropdown options.

  10. keep_selected_in_dropdown: false | 'plain' | 'checkboxes' = false
    

    Controls whether selected options remain visible in dropdown. false (default) hides selected options. 'plain' shows them with visual distinction. 'checkboxes' prefixes each option with a checkbox.

  11. labels: Partial<MultiSelectLabels> = {}
    

    Overrides for the strings MultiSelect renders itself, merged over the MULTI_SELECT_LABELS defaults exported from svelte-widgets/labels. Covers the +N more / show less chip toggle, group headers, the three disabled select-all titles, screen-reader announcements and form-validity messages. Entries that interpolate a count or a label are functions, so a locale controls its own word order and plural rules. A key set to undefined falls back to its default, so condition ? translation : undefined is safe. Strings a dedicated prop supplies outright (remove_btn_title, no_matching_options_msg, …) are not part of this record. Every other component that renders text of its own takes the same prop.

    <MultiSelect
      {options}
      max_visible_chips={3}
      labels={{
        more_chips: (hidden) => `+${hidden} weitere`,
        show_less: `weniger anzeigen`,
      }}
    />
    
  12. select_all_option: boolean | string = false
    

    Adds a “Select All” option at the top of the dropdown. true shows default label, or pass a custom string label.

  13. select_all_scope: 'visible' | 'matching' = 'visible'
    

    Which options “Select All” adds. 'visible' (default) adds only the rows the dropdown currently renders, i.e. options in expanded groups up to the max_options limit (virtualization doesn’t narrow the scope). 'matching' adds every option matching the current search, including those in collapsed groups and beyond max_options. 'matching' requires local options and is disabled when load_options is set, since the component can’t know the full remote result set.

  14. range_select: boolean = false
    

    Whether Shift-click and Shift+Arrow select an inclusive range of options. The first plain click (or the option active before Shift+Arrow) sets the anchor, then the range spans from the anchor to the shift-targeted option. Disabled options are skipped, max_select is respected. Off by default since enabling it changes what Shift-click does for existing consumers.

  15. li_select_all_class: string = ''
    

    CSS class applied to the “Select All” <li> element.

  16. selected_options_draggable: boolean = !sort_selected
    

    Whether selected options can be reordered by dragging.

  17. selected_flip_params: FlipParams = { duration: 100 }
    

    Animation parameters for the Svelte flip animation when reordering selected options via drag-and-drop. Set { duration: 0 } to disable animation. Accepts duration, delay, and easing properties.

Keyboard Shortcuts

  1. shortcuts: Partial<KeyboardShortcuts> = {}
    

    Override default keyboard shortcuts. Shortcut format: "modifier+...+key" where modifiers can be ctrl, shift, alt, meta, cmd. Set a shortcut to null to disable it. Custom shortcuts take precedence over built-in key handlers (Enter, Escape, ArrowUp/Down, Backspace).

    Available shortcuts and their defaults:

    KeyDefaultAction
    select_allnullSelect all visible options. Opt in with { select_all: 'ctrl+a' } — off by default so it doesn’t hijack the browser’s native Ctrl+A
    clear_all'meta+backspace' / 'ctrl+backspace'Deselect all options (only while chips are present and the search box is empty)
    opennullOpen dropdown
    closenullClose dropdown (Escape works by default)

Message Props

  1. no_matching_options_msg: string = 'No matching options'
    

    Message when search yields no results.

  2. duplicate_option_msg: string = 'This option is already selected'
    

    Message when user tries to create duplicate option.

  3. default_disabled_title: string = 'This option is disabled'
    

    Tooltip for disabled options.

  4. disabled_input_title: string = 'This input is disabled'
    

    Tooltip when component is disabled.

  5. remove_all_title: string = 'Remove all'
    

    Tooltip for remove-all button.

  6. remove_btn_title: string = 'Remove'
    

    Tooltip for individual remove buttons.

  7. max_select_msg: ((current: number, max: number) => string) | null = (current, max) =>
      max > 1 ? `${current}/${max}` : ``
    

    Renders a 2/5 counter next to the input. The default returns an empty string when max_select <= 1. null = no message.

DOM Element References (bindable)

These give you access to DOM elements after the component mounts:

  1. input: HTMLInputElement | null = null  // bindable
    

    Handle to the main <input> DOM element.

  2. form_input: HTMLInputElement | null = null  // bindable
    

    Handle to the hidden form input used for validation.

  3. outer_div: HTMLDivElement | null = null  // bindable
    

    Handle to the outer wrapper <div> element.

Styling Props

For custom styling with CSS frameworks or one-off styles:

  1. style: string | null = null
    

    CSS rules for the outer wrapper div.

  2. input_style: string | null = null
    

    CSS rules for the main input element.

  3. ul_selected_style: string | null = null
    

    CSS rules for the selected options list.

  4. ul_options_style: string | null = null
    

    CSS rules for the dropdown options list.

  5. li_selected_style: string | null = null
    

    CSS rules for selected option list items.

  6. li_option_style: string | null = null
    

    CSS rules for dropdown option list items.

CSS Class Props

For use with CSS frameworks like Tailwind:

  1. outer_div_class: string = ''
    

    CSS class for outer wrapper div.

  2. input_class: string = ''
    

    CSS class for main input element.

  3. ul_selected_class: string = ''
    

    CSS class for selected options list.

  4. ul_options_class: string = ''
    

    CSS class for dropdown options list.

  5. li_selected_class: string = ''
    

    CSS class for selected option items.

  6. li_option_class: string = ''
    

    CSS class for dropdown option items.

  7. li_active_option_class: string = ''
    

    CSS class for the currently active dropdown option.

  8. li_user_msg_class: string = ''
    

    CSS class for user messages (no matches, create option, etc.).

  9. li_active_user_msg_class: string = ''
    

    CSS class for active user messages.

  10. max_select_msg_class: string = ''
    

    CSS class for the “X of Y selected” message.

Read-only Props (bindable)

These reflect internal component state:

  1. matching_options: Option[] = []  // bindable
    

    Currently filtered options based on search text.

Bindable Props

selected, value, search_text, open, max_select, active_index, active_option, invalid, input, outer_div, form_input, options, matching_options, collapsed_groups, collapse_all_groups, expand_all_groups, load_error

Snippets

MultiSelect.svelte accepts the following named snippets:

  1. #snippet option({ option, idx, selected, active, disabled }): Customize rendering of dropdown options. Receives the option, its zero-indexed position (idx) in the dropdown, whether it is selected, active (keyboard-highlighted), and disabled.
  2. #snippet selected_item({ option, idx }): Customize rendering of selected items. Receives as props an option and the zero-indexed position (idx) it has in the list of selected items.
  3. #snippet children({ option, idx, type }): Convenience snippet that applies to both dropdown options AND selected items. Use this when you want the same custom rendering for both. Takes precedence if option or selected_item are not provided. type is 'selected' when rendering a selected pill and 'option' when rendering a dropdown item, allowing conditional styling/content by context.
  4. #snippet spinner(): Custom spinner component to display when in loading state. Receives no props.
  5. #snippet disabled_icon(): Custom icon to display inside the input when in disabled state. Receives no props. Use an empty {#snippet disabled_icon()}{/snippet} to remove the default disabled icon.
  6. #snippet expand_icon({ open, disabled }): Allows setting a custom icon to indicate to users that the Multiselect text input field is expandable into a dropdown list. open is true if the dropdown is visible and false if hidden. disabled reflects the component’s disabled state. Use the expand_icon_position prop to control which side of the input the icon renders on.
  7. #snippet remove_icon({ option, is_remove_all }): Custom icon to display as remove button. Used both by per-option remove buttons (is_remove_all: false, option is the item being removed) and the ‘remove all’ button (is_remove_all: true, option is undefined).
  8. #snippet user_msg({ search_text, msg_type, msg }): Displayed like a dropdown item when the list is empty and user is allowed to create custom options based on text input (or if the user’s text input clashes with an existing option). Receives props:
    • search_text: The text user typed into search input.
    • msg_type: false | 'create' | 'dupe' | 'no-match': 'dupe' means user input is a duplicate of an existing option. 'create' means user is allowed to convert their input into a new option not previously in the dropdown. 'no-match' means user input doesn’t match any dropdown items and users are not allowed to create new options. false means none of the above.
    • msg: Will be duplicate_option_msg or create_option_msg based on whether user input is a duplicate or can be created as new option. Note this snippet replaces the default UI for displaying these messages so the snippet needs to render them instead (unless purposely not showing a message).
  9. #snippet before_input({ selected, disabled, invalid, id, placeholder, open, required, search_text }): Placed before the selected chips and search input. For arbitrary content like a search icon or prefix badge.
  10. #snippet after_input({ selected, disabled, invalid, id, placeholder, open, required, search_text }): Placed after the search input. For arbitrary content like icons or temporary messages. Can serve as a more dynamic, more customizable alternative to the placeholder prop.

Example using several snippets:

<MultiSelect options={[`Red`, `Green`, `Blue`, `Yellow`, `Purple`]}>
  {#snippet children({ idx, option, type })}
    <span style="display: flex; align-items: center; gap: 6pt">
      <span
        style:background={`${option}`}
        style="border-radius: 50%; width: 1em; height: 1em"
      ></span>
      {#if type === `option`}{idx + 1}{/if}
      {option}
    </span>
  {/snippet}
  {#snippet spinner()}
    <CustomSpinner />
  {/snippet}
  {#snippet remove_icon({ is_remove_all })}
    <strong>{is_remove_all ? `Clear` : `X`}</strong>
  {/snippet}
</MultiSelect>

Events

MultiSelect.svelte provides the following event callback props:

  1. on_add={({ option, selected }) => console.log(option, selected)}
    

    Triggers when a new option is selected. option is the newly selected option, selected is the updated array of all selected options.

  2. on_create={({ option }) => console.log(option)}
    

    Triggers when a user creates a new option (when allow_user_options is enabled). The created option is provided as option. Doubles as a validation hook: return false to reject the option, return a replacement option to transform it, or undefined to accept it as-is. May be async — paste handling awaits it.

  3. on_remove={({ option, selected }) => console.log(option, selected)}
    

    Triggers when a single selected option is removed. option is the removed option, selected is the updated array of remaining selected options.

  4. on_remove_all={({ options }) => console.log(options)}
    

    Triggers when all selected options are removed. The options payload gives the options that were removed (might not be all if min_select is set).

  5. on_select_all={({ options, scope }) => console.log(options, scope)}
    

    Triggers when the “Select All” option is clicked (requires select_all_option to be enabled). The options payload contains the options that were added. scope is the active select_all_scope for the top-level “Select All”, and undefined when a group’s own select-all fired the event.

  6. on_range_select={({ added, from, to, selected }) => console.log(added, from, to)}
    

    Triggers when a Shift-click or Shift+Arrow selects a range (requires range_select). added are the newly selected options (already excluding disabled, duplicate and over-max_select ones), from is the anchor option, to is the shift-targeted option, and selected is the resulting selection.

  7. on_reorder={({ options, previous }) => console.log(options, previous)}
    

    Triggers when selected options are reordered via drag-and-drop (enabled by default when sort_selected is false). options is the newly ordered array, previous is the array before reordering.

  8. on_change={({ type, option, options }) => console.log(type, option ?? options)}
    

    Triggers when an option is either added (selected) or removed from selected, all selected options are removed at once, a range is selected, or selected options are reordered via drag-and-drop. type is one of 'add' | 'remove' | 'remove_all' | 'select_all' | 'range_select' | 'reorder' and payload will be option: Option or options: Option[], respectively.

  9. on_open={({ event }) => console.log(`Dropdown opened by`, event)}
    

    Triggers when the dropdown list of options appears. event is the DOM’s FocusEvent, KeyboardEvent or ClickEvent that triggered the open.

  10. on_close={({ event }) => console.log(`Dropdown closed by`, event)}
    

    Triggers when the dropdown list of options disappears. event is the DOM’s FocusEvent, KeyboardEvent or ClickEvent that triggered the close.

  11. on_search={({ search_text, matching_options }) => console.log(search_text, matching_options.length)}
    

    Triggers (debounced, 150ms) when the search text changes. Useful for analytics or loading remote options. search_text is the current input value, matching_options is the array of options matching the search.

  12. on_max_reached={({ selected, max_select, attempted_option }) => console.log(attempted_option)}
    

    Triggers when a user tries to select more options than max_select allows. Useful for showing feedback. Does not fire for max_select=1 (which uses replace behavior).

  13. on_duplicate={({ option }) => console.log(`Duplicate:`, option)}
    

    Triggers when a user tries to add an already-selected option (when duplicates=false). Useful for showing feedback to the user.

  14. on_activate={({ option, index }) => console.log(`Active:`, option, index)}
    

    Triggers during keyboard navigation (ArrowUp/ArrowDown) through options. option is the newly active option, index is its position. Does not fire on mouse hover.

  15. on_collapse_all={({ groups }) => console.log(`Collapsed:`, groups)}
    

    Triggers when all groups are collapsed (e.g. via collapse_all_groups()). groups lists the group names that were collapsed.

  16. on_expand_all={({ groups }) => console.log(`Expanded:`, groups)}
    

    Triggers when all groups are expanded (e.g. via expand_all_groups()). groups lists the group names that were expanded.

The following example shows an alert whenever one or more options are added or removed:

<MultiSelect
  on_change={({ type, option, options }) => {
    if (type === 'add') alert(`You added ${option}`)
    if (type === 'remove') alert(`You removed ${option}`)
    if (type === 'remove_all') alert(`You removed ${options}`)
    if (type === 'select_all') alert(`You selected all: ${options}`)
    if (type === 'reorder') alert(`New order: ${options}`)
  }}
/>

Note: Depending on the data passed to the component the option(s) payload will either be objects or simple strings/numbers.

This component also forwards these DOM events from the <input> node: blur, click, focus, input, keydown, keyup, mousedown, mouseenter, mouseleave, touchcancel, touchend, touchmove, touchstart. The custom on_change callback reports selection changes separately from native DOM events. Registering listeners for the forwarded events works the same:

<MultiSelect
  options={[1, 2, 3]}
  onkeyup={(event) => console.log('key', event.target.value)}
/>

TypeScript

The type of options is inferred automatically from the data you pass. E.g.

const obj_options = [
  { label: `foo`, value: 42 },
  { label: `bar`, value: 69 },
]
// type Option = { label: string, value: number }
const str_options = [`foo`, `bar`]
// type Option = string
const num_options = [42, 69]
// type Option = number

The inferred type of Option is used to enforce type-safety on derived props like selected as well as snippets. E.g. you’ll get an error when trying to use a snippet that expects a string if your options are objects (see this comment for example screenshots).

You can also import the types this component uses for downstream applications:

import {
  LoadOptions, // Dynamic option loading callback
  LoadOptionsConfig,
  LoadOptionsFn,
  LoadOptionsParams,
  LoadOptionsResult,
  FormSerialize, // Type signature for custom form serialization
  MultiSelectEvents,
  MultiSelectSnippets,
  ObjectOption,
  Option,
} from 'svelte-widgets'

Styling

There are 3 ways to style this component. The simplified DOM structure below shows which elements each option affects:

<div class="multiselect">
  <ul class="selected">
    <li>Selected 1</li>
    <li>Selected 2</li>
  </ul>
  <ul class="options">
    <li>Option 1</li>
    <li>Option 2</li>
  </ul>
</div>

With CSS variables

If you only want to make small adjustments, you can pass the following CSS variables directly to the component as props or define them in a :global() CSS context. All variables have sensible defaults defined inside MultiSelect.svelte itself.

Minimal example that changes the background color of the options dropdown:

<MultiSelect --sms-options-bg="white" />
  • div.multiselect

    • border: var(--sms-border, 1px solid light-dark(lightgray, #555)): Change this to e.g. to 1px solid red to indicate this form field is in an invalid state.
    • border-radius: var(--sms-border-radius, 3pt)
    • padding: var(--sms-padding, 0 3pt)
    • background: var(--sms-bg, light-dark(white, #222226))
    • color: var(--sms-text-color, light-dark(#222, #eee)): Text color. Defaults to a theme-aware color paired with --sms-bg so the component stays readable on dark pages that never declare color-scheme (where light-dark() falls back to light). Set --sms-text-color: inherit to blend with the surrounding page instead.
    • min-height: var(--sms-min-height, 22pt)
    • width: var(--sms-width)
    • max-width: var(--sms-max-width)
    • margin: var(--sms-margin)
    • font-size: var(--sms-font-size, inherit)
  • div.multiselect.open

    • z-index: var(--sms-open-z-index, 4): Increase this if needed to ensure the dropdown list is displayed atop all other page elements.
  • div.multiselect:focus-within

    • border: var(--sms-focus-border, 1px solid var(--sms-active-color, cornflowerblue)): Border when component has focus. Defaults to --sms-active-color which in turn defaults to cornflowerblue.
  • div.multiselect.disabled

    • background: var(--sms-disabled-bg, light-dark(lightgray, #444)): Background when in disabled state.
  • div.multiselect input::placeholder

    • color: var(--sms-placeholder-color)
    • opacity: var(--sms-placeholder-opacity)
  • div.multiselect > ul.selected > li

    • background: var(--sms-selected-bg, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))): Background of selected options.
    • padding: var(--sms-selected-li-padding, 0 2pt 0 5pt): Padding of selected options.
    • color: var(--sms-selected-text-color, var(--sms-text-color, light-dark(#222, #eee))): Text color for selected options.
  • ul.selected > li button:hover, button.remove-all:hover, button:focus

    • color: var(--sms-remove-btn-hover-color, inherit): Color of the remove-icon buttons for removing all or individual selected options when in :focus or :hover state.
    • background: var(--sms-remove-btn-hover-bg, light-dark(rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2))): Background for hovered remove buttons.
  • div.multiselect > ul.options

    • background: var(--sms-options-bg, light-dark(#fcfcfc, #222226)): Background of dropdown list.
    • color: var(--sms-text-color, light-dark(#222, #eee)): Text color of dropdown options. Paired with --sms-options-bg since the dropdown is portalled to document.body and no longer inherits the component’s text color.
    • max-height: var(--sms-options-max-height, 50vh): Maximum height of options dropdown.
    • overscroll-behavior: var(--sms-options-overscroll, none): Whether scroll events bubble to parent elements when reaching the top/bottom of the options dropdown. See MDN.
    • z-index: var(--sms-options-z-index, 3): Z-index for the dropdown options list.
    • box-shadow: var(--sms-options-shadow, light-dark(0 0 14pt -8pt black, 0 0 14pt -4pt rgba(0, 0, 0, 0.8))): Box shadow of dropdown list.
    • border: var(--sms-options-border, 1px solid light-dark(lightgray, #555))
    • border-width: var(--sms-options-border-width, 1px)
    • border-radius: var(--sms-options-border-radius, 1ex)
    • padding: var(--sms-options-padding, 0)
    • margin: var(--sms-options-margin, 6pt 0 0 0)
  • div.multiselect > ul.options > li

    • padding: var(--sms-options-li-padding, 2pt 1ex): Padding of each option in the dropdown list.
    • scroll-margin: var(--sms-options-scroll-margin, 100px): Top/bottom margin to keep between dropdown list items and top/bottom screen edge when auto-scrolling list to keep items in view.
  • div.multiselect > ul.options > li.selected

    • background: var(--sms-li-selected-plain-bg, light-dark(rgba(0, 123, 255, 0.1), rgba(100, 180, 255, 0.2))): Background of selected list items in options pane.
    • border-left: var(--sms-li-selected-plain-border, 1px solid var(--sms-active-color, cornflowerblue)): Left border of selected list items in options pane.
  • div.multiselect > ul.options > li.active

    • background: var(--sms-li-active-bg, var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))): Background of active options. Options in the dropdown list become active either by mouseover or by navigating to them with arrow keys. Selected options become active when selected_options_draggable=true and an option is being dragged to a new position. Note the active option in that case is not the dragged option but the option under it whose place it will take on drag end.
  • div.multiselect > ul.options > li.disabled

    • background: var(--sms-li-disabled-bg, light-dark(#f5f5f6, #2a2a2a)): Background of disabled options in the dropdown list.
    • color: var(--sms-li-disabled-text, light-dark(#b8b8b8, #666)): Text color of disabled option in the dropdown list.
  • div.multiselect > ul.options > li.select-all

    • border-bottom: var(--sms-select-all-border-bottom, 1px solid light-dark(lightgray, #555)): Bottom border separating “Select All” from regular options.
    • font-weight: var(--sms-select-all-font-weight, 500): Font weight of “Select All” text.
    • color: var(--sms-select-all-color, inherit): Text color of “Select All” option.
    • background: var(--sms-select-all-bg, transparent): Background of “Select All” option.
    • margin-bottom: var(--sms-select-all-margin-bottom, 2pt): Space below “Select All” option.
    • background (hover): var(--sms-select-all-hover-bg, ...): Background of “Select All” on hover. Falls back to --sms-li-active-bg then --sms-active-color.
  • div.multiselect > ul.options > li.group-header

    • font-weight: var(--sms-group-header-font-weight, 600): Font weight of group headers.
    • font-size: var(--sms-group-header-font-size, 0.9em): Font size of group headers.
    • color: var(--sms-group-header-color, light-dark(#666, #aaa)): Text color of group headers.
    • background: var(--sms-group-header-bg, transparent): Background of group headers.
    • padding: var(--sms-group-header-padding, 2pt 1ex): Padding around group header text.
    • text-transform: var(--sms-group-header-text-transform, uppercase): Text transform for group headers.
    • letter-spacing: var(--sms-group-header-letter-spacing, 0.5px): Letter spacing for group headers.
    • margin-top: var(--sms-group-header-margin-top, 4pt): Top margin for group headers (except the first).
    • border-top: var(--sms-group-header-border-top, 1px solid light-dark(#eee, #333)): Top border for group headers (except the first).
    • background (hover): var(--sms-group-header-hover-bg, light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05))): Background of collapsible group headers on hover.
    • background (sticky): var(--sms-group-header-sticky-bg, ...): Background when sticky_group_headers is enabled. Falls back to --sms-options-bg.
  • div.multiselect > ul.options > li (grouped options)

    • padding-left: var(--sms-group-item-padding-left, var(--sms-group-option-indent, 1.5ex)): Indentation for options within a group.
  • Group chevron icon

    • transition: transform var(--sms-group-collapse-duration, 0.15s) ease-out: Animation duration for group collapse/expand chevron rotation.
  • Group “Select/Deselect All” button

    • background (hover): var(--sms-group-select-all-hover-bg, light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1))): Background of per-group select-all button on hover.
    • color (deselect): var(--sms-group-deselect-color, light-dark(#c44, #f77)): Text color of “Deselect All” button (when all group options are already selected).
  • ::highlight(sms-search-matches): applies to search results in dropdown list that match the current search query if highlight_matches=true. These styles cannot be set via CSS variables. Instead, use a new rule set. For example:

    ::highlight(sms-search-matches) {
      color: orange;
      background: rgba(0, 0, 0, 0.15);
      text-decoration: underline;
    }
    

With CSS frameworks

The second method allows you to pass in custom classes to the important DOM elements of this component to target them with frameworks like Tailwind CSS.

  • outer_div_class: wrapper div enclosing the whole component
  • ul_selected_class: list of selected options
  • li_selected_class: selected list items
  • ul_options_class: available options listed in the dropdown when component is in open state
  • li_option_class: list items selectable from dropdown list
  • li_active_option_class: the currently active dropdown list item (i.e. hovered or navigated to with arrow keys)
  • li_select_all_class: the “Select All” option at the top of the dropdown (when select_all_option is enabled)
  • li_user_msg_class: user message (last child of dropdown list when no options match user input)
  • li_active_user_msg_class: user message when active (i.e. hovered or navigated to with arrow keys)
  • max_select_msg_class: small span toward the right end of the input field displaying to the user how many of the allowed number of options they’ve already selected

This simplified version of the DOM structure of the component shows where these classes are inserted:

<div class="multiselect {outer_div_class}">
  <input class={input_class} />
  <ul class="selected {ul_selected_class}">
    <li class={li_selected_class}>Selected 1</li>
    <li class={li_selected_class}>Selected 2</li>
  </ul>
  <span class="max-select-msg {max_select_msg_class}">2/5</span>
  <ul class="options {ul_options_class}">
    <li class="select-all {li_select_all_class}">Select all</li>
    <li class={li_option_class}>Option 1</li>
    <li class="{li_option_class} {li_active_option_class}">
      Option 2 (currently active)
    </li>
    ...
    <li class="{li_user_msg_class} {li_active_user_msg_class}">Create this option...</li>
  </ul>
</div>

With global CSS

The following :global() CSS selectors provide fine-grained control over every part of the component. ul.selected is the list of currently selected options rendered inside the component’s input whereas ul.options is the list of available options that slides out when the component is in its open state. See also simplified DOM structure.

:global(div.multiselect) {
  /* top-level wrapper div */
}
:global(div.multiselect.open) {
  /* top-level wrapper div when dropdown open */
}
:global(div.multiselect.disabled) {
  /* top-level wrapper div when in disabled state */
}
:global(div.multiselect > ul.selected) {
  /* selected list */
}
:global(div.multiselect > ul.selected > li) {
  /* selected list items */
}
:global(div.multiselect button) {
  /* target all buttons in this component */
}
:global(div.multiselect > ul.selected > li button, button.remove-all) {
  /* buttons to remove a single or all selected options at once */
}
:global(div.multiselect > input[autocomplete]) {
  /* input inside the top-level wrapper div */
}
:global(div.multiselect > ul.options) {
  /* dropdown options */
}
:global(div.multiselect > ul.options > li) {
  /* dropdown list items */
}
:global(div.multiselect > ul.options > li.selected) {
  /* selected options in the dropdown list */
}
:global(div.multiselect > ul.options > li:not(.selected):hover) {
  /* unselected but hovered options in the dropdown list */
}
:global(div.multiselect > ul.options > li.active) {
  /* active means item was navigated to with up/down arrow keys */
  /* ready to be selected by pressing enter */
}
:global(div.multiselect > ul.options > li.disabled) {
  /* options with disabled key set to true (see props above) */
}
:global(div.multiselect > ul.options > li.select-all) {
  /* the "Select All" option at the top of the dropdown */
}