Skip to main content

MultiSelect

Choose multiple collection items in a searchable tag field.

Use MultiSelect for several selected collection items displayed as tags. Its values resolve through collection items; use Select for one choice or Combobox for editable single selection.

Basic usage#

import { Field, MultiSelect } from 'moraine'

export function Example() {
  return (
    <Field label="Skills">
      <MultiSelect
        items={[
          { label: 'Design', value: 'design' },
          { label: 'Code', value: 'code' },
        ]}
        placeholder="Choose skills"
      />
    </Field>
  )
}

Playground#

DesignDevelopment
Props
Slots

Anatomy#

MultiSelect [component; no DOM]
├── control [slot]
│   ├── leading [slot]
│   ├── tagsContainer [slot]
│   │   ├── tag [slot]
│   │   │   ├── tagLabel [slot]
│   │   │   └── tagRemove [slot]
│   │   ├── tagOverflow [slot]
│   │   └── input [slot]
│   ├── clear [slot]
│   └── trigger [slot]
└── positioner [internal]
    └── content [slot]
        ├── listbox [slot]
        │   ├── group [slot]
        │   │   └── groupLabel [slot]
        │   └── item [slot]
        │       ├── itemLeading [slot]
        │       ├── itemWrapper [slot]
        │       │   ├── itemLabel [slot]
        │       │   └── itemDescription [slot]
        │       └── itemIndicator [slot]
        └── empty [slot]

MultiSelect renders its portaled popup collection internally. Tags have a separate rendering path through tagRender.

Usage#

Values and tag lifecycle#

The control slot wraps the interactive tags container and floating anchor. When non-editable, clicking the control opens the panel by default; when editable (search or createItem), clicking the control focuses the input without opening the panel so users can type immediately. Use openOnControlClick to explicitly override this default behavior.

value contains the selected item values as an array. An empty array means no selection; an empty string can be one of the item values. Pair value with onValueChange for controlled selection. Without search, printable keys trigger collection typeahead. Enabling search={true} allows users to type directly in the field to filter available options.

import { Button, MultiSelect } from 'moraine'
import type { MultiSelectT } from 'moraine'
import { createSignal } from 'solid-js'

const FRAMEWORKS: MultiSelectT.Item[] = [
  { label: 'SolidJS', value: 'solid', icon: 'i-lucide:atom' },
  { label: 'Vue.js', value: 'vue', icon: 'i-lucide:sparkles' },
  { label: 'React', value: 'react', icon: 'i-lucide:box' },
  { label: 'Svelte', value: 'svelte', icon: 'i-lucide:flame' },
  { label: 'Astro', value: 'astro', icon: 'i-lucide:rocket' },
]

export function UsageTags() {
  const [selected, setSelected] = createSignal<string[]>(['solid', 'svelte'])

  return (
    <div class="max-w-md w-full space-y-3">
      <MultiSelect
        search
        placeholder="Select frameworks..."
        items={FRAMEWORKS}
        value={selected()}
        onValueChange={setSelected}
        allowClear
      />
      <div class="text-xs flex items-center justify-between">
        <span class="text-muted-foreground">
          Committed Values:{' '}
          <span class="text-foreground font-medium font-mono">{JSON.stringify(selected())}</span>
        </span>
        <Button
          variant="ghost"
          size="sm"
          disabled={selected().length === 0}
          onClick={() => setSelected([])}
        >
          Clear All
        </Button>
      </div>
    </div>
  )
}

Item creation and delimiters#

Providing createItem transforms unmatched query text into a valid TItem on Enter or when a token separator is encountered. Separator characters (like comma or space) and pasted strings are automatically parsed through the same creation path.

import { MultiSelect } from 'moraine'
import { createSignal } from 'solid-js'

export function UsageCreation() {
  const [tags, setTags] = createSignal<string[]>(['frontend', 'performance'])

  return (
    <div class="max-w-md w-full space-y-2">
      <MultiSelect
        value={tags()}
        onValueChange={setTags}
        createItem={(input) => ({ label: input.trim(), value: input.trim().toLowerCase() })}
        tokenSeparators={[',', ' ']}
        placeholder="Type and press Enter, comma, or space..."
        allowClear
      />
      <p class="text-xs text-muted-foreground">
        Active tags:{' '}
        <span class="text-foreground font-medium font-mono">{tags().join(', ') || '(none)'}</span>
      </p>
    </div>
  )
}

Keyboard interaction#

Key Behavior
Backspace When input is empty, deletes the immediately preceding tag
ArrowDown / ArrowUp Open popup or move focus through the options list
Enter Commit the highlighted item (or create an item when input text is present)
Escape Close the dropdown or clear active query text

Virtualization#

Add createListVirtualizer when rendering the full collection makes opening or scrolling slow.

Read the Virtualization guide for setup, row measurements, stable keys, and keyboard navigation.

import { MultiSelect } from 'moraine'
import type { MultiSelectT } from 'moraine'
import { createListVirtualizer } from 'moraine/virtualizer'

const OPTIONS: MultiSelectT.Item<string>[] = Array.from({ length: 10_000 }, (_, index) => ({
  value: `option-${index}`,
  label: `Option ${index + 1}`,
}))

export function Virtualization() {
  const virtualizer = createListVirtualizer<
    MultiSelectT.Row<MultiSelectT.Item<string>>,
    HTMLDivElement,
    HTMLDivElement
  >({
    estimateSize: (entry) => (entry.type === 'label' ? 30 : 32),
    getItemKey: (entry) => entry.key,
    overscan: 8,
  })

  return (
    <div class="max-w-md w-full">
      <MultiSelect
        items={OPTIONS}
        search
        openOnControlClick
        placeholder="Search across 10,000 options..."
        virtualRender={virtualizer.virtualRender}
        scrollToItem={(_, entryIndex) => virtualizer.scrollToIndex(entryIndex)}
        classes={{ listbox: 'h-80 max-h-80' }}
      />
    </div>
  )
}

Examples#

Custom tag rendering#

Use tagRender to customize chip presentation with colors, status dots, badges, or custom remove buttons.

import { Icon, MultiSelect } from 'moraine'
import type { MultiSelectT } from 'moraine'

interface LabelItem extends MultiSelectT.Item {
  color: string
}

const LABELS: LabelItem[] = [
  {
    label: 'bug',
    value: 'bug',
    color: 'bg-red-500/15 border-red-500/30 text-red-700 dark:text-red-400',
  },
  {
    label: 'feature',
    value: 'feature',
    color: 'bg-purple-500/15 border-purple-500/30 text-purple-700 dark:text-purple-400',
  },
  {
    label: 'documentation',
    value: 'docs',
    color: 'bg-blue-500/15 border-blue-500/30 text-blue-700 dark:text-blue-400',
  },
  {
    label: 'performance',
    value: 'perf',
    color: 'bg-amber-500/15 border-amber-500/30 text-amber-700 dark:text-amber-400',
  },
  {
    label: 'security',
    value: 'security',
    color: 'bg-emerald-500/15 border-emerald-500/30 text-emerald-700 dark:text-emerald-400',
  },
]

export function CustomTags() {
  return (
    <div class="max-w-md w-full space-y-2">
      <label class="text-xs text-muted-foreground font-medium block">Issue Labels</label>
      <MultiSelect<LabelItem>
        items={LABELS}
        defaultValue={['bug', 'perf']}
        search
        openOnControlClick
        placeholder="Select labels..."
        tagRender={(props) => {
          const color = () => props.item?.color ?? 'bg-muted border-border text-foreground'
          return (
            <span
              class={`text-xs font-medium px-2 py-0.5 border rounded-full inline-flex gap-1.5 items-center ${color()}`}
            >
              <span class="rounded-full bg-current opacity-70 h-1.5 w-1.5" />
              <span>{props.label}</span>
              <button
                type="button"
                aria-label={`Remove ${props.value}`}
                onClick={(event) => {
                  event.stopPropagation()
                  props.onClose()
                }}
                class="p-0.5 rounded-full inline-flex transition-opacity items-center justify-center hover:opacity-80"
              >
                <Icon name="i-lucide:x" size={11} />
              </button>
            </span>
          )
        }}
      />
    </div>
  )
}

Project assignees#

import { MultiSelect } from 'moraine'
import type { MultiSelectT } from 'moraine'

const TEAM_MEMBERS: MultiSelectT.Item[] = [
  {
    label: 'Sarah Connor',
    value: 'sarah',
    icon: 'i-lucide:user',
    description: '[email protected] · Admin',
  },
  {
    label: 'Alex Rivera',
    value: 'alex',
    icon: 'i-lucide:user',
    description: '[email protected] · Designer',
  },
  {
    label: 'Elena Rostova',
    value: 'elena',
    icon: 'i-lucide:user',
    description: '[email protected] · Engineer',
  },
  {
    label: 'David Kim',
    value: 'david',
    icon: 'i-lucide:user',
    description: '[email protected] · DevOps',
  },
  {
    label: 'Marcus Vance',
    value: 'marcus',
    icon: 'i-lucide:user',
    description: '[email protected] · QA',
  },
]

export function UserChips() {
  return (
    <div class="max-w-md w-full space-y-2">
      <label class="text-xs text-muted-foreground font-medium block">Project Assignees</label>
      <MultiSelect
        items={TEAM_MEMBERS}
        defaultValue={['sarah', 'alex']}
        search
        openOnControlClick
        placeholder="Assign teammates..."
        allowClear
      />
    </div>
  )
}

Selection and display limits#

maxCount is a behavioral constraint preventing additional selections. maxTagCount is purely visual: surplus selections stay committed and are summarized in a +N badge. Use tagOverflow to render the hidden tags in a Tooltip or another custom presentation.

import { Icon, MultiSelect, Tooltip } from 'moraine'
import type { MultiSelectT } from 'moraine'
import { For } from 'solid-js'

const TECH_STACK: MultiSelectT.Item[] = [
  { label: 'SolidJS', value: 'solid', icon: 'i-lucide:atom' },
  { label: 'TypeScript', value: 'ts', icon: 'i-lucide:code' },
  { label: 'Tailwind CSS', value: 'tailwind', icon: 'i-lucide:palette' },
  { label: 'Rust', value: 'rust', icon: 'i-lucide:cog' },
  { label: 'Docker', value: 'docker', icon: 'i-lucide:container' },
]

export function MaxCountMaxTagCount() {
  return (
    <div class="gap-6 grid max-w-2xl w-full sm:grid-cols-2">
      <div class="space-y-1.5">
        <label class="text-xs text-muted-foreground font-medium block">
          Selection Limit (maxCount = 2)
        </label>
        <MultiSelect
          items={TECH_STACK}
          maxCount={2}
          placeholder="Select up to 2 skills..."
          defaultValue={['solid']}
          search
          openOnControlClick
        />
        <p class="text-xs text-muted-foreground">
          Hard cap: prevents adding more once limit is reached.
        </p>
      </div>

      <div class="space-y-1.5">
        <label class="text-xs text-muted-foreground font-medium block">
          Display Limit (maxTagCount = 1)
        </label>
        <MultiSelect
          items={TECH_STACK}
          defaultValue={['solid', 'ts', 'tailwind']}
          maxTagCount={1}
          tagOverflow={(props) => (
            <Tooltip openDelay={200}>
              <Tooltip.Trigger
                as="span"
                class="text-muted-foreground px-1 flex cursor-default items-center"
              >
                +{props.count}
              </Tooltip.Trigger>
              <Tooltip.Content class="p-2 flex gap-1">
                <For each={props.tags}>
                  {(tag) => (
                    <span class="px-2 py-1 rounded bg-muted flex gap-1.5 items-center">
                      {tag.label}
                      <Icon name="i-lucide:x" class="text-muted-foreground size-3.5" />
                    </span>
                  )}
                </For>
              </Tooltip.Content>
            </Tooltip>
          )}
          placeholder="Select tags..."
          search
          openOnControlClick
        />
        <p class="text-xs text-muted-foreground">
          Visual only: hover +N to inspect the collapsed tags.
        </p>
      </div>
    </div>
  )
}

Creatable collection items#

Allow users to select from existing options or dynamically generate new tags via an empty state button.

import { Button, MultiSelect } from 'moraine'
import type { MultiSelectT } from 'moraine'
import { createSignal } from 'solid-js'

const INITIAL_TOPICS: MultiSelectT.Item[] = [
  { label: 'React', value: 'react' },
  { label: 'SolidJS', value: 'solid' },
  { label: 'Vue.js', value: 'vue' },
  { label: 'TypeScript', value: 'ts' },
]

export function CreateNewTags() {
  const [tags, setTags] = createSignal<MultiSelectT.Item['value'][]>(['solid'])

  return (
    <div class="max-w-md w-full space-y-2">
      <label class="text-xs text-muted-foreground font-medium block">
        Topics (Select existing or type to create)
      </label>
      <MultiSelect
        search
        items={INITIAL_TOPICS}
        value={tags()}
        onValueChange={setTags}
        createItem={(input) => ({ value: input.trim().toLowerCase(), label: input.trim() })}
        tokenSeparators={[',', ';']}
        placeholder="Type to create or select..."
        openOnControlClick
        allowClear
        emptyRender={(ctx) => (
          <div class="p-2 text-center">
            <Button
              variant="link"
              size="sm"
              class="text-xs text-primary"
              onClick={() => ctx.create()}
            >
              Create &ldquo;{ctx.inputValue}&rdquo;
            </Button>
          </div>
        )}
      />
      <p class="text-xs text-muted-foreground">Selected values: {tags().join(', ') || 'none'}</p>
    </div>
  )
}

Free-form tags#

Without predefined items, combine createItem and tokenSeparators for a lightweight tag input.

import { MultiSelect } from 'moraine'
import { createSignal } from 'solid-js'

export function FreeFormTags() {
  const [tags, setTags] = createSignal<string[]>(['web', 'ui', 'components'])

  return (
    <div class="max-w-md w-full space-y-2">
      <label class="text-xs text-muted-foreground font-medium block">
        Free-Form Tags (Comma or space separated)
      </label>
      <MultiSelect
        value={tags()}
        onValueChange={setTags}
        createItem={(input) => ({ value: input.trim().toLowerCase(), label: input.trim() })}
        tokenSeparators={[',', ' ']}
        placeholder="Type words, press space or comma..."
        allowClear
      />
      <p class="text-xs text-muted-foreground">Tags array: {JSON.stringify(tags())}</p>
    </div>
  )
}

Grouped options#

Organize large collections into categorized option groups.

import { MultiSelect } from 'moraine'
import type { MultiSelectT } from 'moraine'
import { createSignal } from 'solid-js'

const PERMISSION_GROUPS: MultiSelectT.Entry[] = [
  {
    label: 'User Management',
    type: 'group',
    items: [
      { label: 'Invite Members', value: 'users:invite', icon: 'i-lucide:user-plus' },
      { label: 'Edit Roles', value: 'users:roles', icon: 'i-lucide:shield' },
      { label: 'Remove Members', value: 'users:remove', icon: 'i-lucide:user-minus' },
    ],
  },
  {
    label: 'Content Management',
    type: 'group',
    items: [
      { label: 'Create Articles', value: 'content:create', icon: 'i-lucide:file-plus' },
      { label: 'Publish Content', value: 'content:publish', icon: 'i-lucide:send' },
      { label: 'Delete Content', value: 'content:delete', icon: 'i-lucide:trash-2' },
    ],
  },
  {
    label: 'Billing & Settings',
    type: 'group',
    items: [
      { label: 'View Invoices', value: 'billing:view', icon: 'i-lucide:receipt' },
      { label: 'Modify Subscriptions', value: 'billing:edit', icon: 'i-lucide:credit-card' },
    ],
  },
]

export function GroupedItems() {
  const [selected, setSelected] = createSignal(['users:invite', 'content:publish'])

  return (
    <div class="max-w-md w-full space-y-2">
      <label class="text-xs text-muted-foreground font-medium block">Role Permissions</label>
      <MultiSelect
        placeholder="Select permissions..."
        items={PERMISSION_GROUPS}
        value={selected()}
        onValueChange={setSelected}
        search
        openOnControlClick
        allowClear
      />
    </div>
  )
}

Clearable, disabled, and locked states#

Pre-locked items cannot be deselected, while individual options can be disabled in the listbox.

import { MultiSelect } from 'moraine'
import type { MultiSelectT } from 'moraine'

const MODULES: MultiSelectT.Item[] = [
  { label: 'Core Runtime (Locked)', value: 'core', disabled: true },
  { label: 'Analytics Engine', value: 'analytics' },
  { label: 'Push Notifications', value: 'notifications' },
  { label: 'Search Indexer', value: 'search' },
]

export function ClearAndDisabled() {
  return (
    <div class="max-w-md w-full space-y-4">
      <div class="space-y-1.5">
        <label class="text-xs text-muted-foreground font-medium block">
          Disabled Option inside List (Core Runtime cannot be toggled)
        </label>
        <MultiSelect
          items={MODULES}
          defaultValue={['core', 'analytics']}
          search
          openOnControlClick
          placeholder="Select optional modules..."
        />
      </div>

      <div class="space-y-1.5">
        <label class="text-xs text-muted-foreground font-medium block">Disabled Component</label>
        <MultiSelect
          items={MODULES}
          disabled
          defaultValue={['analytics', 'search']}
          placeholder="Disabled"
        />
      </div>

      <div class="space-y-1.5">
        <label class="text-xs text-muted-foreground font-medium block">Read-only Component</label>
        <MultiSelect
          items={MODULES}
          readOnly
          defaultValue={['core', 'notifications']}
          placeholder="Read-only"
        />
      </div>
    </div>
  )
}

Form integration#

Validate multi-selection schemas (e.g. requiring a minimum number of tags) with createForm.

import { Button, createForm, MultiSelect } from 'moraine'
import { createSignal } from 'solid-js'
import * as v from 'valibot'

const TOPICS = [
  { label: 'TypeScript', value: 'typescript' },
  { label: 'SolidJS', value: 'solidjs' },
  { label: 'Tailwind CSS', value: 'tailwind' },
  { label: 'UnoCSS', value: 'unocss' },
  { label: 'Vite', value: 'vite' },
]

export function FormIntegration() {
  const [submittedTags, setSubmittedTags] = createSignal<string[]>([])
  const form = createForm({
    schema: v.object({
      topics: v.pipe(
        v.array(v.string()),
        v.minLength(2, 'Please select at least 2 relevant topics.'),
      ),
    }),
    initialInput: { topics: ['typescript'] },
    validate: 'input',
  })

  return (
    <form.Form onSubmit={(output) => setSubmittedTags(output.topics)}>
      <div class="max-w-xl space-y-4">
        <form.Field
          name="topics"
          label="Interest Topics"
          description="Select at least 2 topics for your curated developer feed."
          required
        >
          <MultiSelect
            items={TOPICS}
            placeholder="Select topics..."
            search
            openOnControlClick
            allowClear
          />
        </form.Field>
        <div class="flex gap-3 items-center">
          <Button type="submit" variant="secondary" size="sm">
            Validate
          </Button>
          <p class="text-xs text-muted-foreground">
            Submitted:{' '}
            <span class="text-foreground font-medium font-mono">
              {submittedTags().length ? submittedTags().join(', ') : 'none'}
            </span>
          </p>
        </div>
      </div>
    </form.Form>
  )
}

Attributes#

Attributes
data-closedSlot: multi-select-control, multi-select-content, multi-select-triggerDescription: Present when disclosure or transition content is closed.
data-disabledSlot: multi-select-control, multi-select-item, multi-select-triggerDescription: Present when the component, slot, or item is disabled.
data-editableSlot: multi-select-controlDescription: Present when the value can be edited as text.
data-expandedSlot: multi-select-control, multi-select-content, multi-select-triggerDescription: Present when the panel, accordion, or menu is expanded.
data-invalidSlot: multi-select-control, multi-select-triggerDescription: Present when the field or form has a validation error.
data-readonlySlot: multi-select-controlDescription: Present when the field is in read-only mode.
data-requiredSlot: multi-select-controlDescription: Present when the field input is required.
data-tagsSlot: multi-select-controlDescription: Present when the control is displaying selected values as tags.
data-sideSlot: multi-select-contentDescription: Stores the resolved floating or drawer content side.
data-highlightedSlot: multi-select-item, multi-select-item-descriptionDescription: Present when the item is highlighted by pointer or keyboard navigation.
data-selectedSlot: multi-select-itemDescription: Present when the item or tab is selected.
data-duplicateSlot: multi-select-inputDescription: Present when the entered value duplicates an existing value.
data-loadingSlot: multi-select-triggerDescription: Present when the component or async operation is loading.

Props#

Renders a <div> element by default.

Prop