A textarea-based version of the Multiple Selector component, allowing for multi-line text input while maintaining all the functionality of the original selector.
- All features from Multiple Selector
- Auto-resizing textarea with configurable min/max height
- Maintains badge height consistency with
max-h-[22px]
to prevent stretching with textarea - Async search with debounce
- Creatable selector — create options when there is no match
- Grouping functionality
- Working with
react-hook-form
- Customizable loading spinner and empty indicator
- Fixed options, maximum selected count
- Ability to disable default first item selection
- First, install the required shadcn-ui components:
npx shadcn-ui@latest add command badge
- Then, paste code from
MultipleTextAreaSelector
component, alternatively you can useMultipleSelectorCreatable
component insidesrc/components/ui/common/MultipleSelectorCreatable.tsx
:
'use client'
import * as React from 'react'
import { X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import {
Command,
CommandGroup,
CommandItem,
CommandList
} from '@/components/ui/command'
import { cn } from '@/lib/utils'
import { Command as CommandPrimitive, useCommandState } from 'cmdk'
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 MultipleTextAreaSelectorProps {
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 `textarea` */
textareaProps?: Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
'value' | 'placeholder' | 'disabled'
>
/** Minimum height of textarea */
minHeight?: number
/** Maximum height of textarea */
maxHeight?: number
/** hide the clear all button. */
hideClearAllButton?: boolean
}
export interface MultipleTextAreaSelectorRef {
selectedValue: Option[]
textarea: HTMLTextAreaElement
focus: () => void
reset: () => void
}
// Reuse the utility functions from MultipleSelector
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
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value)
React.useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}
const CommandEmpty = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof CommandPrimitive.Empty>
>(({ className, ...props }, forwardedRef) => {
const render = useCommandState(state => state.filtered.count === 0)
if (!render) return null
return (
<div
ref={forwardedRef}
className={cn('py-6 text-center text-sm', className)}
cmdk-empty=''
role='presentation'
{...props}
/>
)
})
CommandEmpty.displayName = 'CommandEmpty'
const MultipleTextAreaSelector = React.forwardRef<
MultipleTextAreaSelectorRef,
MultipleTextAreaSelectorProps
>(
(
{
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,
textareaProps,
minHeight = 52,
maxHeight = 200,
}: MultipleTextAreaSelectorProps,
ref: React.Ref<MultipleTextAreaSelectorRef>
) => {
const textareaRef = React.useRef<HTMLTextAreaElement>(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)
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)
// Add autosize functionality
React.useEffect(() => {
const textarea = textareaRef.current
if (!textarea) return
const setHeight = () => {
textarea.style.height = 'auto'
const scrollHeight = textarea.scrollHeight
textarea.style.height = `${Math.min(
Math.max(scrollHeight, minHeight),
maxHeight
)}px`
}
setHeight()
textarea.addEventListener('input', setHeight)
return () => textarea.removeEventListener('input', setHeight)
}, [maxHeight, minHeight])
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
textarea: textareaRef.current as HTMLTextAreaElement,
focus: () => textareaRef?.current?.focus(),
reset: () => setSelected([])
}),
[selected]
)
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(event.target as Node)
) {
setOpen(false)
textareaRef.current.blur()
}
}
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 textarea = textareaRef.current
if (textarea) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (textarea.value === '' && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1]
if (!lastSelectOption.fixed) {
handleUnselect(selected[selected.length - 1])
}
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (inputValue && creatable) {
if (
!isOptionsExist(options, [
{ value: inputValue, label: inputValue }
]) &&
!selected.find(s => s.value === inputValue)
) {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length)
return
}
const newOptions = [
...selected,
{ value: inputValue, label: inputValue }
]
setSelected(newOptions)
onChange?.(newOptions)
setInputValue('')
}
}
}
if (e.key === 'Escape') {
textarea.blur()
}
}
},
[
handleUnselect,
selected,
inputValue,
creatable,
options,
maxSelected,
onMaxSelected,
onChange
]
)
React.useEffect(() => {
if (open) {
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('touchend', handleClickOutside)
} else {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('touchend', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('touchend', handleClickOutside)
}
}, [open])
React.useEffect(() => {
if (value) {
setSelected(value)
}
}, [value])
React.useEffect(() => {
if (!arrayOptions || onSearch) {
return
}
const newOption = transToGroupOption(arrayOptions || [], groupBy)
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption)
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options])
React.useEffect(() => {
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()
}, [debouncedSearchTerm, groupBy, onSearchSync, open, triggerSearchOnFocus])
React.useEffect(() => {
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()
}, [debouncedSearchTerm, groupBy, onSearch, 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>
)
if (!onSearch && inputValue.length > 0) {
return Item
}
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item
}
return undefined
}
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined
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]
)
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
}
}
return undefined
}, [creatable, commandProps?.filter])
return (
<Command
ref={dropdownRef}
{...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
}
filter={commandFilter()}
>
<div
className={cn(
'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm',
className
)}
>
<div className='relative flex flex-wrap gap-1 p-2'>
{selected.map(option => (
<Badge
key={option.value}
className={cn(
'max-h-[22px] data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
badgeClassName
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
className={cn(
'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2',
(disabled || option.fixed) && 'hidden'
)}
onKeyDown={e => {
if (e.key === 'Enter') {
handleUnselect(option)
}
}}
onMouseDown={e => {
e.preventDefault()
e.stopPropagation()
}}
onClick={() => handleUnselect(option)}
>
<X className='h-3 w-3 text-muted-foreground hover:text-foreground' />
</button>
</Badge>
))}
<textarea
ref={textareaRef}
{...textareaProps}
value={inputValue}
disabled={disabled}
onChange={e => {
setInputValue(e.target.value)
textareaProps?.onChange?.(e)
}}
onBlur={event => {
if (!onScrollbar) {
setOpen(false)
}
textareaProps?.onBlur?.(event)
}}
onFocus={event => {
setOpen(true)
textareaProps?.onFocus?.(event)
}}
placeholder={
hidePlaceholderWhenSelected && selected.length !== 0
? ''
: placeholder
}
className={cn(
'flex-1 bg-transparent outline-none placeholder:text-muted-foreground',
{
'w-full': hidePlaceholderWhenSelected,
'px-3 py-2': selected.length === 0,
'ml-1': selected.length !== 0
},
textareaProps?.className
)}
style={{
minHeight: `${minHeight}px`,
maxHeight: `${maxHeight}px`
}}
/>
</div>
</div>
{/* Command List implementation */}
<div className='relative'>
{open && (
<CommandList
className='absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in'
onMouseLeave={() => setOnScrollbar(false)}
onMouseEnter={() => setOnScrollbar(true)}
onMouseUp={() => textareaRef?.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 => (
<CommandItem
key={option.value}
value={option.label}
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 &&
'cursor-default text-muted-foreground'
)}
>
{option.label}
</CommandItem>
))}
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</Command>
)
}
)
MultipleTextAreaSelector.displayName = 'MultipleTextAreaSelector'
export default MultipleTextAreaSelector
import { MultipleTextAreaSelector } from '@/components/ui/MultipleTextAreaSelector'
const OPTIONS = [
{ label: 'nextjs', value: 'nextjs' },
{ label: 'React', value: 'react' },
{ label: 'Vue', value: 'vue' }
]
export default function Demo() {
return (
<MultipleTextAreaSelector
defaultOptions={OPTIONS}
placeholder='Type or select frameworks...'
minHeight={52}
maxHeight={200}
/>
)
}
import { MultipleTextAreaSelector } from '@/components/ui/MultipleTextAreaSelector'
const OPTIONS = [
{ label: 'nextjs', value: 'nextjs' },
{ label: 'React', value: 'react' },
{ label: 'Vue', value: 'vue' }
]
export default function Demo() {
return (
<MultipleTextAreaSelector
defaultOptions={OPTIONS}
placeholder="Type something that doesn't exist in dropdowns..."
creatable
minHeight={52}
maxHeight={200}
emptyIndicator={
<p className='text-center text-lg leading-10 text-gray-600 dark:text-gray-400'>
no results found.
</p>
}
/>
)
}
Extends all props from Multiple Selector with additional textarea-specific props:
Prop | Type | Description |
---|---|---|
minHeight |
number |
Minimum height of the textarea (default: 52) |
maxHeight |
number |
Maximum height of the textarea (default: 200) |
textareaProps |
TextareaHTMLAttributes |
HTML textarea attributes (excluding value, placeholder, disabled) |
This component is inspired by:
- Multiple Selector from shadcn-ui expansions
- Built on top of the
Command
component from shadcn-ui - Uses cmdk under the hood
- The badge height is intentionally limited to
max-h-[22px]
to maintain consistent UI when used with an expanding textarea - The textarea automatically resizes based on content while respecting min/max height constraints
- All keyboard shortcuts and accessibility features from the original Multiple Selector are maintained
- Press Enter to create a new tag when creatable mode is enabled (use Shift+Enter for new lines)
- Feel free to contribute to the project by opening an issue or a PR.
This component is created by Nidhi Yashwanth.