Skip to main content
Nexus Design System
GitHub

Combobox

Combobox lets the user search a list and pick one option. Use it for lists too long to scan. For a short list without search, use Select.

Selected: astro
'use client';

import * as React from 'react';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/nxds/components/combobox/combobox';

type ComboboxItemData = {
  value: string;
  label: string;
  disabled?: boolean;
  group?: string;
};

const frameworks: ComboboxItemData[] = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];

export default function ComboboxControlled() {
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState('astro');
  const selectedLabel = frameworks.find((f) => f.value === value)?.label;

  const handleSelect = (next: string) => {
    setValue(next === value ? '' : next);
    setOpen(false);
  };

  return (
    <div className="nx:flex nx:flex-col nx:gap-3">
      <Combobox open={open} onOpenChange={setOpen}>
        <ComboboxTrigger className="nx:w-64">
          <span className="nx:min-w-0 nx:flex-1 nx:truncate nx:text-left">
            {selectedLabel ?? 'Select framework'}
          </span>
        </ComboboxTrigger>
        <ComboboxContent label="Framework">
          <ComboboxInput placeholder="Search framework..." />
          <ComboboxList>
            <ComboboxEmpty>No framework found.</ComboboxEmpty>
            <ComboboxGroup>
              {frameworks.map((framework) => (
                <ComboboxItem
                  key={framework.value}
                  value={framework.value}
                  keywords={[framework.label]}
                  selected={value === framework.value}
                  onSelect={handleSelect}
                >
                  {framework.label}
                </ComboboxItem>
              ))}
            </ComboboxGroup>
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
      <output className="nx:typography-body-small nx:text-muted-foreground">
        Selected: {value || 'none'}
      </output>
    </div>
  );
}

Installation

npx nxds-cli@latest add combobox

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

Usage

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/nxds/components/combobox/combobox';

Composition

Combobox
├── ComboboxTrigger
└── ComboboxContent
    ├── ComboboxInput
    └── ComboboxList
        ├── ComboboxEmpty
        └── ComboboxGroup
            └── ComboboxItem

Examples

Default

Open the trigger, type to filter the options, and pick one.

'use client';

import * as React from 'react';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/nxds/components/combobox/combobox';
import { cn } from '@/components/nxds/lib/utils';

type ComboboxItemData = {
  value: string;
  label: string;
  disabled?: boolean;
  group?: string;
};

const frameworks: ComboboxItemData[] = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];

function toGroups(items: ComboboxItemData[]) {
  if (!items.some((item) => item.group))
    return [{ heading: undefined, options: items }];

  const groups = new Map<string, ComboboxItemData[]>();

  for (const item of items) {
    const heading = item.group ?? 'Other';
    groups.set(heading, [...(groups.get(heading) ?? []), item]);
  }

  return [...groups.entries()].map(([heading, options]) => ({
    heading,
    options,
  }));
}

function ComboboxExample({
  defaultValue = '',
  emptyText = 'No framework found.',
  items = frameworks,
  placeholder = 'Select framework',
  searchPlaceholder = 'Search framework...',
  triggerClassName = 'nx:w-64',
  ...triggerProps
}: {
  defaultValue?: string;
  emptyText?: string;
  items?: ComboboxItemData[];
  placeholder?: string;
  searchPlaceholder?: string;
  triggerClassName?: string;
} & Omit<
  React.ComponentProps<typeof ComboboxTrigger>,
  'children' | 'className'
>) {
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState(defaultValue);
  const selectedLabel = items.find((item) => item.value === value)?.label;

  const handleSelect = (next: string) => {
    setValue(next === value ? '' : next);
    setOpen(false);
  };

  return (
    <Combobox open={open} onOpenChange={setOpen}>
      <ComboboxTrigger className={triggerClassName} {...triggerProps}>
        <span
          className={cn(
            'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
            selectedLabel ? undefined : 'nx:text-muted-foreground'
          )}
        >
          {selectedLabel ?? placeholder}
        </span>
      </ComboboxTrigger>
      <ComboboxContent label="Framework">
        <ComboboxInput placeholder={searchPlaceholder} />
        <ComboboxList>
          <ComboboxEmpty>{emptyText}</ComboboxEmpty>
          {toGroups(items).map(({ heading, options }) => (
            <ComboboxGroup key={heading ?? '_root'} heading={heading}>
              {options.map((option) => (
                <ComboboxItem
                  key={option.value}
                  value={option.value}
                  keywords={[option.label]}
                  disabled={option.disabled}
                  selected={value === option.value}
                  onSelect={handleSelect}
                >
                  {option.label}
                </ComboboxItem>
              ))}
            </ComboboxGroup>
          ))}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  );
}

export default function ComboboxDefault() {
  return <ComboboxExample />;
}

Sizes

Set size on the trigger to sm, the default, or lg.

'use client';

import * as React from 'react';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/nxds/components/combobox/combobox';
import { cn } from '@/components/nxds/lib/utils';

type ComboboxItemData = {
  value: string;
  label: string;
  disabled?: boolean;
  group?: string;
};

const frameworks: ComboboxItemData[] = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];

function toGroups(items: ComboboxItemData[]) {
  if (!items.some((item) => item.group))
    return [{ heading: undefined, options: items }];

  const groups = new Map<string, ComboboxItemData[]>();

  for (const item of items) {
    const heading = item.group ?? 'Other';
    groups.set(heading, [...(groups.get(heading) ?? []), item]);
  }

  return [...groups.entries()].map(([heading, options]) => ({
    heading,
    options,
  }));
}

function ComboboxExample({
  defaultValue = '',
  emptyText = 'No framework found.',
  items = frameworks,
  placeholder = 'Select framework',
  searchPlaceholder = 'Search framework...',
  triggerClassName = 'nx:w-64',
  ...triggerProps
}: {
  defaultValue?: string;
  emptyText?: string;
  items?: ComboboxItemData[];
  placeholder?: string;
  searchPlaceholder?: string;
  triggerClassName?: string;
} & Omit<
  React.ComponentProps<typeof ComboboxTrigger>,
  'children' | 'className'
>) {
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState(defaultValue);
  const selectedLabel = items.find((item) => item.value === value)?.label;

  const handleSelect = (next: string) => {
    setValue(next === value ? '' : next);
    setOpen(false);
  };

  return (
    <Combobox open={open} onOpenChange={setOpen}>
      <ComboboxTrigger className={triggerClassName} {...triggerProps}>
        <span
          className={cn(
            'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
            selectedLabel ? undefined : 'nx:text-muted-foreground'
          )}
        >
          {selectedLabel ?? placeholder}
        </span>
      </ComboboxTrigger>
      <ComboboxContent label="Framework">
        <ComboboxInput placeholder={searchPlaceholder} />
        <ComboboxList>
          <ComboboxEmpty>{emptyText}</ComboboxEmpty>
          {toGroups(items).map(({ heading, options }) => (
            <ComboboxGroup key={heading ?? '_root'} heading={heading}>
              {options.map((option) => (
                <ComboboxItem
                  key={option.value}
                  value={option.value}
                  keywords={[option.label]}
                  disabled={option.disabled}
                  selected={value === option.value}
                  onSelect={handleSelect}
                >
                  {option.label}
                </ComboboxItem>
              ))}
            </ComboboxGroup>
          ))}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  );
}

export default function ComboboxSizes() {
  return (
    <div className="nx:flex nx:w-64 nx:flex-col nx:gap-3">
      <ComboboxExample
        size="sm"
        placeholder="Small"
        triggerClassName="nx:w-full"
      />
      <ComboboxExample placeholder="Default" triggerClassName="nx:w-full" />
      <ComboboxExample
        size="lg"
        placeholder="Large"
        triggerClassName="nx:w-full"
      />
    </div>
  );
}

Grouped

Wrap options in ComboboxGroup with a heading to sort them into labeled groups.

'use client';

import * as React from 'react';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/nxds/components/combobox/combobox';
import { cn } from '@/components/nxds/lib/utils';

type ComboboxItemData = {
  value: string;
  label: string;
  disabled?: boolean;
  group?: string;
};

const frameworks: ComboboxItemData[] = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];

const groupedFrameworks: ComboboxItemData[] = [
  { value: 'next', label: 'Next.js', group: 'React' },
  { value: 'remix', label: 'Remix', group: 'React' },
  { value: 'sveltekit', label: 'SvelteKit', group: 'Svelte' },
  { value: 'nuxt', label: 'Nuxt.js', group: 'Vue' },
  { value: 'astro', label: 'Astro', group: 'Other' },
];

function toGroups(items: ComboboxItemData[]) {
  if (!items.some((item) => item.group))
    return [{ heading: undefined, options: items }];

  const groups = new Map<string, ComboboxItemData[]>();

  for (const item of items) {
    const heading = item.group ?? 'Other';
    groups.set(heading, [...(groups.get(heading) ?? []), item]);
  }

  return [...groups.entries()].map(([heading, options]) => ({
    heading,
    options,
  }));
}

function ComboboxExample({
  defaultValue = '',
  emptyText = 'No framework found.',
  items = frameworks,
  placeholder = 'Select framework',
  searchPlaceholder = 'Search framework...',
  triggerClassName = 'nx:w-64',
  ...triggerProps
}: {
  defaultValue?: string;
  emptyText?: string;
  items?: ComboboxItemData[];
  placeholder?: string;
  searchPlaceholder?: string;
  triggerClassName?: string;
} & Omit<
  React.ComponentProps<typeof ComboboxTrigger>,
  'children' | 'className'
>) {
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState(defaultValue);
  const selectedLabel = items.find((item) => item.value === value)?.label;

  const handleSelect = (next: string) => {
    setValue(next === value ? '' : next);
    setOpen(false);
  };

  return (
    <Combobox open={open} onOpenChange={setOpen}>
      <ComboboxTrigger className={triggerClassName} {...triggerProps}>
        <span
          className={cn(
            'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
            selectedLabel ? undefined : 'nx:text-muted-foreground'
          )}
        >
          {selectedLabel ?? placeholder}
        </span>
      </ComboboxTrigger>
      <ComboboxContent label="Framework">
        <ComboboxInput placeholder={searchPlaceholder} />
        <ComboboxList>
          <ComboboxEmpty>{emptyText}</ComboboxEmpty>
          {toGroups(items).map(({ heading, options }) => (
            <ComboboxGroup key={heading ?? '_root'} heading={heading}>
              {options.map((option) => (
                <ComboboxItem
                  key={option.value}
                  value={option.value}
                  keywords={[option.label]}
                  disabled={option.disabled}
                  selected={value === option.value}
                  onSelect={handleSelect}
                >
                  {option.label}
                </ComboboxItem>
              ))}
            </ComboboxGroup>
          ))}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  );
}

export default function ComboboxGrouped() {
  return <ComboboxExample items={groupedFrameworks} />;
}

With disabled option

Set disabled on an item so it shows but cannot be picked.

'use client';

import * as React from 'react';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/nxds/components/combobox/combobox';
import { cn } from '@/components/nxds/lib/utils';

type ComboboxItemData = {
  value: string;
  label: string;
  disabled?: boolean;
  group?: string;
};

const frameworks: ComboboxItemData[] = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];

function toGroups(items: ComboboxItemData[]) {
  if (!items.some((item) => item.group))
    return [{ heading: undefined, options: items }];

  const groups = new Map<string, ComboboxItemData[]>();

  for (const item of items) {
    const heading = item.group ?? 'Other';
    groups.set(heading, [...(groups.get(heading) ?? []), item]);
  }

  return [...groups.entries()].map(([heading, options]) => ({
    heading,
    options,
  }));
}

function ComboboxExample({
  defaultValue = '',
  emptyText = 'No framework found.',
  items = frameworks,
  placeholder = 'Select framework',
  searchPlaceholder = 'Search framework...',
  triggerClassName = 'nx:w-64',
  ...triggerProps
}: {
  defaultValue?: string;
  emptyText?: string;
  items?: ComboboxItemData[];
  placeholder?: string;
  searchPlaceholder?: string;
  triggerClassName?: string;
} & Omit<
  React.ComponentProps<typeof ComboboxTrigger>,
  'children' | 'className'
>) {
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState(defaultValue);
  const selectedLabel = items.find((item) => item.value === value)?.label;

  const handleSelect = (next: string) => {
    setValue(next === value ? '' : next);
    setOpen(false);
  };

  return (
    <Combobox open={open} onOpenChange={setOpen}>
      <ComboboxTrigger className={triggerClassName} {...triggerProps}>
        <span
          className={cn(
            'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
            selectedLabel ? undefined : 'nx:text-muted-foreground'
          )}
        >
          {selectedLabel ?? placeholder}
        </span>
      </ComboboxTrigger>
      <ComboboxContent label="Framework">
        <ComboboxInput placeholder={searchPlaceholder} />
        <ComboboxList>
          <ComboboxEmpty>{emptyText}</ComboboxEmpty>
          {toGroups(items).map(({ heading, options }) => (
            <ComboboxGroup key={heading ?? '_root'} heading={heading}>
              {options.map((option) => (
                <ComboboxItem
                  key={option.value}
                  value={option.value}
                  keywords={[option.label]}
                  disabled={option.disabled}
                  selected={value === option.value}
                  onSelect={handleSelect}
                >
                  {option.label}
                </ComboboxItem>
              ))}
            </ComboboxGroup>
          ))}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  );
}

export default function ComboboxWithDisabledOption() {
  return (
    <ComboboxExample
      items={[
        { value: 'next', label: 'Next.js' },
        { value: 'sveltekit', label: 'SvelteKit', disabled: true },
        { value: 'astro', label: 'Astro' },
      ]}
    />
  );
}

Disabled

Set disabled on the trigger so the popover cannot open.

'use client';

import * as React from 'react';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/nxds/components/combobox/combobox';
import { cn } from '@/components/nxds/lib/utils';

type ComboboxItemData = {
  value: string;
  label: string;
  disabled?: boolean;
  group?: string;
};

const frameworks: ComboboxItemData[] = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];

function toGroups(items: ComboboxItemData[]) {
  if (!items.some((item) => item.group))
    return [{ heading: undefined, options: items }];

  const groups = new Map<string, ComboboxItemData[]>();

  for (const item of items) {
    const heading = item.group ?? 'Other';
    groups.set(heading, [...(groups.get(heading) ?? []), item]);
  }

  return [...groups.entries()].map(([heading, options]) => ({
    heading,
    options,
  }));
}

function ComboboxExample({
  defaultValue = '',
  emptyText = 'No framework found.',
  items = frameworks,
  placeholder = 'Select framework',
  searchPlaceholder = 'Search framework...',
  triggerClassName = 'nx:w-64',
  ...triggerProps
}: {
  defaultValue?: string;
  emptyText?: string;
  items?: ComboboxItemData[];
  placeholder?: string;
  searchPlaceholder?: string;
  triggerClassName?: string;
} & Omit<
  React.ComponentProps<typeof ComboboxTrigger>,
  'children' | 'className'
>) {
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState(defaultValue);
  const selectedLabel = items.find((item) => item.value === value)?.label;

  const handleSelect = (next: string) => {
    setValue(next === value ? '' : next);
    setOpen(false);
  };

  return (
    <Combobox open={open} onOpenChange={setOpen}>
      <ComboboxTrigger className={triggerClassName} {...triggerProps}>
        <span
          className={cn(
            'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
            selectedLabel ? undefined : 'nx:text-muted-foreground'
          )}
        >
          {selectedLabel ?? placeholder}
        </span>
      </ComboboxTrigger>
      <ComboboxContent label="Framework">
        <ComboboxInput placeholder={searchPlaceholder} />
        <ComboboxList>
          <ComboboxEmpty>{emptyText}</ComboboxEmpty>
          {toGroups(items).map(({ heading, options }) => (
            <ComboboxGroup key={heading ?? '_root'} heading={heading}>
              {options.map((option) => (
                <ComboboxItem
                  key={option.value}
                  value={option.value}
                  keywords={[option.label]}
                  disabled={option.disabled}
                  selected={value === option.value}
                  onSelect={handleSelect}
                >
                  {option.label}
                </ComboboxItem>
              ))}
            </ComboboxGroup>
          ))}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  );
}

export default function ComboboxDisabled() {
  return <ComboboxExample disabled defaultValue="next" />;
}

Invalid field

Set aria-invalid and place the combobox in a Field to show an error.

Choose the framework used by this project.

'use client';

import * as React from 'react';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/nxds/components/combobox/combobox';
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from '@/components/nxds/components/field/field';
import { cn } from '@/components/nxds/lib/utils';

type ComboboxItemData = {
  value: string;
  label: string;
  disabled?: boolean;
  group?: string;
};

const frameworks: ComboboxItemData[] = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];

function toGroups(items: ComboboxItemData[]) {
  if (!items.some((item) => item.group))
    return [{ heading: undefined, options: items }];

  const groups = new Map<string, ComboboxItemData[]>();

  for (const item of items) {
    const heading = item.group ?? 'Other';
    groups.set(heading, [...(groups.get(heading) ?? []), item]);
  }

  return [...groups.entries()].map(([heading, options]) => ({
    heading,
    options,
  }));
}

function ComboboxExample({
  defaultValue = '',
  emptyText = 'No framework found.',
  items = frameworks,
  placeholder = 'Select framework',
  searchPlaceholder = 'Search framework...',
  triggerClassName = 'nx:w-64',
  ...triggerProps
}: {
  defaultValue?: string;
  emptyText?: string;
  items?: ComboboxItemData[];
  placeholder?: string;
  searchPlaceholder?: string;
  triggerClassName?: string;
} & Omit<
  React.ComponentProps<typeof ComboboxTrigger>,
  'children' | 'className'
>) {
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState(defaultValue);
  const selectedLabel = items.find((item) => item.value === value)?.label;

  const handleSelect = (next: string) => {
    setValue(next === value ? '' : next);
    setOpen(false);
  };

  return (
    <Combobox open={open} onOpenChange={setOpen}>
      <ComboboxTrigger className={triggerClassName} {...triggerProps}>
        <span
          className={cn(
            'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
            selectedLabel ? undefined : 'nx:text-muted-foreground'
          )}
        >
          {selectedLabel ?? placeholder}
        </span>
      </ComboboxTrigger>
      <ComboboxContent label="Framework">
        <ComboboxInput placeholder={searchPlaceholder} />
        <ComboboxList>
          <ComboboxEmpty>{emptyText}</ComboboxEmpty>
          {toGroups(items).map(({ heading, options }) => (
            <ComboboxGroup key={heading ?? '_root'} heading={heading}>
              {options.map((option) => (
                <ComboboxItem
                  key={option.value}
                  value={option.value}
                  keywords={[option.label]}
                  disabled={option.disabled}
                  selected={value === option.value}
                  onSelect={handleSelect}
                >
                  {option.label}
                </ComboboxItem>
              ))}
            </ComboboxGroup>
          ))}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  );
}

export default function ComboboxInvalidField() {
  return (
    <Field data-invalid>
      <FieldLabel htmlFor="framework-invalid">Framework</FieldLabel>
      <ComboboxExample
        id="framework-invalid"
        aria-invalid
        aria-describedby="framework-invalid-description framework-invalid-error"
      />
      <FieldDescription id="framework-invalid-description">
        Choose the framework used by this project.
      </FieldDescription>
      <FieldError id="framework-invalid-error">
        Select a supported framework.
      </FieldError>
    </Field>
  );
}

This example also needs: Field.

Props

Combobox

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

ComboboxContent

ComboboxContent props
PropTypeDefaultDescription
labelstring"Options"

Accessible name for the search field and popover. Use the field's label text.

ComboboxEmpty

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

ComboboxGroup

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

ComboboxInput

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

ComboboxItem

ComboboxItem props
PropTypeDefaultDescription
selectedbooleanfalse

Marks the option as the current selection with a check.

ComboboxList

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

ComboboxSeparator

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

ComboboxTrigger

ComboboxTrigger props
PropTypeDefaultDescription
asChildbooleanNo default documented

Renders the child, such as an <a>, with button styles. The loading spinner and icon slots then don't render.

endIconReactNodeNo default documented

Decorative icon after the label. Use endIcon or startIcon, not both.

loadingbooleanNo default documented

Disables the button and shows a spinner in place of its content.

size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"default"No description
startIconReactNodeNo default documented

Decorative icon before the label. Use startIcon or endIcon, not both.

variant"link" | "default" | "error" | "destructive" | "outline" | "dashed" | "secondary" | "ghost" | null"outline"No description

Combobox lets the user search a list and pick one option. Use it for lists too long to scan. For a short list without search, use Select.

<script setup lang="ts">
import { ref } from 'vue';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/combobox';

const open = ref(false);
const value = ref('astro');
const frameworks = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];
</script>

<template>
  <div class="nx:flex nx:flex-col nx:gap-3">
    <Combobox v-model:open="open">
      <ComboboxTrigger class="nx:w-64">
        <span class="nx:min-w-0 nx:flex-1 nx:truncate nx:text-left">{{
          frameworks.find((framework) => framework.value === value)?.label ??
          'Select framework'
        }}</span>
      </ComboboxTrigger>
      <ComboboxContent label="Framework">
        <ComboboxInput placeholder="Search framework..." />
        <ComboboxList>
          <ComboboxEmpty>No framework found.</ComboboxEmpty>
          <ComboboxGroup>
            <ComboboxItem
              v-for="framework in frameworks"
              :key="framework.value"
              :value="framework.value"
              :keywords="[framework.label]"
              :selected="value === framework.value"
              @select="
                value = $event === value ? '' : $event;
                open = false;
              "
              >{{ framework.label }}</ComboboxItem
            >
          </ComboboxGroup>
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
    <output class="nx:typography-body-small nx:text-muted-foreground"
      >Selected: {{ value || 'none' }}</output
    >
  </div>
</template>

Installation

  1. Install these components first:

    Button, ButtonGroup, Command, Dialog, Popover, Spinner.

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

    components/combobox/ComboboxContent.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import { type PopoverContentEmits, useForwardPropsEmits } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { Command } from '../command';
    import { PopoverContent, type PopoverContentProps } from '../popover';
    
    export interface ComboboxContentProps extends Omit<
      PopoverContentProps,
      'class'
    > {
      /**
       * Accessible name for the search field and popover. Use the field's label text.
       */
      label?: string;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<ComboboxContentProps>(), {
      align: 'start',
      sideOffset: 4,
      label: 'Options',
    });
    const emits = defineEmits<PopoverContentEmits>();
    
    const delegated = reactiveOmit(props, 'class', 'label');
    const forwarded = useForwardPropsEmits(delegated, emits);
    </script>
    
    <template>
      <PopoverContent
        v-bind="forwarded"
        data-slot="combobox-content"
        :aria-label="label"
        :class="
          cn('nx:w-(--reka-popover-trigger-width) nx:min-w-48 nx:p-0', props.class)
        "
      >
        <Command data-slot="combobox" :label="label" class="nx:bg-transparent">
          <slot />
        </Command>
      </PopoverContent>
    </template>
    
    components/combobox/ComboboxEmpty.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { CommandEmpty } from '../command';
    
    export interface ComboboxEmptyProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ComboboxEmptyProps>();
    </script>
    
    <template>
      <CommandEmpty
        data-slot="combobox-empty"
        :class="cn('nx:text-muted-foreground', props.class)"
      >
        <slot />
      </CommandEmpty>
    </template>
    
    components/combobox/ComboboxGroup.vue
    <script setup lang="ts">
    import { CommandGroup, type CommandGroupProps } from '../command';
    
    export type ComboboxGroupProps = CommandGroupProps;
    
    const props = withDefaults(defineProps<ComboboxGroupProps>(), {
      forceMount: false,
    });
    </script>
    
    <template>
      <CommandGroup v-bind="props" data-slot="combobox-group">
        <slot />
      </CommandGroup>
    </template>
    
    components/combobox/ComboboxInput.vue
    <script setup lang="ts">
    import { CommandInput, type CommandInputProps } from '../command';
    
    export type ComboboxInputProps = CommandInputProps;
    
    const props = defineProps<ComboboxInputProps>();
    const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
    </script>
    
    <template>
      <CommandInput
        v-bind="props"
        data-slot="combobox-input"
        @update:model-value="emit('update:modelValue', $event)"
      />
    </template>
    
    components/combobox/ComboboxItem.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    
    import { IconCheck } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    import { CommandItem, type CommandItemProps } from '../command';
    
    export interface ComboboxItemProps extends CommandItemProps {
      /**
       * Marks the option as the current selection with a check.
       */
      selected?: boolean;
    }
    
    const props = withDefaults(defineProps<ComboboxItemProps>(), {
      selected: false,
      disabled: false,
      forceMount: undefined,
    });
    const emit = defineEmits<{ select: [value: string] }>();
    
    const delegated = reactiveOmit(props, 'selected');
    </script>
    
    <template>
      <CommandItem
        v-bind="delegated"
        data-slot="combobox-item"
        @select="emit('select', $event)"
      >
        <slot />
        <IconCheck
          aria-hidden="true"
          data-slot="combobox-item-indicator"
          :class="
            cn(
              'nx:ml-auto nx:transition-opacity',
              selected ? 'nx:opacity-100' : 'nx:opacity-0'
            )
          "
        />
      </CommandItem>
    </template>
    
    components/combobox/ComboboxList.vue
    <script setup lang="ts">
    import { CommandList, type CommandListProps } from '../command';
    
    export type ComboboxListProps = CommandListProps;
    
    const props = defineProps<ComboboxListProps>();
    </script>
    
    <template>
      <CommandList v-bind="props" data-slot="combobox-list">
        <slot />
      </CommandList>
    </template>
    
    components/combobox/ComboboxSeparator.vue
    <script setup lang="ts">
    import { CommandSeparator, type CommandSeparatorProps } from '../command';
    
    export type ComboboxSeparatorProps = CommandSeparatorProps;
    
    const props = withDefaults(defineProps<ComboboxSeparatorProps>(), {
      alwaysRender: false,
    });
    </script>
    
    <template>
      <CommandSeparator v-bind="props" data-slot="combobox-separator" />
    </template>
    
    components/combobox/ComboboxTrigger.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import { PopoverTrigger } from 'reka-ui';
    import type { HTMLAttributes, VNode } from 'vue';
    
    import { IconSelector } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    import { Button, type ButtonProps } from '../button';
    
    export interface ComboboxTriggerProps extends Omit<ButtonProps, 'class'> {
      class?: HTMLAttributes['class'];
    }
    
    defineOptions({ inheritAttrs: false });
    
    const props = withDefaults(defineProps<ComboboxTriggerProps>(), {
      as: 'button',
      variant: 'outline',
      type: 'button',
    });
    
    const slots = defineSlots<{
      default?: () => VNode[];
      'start-icon'?: () => VNode[];
      'end-icon'?: () => VNode[];
    }>();
    
    const delegated = reactiveOmit(props, 'class');
    </script>
    
    <template>
      <PopoverTrigger as-child>
        <Button
          v-bind="{ ...delegated, ...$attrs }"
          data-slot="combobox-trigger"
          :class="
            cn(
              'nx:w-full nx:justify-between',
              'nx:aria-invalid:border-error-border nx:aria-invalid:focus-visible:outline-focus-error',
              props.class
            )
          "
        >
          <template v-if="slots['start-icon']" #start-icon>
            <slot name="start-icon" />
          </template>
          <slot />
          <IconSelector
            aria-hidden="true"
            data-slot="combobox-trigger-icon"
            class="nx:shrink-0 nx:text-muted-foreground"
          />
          <template v-if="slots['end-icon']" #end-icon>
            <slot name="end-icon" />
          </template>
        </Button>
      </PopoverTrigger>
    </template>
    
    components/combobox/index.ts
    export {
      default as ComboboxContent,
      type ComboboxContentProps,
    } from './ComboboxContent.vue';
    export {
      default as ComboboxEmpty,
      type ComboboxEmptyProps,
    } from './ComboboxEmpty.vue';
    export {
      default as ComboboxGroup,
      type ComboboxGroupProps,
    } from './ComboboxGroup.vue';
    export {
      default as ComboboxInput,
      type ComboboxInputProps,
    } from './ComboboxInput.vue';
    export {
      default as ComboboxItem,
      type ComboboxItemProps,
    } from './ComboboxItem.vue';
    export {
      default as ComboboxList,
      type ComboboxListProps,
    } from './ComboboxList.vue';
    export {
      default as ComboboxSeparator,
      type ComboboxSeparatorProps,
    } from './ComboboxSeparator.vue';
    export {
      default as ComboboxTrigger,
      type ComboboxTriggerProps,
    } from './ComboboxTrigger.vue';
    export {
      PopoverRoot as Combobox,
      type PopoverRootProps as ComboboxProps,
    } from 'reka-ui';
    
  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 { Combobox, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger } from '@/components/combobox';

Examples

Default

Open the trigger, type to filter the options, and pick one.

<script setup lang="ts">
import { ref } from 'vue';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/combobox';

const open = ref(false);
const value = ref('');
const frameworks = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];
</script>

<template>
  <Combobox v-model:open="open">
    <ComboboxTrigger class="nx:w-64">
      <span
        :class="[
          'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
          { 'nx:text-muted-foreground': !value },
        ]"
        >{{
          frameworks.find((framework) => framework.value === value)?.label ??
          'Select framework'
        }}</span
      >
    </ComboboxTrigger>
    <ComboboxContent label="Framework">
      <ComboboxInput placeholder="Search framework..." />
      <ComboboxList>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxGroup>
          <ComboboxItem
            v-for="framework in frameworks"
            :key="framework.value"
            :value="framework.value"
            :keywords="[framework.label]"
            :selected="value === framework.value"
            @select="
              value = $event === value ? '' : $event;
              open = false;
            "
            >{{ framework.label }}</ComboboxItem
          >
        </ComboboxGroup>
      </ComboboxList>
    </ComboboxContent>
  </Combobox>
</template>

Sizes

Set size on the trigger to sm, the default, or lg.

<script setup lang="ts">
import { reactive } from 'vue';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/combobox';

const pickers = reactive([
  { size: 'sm' as const, placeholder: 'Small', open: false, value: '' },
  { size: undefined, placeholder: 'Default', open: false, value: '' },
  { size: 'lg' as const, placeholder: 'Large', open: false, value: '' },
]);
const frameworks = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];
</script>

<template>
  <div class="nx:flex nx:w-64 nx:flex-col nx:gap-3">
    <Combobox
      v-for="picker in pickers"
      :key="picker.placeholder"
      v-model:open="picker.open"
    >
      <ComboboxTrigger :size="picker.size" class="nx:w-full">
        <span
          :class="[
            'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
            { 'nx:text-muted-foreground': !picker.value },
          ]"
          >{{
            frameworks.find((framework) => framework.value === picker.value)
              ?.label ?? picker.placeholder
          }}</span
        >
      </ComboboxTrigger>
      <ComboboxContent label="Framework">
        <ComboboxInput placeholder="Search framework..." />
        <ComboboxList>
          <ComboboxEmpty>No framework found.</ComboboxEmpty>
          <ComboboxGroup>
            <ComboboxItem
              v-for="framework in frameworks"
              :key="framework.value"
              :value="framework.value"
              :keywords="[framework.label]"
              :selected="picker.value === framework.value"
              @select="
                picker.value = $event === picker.value ? '' : $event;
                picker.open = false;
              "
              >{{ framework.label }}</ComboboxItem
            >
          </ComboboxGroup>
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  </div>
</template>

Grouped

Wrap options in ComboboxGroup with a heading to sort them into labeled groups.

<script setup lang="ts">
import { ref } from 'vue';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/combobox';

const open = ref(false);
const value = ref('');
const groups = [
  {
    heading: 'React',
    options: [
      { value: 'next', label: 'Next.js' },
      { value: 'remix', label: 'Remix' },
    ],
  },
  {
    heading: 'Svelte',
    options: [{ value: 'sveltekit', label: 'SvelteKit' }],
  },
  { heading: 'Vue', options: [{ value: 'nuxt', label: 'Nuxt.js' }] },
  { heading: 'Other', options: [{ value: 'astro', label: 'Astro' }] },
];
</script>

<template>
  <Combobox v-model:open="open">
    <ComboboxTrigger class="nx:w-64">
      <span
        :class="[
          'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
          { 'nx:text-muted-foreground': !value },
        ]"
        >{{
          groups
            .flatMap((group) => group.options)
            .find((option) => option.value === value)?.label ??
          'Select framework'
        }}</span
      >
    </ComboboxTrigger>
    <ComboboxContent label="Framework">
      <ComboboxInput placeholder="Search framework..." />
      <ComboboxList>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxGroup
          v-for="group in groups"
          :key="group.heading"
          :heading="group.heading"
        >
          <ComboboxItem
            v-for="option in group.options"
            :key="option.value"
            :value="option.value"
            :keywords="[option.label]"
            :selected="value === option.value"
            @select="
              value = $event === value ? '' : $event;
              open = false;
            "
            >{{ option.label }}</ComboboxItem
          >
        </ComboboxGroup>
      </ComboboxList>
    </ComboboxContent>
  </Combobox>
</template>

With disabled option

Set disabled on an item so it shows but cannot be picked.

<script setup lang="ts">
import { ref } from 'vue';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/combobox';

const open = ref(false);
const value = ref('');
const frameworks = [
  { value: 'next', label: 'Next.js', disabled: false },
  { value: 'sveltekit', label: 'SvelteKit', disabled: true },
  { value: 'astro', label: 'Astro', disabled: false },
];
</script>

<template>
  <Combobox v-model:open="open">
    <ComboboxTrigger class="nx:w-64">
      <span
        :class="[
          'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
          { 'nx:text-muted-foreground': !value },
        ]"
        >{{
          frameworks.find((framework) => framework.value === value)?.label ??
          'Select framework'
        }}</span
      >
    </ComboboxTrigger>
    <ComboboxContent label="Framework">
      <ComboboxInput placeholder="Search framework..." />
      <ComboboxList>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxGroup>
          <ComboboxItem
            v-for="framework in frameworks"
            :key="framework.value"
            :value="framework.value"
            :keywords="[framework.label]"
            :disabled="framework.disabled"
            :selected="value === framework.value"
            @select="
              value = $event === value ? '' : $event;
              open = false;
            "
            >{{ framework.label }}</ComboboxItem
          >
        </ComboboxGroup>
      </ComboboxList>
    </ComboboxContent>
  </Combobox>
</template>

Disabled

Set disabled on the trigger so the popover cannot open.

<script setup lang="ts">
import { ref } from 'vue';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/combobox';

const open = ref(false);
const value = ref('next');
const frameworks = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];
</script>

<template>
  <Combobox v-model:open="open">
    <ComboboxTrigger class="nx:w-64" disabled>
      <span
        :class="[
          'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
          { 'nx:text-muted-foreground': !value },
        ]"
        >{{
          frameworks.find((framework) => framework.value === value)?.label ??
          'Select framework'
        }}</span
      >
    </ComboboxTrigger>
    <ComboboxContent label="Framework">
      <ComboboxInput placeholder="Search framework..." />
      <ComboboxList>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxGroup>
          <ComboboxItem
            v-for="framework in frameworks"
            :key="framework.value"
            :value="framework.value"
            :keywords="[framework.label]"
            :selected="value === framework.value"
            @select="
              value = $event === value ? '' : $event;
              open = false;
            "
            >{{ framework.label }}</ComboboxItem
          >
        </ComboboxGroup>
      </ComboboxList>
    </ComboboxContent>
  </Combobox>
</template>

Invalid field

Set aria-invalid and place the combobox in a Field to show an error.

<script setup lang="ts">
import { ref } from 'vue';

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
} from '@/components/combobox';
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from '@/components/field';

const open = ref(false);
const value = ref('');
const frameworks = [
  { value: 'next', label: 'Next.js' },
  { value: 'sveltekit', label: 'SvelteKit' },
  { value: 'nuxt', label: 'Nuxt.js' },
  { value: 'remix', label: 'Remix' },
  { value: 'astro', label: 'Astro' },
];
</script>

<template>
  <Field data-invalid>
    <FieldLabel for="framework-invalid">Framework</FieldLabel>
    <Combobox v-model:open="open">
      <ComboboxTrigger
        id="framework-invalid"
        class="nx:w-64"
        aria-invalid="true"
        aria-describedby="framework-invalid-description framework-invalid-error"
      >
        <span
          :class="[
            'nx:min-w-0 nx:flex-1 nx:truncate nx:text-left',
            { 'nx:text-muted-foreground': !value },
          ]"
          >{{
            frameworks.find((framework) => framework.value === value)?.label ??
            'Select framework'
          }}</span
        >
      </ComboboxTrigger>
      <ComboboxContent label="Framework">
        <ComboboxInput placeholder="Search framework..." />
        <ComboboxList>
          <ComboboxEmpty>No framework found.</ComboboxEmpty>
          <ComboboxGroup>
            <ComboboxItem
              v-for="framework in frameworks"
              :key="framework.value"
              :value="framework.value"
              :keywords="[framework.label]"
              :selected="value === framework.value"
              @select="
                value = $event === value ? '' : $event;
                open = false;
              "
              >{{ framework.label }}</ComboboxItem
            >
          </ComboboxGroup>
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
    <FieldDescription id="framework-invalid-description">
      Choose the framework used by this project.
    </FieldDescription>
    <FieldError id="framework-invalid-error">
      Select a supported framework.
    </FieldError>
  </Field>
</template>

This example also needs: Field.

Props

ComboboxContent

ComboboxContent props
PropTypeDefaultDescription
labelstring"Options"

Accessible name for the search field and popover. Use the field's label text.

ComboboxEmpty

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

ComboboxGroup

ComboboxGroup 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.

ComboboxInput

ComboboxInput props
PropTypeDefaultDescription
modelValuestringNo default documented

The search text. Bind it with v-model to read or set the search.

ComboboxItem

ComboboxItem 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, such as synonyms.

selectedbooleanfalse

Marks the option as the current selection with a check.

valuestringNo default documented

What the search matches and select passes. Defaults to the item's text.

ComboboxList

ComboboxList props
PropTypeDefaultDescription
labelstringNo default documented

Accessible name for the list.

ComboboxSeparator

ComboboxSeparator props
PropTypeDefaultDescription
alwaysRenderbooleanfalse

Keeps the separator while a search is active.

ComboboxTrigger

ComboboxTrigger props
PropTypeDefaultDescription
disabledbooleanNo default documentedNo description
loadingbooleanNo default documented

Disables the button and shows a spinner in place of its content.

size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"default"No description
type"button" | "submit" | "reset""button"No description
variant"default" | "destructive" | "error" | "outline" | "dashed" | "secondary" | "ghost" | "link" | null"outline"No description