Last active
December 11, 2025 09:23
-
-
Save devhammed/ae071cc7b0da71569ed638e80b6c8ab4 to your computer and use it in GitHub Desktop.
ShadCN UI Combobox (based on Command and Popover components).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; | |
| import { CheckIcon, SearchIcon, UnfoldMoreIcon } from '@/components/ui/icons'; | |
| import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; | |
| import { useControlledState } from '@/hooks/use-controlled-state'; | |
| import { cn } from '@/lib/utils'; | |
| import { Command as CommandPrimitive } from 'cmdk'; | |
| import * as React from 'react'; | |
| import ReactDOM from 'react-dom'; | |
| type ComboboxContextValue = { | |
| open: boolean; | |
| onOpenChange: (open: boolean) => void; | |
| value: string; | |
| onValueChange: (value: string) => void; | |
| valueNode: HTMLSpanElement | null; | |
| onValueNodeChange: (node: HTMLSpanElement | null) => void; | |
| valueNodeHasChildren: boolean; | |
| onValueNodeHasChildrenChange: (hasChildren: boolean) => void; | |
| triggerNode: HTMLButtonElement | null; | |
| onTriggerNodeChange: (node: HTMLButtonElement | null) => void; | |
| shouldFilter?: boolean; | |
| vimBindings?: boolean; | |
| label?: string; | |
| loop?: boolean; | |
| filter?: (query: string, item: string, keywords?: string[]) => number; | |
| disablePointerSelection?: boolean; | |
| }; | |
| const ComboboxContext = React.createContext<ComboboxContextValue | null>(null); | |
| function useComboboxContext() { | |
| const context = React.useContext(ComboboxContext); | |
| if (!context) { | |
| throw new Error('useComboboxContext must be used within a <Combobox />.'); | |
| } | |
| return context; | |
| } | |
| function shouldShowPlaceholder(value?: string): boolean { | |
| return value === '' || value === undefined; | |
| } | |
| function Combobox({ | |
| value: valueProp, | |
| defaultValue, | |
| onValueChange: onValueChangeProp, | |
| open: openProp, | |
| defaultOpen, | |
| onOpenChange: onOpenChangeProp, | |
| shouldFilter, | |
| vimBindings, | |
| label, | |
| loop, | |
| filter, | |
| disablePointerSelection, | |
| name, | |
| children, | |
| ...props | |
| }: React.ComponentProps<typeof Popover> & | |
| React.ComponentPropsWithoutRef<typeof CommandPrimitive> & { | |
| name?: string; | |
| }) { | |
| const [valueNode, onValueNodeChange] = React.useState<HTMLSpanElement | null>(null); | |
| const [valueNodeHasChildren, onValueNodeHasChildrenChange] = React.useState(false); | |
| const [triggerNode, onTriggerNodeChange] = React.useState<HTMLButtonElement | null>(null); | |
| const [value, onValueChange] = useControlledState(defaultValue ?? '', valueProp, onValueChangeProp); | |
| const [open, onOpenChange] = useControlledState(defaultOpen ?? false, openProp, onOpenChangeProp); | |
| return ( | |
| <Popover open={open} onOpenChange={onOpenChange} data-slot="combobox" {...props}> | |
| <ComboboxContext | |
| value={{ | |
| open, | |
| onOpenChange, | |
| value, | |
| onValueChange, | |
| valueNode, | |
| onValueNodeChange, | |
| valueNodeHasChildren, | |
| onValueNodeHasChildrenChange, | |
| triggerNode, | |
| onTriggerNodeChange, | |
| shouldFilter, | |
| vimBindings, | |
| label, | |
| loop, | |
| filter, | |
| disablePointerSelection, | |
| }} | |
| > | |
| {children} | |
| {name && <input type="hidden" name={name} value={value} />} | |
| </ComboboxContext> | |
| </Popover> | |
| ); | |
| } | |
| function ComboboxGroup({ className, ...props }: React.ComponentProps<typeof CommandGroup>) { | |
| return ( | |
| <CommandGroup | |
| data-slot="combobox-group" | |
| className={cn('*:[[role=group]]:grid *:[[role=group]]:gap-1.5', className)} | |
| {...props} | |
| /> | |
| ); | |
| } | |
| function ComboboxEmpty({ className, ...props }: React.ComponentProps<typeof CommandEmpty>) { | |
| return <CommandEmpty data-slot="combobox-empty" className={cn('p-2', className)} {...props} />; | |
| } | |
| function ComboboxInput({ | |
| className, | |
| value: valueProp, | |
| onValueChange: onValueChangeProp, | |
| ref, | |
| ...props | |
| }: React.ComponentProps<typeof CommandInput>) { | |
| const { open } = useComboboxContext(); | |
| const [value, onValueChange] = useControlledState('', valueProp, onValueChangeProp); | |
| React.useEffect(() => { | |
| if (!open) { | |
| onValueChange(''); | |
| } | |
| }, [open, onValueChange]); | |
| return ( | |
| <div className="flex items-center gap-2 rounded-xl bg-muted px-3" cmdk-input-wrapper=""> | |
| <SearchIcon className="size-4 shrink-0 opacity-60 sm:size-4.5" /> | |
| <CommandPrimitive.Input | |
| ref={ref} | |
| value={value} | |
| onValueChange={onValueChange} | |
| className={cn( | |
| 'flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-base', | |
| className, | |
| )} | |
| {...props} | |
| /> | |
| </div> | |
| ); | |
| } | |
| function ComboboxList({ ...props }: React.ComponentProps<typeof CommandList>) { | |
| return <CommandList data-slot="combobox-list" {...props} />; | |
| } | |
| function ComboboxValue({ | |
| children, | |
| placeholder, | |
| ...props | |
| }: React.ComponentProps<'span'> & { placeholder?: React.ReactNode }) { | |
| const { value, onValueNodeChange, onValueNodeHasChildrenChange } = useComboboxContext(); | |
| const hasChildren = children !== undefined; | |
| React.useLayoutEffect(() => { | |
| onValueNodeHasChildrenChange(hasChildren); | |
| }, [onValueNodeHasChildrenChange, hasChildren]); | |
| return ( | |
| <span ref={onValueNodeChange} data-slot="combobox-value" {...props}> | |
| {shouldShowPlaceholder(value) ? <>{placeholder}</> : children} | |
| </span> | |
| ); | |
| } | |
| function ComboboxTrigger({ className, children, ...props }: React.ComponentProps<typeof PopoverTrigger>) { | |
| const { open, value, onTriggerNodeChange } = useComboboxContext(); | |
| return ( | |
| <PopoverTrigger | |
| data-slot="combobox-trigger" | |
| role="combobox" | |
| aria-expanded={open} | |
| ref={onTriggerNodeChange} | |
| data-placeholder={shouldShowPlaceholder(value) ? '' : undefined} | |
| className={cn( | |
| 'flex h-10 w-full items-center justify-between rounded-xl border', | |
| 'border-input bg-muted px-3.5 py-2.5 text-sm shadow-xs transition-[color,box-shadow]', | |
| 'outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50', | |
| 'disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive', | |
| 'aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground', | |
| '*:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center', | |
| '*:data-[slot=select-value]:gap-2 sm:h-12 sm:text-base', | |
| 'dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none', | |
| "[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3", | |
| "[&_svg:not([class*='text-'])]:text-muted-foreground [&>span]:line-clamp-1", | |
| className, | |
| )} | |
| {...props} | |
| > | |
| {children} | |
| <UnfoldMoreIcon data-slot="combobox-icon" className="size-4" /> | |
| </PopoverTrigger> | |
| ); | |
| } | |
| function ComboboxContent({ className, children, ...props }: React.ComponentProps<typeof PopoverContent>) { | |
| const { open, triggerNode, shouldFilter, vimBindings, label, loop, filter, disablePointerSelection } = | |
| useComboboxContext(); | |
| const [fragment, setFragment] = React.useState<DocumentFragment>(); | |
| React.useLayoutEffect(() => { | |
| setFragment(new DocumentFragment()); | |
| }, [setFragment]); | |
| if (!open) { | |
| const frag = fragment as Element | undefined; | |
| return frag ? ReactDOM.createPortal(<Command>{children}</Command>, frag) : null; | |
| } | |
| return ( | |
| <PopoverContent | |
| data-slot="combobox-content" | |
| className={cn( | |
| 'w-(--combobox-trigger-width) overflow-hidden p-2 *:[[cmdk-root]]:grid *:[[cmdk-root]]:gap-2', | |
| className, | |
| )} | |
| onWheel={(e) => e.stopPropagation()} | |
| style={{ | |
| '--combobox-trigger-width': `${triggerNode?.getBoundingClientRect().width ?? 0}px`, | |
| }} | |
| {...props} | |
| > | |
| <Command | |
| shouldFilter={shouldFilter} | |
| disablePointerSelection={disablePointerSelection} | |
| vimBindings={vimBindings} | |
| label={label} | |
| loop={loop} | |
| filter={filter} | |
| > | |
| {children} | |
| </Command> | |
| </PopoverContent> | |
| ); | |
| } | |
| function ComboboxItem({ | |
| value, | |
| ref, | |
| textValue: textValueProp, | |
| className, | |
| children, | |
| ...props | |
| }: Omit<React.ComponentProps<typeof CommandItem>, 'value'> & { value: string; textValue?: string }) { | |
| if (value === '') { | |
| throw new Error( | |
| 'A <ComboboxItem /> must have a value prop that is not an empty string. This is because the Combobox value can be set to an empty string to clear the selection and show the placeholder.', | |
| ); | |
| } | |
| const innerRef = React.useRef<HTMLDivElement>(null); | |
| const { | |
| open, | |
| value: contextValue, | |
| valueNode, | |
| valueNodeHasChildren, | |
| onOpenChange, | |
| onValueChange, | |
| } = useComboboxContext(); | |
| const [textValue, setTextValue] = React.useState(textValueProp ?? ''); | |
| const isSelected = React.useMemo(() => contextValue === value, [contextValue, value]); | |
| const handleOnSelect = React.useCallback( | |
| (value: string) => { | |
| onValueChange(value); | |
| onOpenChange(false); | |
| }, | |
| [onValueChange, onOpenChange], | |
| ); | |
| const handleRef = React.useCallback( | |
| (node: HTMLDivElement | null) => { | |
| innerRef.current = node; | |
| if (ref) { | |
| if (typeof ref === 'function') { | |
| ref(node); | |
| } else { | |
| ref.current = node; | |
| } | |
| } | |
| }, | |
| [innerRef, ref], | |
| ); | |
| React.useLayoutEffect(() => { | |
| if (open && isSelected) { | |
| setTimeout(() => innerRef.current?.scrollIntoView({ block: 'nearest' }), 0); | |
| } | |
| }, [isSelected, open, innerRef]); | |
| React.useLayoutEffect(() => { | |
| if (!textValueProp && !textValue) { | |
| setTextValue(innerRef.current?.textContent ?? ''); | |
| } | |
| }, [innerRef, setTextValue, textValue, textValueProp]); | |
| return ( | |
| <> | |
| <CommandItem | |
| value={value} | |
| ref={handleRef} | |
| keywords={[textValue]} | |
| data-slot="combobox-item" | |
| onSelect={handleOnSelect} | |
| key={`${value}-${textValue}`} | |
| data-state={isSelected ? 'selected' : 'unselected'} | |
| className={cn( | |
| 'relative flex h-9 w-full cursor-pointer items-center gap-2', | |
| 'rounded-xl p-1.5 px-2.5 text-sm outline-hidden select-none', | |
| 'focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50', | |
| 'font-normal sm:h-10 sm:py-2 sm:text-base', | |
| 'data-[state=selected]:bg-primary/10 data-[state=selected]:font-medium data-[state=selected]:text-primary', | |
| '[&_svg]:pointer-events-none [&_svg]:shrink-0', | |
| "[&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground", | |
| '*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2', | |
| className, | |
| )} | |
| {...props} | |
| > | |
| {children} | |
| <span className="absolute right-2 flex size-3.5 items-center justify-center opacity-0 in-data-[state=selected]:text-primary in-data-[state=selected]:opacity-100"> | |
| <CheckIcon className="size-3 text-current" /> | |
| </span> | |
| </CommandItem> | |
| {isSelected && valueNode && !valueNodeHasChildren ? ReactDOM.createPortal(children, valueNode) : null} | |
| </> | |
| ); | |
| } | |
| export { | |
| Combobox, | |
| ComboboxContent, | |
| ComboboxEmpty, | |
| ComboboxGroup, | |
| ComboboxInput, | |
| ComboboxItem, | |
| ComboboxList, | |
| ComboboxTrigger, | |
| ComboboxValue, | |
| useComboboxContext, | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useCallback, useLayoutEffect, useRef, useState } from 'react'; | |
| import { flushSync } from 'react-dom'; | |
| export function useControlledState<T>( | |
| initialValue: T, | |
| controlledValue?: T, | |
| onChange?: (value: T) => void, | |
| ): [T, (value: T | ((prev: T) => T)) => void] { | |
| const [internalValue, setInternalValue] = useState(initialValue); | |
| const isControlled = controlledValue !== undefined; | |
| const stateRef = useRef<T>(initialValue); | |
| const controlledRef = useRef<T | undefined>(controlledValue); | |
| useLayoutEffect(() => { | |
| stateRef.current = internalValue; | |
| }, [internalValue]); | |
| useLayoutEffect(() => { | |
| controlledRef.current = controlledValue; | |
| }, [controlledValue]); | |
| return [ | |
| isControlled ? controlledValue : internalValue, | |
| useCallback( | |
| (next: T | ((prev: T) => T)) => { | |
| const prev = isControlled ? controlledRef.current! : stateRef.current; | |
| const resolved = typeof next === 'function' ? (next as (p: T) => T)(prev) : next; | |
| flushSync(() => (isControlled ? onChange?.(resolved) : setInternalValue(resolved))); | |
| if (!isControlled) { | |
| onChange?.(resolved); | |
| } | |
| }, | |
| [isControlled, onChange, setInternalValue], | |
| ), | |
| ] as const; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment