Skip to main content
Nexus Design System
GitHub

MultiSelect

MultiSelect lets the user search a list and pick several options, shown as chips. Use it for tags, filters and other choices with several answers. For one option, use Combobox or Select.

'use client';

import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  type MultiSelectProps,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/nxds/components/multi-select/multi-select';

const FRAMEWORKS = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'angular', label: 'Angular' },
  { value: 'solid', label: 'Solid' },
  { value: 'qwik', label: 'Qwik' },
];

function Frameworks({
  placeholder = 'Select frameworks',
  triggerClassName = 'nx:w-80',
  ...props
}: Partial<MultiSelectProps> & {
  placeholder?: string;
  triggerClassName?: string;
}) {
  return (
    <MultiSelect {...props}>
      <MultiSelectTrigger aria-label="Frameworks" className={triggerClassName}>
        <MultiSelectValue placeholder={placeholder} />
      </MultiSelectTrigger>
      <MultiSelectContent
        searchPlaceholder="Search frameworks…"
        emptyMessage="No frameworks found."
      >
        {FRAMEWORKS.map((option) => (
          <MultiSelectItem key={option.value} value={option.value}>
            {option.label}
          </MultiSelectItem>
        ))}
      </MultiSelectContent>
    </MultiSelect>
  );
}

export default function MultiSelectDefault() {
  return <Frameworks />;
}

Installation

npx nxds-cli@latest add multi-select

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectGroup,
  MultiSelectItem,
  MultiSelectSeparator,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/nxds/components/multi-select/multi-select';

Composition

MultiSelect
├── MultiSelectTrigger
│   └── MultiSelectValue
└── MultiSelectContent
    ├── MultiSelectItem
    ├── MultiSelectGroup
    │   └── MultiSelectItem
    └── MultiSelectSeparator

Examples

Grouped

Sort options under headings with MultiSelectGroup and a separator.

'use client';

import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectGroup,
  MultiSelectItem,
  MultiSelectSeparator,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/nxds/components/multi-select/multi-select';

export default function MultiSelectGrouped() {
  return (
    <MultiSelect defaultValues={['react']}>
      <MultiSelectTrigger aria-label="Frameworks" className="nx:w-80">
        <MultiSelectValue placeholder="Select frameworks" />
      </MultiSelectTrigger>
      <MultiSelectContent
        searchPlaceholder="Search frameworks…"
        emptyMessage="No frameworks found."
      >
        <MultiSelectGroup heading="Libraries">
          <MultiSelectItem value="react">React</MultiSelectItem>
          <MultiSelectItem value="solid">Solid</MultiSelectItem>
        </MultiSelectGroup>
        <MultiSelectSeparator />
        <MultiSelectGroup heading="Frameworks">
          <MultiSelectItem value="vue">Vue</MultiSelectItem>
          <MultiSelectItem value="svelte">Svelte</MultiSelectItem>
          <MultiSelectItem value="angular">Angular</MultiSelectItem>
        </MultiSelectGroup>
      </MultiSelectContent>
    </MultiSelect>
  );
}

Wrap chips

Set overflowBehavior="wrap" to wrap every chip onto more lines.

'use client';

import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/nxds/components/multi-select/multi-select';

const FRAMEWORKS = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'angular', label: 'Angular' },
  { value: 'solid', label: 'Solid' },
  { value: 'qwik', label: 'Qwik' },
];

export default function MultiSelectWrapChips() {
  return (
    <MultiSelect defaultValues={['react', 'vue', 'svelte', 'angular', 'solid']}>
      <MultiSelectTrigger aria-label="Frameworks" className="nx:w-72">
        <MultiSelectValue
          placeholder="Select frameworks"
          overflowBehavior="wrap"
        />
      </MultiSelectTrigger>
      <MultiSelectContent
        searchPlaceholder="Search frameworks…"
        emptyMessage="No frameworks found."
      >
        {FRAMEWORKS.map((option) => (
          <MultiSelectItem key={option.value} value={option.value}>
            {option.label}
          </MultiSelectItem>
        ))}
      </MultiSelectContent>
    </MultiSelect>
  );
}

Overflow collapse

Set overflowBehavior="cutoff" to show chips that don't fit as a +N badge.

'use client';

import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/nxds/components/multi-select/multi-select';

const FRAMEWORKS = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'angular', label: 'Angular' },
  { value: 'solid', label: 'Solid' },
  { value: 'qwik', label: 'Qwik' },
];

export default function MultiSelectOverflowCollapse() {
  return (
    <MultiSelect
      defaultValues={['react', 'vue', 'svelte', 'angular', 'solid', 'qwik']}
    >
      <MultiSelectTrigger aria-label="Frameworks" className="nx:w-64">
        <MultiSelectValue
          placeholder="Select frameworks"
          overflowBehavior="cutoff"
        />
      </MultiSelectTrigger>
      <MultiSelectContent
        searchPlaceholder="Search frameworks…"
        emptyMessage="No frameworks found."
      >
        {FRAMEWORKS.map((option) => (
          <MultiSelectItem key={option.value} value={option.value}>
            {option.label}
          </MultiSelectItem>
        ))}
      </MultiSelectContent>
    </MultiSelect>
  );
}

Disabled

Disable the trigger to show the selection without letting anyone change it.

'use client';

import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/nxds/components/multi-select/multi-select';

const FRAMEWORKS = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'angular', label: 'Angular' },
  { value: 'solid', label: 'Solid' },
  { value: 'qwik', label: 'Qwik' },
];

export default function MultiSelectDisabled() {
  return (
    <MultiSelect defaultValues={['react']}>
      <MultiSelectTrigger aria-label="Frameworks" disabled className="nx:w-80">
        <MultiSelectValue placeholder="Select frameworks" />
      </MultiSelectTrigger>
      <MultiSelectContent>
        {FRAMEWORKS.map((option) => (
          <MultiSelectItem key={option.value} value={option.value}>
            {option.label}
          </MultiSelectItem>
        ))}
      </MultiSelectContent>
    </MultiSelect>
  );
}

With disabled option

Disable one option so it shows in the list but cannot be picked.

'use client';

import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/nxds/components/multi-select/multi-select';

export default function MultiSelectWithDisabledOption() {
  return (
    <MultiSelect>
      <MultiSelectTrigger aria-label="Frameworks" className="nx:w-80">
        <MultiSelectValue placeholder="Select frameworks" />
      </MultiSelectTrigger>
      <MultiSelectContent
        searchPlaceholder="Search frameworks…"
        emptyMessage="No frameworks found."
      >
        <MultiSelectItem value="react">React</MultiSelectItem>
        <MultiSelectItem value="vue" disabled>
          Vue
        </MultiSelectItem>
        <MultiSelectItem value="svelte">Svelte</MultiSelectItem>
      </MultiSelectContent>
    </MultiSelect>
  );
}

Invalid field

Set aria-invalid on the trigger inside an invalid Field to show the error state.

Choose at least one framework.

'use client';

import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from '@/components/nxds/components/field/field';
import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/nxds/components/multi-select/multi-select';

const FRAMEWORKS = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'angular', label: 'Angular' },
  { value: 'solid', label: 'Solid' },
  { value: 'qwik', label: 'Qwik' },
];

export default function MultiSelectInvalidField() {
  return (
    <Field data-invalid>
      <FieldLabel>Frameworks</FieldLabel>
      <MultiSelect>
        <MultiSelectTrigger
          aria-label="Frameworks"
          aria-invalid
          className="nx:w-80"
        >
          <MultiSelectValue placeholder="Select frameworks" />
        </MultiSelectTrigger>
        <MultiSelectContent
          searchPlaceholder="Search frameworks…"
          emptyMessage="No frameworks found."
        >
          {FRAMEWORKS.map((option) => (
            <MultiSelectItem key={option.value} value={option.value}>
              {option.label}
            </MultiSelectItem>
          ))}
        </MultiSelectContent>
      </MultiSelect>
      <FieldDescription>Choose at least one framework.</FieldDescription>
      <FieldError>Framework selection is required.</FieldError>
    </Field>
  );
}

This example also needs: Field.

Props

MultiSelect

MultiSelect props
PropTypeDefaultDescription
defaultValuesstring[]No default documented

Initial selected values for uncontrolled usage.

onValuesChange((values: string[]) => void)No default documented

Called with the new selected values when the selection changes.

valuesstring[]No default documented

Controlled selected values.

MultiSelectContent

MultiSelectContent props
PropTypeDefaultDescription
emptyMessageReactNode"No results found."

Shown when the search matches no options.

searchPlaceholderstring"Search…"

Placeholder for the search input.

MultiSelectGroup

MultiSelectGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

MultiSelectItem

MultiSelectItem props
PropTypeDefaultDescription
badgeLabelReactNodeNo default documented

Chip text when it differs from the option content, such as an icon option.

onSelect((value: string) => void)No default documented

Called with the option's value after it is toggled.

valueRequiredstringNo default documentedNo description

MultiSelectSeparator

MultiSelectSeparator adds no props of its own; it accepts the props of the element or primitive it wraps.

MultiSelectTrigger

MultiSelectTrigger adds no props of its own; it accepts the props of the element or primitive it wraps.

MultiSelectValue

MultiSelectValue props
PropTypeDefaultDescription
clickToRemovebooleantrue

Shows each selected value as a removable chip.

overflowBehavior"wrap" | "wrap-when-open" | "cutoff""wrap-when-open"

What chips do when they overflow the field:

  • wrap: wrap onto more lines.
  • wrap-when-open: wrap while the list is open, show +N when closed.
  • cutoff: always show the extra chips as a +N badge.
placeholderReactNode"Select options"

Shown when nothing is selected.

MultiSelect lets the user search a list and pick several options, shown as chips. Use it for tags, filters and other choices with several answers. For one option, use Combobox or Select.

<script setup lang="ts">
import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/multi-select';

const frameworks = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'angular', label: 'Angular' },
  { value: 'solid', label: 'Solid' },
  { value: 'qwik', label: 'Qwik' },
];
</script>

<template>
  <MultiSelect>
    <MultiSelectTrigger aria-label="Frameworks" class="nx:w-80">
      <MultiSelectValue>
        <template #placeholder>Select frameworks</template>
      </MultiSelectValue>
    </MultiSelectTrigger>
    <MultiSelectContent search-placeholder="Search frameworks…">
      <template #empty-message>No frameworks found.</template>
      <MultiSelectItem
        v-for="option in frameworks"
        :key="option.value"
        :value="option.value"
        >{{ option.label }}</MultiSelectItem
      >
    </MultiSelectContent>
  </MultiSelect>
</template>

Installation

  1. Install these components first:

    Badge, Command, Dialog, Popover.

  2. Copy and paste the following code into your project.

    components/multi-select/MultiSelect.vue
    <script setup lang="ts">
    import { PopoverRoot } from 'reka-ui';
    import { computed, provide, ref, type VNode } from 'vue';
    
    import { flattenedNodes } from '../../lib/slots';
    
    import MultiSelectItem from './MultiSelectItem.vue';
    
    import { MultiSelectKey } from '.';
    
    export interface MultiSelectProps {
      /**
       * Controlled selected values.
       */
      values?: string[];
      /**
       * Initial selected values for uncontrolled usage.
       */
      defaultValues?: string[];
    }
    
    const props = defineProps<MultiSelectProps>();
    const emit = defineEmits<{
      /**
       * Called with the new selected values when the selection changes.
       */
      'values-change': [values: string[]];
    }>();
    const slots = defineSlots<{ default?: () => VNode[] }>();
    
    function toggleValue(values: Set<string>, value: string) {
      const next = new Set(values);
    
      // Set.delete returns true when the value was present (and removes it), so a
      // failed delete means it wasn't selected — add it instead.
      if (!next.delete(value)) next.add(value);
    
      return next;
    }
    
    type Slots = Record<string, (() => VNode[]) | undefined>;
    
    /**
     * Walks the slot's nodes and maps each option's `value` to the label its chip
     * shows — the `badge-label` slot, falling back to the option content. The
     * options sit in a closed popover, so they can't report their labels
     * themselves.
     */
    function itemLabels(
      nodes: VNode[] | undefined,
      labels = new Map<string, () => VNode[]>()
    ) {
      for (const node of flattenedNodes(nodes)) {
        const children = node.children;
        if (node.type === MultiSelectItem) {
          const itemSlots = children as Slots | null;
          const label = itemSlots?.['badge-label'] ?? itemSlots?.default;
          if (label) labels.set(String(node.props?.value), label);
          continue;
        }
        if (Array.isArray(children)) itemLabels(children as VNode[], labels);
        else if (children && typeof children === 'object') {
          itemLabels((children as Slots).default?.(), labels);
        }
      }
      return labels;
    }
    
    const open = ref(false);
    const uncontrolled = ref(new Set(props.values ?? props.defaultValues));
    
    const selectedValues = computed(() =>
      props.values ? new Set(props.values) : uncontrolled.value
    );
    
    function toggle(value: string) {
      const nextValues = toggleValue(selectedValues.value, value);
    
      if (props.values === undefined) uncontrolled.value = nextValues;
      emit('values-change', [...nextValues]);
    }
    
    provide(MultiSelectKey, {
      open,
      selectedValues,
      labels: () => itemLabels(slots.default?.()),
      toggle,
    });
    </script>
    
    <template>
      <PopoverRoot v-model:open="open">
        <slot />
      </PopoverRoot>
    </template>
    
    components/multi-select/MultiSelectContent.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import { type PopoverContentEmits, useForwardPropsEmits } from 'reka-ui';
    import type { HTMLAttributes, VNode } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { Command, CommandEmpty, CommandInput, CommandList } from '../command';
    import { PopoverContent, type PopoverContentProps } from '../popover';
    
    export interface MultiSelectContentProps extends Omit<
      PopoverContentProps,
      'class'
    > {
      /**
       * Placeholder for the search input.
       */
      searchPlaceholder?: string;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<MultiSelectContentProps>(), {
      align: 'start',
      sideOffset: 6,
      searchPlaceholder: 'Search…',
    });
    const emits = defineEmits<PopoverContentEmits>();
    defineSlots<{
      default?: () => VNode[];
      /**
       * Shown when the search matches no options.
       */
      'empty-message'?: () => VNode[];
    }>();
    
    const delegated = reactiveOmit(props, 'class', 'searchPlaceholder');
    const forwarded = useForwardPropsEmits(delegated, emits);
    </script>
    
    <template>
      <PopoverContent
        v-bind="forwarded"
        data-slot="multi-select-content"
        aria-label="Options"
        :class="
          cn('nx:w-(--reka-popover-trigger-width) nx:min-w-56 nx:p-0', props.class)
        "
      >
        <Command>
          <CommandInput :placeholder="searchPlaceholder" />
          <CommandList>
            <CommandEmpty>
              <slot name="empty-message">No results found.</slot>
            </CommandEmpty>
            <slot />
          </CommandList>
        </Command>
      </PopoverContent>
    </template>
    
    components/multi-select/MultiSelectGroup.vue
    <script setup lang="ts">
    import { CommandGroup, type CommandGroupProps } from '../command';
    
    export type MultiSelectGroupProps = CommandGroupProps;
    
    const props = withDefaults(defineProps<MultiSelectGroupProps>(), {
      forceMount: false,
    });
    </script>
    
    <template>
      <CommandGroup v-bind="props" data-slot="multi-select-group">
        <slot />
      </CommandGroup>
    </template>
    
    components/multi-select/MultiSelectItem.vue
    <script setup lang="ts">
    import { Text, type VNode } from 'vue';
    
    import { IconCheck } from '../../lib/icons';
    import { renderedNodes } from '../../lib/slots';
    import { cn } from '../../lib/utils';
    import { CommandItem, type CommandItemProps } from '../command';
    
    import { injectMultiSelect } from '.';
    
    export interface MultiSelectItemProps extends Omit<
      CommandItemProps,
      'value' | 'keywords'
    > {
      value: string;
      /** More words the search matches. Defaults to the option's text. */
      keywords?: string[];
    }
    
    const props = withDefaults(defineProps<MultiSelectItemProps>(), {
      disabled: false,
      forceMount: undefined,
    });
    const emit = defineEmits<{
      /**
       * Called with the option's value after it is toggled.
       */
      select: [value: string];
    }>();
    const slots = defineSlots<{
      default?: () => VNode[];
      /**
       * Chip text when it differs from the option content, such as an icon option.
       */
      'badge-label'?: () => VNode[];
    }>();
    
    const { selectedValues, toggle } = injectMultiSelect();
    
    function handleSelect() {
      toggle(props.value);
      emit('select', props.value);
    }
    
    /** The option's text when its content is plain text, so the search finds it by its label. */
    function searchKeywords() {
      if (props.keywords) return props.keywords;
      const nodes = renderedNodes(slots.default?.());
      if (!nodes.length || !nodes.every((node) => node.type === Text)) return;
      return [
        nodes
          .map((node) => String(node.children))
          .join('')
          .trim(),
      ];
    }
    </script>
    
    <template>
      <CommandItem
        data-slot="multi-select-item"
        :data-checked="selectedValues.has(value) || undefined"
        :value="value"
        :keywords="searchKeywords()"
        :disabled="disabled"
        :force-mount="forceMount"
        :class="cn('nx:gap-2', props.class)"
        @select="handleSelect"
      >
        <span
          aria-hidden="true"
          data-slot="multi-select-indicator"
          :class="
            cn(
              'nx:flex nx:size-4 nx:shrink-0 nx:items-center nx:justify-center nx:rounded-sm nx:border-default nx:transition-colors',
              selectedValues.has(value)
                ? 'nx:border-transparent nx:bg-primary-background nx:text-primary-foreground'
                : 'nx:border-border-default nx:bg-background'
            )
          "
        >
          <IconCheck v-if="selectedValues.has(value)" class="nx:size-3" />
        </span>
        <span class="nx:min-w-0 nx:flex-1"><slot /></span>
      </CommandItem>
    </template>
    
    components/multi-select/MultiSelectSeparator.vue
    <script setup lang="ts">
    import { CommandSeparator, type CommandSeparatorProps } from '../command';
    
    export type MultiSelectSeparatorProps = CommandSeparatorProps;
    
    const props = withDefaults(defineProps<MultiSelectSeparatorProps>(), {
      alwaysRender: false,
    });
    </script>
    
    <template>
      <CommandSeparator v-bind="props" data-slot="multi-select-separator" />
    </template>
    
    components/multi-select/MultiSelectTrigger.vue
    <script setup lang="ts">
    import { PopoverTrigger } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { IconChevronDown } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    
    import { injectMultiSelect } from '.';
    
    export interface MultiSelectTriggerProps {
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = defineProps<MultiSelectTriggerProps>();
    
    const { open, selectedValues, toggle } = injectMultiSelect();
    
    function handleKeyDown(event: KeyboardEvent) {
      if (event.key !== 'Backspace' && event.key !== 'Delete') return;
    
      const values = [...selectedValues.value];
      const last = values[values.length - 1];
      if (last !== undefined) toggle(last);
    }
    </script>
    
    <template>
      <PopoverTrigger as-child>
        <!-- The button's attributes win over Reka's, as the button's props win over Radix's in React. -->
        <button
          v-bind="{
            type: 'button',
            'aria-haspopup': 'listbox',
            'aria-expanded': open,
            'data-slot': 'multi-select-trigger',
            ...$attrs,
          }"
          :class="
            cn(
              'nx:group/multi-select nx:flex nx:h-auto nx:min-h-10 nx:w-full nx:items-center nx:justify-between nx:gap-2 nx:rounded-md nx:border-default nx:border-border-default nx:bg-container nx:text-inherit nx:px-3 nx:py-1.5 nx:typography-body-default nx:whitespace-nowrap nx:transition-field',
              'nx:enabled:hover:bg-container-hover',
              'nx:focus-visible:outline-default nx:focus-visible:outline-focus-default nx:focus-visible:border-focus-default',
              'nx:aria-invalid:border-error-border nx:aria-invalid:focus-visible:outline-focus-error nx:aria-invalid:focus-visible:border-focus-error',
              'nx:disabled:cursor-not-allowed nx:disabled:border-border-disabled nx:disabled:bg-disabled nx:disabled:text-disabled-foreground',
              props.class
            )
          "
          @keydown="handleKeyDown"
        >
          <slot />
          <IconChevronDown
            aria-hidden="true"
            class="nx:size-4 nx:shrink-0 nx:text-muted-foreground nx:group-disabled/multi-select:text-disabled-foreground"
          />
        </button>
      </PopoverTrigger>
    </template>
    
    components/multi-select/MultiSelectValue.vue
    <script setup lang="ts">
    import {
      useDebounceFn,
      useMutationObserver,
      useResizeObserver,
    } from '@vueuse/core';
    import {
      computed,
      type FunctionalComponent,
      type HTMLAttributes,
      ref,
      useTemplateRef,
      type VNode,
    } from 'vue';
    
    import { IconX } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    import { Badge } from '../badge';
    
    import { injectMultiSelect } from '.';
    
    export interface MultiSelectValueProps {
      /**
       * Shows each selected value as a removable chip.
       */
      clickToRemove?: boolean;
      /**
       * What chips do when they overflow the field:
       * - `wrap`: wrap onto more lines.
       * - `wrap-when-open`: wrap while the list is open, show `+N` when closed.
       * - `cutoff`: always show the extra chips as a `+N` badge.
       */
      overflowBehavior?: 'wrap' | 'wrap-when-open' | 'cutoff';
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<MultiSelectValueProps>(), {
      clickToRemove: true,
      overflowBehavior: 'wrap-when-open',
    });
    defineSlots<{
      /**
       * Shown when nothing is selected.
       */
      placeholder?: () => VNode[];
    }>();
    
    const { selectedValues, labels, open, toggle } = injectMultiSelect();
    const overflowAmount = ref(0);
    const container = useTemplateRef<HTMLDivElement>('container');
    
    const shouldWrap = computed(
      () =>
        props.overflowBehavior === 'wrap' ||
        (props.overflowBehavior === 'wrap-when-open' && open.value)
    );
    
    /**
     * Hides chips from the end of `container` until the row stops overflowing,
     * and returns how many it hid.
     */
    function hideOverflowingChips(container: HTMLDivElement) {
      const chips = container.querySelectorAll<HTMLElement>('[data-selected-item]');
    
      // Reset to the fully-expanded state first; the count becomes the `+N` badge,
      // whose visibility is driven from that count in render (single source).
      chips.forEach((chip) => chip.style.removeProperty('display'));
    
      let amount = 0;
    
      for (let i = chips.length - 1; i >= 0; i--) {
        const chip = chips[i];
    
        if (!chip) continue;
        if (container.scrollWidth <= container.clientWidth) break;
    
        amount = chips.length - i;
        chip.style.display = 'none';
      }
    
      return amount;
    }
    
    function check() {
      if (container.value)
        overflowAmount.value = hideOverflowingChips(container.value);
    }
    
    useMutationObserver(container, check, {
      childList: true,
      attributes: true,
      attributeFilter: ['class', 'style'],
    });
    useResizeObserver(container, useDebounceFn(check, 100));
    
    function removeChip(event: MouseEvent, value: string) {
      event.stopPropagation();
      toggle(value);
    }
    
    // Reads every option's label in one walk of MultiSelect's slot per render.
    const WithLabels: FunctionalComponent<
      Record<string, never>,
      [],
      { default: (props: { labels: ReturnType<typeof labels> }) => VNode[] }
    > = (_, { slots }) => slots.default({ labels: labels() });
    
    const ChipLabel: FunctionalComponent<{
      label: (() => VNode[]) | undefined;
      value: string;
    }> = ({ label, value }) => label?.() ?? value;
    </script>
    
    <template>
      <span
        v-if="selectedValues.size === 0"
        data-slot="multi-select-placeholder"
        class="nx:truncate nx:text-muted-foreground"
      >
        <slot name="placeholder">Select options</slot>
      </span>
      <div
        v-else
        ref="container"
        data-slot="multi-select-value"
        :class="
          cn(
            'nx:flex nx:min-w-0 nx:flex-1 nx:items-center nx:gap-1.5 nx:overflow-hidden',
            shouldWrap && 'nx:h-full nx:flex-wrap',
            props.class
          )
        "
      >
        <WithLabels v-slot="{ labels: chipLabels }">
          <Badge
            v-for="value in selectedValues"
            :key="value"
            data-slot="multi-select-chip"
            data-selected-item="true"
            variant="secondary"
            fill="outline"
            :is-caps="false"
            :class="
              cn(
                'nx:group/chip nx:max-w-40 nx:gap-1',
                clickToRemove && 'nx:cursor-pointer'
              )
            "
            @click="clickToRemove ? removeChip($event, value) : undefined"
          >
            <span class="nx:truncate"
              ><ChipLabel :label="chipLabels.get(value)" :value="value"
            /></span>
            <IconX
              v-if="clickToRemove"
              aria-hidden="true"
              class="nx:size-3 nx:text-muted-foreground nx:transition-colors nx:group-hover/chip:text-error-subtle-foreground"
            />
          </Badge>
        </WithLabels>
        <Badge
          data-slot="multi-select-overflow"
          variant="secondary"
          fill="light"
          :is-caps="false"
          :style="{
            display: shouldWrap || overflowAmount === 0 ? 'none' : undefined,
          }"
          >+{{ overflowAmount }}</Badge
        >
      </div>
    </template>
    
    components/multi-select/index.ts
    import {
      type ComputedRef,
      inject,
      type InjectionKey,
      type Ref,
      type VNode,
    } from 'vue';
    
    export {
      default as MultiSelect,
      type MultiSelectProps,
    } from './MultiSelect.vue';
    export {
      default as MultiSelectContent,
      type MultiSelectContentProps,
    } from './MultiSelectContent.vue';
    export {
      default as MultiSelectGroup,
      type MultiSelectGroupProps,
    } from './MultiSelectGroup.vue';
    export {
      default as MultiSelectItem,
      type MultiSelectItemProps,
    } from './MultiSelectItem.vue';
    export {
      default as MultiSelectSeparator,
      type MultiSelectSeparatorProps,
    } from './MultiSelectSeparator.vue';
    export {
      default as MultiSelectTrigger,
      type MultiSelectTriggerProps,
    } from './MultiSelectTrigger.vue';
    export {
      default as MultiSelectValue,
      type MultiSelectValueProps,
    } from './MultiSelectValue.vue';
    
    export const MultiSelectKey: InjectionKey<{
      open: Ref<boolean>;
      selectedValues: ComputedRef<Set<string>>;
      /** Each option's chip label by value. Call it while rendering. */
      labels: () => Map<string, () => VNode[]>;
      toggle: (value: string) => void;
    }> = Symbol('multi-select');
    
    /** The selection the MultiSelect provides to its parts. */
    export function injectMultiSelect() {
      const multiSelect = inject(MultiSelectKey);
      if (!multiSelect) {
        throw new Error('MultiSelect parts must be used inside <MultiSelect>.');
      }
      return multiSelect;
    }
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { MultiSelect, MultiSelectContent, MultiSelectGroup, MultiSelectItem, MultiSelectSeparator, MultiSelectTrigger, MultiSelectValue } from '@/components/multi-select';

Examples

Grouped

Sort options under headings with MultiSelectGroup and a separator.

<script setup lang="ts">
import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectGroup,
  MultiSelectItem,
  MultiSelectSeparator,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/multi-select';
</script>

<template>
  <MultiSelect :default-values="['react']">
    <MultiSelectTrigger aria-label="Frameworks" class="nx:w-80">
      <MultiSelectValue>
        <template #placeholder>Select frameworks</template>
      </MultiSelectValue>
    </MultiSelectTrigger>
    <MultiSelectContent search-placeholder="Search frameworks…">
      <template #empty-message>No frameworks found.</template>
      <MultiSelectGroup heading="Libraries">
        <MultiSelectItem value="react">React</MultiSelectItem>
        <MultiSelectItem value="solid">Solid</MultiSelectItem>
      </MultiSelectGroup>
      <MultiSelectSeparator />
      <MultiSelectGroup heading="Frameworks">
        <MultiSelectItem value="vue">Vue</MultiSelectItem>
        <MultiSelectItem value="svelte">Svelte</MultiSelectItem>
        <MultiSelectItem value="angular">Angular</MultiSelectItem>
      </MultiSelectGroup>
    </MultiSelectContent>
  </MultiSelect>
</template>

Wrap chips

Set overflowBehavior="wrap" to wrap every chip onto more lines.

<script setup lang="ts">
import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/multi-select';

const frameworks = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'angular', label: 'Angular' },
  { value: 'solid', label: 'Solid' },
  { value: 'qwik', label: 'Qwik' },
];
</script>

<template>
  <MultiSelect :default-values="['react', 'vue', 'svelte', 'angular', 'solid']">
    <MultiSelectTrigger aria-label="Frameworks" class="nx:w-72">
      <MultiSelectValue overflow-behavior="wrap">
        <template #placeholder>Select frameworks</template>
      </MultiSelectValue>
    </MultiSelectTrigger>
    <MultiSelectContent search-placeholder="Search frameworks…">
      <template #empty-message>No frameworks found.</template>
      <MultiSelectItem
        v-for="option in frameworks"
        :key="option.value"
        :value="option.value"
        >{{ option.label }}</MultiSelectItem
      >
    </MultiSelectContent>
  </MultiSelect>
</template>

Overflow collapse

Set overflowBehavior="cutoff" to show chips that don't fit as a +N badge.

<script setup lang="ts">
import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/multi-select';

const frameworks = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'angular', label: 'Angular' },
  { value: 'solid', label: 'Solid' },
  { value: 'qwik', label: 'Qwik' },
];
</script>

<template>
  <MultiSelect
    :default-values="['react', 'vue', 'svelte', 'angular', 'solid', 'qwik']"
  >
    <MultiSelectTrigger aria-label="Frameworks" class="nx:w-64">
      <MultiSelectValue overflow-behavior="cutoff">
        <template #placeholder>Select frameworks</template>
      </MultiSelectValue>
    </MultiSelectTrigger>
    <MultiSelectContent search-placeholder="Search frameworks…">
      <template #empty-message>No frameworks found.</template>
      <MultiSelectItem
        v-for="option in frameworks"
        :key="option.value"
        :value="option.value"
        >{{ option.label }}</MultiSelectItem
      >
    </MultiSelectContent>
  </MultiSelect>
</template>

Disabled

Disable the trigger to show the selection without letting anyone change it.

<script setup lang="ts">
import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/multi-select';

const frameworks = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'angular', label: 'Angular' },
  { value: 'solid', label: 'Solid' },
  { value: 'qwik', label: 'Qwik' },
];
</script>

<template>
  <MultiSelect :default-values="['react']">
    <MultiSelectTrigger aria-label="Frameworks" disabled class="nx:w-80">
      <MultiSelectValue>
        <template #placeholder>Select frameworks</template>
      </MultiSelectValue>
    </MultiSelectTrigger>
    <MultiSelectContent>
      <MultiSelectItem
        v-for="option in frameworks"
        :key="option.value"
        :value="option.value"
        >{{ option.label }}</MultiSelectItem
      >
    </MultiSelectContent>
  </MultiSelect>
</template>

With disabled option

Disable one option so it shows in the list but cannot be picked.

<script setup lang="ts">
import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/multi-select';
</script>

<template>
  <MultiSelect>
    <MultiSelectTrigger aria-label="Frameworks" class="nx:w-80">
      <MultiSelectValue>
        <template #placeholder>Select frameworks</template>
      </MultiSelectValue>
    </MultiSelectTrigger>
    <MultiSelectContent search-placeholder="Search frameworks…">
      <template #empty-message>No frameworks found.</template>
      <MultiSelectItem value="react">React</MultiSelectItem>
      <MultiSelectItem value="vue" disabled>Vue</MultiSelectItem>
      <MultiSelectItem value="svelte">Svelte</MultiSelectItem>
    </MultiSelectContent>
  </MultiSelect>
</template>

Invalid field

Set aria-invalid on the trigger inside an invalid Field to show the error state.

<script setup lang="ts">
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from '@/components/field';
import {
  MultiSelect,
  MultiSelectContent,
  MultiSelectItem,
  MultiSelectTrigger,
  MultiSelectValue,
} from '@/components/multi-select';

const frameworks = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'angular', label: 'Angular' },
  { value: 'solid', label: 'Solid' },
  { value: 'qwik', label: 'Qwik' },
];
</script>

<template>
  <Field data-invalid>
    <FieldLabel>Frameworks</FieldLabel>
    <MultiSelect>
      <MultiSelectTrigger
        aria-label="Frameworks"
        aria-invalid="true"
        class="nx:w-80"
      >
        <MultiSelectValue>
          <template #placeholder>Select frameworks</template>
        </MultiSelectValue>
      </MultiSelectTrigger>
      <MultiSelectContent search-placeholder="Search frameworks…">
        <template #empty-message>No frameworks found.</template>
        <MultiSelectItem
          v-for="option in frameworks"
          :key="option.value"
          :value="option.value"
          >{{ option.label }}</MultiSelectItem
        >
      </MultiSelectContent>
    </MultiSelect>
    <FieldDescription>Choose at least one framework.</FieldDescription>
    <FieldError>Framework selection is required.</FieldError>
  </Field>
</template>

This example also needs: Field.

Props

MultiSelect

MultiSelect props
PropTypeDefaultDescription
defaultValuesstring[]No default documented

Initial selected values for uncontrolled usage.

valuesstring[]No default documented

Controlled selected values.

MultiSelectContent

MultiSelectContent props
PropTypeDefaultDescription
searchPlaceholderstring"Search…"

Placeholder for the search input.

MultiSelectGroup

MultiSelectGroup props
PropTypeDefaultDescription
forceMountbooleanfalse

Shows the group whatever the search.

headingstringNo default documented

Heading shown above the group's items. Screen readers read it as the group's name.

MultiSelectItem

MultiSelectItem props
PropTypeDefaultDescription
disabledbooleanfalse

Shows the item dimmed and skips it in keyboard navigation.

forceMountbooleanNo default documented

Shows the item whatever the search.

keywordsstring[]No default documented

More words the search matches. Defaults to the option's text.

valueRequiredstringNo default documentedNo description

MultiSelectSeparator

MultiSelectSeparator props
PropTypeDefaultDescription
alwaysRenderbooleanfalse

Keeps the separator while a search is active.

MultiSelectTrigger

MultiSelectTrigger adds no props of its own; it accepts the props of the element or primitive it wraps.

MultiSelectValue

MultiSelectValue props
PropTypeDefaultDescription
clickToRemovebooleantrue

Shows each selected value as a removable chip.

overflowBehavior"wrap-when-open" | "wrap" | "cutoff""wrap-when-open"

What chips do when they overflow the field:

  • wrap: wrap onto more lines.
  • wrap-when-open: wrap while the list is open, show +N when closed.
  • cutoff: always show the extra chips as a +N badge.