Ensure you have a Next.js project set up. If not, create one using the following command:
npx create-next-app my-app --typescript
cd my-app
To use shadcn components, you need to install them. You can install the specific components required for the multi-select component as follows:
npx shadcn-ui@latest init
npx shadcn-ui@latest add command popover button separator badge
Create a new file named multi-select.tsx
in your components
directory and add the following code:
// src/components/multi-select.tsx
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
CheckIcon,
XCircle,
ChevronDown,
XIcon,
WandSparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
const multiSelectVariants = cva(
"m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300",
{
variants: {
variant: {
default:
"border-foreground/10 drop-shadow-md text-foreground bg-card hover:bg-card/80",
secondary:
"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
inverted: "inverted",
},
},
defaultVariants: {
variant: "default",
},
}
);
interface MultiSelectProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof multiSelectVariants> {
options: {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }>;
}[];
onValueChange: (value: string[]) => void;
defaultValue?: string[];
placeholder?: string;
animation?: number;
asChild?: boolean;
className?: string;
}
export const MultiSelect = React.forwardRef<
HTMLButtonElement,
MultiSelectProps
>(
(
{
options,
onValueChange,
variant,
defaultValue = [],
placeholder = "Select options",
animation = 0,
asChild = false,
className,
...props
},
ref
) => {
const [selectedValues, setSelectedValues] =
React.useState<string[]>(defaultValue);
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const [isAnimating, setIsAnimating] = React.useState(animation > 0);
React.useEffect(() => {
if (defaultValue.length > 0) {
setSelectedValues(defaultValue);
}
}, [defaultValue]);
const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === "Enter") {
setIsPopoverOpen(true);
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedValues = [...selectedValues];
newSelectedValues.pop();
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
}
};
const toggleOption = (value: string) => {
const newSelectedValues = selectedValues.includes(value)
? selectedValues.filter((v) => v !== value)
: [...selectedValues, value];
setSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
};
const handleClear = () => {
setSelectedValues([]);
onValueChange([]);
};
const handleTogglePopover = () => {
setIsPopoverOpen((prev) => !prev);
};
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
{...props}
onClick={handleTogglePopover}
className={cn(
"flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-card",
className
)}
>
{selectedValues.length > 0 ? (
<div className="flex justify-between items-center w-full">
<div className="flex flex-wrap items-center">
{selectedValues.map((value) => {
const option = options.find((o) => o.value === value);
const IconComponent = option?.icon;
return (
<Badge
key={value}
className={cn(
isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant, className })
)}
style={{
animationDuration: `${animation}s`,
}}
>
{IconComponent && (
<IconComponent className="h-4 w-4 mr-2" />
)}
{option?.label}
<XCircle
className="ml-2 h-4 w-4 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
toggleOption(value);
}}
/>
</Badge>
);
})}
</div>
<div className="flex items-center justify-between">
<XIcon
className="h-4 mx-2 cursor-pointer text-muted-foreground"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
/>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
</div>
</div>
) : (
<div className="flex items-center justify-between w-full mx-auto">
<span className="text-sm text-muted-foreground mx-3">
{placeholder}
</span>
<ChevronDown className="h-4 cursor-pointer text-muted-foreground mx-2" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[200px] p-0 drop-shadow-sm"
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command>
<CommandInput
placeholder="Search..."
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => toggleOption(option.value)}
style={{
pointerEvents: "auto",
opacity: 1,
}}
className="cursor-pointer"
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
{selectedValues.length > 0 && (
<>
<CommandItem
onSelect={handleClear}
style={{
pointerEvents: "auto",
opacity: 1,
}}
className="flex-1 justify-center cursor-pointer"
>
Clear
</CommandItem>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
</>
)}
<CommandSeparator />
<CommandItem
onSelect={() => setIsPopoverOpen(false)}
style={{
pointerEvents: "auto",
opacity: 1,
}}
className="flex-1 justify-center cursor-pointer"
>
Close
</CommandItem>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
{animation > 0 && selectedValues.length > 0 && (
<WandSparkles
className={cn(
"cursor-pointer my-2 text-foreground bg-background w-3 h-3",
isAnimating ? "" : "text-muted-foreground"
)}
onClick={() => setIsAnimating(!isAnimating)}
/>
)}
</Popover>
);
}
);
MultiSelect.displayName = "MultiSelect";
Update your page or root component, for example, page.tsx
in your app
directory with the following code:
// src/app/page.tsx
"use client"; // mandatory
import React, { useState } from "react";
import { MultiSelect } from "@/components/multi-select";
import { Cat, Dog, Fish, Rabbit, Turtle } from "lucide-react";
const frameworksList = [
{
value: "react",
label: "React",
icon: Turtle,
},
{
value: "angular",
label: "Angular",
icon: Cat,
},
{
value: "vue",
label: "Vue",
icon: Dog,
},
{
value: "svelte",
label: "Svelte",
icon: Rabbit,
},
{
value: "ember",
label: "Ember",
icon: Fish,
},
];
function Home() {
const [selectedFrameworks, setSelectedFrameworks] = useState<string[]>([
"react", //optional
"angular", //optional
]);
return (
<div className="p-4 max-w-xl">
<h1 className="text-2xl font-bold mb-4">Multi-Select Component</h1>
<MultiSelect
options={frameworksList}
onValueChange={setSelectedFrameworks}
defaultValue={selectedFrameworks} // optional
placeholder="Select frameworks" // optional
animation={2} // optional
variant="inverted" // optional
/>
<div className="mt-4">
<h2 className="text-xl font-semibold">Selected Frameworks:</h2>
<ul className="list-disc list-inside">
{selectedFrameworks.map((framework) => (
<li key={framework}>{framework}</li>
))}
</ul>
</div>
</div>
);
}
export default Home;
Run your Next.js project to see the multi-select component in action.
npm run dev
Visit http://localhost:3000
to see the multi-select component integrated into your Next.js application.
The MultiSelect
component comes with several props that allow you to customize its behavior and appearance. Here's a detailed explanation of each prop:
-
options
: An array of objects representing the selectable options. Each object should havelabel
andvalue
properties, and optionally anicon
component.options={[ { label: "React", value: "react", icon: Turtle }, { label: "Angular", value: "angular", icon: Cat }, // more options... ]}
-
defaultValue
: An array of strings representing the default selected values.defaultValue={["react", "angular"]}
-
onValueChange
: A callback function that gets called whenever the selected values change. It receives the updated array of selected values as an argument.onValueChange={(selectedValues) => { console.log(selectedValues); }}
-
placeholder
: A string to display when no options are selected.placeholder="Select frameworks"
-
variant
: A string to apply predefined styles to the component. Possible values includedefault
,secondary
,destructive
, andinverted
.variant="inverted"
-
className
: A string of additional CSS classes to apply to the component.className="my-custom-class"
-
animation
: A number representing the duration of the animation in seconds. If greater than 0, the selected badges will animate.animation={0.5}
You can add custom icons to each option in the multi-select component. Here’s how you can manage icons:
-
Import or Define Icon Components: Ensure you have the icon components available. You can import them from a library
lucide-react
or define your custom icons. -
Add Icons to Options: Include the
icon
property in each option object, pointing to the respective icon component. -
Display Icons in Multi-Select: The
MultiSelect
component already handles the display of icons. Ensure each option object passed to theoptions
prop includes anicon
property.
You have successfully integrated a multi-select component in your Next.js project using shadcn components. You can manage the component's behavior and appearance using various props to customize it according to your needs. Additionally, you can add custom icons to each option to enhance the visual appeal and usability of the multi-select component.