Skip to content

Instantly share code, notes, and snippets.

@devhammed
Last active December 11, 2025 09:23
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, 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,
};
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