Shadcn provides a fantastic set of beautiful UI components right out of the box. One of the most commonly used components is a selector. However, the component from shadcn (which is based on Radix UI) lacks certain features, such as search functionality and the ability to clear selected options.
In this guide, I'll be implementing a custom select dropdown component that supports searching and clearing options.
Select Dropdown
Let's start with a list of options:
const options = [
{ value: "apple": label: "Apple" },
{ value: "banana": label: "Banana" },
{ value: "avocado": label: "Avocado" },
// ...
];
First, I will create a basic dropdown using <Command>
and <Popover>
that:
- Displays a list of options
- Shows a checkmark for the selected option
- Includes a close button
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export type SelectOption = {
value: string;
label: string;
};
export const InputSelect: React.FC<{
options: SelectOption[];
value?: string;
onValueChange?: (v: string) => void;
className?: string;
style?: React.CSSProperties;
children: React.ReactNode;
}> = ({
options,
value = "",
onValueChange,
className,
children,
}) => {
const [selectedValue, setSelectedValue] = React.useState<string>(value);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const onOptionSelect = (option: string) => {
setSelectedValue(option);
onValueChange?.(option);
setIsPopoverOpen(false);
};
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
<PopoverContent className={cn("w-auto p-0", className)} align="start">
<Command>
<CommandList className="max-h-[unset] overflow-y-hidden">
<CommandGroup className="max-h-[20rem] min-h-[10rem] overflow-y-auto">
{options.map((option) => {
const isSelected = selectedValue === option.value;
return (
<CommandItem
key={option.value}
onSelect={() => onOptionSelect(option.value)}
className="cursor-pointer"
>
<div
className={cn(
"mr-1 flex h-4 w-4 items-center justify-center",
isSelected ? "text-primary" : "invisible"
)}
>
<CheckIcon className="w-4 h-4" />
</div>
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
className="justify-center flex-1 max-w-full cursor-pointer"
>
Close
</CommandItem>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
InputSelect.displayName = "InputSelect";
Add Search Functionality
To enhance usability, I'll integrate <CommandInput>
for built-in search capabilities and <CommandEmpty>
to display a message when no results are found.
export const InputSelect = () => {
// ...
return (
<Popover {...}>
<PopoverTrigger {...} />
<PopoverContent {...}>
<Command>
<CommandInput placeholder="Search..." />
<CommandList {...}>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup {...}>
// ...
</CommandGroup>
<CommandSeparator />
<CommandGroup>
// ...
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
Adding a Clear Option
I also want to provide a button to clear the selected value when one is chosen.
import { Separator } from "@/components/ui/separator";
export const InputSelect: React.FC<{ ... }> = ({ ... }) => {
// ...
const onClearAllOptions = () => {
setSelectedValue("");
onValueChange?.("");
setIsPopoverOpen(false);
};
return (
<Popover {...}>
<PopoverTrigger {...} />
<PopoverContent {...}>
<Command>
<CommandInput {...} />
<CommandList {...}>
<CommandEmpty {...} />
<CommandGroup {...}>
// ...
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
{selectedValue && (
<>
<CommandItem
onSelect={onClearAllOptions}
className="justify-center flex-1 cursor-pointer"
>
Clear
</CommandItem>
<Separator
orientation="vertical"
className="flex h-full mx-2 min-h-6"
/>
</>
)}
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
className="justify-center flex-1 max-w-full cursor-pointer"
>
Close
</CommandItem>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
So far so good, now I have a dropdown looking like so:
Add Dropdown Trigger
Now, for the last step, I can add a trigger to toggle open/close on the dropdown. I could just use a <Button>
for that.
import { Separator } from "@/components/ui/separator";
export const InputSelect: React.FC<{ ... }> = ({ ... }) => {
// ...
const onClearAllOptions = () => {
setSelectedValue("");
onValueChange?.("");
setIsPopoverOpen(false);
};
return (
<Popover {...}>
<PopoverTrigger asChild>
<Button
onClick={() => setIsPopoverOpen((prev) => !prev)}
variant="outline"
type="button"
className="flex h-11 w-full items-center justify-between p-1 [&_svg]:pointer-events-auto"
>
{selectedValue ? (
<div className="flex items-center justify-between w-full">
<div className="flex items-center justify-between w-full">
<div className="flex items-center px-3 text-foreground">
{options.find((v) => v.value === selectedValue)?.label}
</div>
<div className="flex items-center justify-between">
{selectedValue && (
<>
<X
className="mx-1 h-4 cursor-pointer text-muted-foreground"
onClick={(e) => {
e.stopPropagation();
onClearAllOptions();
}}
/>
<Separator orientation="vertical" className="flex h-full min-h-6" />
</>
)}
<ChevronDown className="h-4 mx-1 cursor-pointer text-muted-foreground" />
</div>
</div>
<div className="flex items-center justify-between">
{selectedValue && (
<>
<X
className={cn(
"mx-1 h-4 cursor-pointer text-muted-foreground",
)}
onClick={(e) => {
e.stopPropagation();
onClearAllOptions();
}}
/>
<Separator orientation="vertical" className="flex h-full min-h-6" />
</>
)}
<ChevronDown className="h-4 mx-1 cursor-pointer text-muted-foreground" />
</div>
</div>
) : (
<div className="flex items-center justify-between w-full mx-auto">
<span className="px-3 text-sm text-muted-foreground">{placeholder}</span>
<ChevronDown className="h-4 mx-1 cursor-pointer text-muted-foreground" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent {...}>
// ...
</PopoverContent>
</Popover>
);
};
With these enhancements, I've built a fully functional <InputSelect>
component that supports searching and clearing selected options. I can now just use this component anywhere in my app like so:
import * as React from "react";
import { InputSelect } from "path/to/input-select";
export const MyAwesomeComponent = () => {
const [value, setValue] = React.useState("apple");
const options = [...];
return (
<div>
<InputSelect
options={options}
value={value}
onValueChange{(v) => setValue(v)}
/>
</div>
);
}
Extend customizability (optional)
In React, I can technically pass any children
prop to a component so long as it renders as a functional component.
For example:
// By default React accepts children prop as a ReactNode type
export const CompA = ({ children: React.ReactNode }) => (
<div>{children}</div>
);
export const CompB = () => <CompA>hello</CompA>;
// We can also modify to accept a function!
export const CompA = ({ children: (v: { value: string; }) => React.ReactNode }) => (
<div>{children({ value: "foo" })}</div>
);
export const CompB = () => <CompA>{(prop) => <div>{prop.value}</div>}</CompA>;
So, for the <InputSelect>
, I can extract the InputSelectTrigger out as a separate component to provide additional customizability
export interface InputSelectProvided {
options: SelectOption[];
onValueChange?: (v: string) => void;
selectedValue: string;
setSelectedValue: React.Dispatch<React.SetStateAction<string>>;
isPopoverOpen: boolean;
setIsPopoverOpen: React.Dispatch<React.SetStateAction<boolean>>;
onOptionSelect: (v: string) => void;
onClearAllOptions: () => void;
}
export const InputSelect: React.FC<{
options: SelectOption[];
value?: string;
onValueChange?: (v: string) => void;
className?: string;
style?: React.CSSProperties;
children: (v: InputSelectProvided) => React.ReactNode;
}> = ({
options,
value = "",
onValueChange,
className,
children,
...restProps
}) => {
// ...
return (
<Popover {...}>
<PopoverTrigger asChild>
{children({
options,
onValueChange,
selectedValue,
setSelectedValue,
isPopoverOpen,
setIsPopoverOpen,
onOptionSelect,
onClearAllOptions,
})}
</PopoverTrigger>
// ...
</Popover>
);
};
InputSelect.displayName = "InputSelect";
export const InputSelectTrigger = React.forwardRef<
HTMLButtonElement,
InputSelectProvided & {
placeholder?: string;
className?: string;
children?: (v: SelectOption) => React.ReactNode;
style?: React.CSSProperties;
}
>(
(
{
options,
// onValueChange,
selectedValue,
// setSelectedValue,
// isPopoverOpen,
setIsPopoverOpen,
// onOptionSelect,
onClearAllOptions,
placeholder = "Select...",
className,
style,
...restProps,
},
ref,
) => {
return (
<Button
ref={ref}
onClick={() => setIsPopoverOpen((prev) => !prev)}
variant="outline"
type="button"
className={cn(
"flex h-11 w-full items-center justify-between p-1 [&_svg]:pointer-events-auto",
className,
)}
style={style}
{...restProps}
>
{selectedValue ? (
<div className="flex items-center justify-between w-full">
<div className="flex items-center px-3 text-foreground">
{option?.label}
</div>
<div className="flex items-center justify-between">
{selectedValue && clearable && (
<>
<X
className={cn(
"mx-1 h-4 cursor-pointer text-muted-foreground",
)}
onClick={(e) => {
e.stopPropagation();
onClearAllOptions();
}}
/>
<Separator orientation="vertical" className="flex h-full min-h-6" />
</>
)}
<ChevronDown className="h-4 mx-1 cursor-pointer text-muted-foreground" />
</div>
</div>
) : (
<div className="flex items-center justify-between w-full mx-auto">
<span className="px-3 text-sm text-muted-foreground">{placeholder}</span>
<ChevronDown className="h-4 mx-1 cursor-pointer text-muted-foreground" />
</div>
)}
</Button>
);
},
);
InputSelectTrigger.displayName = "InputSelectTrigger";
Now, I can also pass additional props to the <InputSelectTrigger>
when I need it.
import * as React from "react";
import { InputSelect, InputSelectTrigger } from "path/to/input-select";
export const MyAwesomeComponent = () => {
const [value, setValue] = React.useState("apple");
const options = [...];
return (
<div>
<InputSelect
options={options}
value={value}
onValueChange{(v) => setValue(v)}
>
{(provided) => <InputSelectTrigger {...provided} className="additional-styling" />}
</InputSelect>
</div>
);
}
Links
Demo: https://shadcn-components-extend.vercel.app/?component=input-select
Code: https://github.com/Vic-ong/shadcn-components-extend/blob/main/src/components/extend/input-select.tsx
Top comments (3)
Very Informative, Thanks for sharing.
I found one problem on demo page: I cant able to open the drop-down with keys even the focus on it.
I'm using the
<Popover>
component from shadcn so when the component is focused, you can open it by pressing "Enter"Ref: ui.shadcn.com/docs/components/popover