Select components are one of the most common form elements in modern web applications. Whether you’re letting users choose a country, timezone, project status, or multiple technologies, a good select component makes forms easier to use.

In this guide, you’ll learn how to add Shadcn Select Components to your React and Next.js projects. We’ll cover installation, build three practical select examples, and explain when to use a select instead of checkboxes.

Before getting started, make sure your project already includes:

• React or Next.js
• Tailwind CSS
• Shadcn UI
• Base UI dependency

With just a few installation steps, you can integrate production-ready select components into your own projects and customize them to match your design system.

Country Selection

This is the select field a user cannot skip…

It’s commonly used for country selection, payment methods, departments, languages, and other mandatory fields.

Install Using NPM Command

The easiest way to add the Select component is with the Shadcn CLI.

npx shadcn@latest add @shadcn-space/select-01

Manually Code Installation

If you prefer to create the component manually, install the required dependency:

bun add @base-ui/react

Then create the Checkbox component using the code below.

import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"

const northAmerica = [
{ label: "Eastern Standard Time", value: "est" },
{ label: "Central Standard Time", value: "cst" },
{ label: "Mountain Standard Time", value: "mst" },
{ label: "Pacific Standard Time", value: "pst" },
{ label: "Alaska Standard Time", value: "akst" },
{ label: "Hawaii Standard Time", value: "hst" },
]

const europeAfrica = [
{ label: "Greenwich Mean Time", value: "gmt" },
{ label: "Central European Time", value: "cet" },
{ label: "Eastern European Time", value: "eet" },
{ label: "Western European Summer Time", value: "west" },
{ label: "Central Africa Time", value: "cat" },
{ label: "East Africa Time", value: "eat" },
]

const asia = [
{ label: "Moscow Time", value: "msk" },
{ label: "India Standard Time", value: "ist" },
{ label: "China Standard Time", value: "cst_china" },
{ label: "Japan Standard Time", value: "jst" },
{ label: "Korea Standard Time", value: "kst" },
{ label: "Indonesia Central Standard Time", value: "ist_indonesia" },
]

const australiaPacific = [
{ label: "Australian Western Standard Time", value: "awst" },
{ label: "Australian Central Standard Time", value: "acst" },
{ label: "Australian Eastern Standard Time", value: "aest" },
{ label: "New Zealand Standard Time", value: "nzst" },
{ label: "Fiji Time", value: "fjt" },
]

const southAmerica = [
{ label: "Argentina Time", value: "art" },
{ label: "Bolivia Time", value: "bot" },
{ label: "Brasilia Time", value: "brt" },
{ label: "Chile Standard Time", value: "clt" },
]

const items = [
{ label: "Select a timezone", value: null },
...northAmerica,
...europeAfrica,
...asia,
...australiaPacific,
...southAmerica,
]

export function SelectScrollable() {
return (
 <Select items={items}>
   <SelectTrigger className="w-full max-w-64">
     <SelectValue />
   </SelectTrigger>
   <SelectContent>
     <SelectGroup>
       <SelectLabel>North America</SelectLabel>
       {northAmerica.map((item) => (
         <SelectItem key={item.value} value={item.value}>
           {item.label}
         </SelectItem>
       ))}
     </SelectGroup>
     <SelectGroup>
       <SelectLabel>Europe & Africa</SelectLabel>
       {europeAfrica.map((item) => (
         <SelectItem key={item.value} value={item.value}>
           {item.label}
         </SelectItem>
       ))}
     </SelectGroup>
     <SelectGroup>
       <SelectLabel>Asia</SelectLabel>
       {asia.map((item) => (
         <SelectItem key={item.value} value={item.value}>
           {item.label}
         </SelectItem>
       ))}
     </SelectGroup>
     <SelectGroup>
       <SelectLabel>Australia & Pacific</SelectLabel>
       {australiaPacific.map((item) => (
         <SelectItem key={item.value} value={item.value}>
           {item.label}
         </SelectItem>
       ))}
     </SelectGroup>
     <SelectGroup>
       <SelectLabel>South America</SelectLabel>
       {southAmerica.map((item) => (
         <SelectItem key={item.value} value={item.value}>
           {item.label}
         </SelectItem>
       ))}
     </SelectGroup>
   </SelectContent>
 </Select>
)
}

Since the component supports native form validation, users receive immediate feedback if they try to submit without choosing an option.

Scrollable Timezone Select

This is mostly used in projects for timezone selection.

When a dropdown contains dozens of options, showing everything at once becomes difficult to navigate. A scrollable select keeps the interface clean while allowing users to browse long lists efficiently.

Install Using NPM Command

The easiest way to add the Select component is with the Shadcn CLI.

npx shadcn@latest add select

Manually Code Installation

If you prefer to create the component manually, install the required dependency:

bun add @base-ui/react

Then create the Checkbox component using the code below.

import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"

const northAmerica = [
{ label: "Eastern Standard Time", value: "est" },
{ label: "Central Standard Time", value: "cst" },
{ label: "Mountain Standard Time", value: "mst" },
{ label: "Pacific Standard Time", value: "pst" },
{ label: "Alaska Standard Time", value: "akst" },
{ label: "Hawaii Standard Time", value: "hst" },
]

const europeAfrica = [
{ label: "Greenwich Mean Time", value: "gmt" },
{ label: "Central European Time", value: "cet" },
{ label: "Eastern European Time", value: "eet" },
{ label: "Western European Summer Time", value: "west" },
{ label: "Central Africa Time", value: "cat" },
{ label: "East Africa Time", value: "eat" },
]

const asia = [
{ label: "Moscow Time", value: "msk" },
{ label: "India Standard Time", value: "ist" },
{ label: "China Standard Time", value: "cst_china" },
{ label: "Japan Standard Time", value: "jst" },
{ label: "Korea Standard Time", value: "kst" },
{ label: "Indonesia Central Standard Time", value: "ist_indonesia" },
]

const australiaPacific = [
{ label: "Australian Western Standard Time", value: "awst" },
{ label: "Australian Central Standard Time", value: "acst" },
{ label: "Australian Eastern Standard Time", value: "aest" },
{ label: "New Zealand Standard Time", value: "nzst" },
{ label: "Fiji Time", value: "fjt" },
]

const southAmerica = [
{ label: "Argentina Time", value: "art" },
{ label: "Bolivia Time", value: "bot" },
{ label: "Brasilia Time", value: "brt" },
{ label: "Chile Standard Time", value: "clt" },
]

const items = [
{ label: "Select a timezone", value: null },
...northAmerica,
...europeAfrica,
...asia,
...australiaPacific,
...southAmerica,
]

export function SelectScrollable() {
return (
 <Select items={items}>
   <SelectTrigger className="w-full max-w-64">
     <SelectValue />
   </SelectTrigger>
   <SelectContent>
     <SelectGroup>
       <SelectLabel>North America</SelectLabel>
       {northAmerica.map((item) => (
         <SelectItem key={item.value} value={item.value}>
           {item.label}
         </SelectItem>
       ))}
     </SelectGroup>
     <SelectGroup>
       <SelectLabel>Europe & Africa</SelectLabel>
       {europeAfrica.map((item) => (
         <SelectItem key={item.value} value={item.value}>
           {item.label}
         </SelectItem>
       ))}
     </SelectGroup>
     <SelectGroup>
       <SelectLabel>Asia</SelectLabel>
       {asia.map((item) => (
         <SelectItem key={item.value} value={item.value}>
           {item.label}
         </SelectItem>
       ))}
     </SelectGroup>
     <SelectGroup>
       <SelectLabel>Australia & Pacific</SelectLabel>
       {australiaPacific.map((item) => (
         <SelectItem key={item.value} value={item.value}>
           {item.label}
         </SelectItem>
       ))}
     </SelectGroup>
     <SelectGroup>
       <SelectLabel>South America</SelectLabel>
       {southAmerica.map((item) => (
         <SelectItem key={item.value} value={item.value}>
           {item.label}
         </SelectItem>
       ))}
     </SelectGroup>
   </SelectContent>
 </Select>
)
}

Timezone selectors are commonly used in scheduling apps, booking systems, team collaboration tools, SaaS dashboards, and calendar applications where users need to choose from many global timezones.

Why use a Scrollable Select?

  • Handles long option lists

  • Better user experience

  • Organized with groups

  • Easy keyboard navigation

  • Perfect for timezone selection

Multi Select Component

This essential select component for large applications.

Installation using NPM Command

pnpm dlx shadcn@latest add @shadcn-space/select-06

Manually Code Installation

If you prefer to create the component manually, install the required dependency:

bun add @base-ui/react

Then create the Select component using the code below.

import { Label } from '@/components/ui/label'
import type { Option } from '@/components/ui/multi-select'
import MultipleSelector from '@/components/ui/multi-select'

const technologies: Option[] = [
  {
    value: 'frontend',
    label: 'Frontend Development'
  },
  {
    value: 'backend',
    label: 'Backend Development'
  },
  {
    value: 'fullstack',
    label: 'Full Stack'
  },
  {
    value: 'mobile',
    label: 'Mobile Development'
  },
  {
    value: 'ai_ml',
    label: 'AI & Machine Learning'
  },
  {
    value: 'data_science',
    label: 'Data Science'
  },
  {
    value: 'cloud',
    label: 'Cloud Computing'
  },
  {
    value: 'devops',
    label: 'DevOps'
  },
  {
    value: 'cybersecurity',
    label: 'Cybersecurity',
    disable: true
  },
  {
    value: 'blockchain',
    label: 'Blockchain'
  },
  {
    value: 'iot',
    label: 'Internet of Things (IoT)'
  },
  {
    value: 'ar_vr',
    label: 'AR / VR'
  },
  {
    value: 'game_dev',
    label: 'Game Development'
  },
  {
    value: 'web3',
    label: 'Web3',
    disable: true
  },
  {
    value: 'testing',
    label: 'Software Testing'
  },
  {
    value: 'ui_ux',
    label: 'UI / UX Design'
  }
]

const MultipleSelectWithPlaceholderDemo = () => {
  return (
    <div className='w-full max-w-xs space-y-2'>
      <Label>Technology Preferences</Label>
      <MultipleSelector
        commandProps={{
          label: 'Select technologies'
        }}
        defaultOptions={technologies}
        placeholder='Select technologies'
        emptyIndicator={<p className='text-center text-sm'>No results found</p>}
        className='w-full'
      />
    </div>
  )
}

export default MultipleSelectWithPlaceholderDemo

'use client'

import * as React from 'react'

import { useEffect } from 'react'

import { Command as CommandPrimitive, useCommandState } from 'cmdk'
import { XIcon } from 'lucide-react'

import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { cn } from '@/lib/utils'
import { Popover as PopoverPrimitive } from '@base-ui/react/popover'

export interface Option {
  value: string
  label: string
  disable?: boolean

  /** fixed option that can't be removed. */
  fixed?: boolean

  /** Group the options by providing key. */
  [key: string]: string | boolean | undefined
}
interface GroupOption {
  [key: string]: Option[]
}

interface MultipleSelectorProps {
  value?: Option[]
  defaultOptions?: Option[]

  /** manually controlled options */
  options?: Option[]
  placeholder?: string

  /** Loading component. */
  loadingIndicator?: React.ReactNode

  /** Empty component. */
  emptyIndicator?: React.ReactNode

  /** Debounce time for async search. Only work with `onSearch`. */
  delay?: number

  /**
   * Only work with `onSearch` prop. Trigger search when `onFocus`.
   * For example, when user click on the input, it will trigger the search to get initial options.
   **/
  triggerSearchOnFocus?: boolean

  /** async search */
  onSearch?: (value: string) => Promise<Option[]>

  /**
   * sync search. This search will not showing loadingIndicator.
   * The rest props are the same as async search.
   * i.e.: creatable, groupBy, delay.
   **/
  onSearchSync?: (value: string) => Option[]
  onChange?: (options: Option[]) => void

  /** Limit the maximum number of selected options. */
  maxSelected?: number

  /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
  onMaxSelected?: (maxLimit: number) => void

  /** Hide the placeholder when there are options selected. */
  hidePlaceholderWhenSelected?: boolean
  disabled?: boolean

  /** Group the options base on provided key. */
  groupBy?: string
  className?: string
  badgeClassName?: string

  /**
   * First item selected is a default behavior by cmdk. That is why the default is true.
   * This is a workaround solution by add a dummy item.
   *
   * @reference: https://github.com/pacocoursey/cmdk/issues/171
   */
  selectFirstItem?: boolean

  /** Allow user to create option when there is no option matched. */
  creatable?: boolean

  /** Props of `Command` */
  commandProps?: React.ComponentPropsWithoutRef<typeof Command>

  /** Props of `CommandInput` */
  inputProps?: Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>, 'value' | 'placeholder' | 'disabled'>

  /** hide the clear all button. */
  hideClearAllButton?: boolean
}

export interface MultipleSelectorRef {
  selectedValue: Option[]
  input: HTMLInputElement
  focus: () => void
  reset: () => void
}

export function useDebounce<T>(value: T, delay?: number): T {
  const [debouncedValue, setDebouncedValue] = React.useState<T>(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay || 500)

    return () => {
      clearTimeout(timer)
    }
  }, [value, delay])

  return debouncedValue
}

function transToGroupOption(options: Option[], groupBy?: string) {
  if (options.length === 0) {
    return {}
  }

  if (!groupBy) {
    return {
      '': options
    }
  }

  const groupOption: GroupOption = {}

  options.forEach(option => {
    const key = (option[groupBy] as string) || ''

    if (!groupOption[key]) {
      groupOption[key] = []
    }

    groupOption[key].push(option)
  })

  return groupOption
}

function removePickedOption(groupOption: GroupOption, picked: Option[]) {
  const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption

  for (const [key, value] of Object.entries(cloneOption)) {
    cloneOption[key] = value.filter(val => !picked.find(p => p.value === val.value))
  }

  return cloneOption
}

function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
  for (const [, value] of Object.entries(groupOption)) {
    if (value.some(option => targetOption.find(p => p.value === option.value))) {
      return true
    }
  }

  return false
}

const CommandEmpty = ({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) => {
  const render = useCommandState(state => state.filtered.count === 0)

  if (!render) return null

  return <div className={cn('px-2 py-4 text-center text-sm', className)} cmdk-empty='' role='presentation' {...props} />
}

CommandEmpty.displayName = 'CommandEmpty'

const MultipleSelector = ({
  value,
  onChange,
  placeholder,
  defaultOptions: arrayDefaultOptions = [],
  options: arrayOptions,
  delay,
  onSearch,
  onSearchSync,
  loadingIndicator,
  emptyIndicator,
  maxSelected = Number.MAX_SAFE_INTEGER,
  onMaxSelected,
  hidePlaceholderWhenSelected,
  disabled,
  groupBy,
  className,
  badgeClassName,
  selectFirstItem = true,
  creatable = false,
  triggerSearchOnFocus = false,
  commandProps,
  inputProps,
  hideClearAllButton = false
}: MultipleSelectorProps) => {
  const inputRef = React.useRef<HTMLInputElement>(null)
  const [open, setOpen] = React.useState(false)
  const [onScrollbar, setOnScrollbar] = React.useState(false)
  const [isLoading, setIsLoading] = React.useState(false)
  const dropdownRef = React.useRef<HTMLDivElement>(null) // Added this

  const [selected, setSelected] = React.useState<Option[]>(value || [])

  const [options, setOptions] = React.useState<GroupOption>(transToGroupOption(arrayDefaultOptions, groupBy))

  const [inputValue, setInputValue] = React.useState('')
  const debouncedSearchTerm = useDebounce(inputValue, delay || 500)

  const handleUnselect = React.useCallback(
    (option: Option) => {
      const newOptions = selected.filter(s => s.value !== option.value)

      setSelected(newOptions)
      onChange?.(newOptions)
    },
    [onChange, selected]
  )

  const handleKeyDown = React.useCallback(
    (e: React.KeyboardEvent<HTMLDivElement>) => {
      const input = inputRef.current

      if (input) {
        if (e.key === 'Delete' || e.key === 'Backspace') {
          if (input.value === '' && selected.length > 0) {
            const lastSelectOption = selected[selected.length - 1]

            // If last item is fixed, we should not remove it.
            if (!lastSelectOption.fixed) {
              handleUnselect(selected[selected.length - 1])
            }
          }
        }

        // This is not a default behavior of the <input /> field
        if (e.key === 'Escape') {
          input.blur()
        }
      }
    },
    [handleUnselect, selected]
  )

  useEffect(() => {
    if (value) {
      setSelected(value)
    }
  }, [value])

  useEffect(() => {
    /** If `onSearch` is provided, do not trigger options updated. */
    if (!arrayOptions || onSearch) {
      return
    }

    const newOption = transToGroupOption(arrayOptions || [], groupBy)

    if (JSON.stringify(newOption) !== JSON.stringify(options)) {
      setOptions(newOption)
    }
  }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options])

  useEffect(() => {
    /** sync search */

    const doSearchSync = () => {
      const res = onSearchSync?.(debouncedSearchTerm)

      setOptions(transToGroupOption(res || [], groupBy))
    }

    const exec = async () => {
      if (!onSearchSync || !open) return

      if (triggerSearchOnFocus) {
        doSearchSync()
      }

      if (debouncedSearchTerm) {
        doSearchSync()
      }
    }

    void exec()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus])

  useEffect(() => {
    /** async search */

    const doSearch = async () => {
      setIsLoading(true)
      const res = await onSearch?.(debouncedSearchTerm)

      setOptions(transToGroupOption(res || [], groupBy))
      setIsLoading(false)
    }

    const exec = async () => {
      if (!onSearch || !open) return

      if (triggerSearchOnFocus) {
        await doSearch()
      }

      if (debouncedSearchTerm) {
        await doSearch()
      }
    }

    void exec()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus])

  const CreatableItem = () => {
    if (!creatable) return undefined

    if (
      isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
      selected.find(s => s.value === inputValue)
    ) {
      return undefined
    }

    const Item = (
      <CommandItem
        value={inputValue}
        className='cursor-pointer'
        onMouseDown={e => {
          e.preventDefault()
          e.stopPropagation()
        }}
        onSelect={(value: string) => {
          if (selected.length >= maxSelected) {
            onMaxSelected?.(selected.length)

            return
          }

          setInputValue('')
          const newOptions = [...selected, { value, label: value }]

          setSelected(newOptions)
          onChange?.(newOptions)
        }}
      >
        {`Create "${inputValue}"`}
      </CommandItem>
    )

    // For normal creatable
    if (!onSearch && inputValue.length > 0) {
      return Item
    }

    // For async search creatable. avoid showing creatable item before loading at first.
    if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
      return Item
    }

    return undefined
  }

  const EmptyItem = React.useCallback(() => {
    if (!emptyIndicator) return undefined

    // For async search that showing emptyIndicator
    if (onSearch && !creatable && Object.keys(options).length === 0) {
      return (
        <CommandItem value='-' disabled>
          {emptyIndicator}
        </CommandItem>
      )
    }

    return <CommandEmpty>{emptyIndicator}</CommandEmpty>
  }, [creatable, emptyIndicator, onSearch, options])

  const selectables = React.useMemo<GroupOption>(() => removePickedOption(options, selected), [options, selected])

  /** Avoid Creatable Selector freezing or lagging when paste a long string. */
  const commandFilter = React.useCallback(() => {
    if (commandProps?.filter) {
      return commandProps.filter
    }

    if (creatable) {
      return (value: string, search: string) => {
        return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1
      }
    }

    // Using default filter in `cmdk`. We don&lsquo;t have to provide it.
    return undefined
  }, [creatable, commandProps?.filter])

  return (
    <Command
      {...commandProps}
      onKeyDown={e => {
        handleKeyDown(e)
        commandProps?.onKeyDown?.(e)
      }}
      className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
      shouldFilter={commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch} // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it.
      filter={commandFilter()}
    >
      <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
        <PopoverPrimitive.Trigger
          nativeButton={false}
          render={(props) => (
            <div
              {...props}
              className={cn(
                'border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive relative min-h-[38px] rounded-md border text-sm transition-[color,box-shadow] outline-none focus-within:ring-[3px] has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50',
                {
                  'p-1': selected.length !== 0,
                  'cursor-text': !disabled && selected.length !== 0
                },
                !hideClearAllButton && 'pr-9',
                className
              )}
              onClick={(e) => {
                if (disabled) return
                inputRef?.current?.focus()
                props.onClick?.(e)
              }}
            >
            <div className='flex flex-wrap gap-1'>
              {selected.map(option => {
                return (
                  <div
                    key={option.value}
                    className={cn(
                      'animate-fadeIn bg-background text-secondary-foreground hover:bg-background relative inline-flex h-7 cursor-default items-center rounded-md border pr-7 pl-2 text-xs font-medium transition-all disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 data-fixed:pr-2',
                      badgeClassName
                    )}
                    data-fixed={option.fixed}
                    data-disabled={disabled || undefined}
                  >
                    {option.label}
                    <button
                      className='text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute -inset-y-px -right-px flex size-7 items-center justify-center rounded-r-md border border-transparent p-0 outline-hidden transition-[color,box-shadow] outline-none focus-visible:ring-[3px]'
                      onKeyDown={e => {
                        if (e.key === 'Enter') {
                          handleUnselect(option)
                        }
                      }}
                      onMouseDown={e => {
                        e.preventDefault()
                        e.stopPropagation()
                      }}
                      onClick={() => handleUnselect(option)}
                      aria-label='Remove'
                    >
                      <XIcon size={14} aria-hidden='true' />
                    </button>
                  </div>
                )
              })}
              {/* Avoid having the "Search" Icon */}
              <CommandPrimitive.Input
                {...inputProps}
                ref={inputRef}
                value={inputValue}
                disabled={disabled}
                onValueChange={value => {
                  setInputValue(value)
                  inputProps?.onValueChange?.(value)
                }}
                onBlur={event => {
                  inputProps?.onBlur?.(event)
                }}
                onFocus={event => {
                  setOpen(true)

                  if (triggerSearchOnFocus) {
                    onSearch?.(debouncedSearchTerm)
                  }

                  inputProps?.onFocus?.(event)
                }}
                placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
                className={cn(
                  'placeholder:text-muted-foreground/70 flex-1 bg-transparent outline-hidden disabled:cursor-not-allowed',
                  {
                    'w-full': hidePlaceholderWhenSelected,
                    'px-3 py-2': selected.length === 0,
                    'ml-1': selected.length !== 0
                  },
                  inputProps?.className
                )}
              />
              <button
                type='button'
                onClick={() => {
                  setSelected(selected.filter(s => s.fixed))
                  onChange?.(selected.filter(s => s.fixed))
                }}
                className={cn(
                  'text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute top-0 right-0 flex size-9 items-center justify-center rounded-md border border-transparent transition-[color,box-shadow] outline-none focus-visible:ring-[3px]',
                  (hideClearAllButton ||
                    disabled ||
                    selected.length < 1 ||
                    selected.filter(s => s.fixed).length === selected.length) &&
                    'hidden'
                )}
                aria-label='Clear all'
              >
                <XIcon size={16} aria-hidden='true' />
              </button>
            </div>
          </div>
        )}
      />
        <PopoverPrimitive.Portal>
          <PopoverPrimitive.Positioner
            side='bottom'
            align='start'
            sideOffset={8}
            className='z-50 w-[var(--anchor-width)]'
          >
            <PopoverPrimitive.Popup
              className={cn(
                'border-input bg-popover text-popover-foreground w-full overflow-hidden rounded-md border shadow-lg outline-none',
                'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95'
              )}
            >
              <CommandList
                className='no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none'
                onMouseLeave={() => {
                  setOnScrollbar(false)
                }}
                onMouseEnter={() => {
                  setOnScrollbar(true)
                }}
                onMouseUp={() => {
                  inputRef?.current?.focus()
                }}
              >
                {isLoading ? (
                  <>{loadingIndicator}</>
                ) : (
                  <>
                    {EmptyItem()}
                    {CreatableItem()}
                    {!selectFirstItem && <CommandItem value='-' className='hidden' />}
                    {Object.entries(selectables).map(([key, dropdowns]) => (
                      <CommandGroup key={key} heading={key} className='h-full overflow-auto'>
                        <>
                          {dropdowns.map(option => {
                            return (
                              <CommandItem
                                key={option.value}
                                value={option.value}
                                disabled={option.disable}
                                onMouseDown={e => {
                                  e.preventDefault()
                                  e.stopPropagation()
                                }}
                                onSelect={() => {
                                  if (selected.length >= maxSelected) {
                                    onMaxSelected?.(selected.length)

                                    return
                                  }

                                  setInputValue('')
                                  const newOptions = [...selected, option]

                                  setSelected(newOptions)
                                  onChange?.(newOptions)
                                }}
                                className={cn(
                                  'cursor-pointer',
                                  option.disable && 'pointer-events-none cursor-not-allowed opacity-50'
                                )}
                              >
                                {option.label}
                              </CommandItem>
                            )
                          })}
                        </>
                      </CommandGroup>
                    ))}
                  </>
                )}
              </CommandList>
            </PopoverPrimitive.Popup>
          </PopoverPrimitive.Positioner>
        </PopoverPrimitive.Portal>
      </PopoverPrimitive.Root>
    </Command>
  )
}

MultipleSelector.displayName = 'MultipleSelector'
export default MultipleSelector

To support selecting multiple options, copy and add the Multi Select component code as below.

'use client'

import * as React from 'react'

import { useEffect } from 'react'

import { Command as CommandPrimitive, useCommandState } from 'cmdk'
import { XIcon } from 'lucide-react'

import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { cn } from '@/lib/utils'
import { Popover as PopoverPrimitive } from '@base-ui/react/popover'

export interface Option {
  value: string
  label: string
  disable?: boolean

  /** fixed option that can't be removed. */
  fixed?: boolean

  /** Group the options by providing key. */
  [key: string]: string | boolean | undefined
}
interface GroupOption {
  [key: string]: Option[]
}

interface MultipleSelectorProps {
  value?: Option[]
  defaultOptions?: Option[]

  /** manually controlled options */
  options?: Option[]
  placeholder?: string

  /** Loading component. */
  loadingIndicator?: React.ReactNode

  /** Empty component. */
  emptyIndicator?: React.ReactNode

  /** Debounce time for async search. Only work with `onSearch`. */
  delay?: number

  /**
   * Only work with `onSearch` prop. Trigger search when `onFocus`.
   * For example, when user click on the input, it will trigger the search to get initial options.
   **/
  triggerSearchOnFocus?: boolean

  /** async search */
  onSearch?: (value: string) => Promise<Option[]>

  /**
   * sync search. This search will not showing loadingIndicator.
   * The rest props are the same as async search.
   * i.e.: creatable, groupBy, delay.
   **/
  onSearchSync?: (value: string) => Option[]
  onChange?: (options: Option[]) => void

  /** Limit the maximum number of selected options. */
  maxSelected?: number

  /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
  onMaxSelected?: (maxLimit: number) => void

  /** Hide the placeholder when there are options selected. */
  hidePlaceholderWhenSelected?: boolean
  disabled?: boolean

  /** Group the options base on provided key. */
  groupBy?: string
  className?: string
  badgeClassName?: string

  /**
   * First item selected is a default behavior by cmdk. That is why the default is true.
   * This is a workaround solution by add a dummy item.
   *
   * @reference: https://github.com/pacocoursey/cmdk/issues/171
   */
  selectFirstItem?: boolean

  /** Allow user to create option when there is no option matched. */
  creatable?: boolean

  /** Props of `Command` */
  commandProps?: React.ComponentPropsWithoutRef<typeof Command>

  /** Props of `CommandInput` */
  inputProps?: Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>, 'value' | 'placeholder' | 'disabled'>

  /** hide the clear all button. */
  hideClearAllButton?: boolean
}

export interface MultipleSelectorRef {
  selectedValue: Option[]
  input: HTMLInputElement
  focus: () => void
  reset: () => void
}

export function useDebounce<T>(value: T, delay?: number): T {
  const [debouncedValue, setDebouncedValue] = React.useState<T>(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay || 500)

    return () => {
      clearTimeout(timer)
    }
  }, [value, delay])

  return debouncedValue
}

function transToGroupOption(options: Option[], groupBy?: string) {
  if (options.length === 0) {
    return {}
  }

  if (!groupBy) {
    return {
      '': options
    }
  }

  const groupOption: GroupOption = {}

  options.forEach(option => {
    const key = (option[groupBy] as string) || ''

    if (!groupOption[key]) {
      groupOption[key] = []
    }

    groupOption[key].push(option)
  })

  return groupOption
}

function removePickedOption(groupOption: GroupOption, picked: Option[]) {
  const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption

  for (const [key, value] of Object.entries(cloneOption)) {
    cloneOption[key] = value.filter(val => !picked.find(p => p.value === val.value))
  }

  return cloneOption
}

function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
  for (const [, value] of Object.entries(groupOption)) {
    if (value.some(option => targetOption.find(p => p.value === option.value))) {
      return true
    }
  }

  return false
}

const CommandEmpty = ({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) => {
  const render = useCommandState(state => state.filtered.count === 0)

  if (!render) return null

  return <div className={cn('px-2 py-4 text-center text-sm', className)} cmdk-empty='' role='presentation' {...props} />
}

CommandEmpty.displayName = 'CommandEmpty'

const MultipleSelector = ({
  value,
  onChange,
  placeholder,
  defaultOptions: arrayDefaultOptions = [],
  options: arrayOptions,
  delay,
  onSearch,
  onSearchSync,
  loadingIndicator,
  emptyIndicator,
  maxSelected = Number.MAX_SAFE_INTEGER,
  onMaxSelected,
  hidePlaceholderWhenSelected,
  disabled,
  groupBy,
  className,
  badgeClassName,
  selectFirstItem = true,
  creatable = false,
  triggerSearchOnFocus = false,
  commandProps,
  inputProps,
  hideClearAllButton = false
}: MultipleSelectorProps) => {
  const inputRef = React.useRef<HTMLInputElement>(null)
  const [open, setOpen] = React.useState(false)
  const [onScrollbar, setOnScrollbar] = React.useState(false)
  const [isLoading, setIsLoading] = React.useState(false)
  const dropdownRef = React.useRef<HTMLDivElement>(null) // Added this

  const [selected, setSelected] = React.useState<Option[]>(value || [])

  const [options, setOptions] = React.useState<GroupOption>(transToGroupOption(arrayDefaultOptions, groupBy))

  const [inputValue, setInputValue] = React.useState('')
  const debouncedSearchTerm = useDebounce(inputValue, delay || 500)

  const handleUnselect = React.useCallback(
    (option: Option) => {
      const newOptions = selected.filter(s => s.value !== option.value)

      setSelected(newOptions)
      onChange?.(newOptions)
    },
    [onChange, selected]
  )

  const handleKeyDown = React.useCallback(
    (e: React.KeyboardEvent<HTMLDivElement>) => {
      const input = inputRef.current

      if (input) {
        if (e.key === 'Delete' || e.key === 'Backspace') {
          if (input.value === '' && selected.length > 0) {
            const lastSelectOption = selected[selected.length - 1]

            // If last item is fixed, we should not remove it.
            if (!lastSelectOption.fixed) {
              handleUnselect(selected[selected.length - 1])
            }
          }
        }

        // This is not a default behavior of the <input /> field
        if (e.key === 'Escape') {
          input.blur()
        }
      }
    },
    [handleUnselect, selected]
  )

  useEffect(() => {
    if (value) {
      setSelected(value)
    }
  }, [value])

  useEffect(() => {
    /** If `onSearch` is provided, do not trigger options updated. */
    if (!arrayOptions || onSearch) {
      return
    }

    const newOption = transToGroupOption(arrayOptions || [], groupBy)

    if (JSON.stringify(newOption) !== JSON.stringify(options)) {
      setOptions(newOption)
    }
  }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options])

  useEffect(() => {
    /** sync search */

    const doSearchSync = () => {
      const res = onSearchSync?.(debouncedSearchTerm)

      setOptions(transToGroupOption(res || [], groupBy))
    }

    const exec = async () => {
      if (!onSearchSync || !open) return

      if (triggerSearchOnFocus) {
        doSearchSync()
      }

      if (debouncedSearchTerm) {
        doSearchSync()
      }
    }

    void exec()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus])

  useEffect(() => {
    /** async search */

    const doSearch = async () => {
      setIsLoading(true)
      const res = await onSearch?.(debouncedSearchTerm)

      setOptions(transToGroupOption(res || [], groupBy))
      setIsLoading(false)
    }

    const exec = async () => {
      if (!onSearch || !open) return

      if (triggerSearchOnFocus) {
        await doSearch()
      }

      if (debouncedSearchTerm) {
        await doSearch()
      }
    }

    void exec()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus])

  const CreatableItem = () => {
    if (!creatable) return undefined

    if (
      isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
      selected.find(s => s.value === inputValue)
    ) {
      return undefined
    }

    const Item = (
      <CommandItem
        value={inputValue}
        className='cursor-pointer'
        onMouseDown={e => {
          e.preventDefault()
          e.stopPropagation()
        }}
        onSelect={(value: string) => {
          if (selected.length >= maxSelected) {
            onMaxSelected?.(selected.length)

            return
          }

          setInputValue('')
          const newOptions = [...selected, { value, label: value }]

          setSelected(newOptions)
          onChange?.(newOptions)
        }}
      >
        {`Create "${inputValue}"`}
      </CommandItem>
    )

    // For normal creatable
    if (!onSearch && inputValue.length > 0) {
      return Item
    }

    // For async search creatable. avoid showing creatable item before loading at first.
    if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
      return Item
    }

    return undefined
  }

  const EmptyItem = React.useCallback(() => {
    if (!emptyIndicator) return undefined

    // For async search that showing emptyIndicator
    if (onSearch && !creatable && Object.keys(options).length === 0) {
      return (
        <CommandItem value='-' disabled>
          {emptyIndicator}
        </CommandItem>
      )
    }

    return <CommandEmpty>{emptyIndicator}</CommandEmpty>
  }, [creatable, emptyIndicator, onSearch, options])

  const selectables = React.useMemo<GroupOption>(() => removePickedOption(options, selected), [options, selected])

  /** Avoid Creatable Selector freezing or lagging when paste a long string. */
  const commandFilter = React.useCallback(() => {
    if (commandProps?.filter) {
      return commandProps.filter
    }

    if (creatable) {
      return (value: string, search: string) => {
        return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1
      }
    }

    // Using default filter in `cmdk`. We don&lsquo;t have to provide it.
    return undefined
  }, [creatable, commandProps?.filter])

  return (
    <Command
      {...commandProps}
      onKeyDown={e => {
        handleKeyDown(e)
        commandProps?.onKeyDown?.(e)
      }}
      className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
      shouldFilter={commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch} // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it.
      filter={commandFilter()}
    >
      <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
        <PopoverPrimitive.Trigger
          nativeButton={false}
          render={(props) => (
            <div
              {...props}
              className={cn(
                'border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive relative min-h-[38px] rounded-md border text-sm transition-[color,box-shadow] outline-none focus-within:ring-[3px] has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50',
                {
                  'p-1': selected.length !== 0,
                  'cursor-text': !disabled && selected.length !== 0
                },
                !hideClearAllButton && 'pr-9',
                className
              )}
              onClick={(e) => {
                if (disabled) return
                inputRef?.current?.focus()
                props.onClick?.(e)
              }}
            >
            <div className='flex flex-wrap gap-1'>
              {selected.map(option => {
                return (
                  <div
                    key={option.value}
                    className={cn(
                      'animate-fadeIn bg-background text-secondary-foreground hover:bg-background relative inline-flex h-7 cursor-default items-center rounded-md border pr-7 pl-2 text-xs font-medium transition-all disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 data-fixed:pr-2',
                      badgeClassName
                    )}
                    data-fixed={option.fixed}
                    data-disabled={disabled || undefined}
                  >
                    {option.label}
                    <button
                      className='text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute -inset-y-px -right-px flex size-7 items-center justify-center rounded-r-md border border-transparent p-0 outline-hidden transition-[color,box-shadow] outline-none focus-visible:ring-[3px]'
                      onKeyDown={e => {
                        if (e.key === 'Enter') {
                          handleUnselect(option)
                        }
                      }}
                      onMouseDown={e => {
                        e.preventDefault()
                        e.stopPropagation()
                      }}
                      onClick={() => handleUnselect(option)}
                      aria-label='Remove'
                    >
                      <XIcon size={14} aria-hidden='true' />
                    </button>
                  </div>
                )
              })}
              {/* Avoid having the "Search" Icon */}
              <CommandPrimitive.Input
                {...inputProps}
                ref={inputRef}
                value={inputValue}
                disabled={disabled}
                onValueChange={value => {
                  setInputValue(value)
                  inputProps?.onValueChange?.(value)
                }}
                onBlur={event => {
                  inputProps?.onBlur?.(event)
                }}
                onFocus={event => {
                  setOpen(true)

                  if (triggerSearchOnFocus) {
                    onSearch?.(debouncedSearchTerm)
                  }

                  inputProps?.onFocus?.(event)
                }}
                placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
                className={cn(
                  'placeholder:text-muted-foreground/70 flex-1 bg-transparent outline-hidden disabled:cursor-not-allowed',
                  {
                    'w-full': hidePlaceholderWhenSelected,
                    'px-3 py-2': selected.length === 0,
                    'ml-1': selected.length !== 0
                  },
                  inputProps?.className
                )}
              />
              <button
                type='button'
                onClick={() => {
                  setSelected(selected.filter(s => s.fixed))
                  onChange?.(selected.filter(s => s.fixed))
                }}
                className={cn(
                  'text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute top-0 right-0 flex size-9 items-center justify-center rounded-md border border-transparent transition-[color,box-shadow] outline-none focus-visible:ring-[3px]',
                  (hideClearAllButton ||
                    disabled ||
                    selected.length < 1 ||
                    selected.filter(s => s.fixed).length === selected.length) &&
                    'hidden'
                )}
                aria-label='Clear all'
              >
                <XIcon size={16} aria-hidden='true' />
              </button>
            </div>
          </div>
        )}
      />
        <PopoverPrimitive.Portal>
          <PopoverPrimitive.Positioner
            side='bottom'
            align='start'
            sideOffset={8}
            className='z-50 w-[var(--anchor-width)]'
          >
            <PopoverPrimitive.Popup
              className={cn(
                'border-input bg-popover text-popover-foreground w-full overflow-hidden rounded-md border shadow-lg outline-none',
                'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95'
              )}
            >
              <CommandList
                className='no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none'
                onMouseLeave={() => {
                  setOnScrollbar(false)
                }}
                onMouseEnter={() => {
                  setOnScrollbar(true)
                }}
                onMouseUp={() => {
                  inputRef?.current?.focus()
                }}
              >
                {isLoading ? (
                  <>{loadingIndicator}</>
                ) : (
                  <>
                    {EmptyItem()}
                    {CreatableItem()}
                    {!selectFirstItem && <CommandItem value='-' className='hidden' />}
                    {Object.entries(selectables).map(([key, dropdowns]) => (
                      <CommandGroup key={key} heading={key} className='h-full overflow-auto'>
                        <>
                          {dropdowns.map(option => {
                            return (
                              <CommandItem
                                key={option.value}
                                value={option.value}
                                disabled={option.disable}
                                onMouseDown={e => {
                                  e.preventDefault()
                                  e.stopPropagation()
                                }}
                                onSelect={() => {
                                  if (selected.length >= maxSelected) {
                                    onMaxSelected?.(selected.length)

                                    return
                                  }

                                  setInputValue('')
                                  const newOptions = [...selected, option]

                                  setSelected(newOptions)
                                  onChange?.(newOptions)
                                }}
                                className={cn(
                                  'cursor-pointer',
                                  option.disable && 'pointer-events-none cursor-not-allowed opacity-50'
                                )}
                              >
                                {option.label}
                              </CommandItem>
                            )
                          })}
                        </>
                      </CommandGroup>
                    ))}
                  </>
                )}
              </CommandList>
            </PopoverPrimitive.Popup>
          </PopoverPrimitive.Positioner>
        </PopoverPrimitive.Portal>
      </PopoverPrimitive.Root>
    </Command>
  )
}

MultipleSelector.displayName = 'MultipleSelector'
export default MultipleSelector

Features of this Multi Select Component

  • Searchable options

  • Multiple selection

  • Remove selected items

  • Keyboard navigation

  • Async search support

  • Create new options

  • Maximum selection limit

  • Grouped options

  • Loading states

  • Empty states

Read Next

Shadcn Select Components make it easy to build modern, accessible forms in React and Next.js.

Whether you’re creating a simple country selector, a grouped time-zone picker, or a searchable multi-select, these components provide reusable building blocks for real-world applications.

In the next guide, we’ll explore Shadcn Calendar Components and see how to create modern, reusable calendar interfaces for React and Next.js applications.