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-01Manually Code Installation
If you prefer to create the component manually, install the required dependency:
bun add @base-ui/reactThen 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 selectManually Code Installation
If you prefer to create the component manually, install the required dependency:
bun add @base-ui/reactThen 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-06Manually Code Installation
If you prefer to create the component manually, install the required dependency:
bun add @base-ui/reactThen 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‘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‘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‘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‘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 MultipleSelectorFeatures 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.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.