Skip to content

Instantly share code, notes, and snippets.

@devhammed
Last active February 5, 2026 21:17
Show Gist options
  • Select an option

  • Save devhammed/ae071cc7b0da71569ed638e80b6c8ab4 to your computer and use it in GitHub Desktop.

Select an option

Save devhammed/ae071cc7b0da71569ed638e80b6c8ab4 to your computer and use it in GitHub Desktop.
ShadCN UI Combobox (based on Command and Popover components).
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useControlledState } from '@/hooks/use-controlled-state'
import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'
import { cn } from '@/lib/utils'
import { Command as CommandPrimitive } from 'cmdk'
import { CheckIcon, ChevronsUpDownIcon, SearchIcon } from 'lucide-react'
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
shouldFilter?: boolean
vimBindings?: boolean
label?: string
loop?: boolean
disabled?: boolean
required?: 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,
disabled,
required,
filter,
disablePointerSelection,
name,
children,
...props
}: React.ComponentProps<typeof Popover> &
React.ComponentPropsWithoutRef<typeof Command> & {
name?: string
disabled?: boolean
required?: boolean
}) {
const [valueNode, onValueNodeChange] = React.useState<HTMLSpanElement | null>(null)
const [valueNodeHasChildren, onValueNodeHasChildrenChange] = React.useState(false)
const [value, onValueChange] = useControlledState(
defaultValue ?? '',
valueProp,
onValueChangeProp
)
const [open, onOpenChange] = useControlledState(defaultOpen ?? false, openProp, onOpenChangeProp)
return (
<ComboboxContext
value={{
open,
onOpenChange,
value,
onValueChange,
valueNode,
onValueNodeChange,
valueNodeHasChildren,
onValueNodeHasChildrenChange,
shouldFilter,
vimBindings,
label,
loop,
disabled,
required,
filter,
disablePointerSelection,
}}
>
<Popover open={open} onOpenChange={onOpenChange} data-slot="combobox" {...props}>
{children}
{name && <input type="hidden" name={name} required={required} value={value} />}
</Popover>
</ComboboxContext>
)
}
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 CommandPrimitive.Input>) {
const { open } = useComboboxContext()
const [value, onValueChange] = useControlledState('', valueProp, onValueChangeProp)
React.useEffect(() => {
if (!open && value !== '') {
onValueChange('')
}
}, [open, value, 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
useIsomorphicLayoutEffect(() => {
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, disabled } = useComboboxContext()
return (
<PopoverTrigger
data-slot="combobox-trigger"
role="combobox"
aria-expanded={open}
disabled={disabled}
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}
<ChevronsUpDownIcon data-slot="combobox-icon" className="size-4" />
</PopoverTrigger>
)
}
function ComboboxContent({
className,
children,
...props
}: React.ComponentProps<typeof PopoverContent>) {
const { open, shouldFilter, vimBindings, label, loop, filter, disablePointerSelection } =
useComboboxContext()
const [fragment, setFragment] = React.useState<DocumentFragment>()
useIsomorphicLayoutEffect(() => {
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-(--radix-popper-anchor-width) overflow-hidden p-2 *:[[cmdk-root]]:grid *:[[cmdk-root]]:gap-2',
className
)}
onWheel={(e) => e.stopPropagation()}
{...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]
)
useIsomorphicLayoutEffect(() => {
if (open && isSelected) {
setTimeout(() => innerRef.current?.scrollIntoView({ block: 'nearest' }), 0)
}
}, [isSelected, open, innerRef])
useIsomorphicLayoutEffect(() => {
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,
type ComboboxContextValue,
}
import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect';
import { useCallback, 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);
useIsomorphicLayoutEffect(() => {
stateRef.current = internalValue;
}, [internalValue]);
useIsomorphicLayoutEffect(() => {
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;
queueMicrotask(() => {
if (!isControlled) {
// Ensure the internal state is up to date with the value before calling
// onChange. This allows you to submit forms as part of the `onChange`
// and gives enough time to update the form field value(s).
flushSync(() => setInternalValue(resolved));
}
onChange?.(resolved);
});
},
[isControlled, onChange, setInternalValue],
),
] as const;
}
import * as React from 'react';
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment