Components
Select
Select
Ultra-modern select component with single/multi-select, searchable options, custom animations, and 3D effects for enhanced user experience.
Select Types
Simple Select
Select a fruit
Selected: None
Multi-Select
Select fruits
Selected: None
Searchable Select
Type to search countries...
Selected: None
With Error State
Please select an option
This field is required
Select Variants
Default Variant
Default style
Filled Variant
Filled style
Outlined Variant
Outlined style
Select Sizes
Small size
Medium size (default)
Large size
Advanced Features
Disabled State
This select is disabled
Large Dataset with Search
Search from many options...
Interactive Form Demo
User Registration Form
Select your country
Choose your favorite fruits
Select size
Keyboard Navigation
Try these keyboard shortcuts:
- Enter/Space: Open dropdown
- Arrow Up/Down: Navigate options
- Enter: Select highlighted option
- Escape: Close dropdown
- Click outside: Close dropdown
Use keyboard to navigate
Installation
1
Install the packages
npm i motion clsx tailwind-merge lucide-react2
Add util file
lib/util.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
3
Copy and paste the following code into your project
select.tsx
"use client";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useRef, useEffect, useId } from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, Check, X, Search } from "lucide-react";
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface SelectProps {
options: Option[];
value?: string | string[];
onChange?: (value: string | string[]) => void;
multiple?: boolean;
searchable?: boolean;
placeholder?: string;
label?: string;
error?: string;
disabled?: boolean;
size?: "sm" | "md" | "lg";
variant?: "default" | "filled" | "outlined";
className?: string;
}
const Select: React.FC<SelectProps> = ({
options,
value,
onChange,
multiple = false,
searchable = false,
placeholder = "Select an option",
label,
error,
disabled = false,
size = "md",
variant = "default",
className,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const selectRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selectId = useId();
const selectedValues = Array.isArray(value) ? value : value ? [value] : [];
const hasError = !!error;
// Filter options based on search term
const filteredOptions = searchable
? options.filter(option =>
option.label.toLowerCase().includes(searchTerm.toLowerCase())
)
: options;
const baseClasses = "relative w-full transition-all duration-200";
const triggerClasses = {
default: "border border-gray-300 bg-white focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:focus:border-blue-400",
filled: "border-transparent bg-gray-100 focus:bg-white focus:border-blue-500 focus:ring-blue-500/20 dark:bg-gray-800 dark:focus:bg-gray-900",
outlined: "border-2 border-gray-300 bg-transparent focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-600",
};
const sizeClasses = {
sm: "px-3 py-2 text-sm",
md: "px-4 py-3 text-base",
lg: "px-4 py-4 text-lg",
};
const errorClasses = "border-red-500 focus:border-red-500 focus:ring-red-500/20";
// Get display text for selected values
const getDisplayText = () => {
if (selectedValues.length === 0) return placeholder;
if (multiple) {
if (selectedValues.length === 1) {
const option = options.find(opt => opt.value === selectedValues[0]);
return option?.label || selectedValues[0];
}
return `${selectedValues.length} selected`;
}
const option = options.find(opt => opt.value === selectedValues[0]);
return option?.label || selectedValues[0];
};
// Handle option selection
const handleOptionSelect = (optionValue: string) => {
if (multiple) {
const newValues = selectedValues.includes(optionValue)
? selectedValues.filter(v => v !== optionValue)
: [...selectedValues, optionValue];
onChange?.(newValues);
} else {
onChange?.(optionValue);
setIsOpen(false);
}
setSearchTerm("");
setHighlightedIndex(-1);
};
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsOpen(true);
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex(prev =>
prev < filteredOptions.length - 1 ? prev + 1 : 0
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex(prev =>
prev > 0 ? prev - 1 : filteredOptions.length - 1
);
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
handleOptionSelect(filteredOptions[highlightedIndex].value);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setHighlightedIndex(-1);
break;
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
setIsOpen(false);
setHighlightedIndex(-1);
setSearchTerm("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Focus management
useEffect(() => {
if (isOpen && searchable && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen, searchable]);
return (
<div className={cn(baseClasses, className)}>
{/* Label */}
{label && (
<motion.label
htmlFor={selectId}
className={cn(
"block text-sm font-medium mb-2 transition-colors duration-200",
hasError ? "text-red-600 dark:text-red-400" :
"text-gray-700 dark:text-gray-300"
)}
animate={{
scale: isOpen || selectedValues.length > 0 ? 1 : 1,
y: isOpen || selectedValues.length > 0 ? 0 : 0,
}}
transition={{ duration: 0.2 }}
>
{label}
</motion.label>
)}
{/* Select Trigger */}
<div ref={selectRef}>
<motion.div
className={cn(
"relative flex items-center justify-between cursor-pointer rounded-lg transition-all duration-200",
triggerClasses[variant],
sizeClasses[size],
hasError && errorClasses,
disabled && "opacity-60 cursor-not-allowed bg-gray-50 dark:bg-gray-900",
isOpen && "ring-2 ring-blue-500/20"
)}
onClick={() => !disabled && setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
tabIndex={disabled ? -1 : 0}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-labelledby={label ? selectId : undefined}
animate={{
scale: isOpen ? 1.01 : 1,
boxShadow: isOpen
? "0 4px 12px rgba(0, 0, 0, 0.1)"
: "0 1px 3px rgba(0, 0, 0, 0.1)",
}}
transition={{ duration: 0.2 }}
>
{/* Selected Value Display */}
<span className={cn(
"flex-1 truncate",
selectedValues.length === 0 && "text-gray-500 dark:text-gray-400"
)}>
{getDisplayText()}
</span>
{/* Multi-select tags */}
{multiple && selectedValues.length > 1 && (
<div className="flex flex-wrap gap-1 mr-2">
{selectedValues.slice(0, 2).map(val => {
const option = options.find(opt => opt.value === val);
return (
<motion.span
key={val}
className="inline-flex items-center px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-md dark:bg-blue-900 dark:text-blue-200"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
>
{option?.label || val}
<button
onClick={(e) => {
e.stopPropagation();
handleOptionSelect(val);
}}
className="ml-1 hover:bg-blue-200 rounded dark:hover:bg-blue-800"
>
<X className="w-3 h-3" />
</button>
</motion.span>
);
})}
{selectedValues.length > 2 && (
<span className="text-xs text-gray-500">
+{selectedValues.length - 2} more
</span>
)}
</div>
)}
{/* Dropdown Arrow */}
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="ml-2"
>
<ChevronDown className="w-4 h-4 text-gray-400" />
</motion.div>
</motion.div>
{/* Dropdown Menu */}
<AnimatePresence>
{isOpen && (
<motion.div
className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700 max-h-60 overflow-hidden"
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
{/* Search Input */}
{searchable && (
<div className="p-2 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
ref={inputRef}
type="text"
placeholder="Search options..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") {
handleKeyDown(e);
}
}}
/>
</div>
</div>
)}
{/* Options List */}
<div className="max-h-48 overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 text-center">
No options found
</div>
) : (
filteredOptions.map((option, index) => {
const isSelected = selectedValues.includes(option.value);
const isHighlighted = index === highlightedIndex;
return (
<motion.div
key={option.value}
className={cn(
"px-4 py-3 cursor-pointer transition-colors duration-150 flex items-center justify-between",
option.disabled && "opacity-50 cursor-not-allowed",
isSelected && "bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-200",
isHighlighted && !isSelected && "bg-gray-100 dark:bg-gray-700",
!option.disabled && !isSelected && "hover:bg-gray-50 dark:hover:bg-gray-700"
)}
onClick={() => !option.disabled && handleOptionSelect(option.value)}
animate={{
backgroundColor: isHighlighted && !isSelected
? "rgba(0, 0, 0, 0.05)"
: isSelected
? "rgba(59, 130, 246, 0.1)"
: "transparent",
}}
transition={{ duration: 0.15 }}
>
<span className="flex-1">{option.label}</span>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.2 }}
>
<Check className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</motion.div>
)}
</motion.div>
);
})
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Error Message */}
<AnimatePresence>
{hasError && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
>
<X className="w-4 h-4 flex-shrink-0" />
{error}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default Select;"use client";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useRef, useEffect, useId } from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, Check, X, Search } from "lucide-react";
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface SelectProps {
options: Option[];
value?: string | string[];
onChange?: (value: string | string[]) => void;
multiple?: boolean;
searchable?: boolean;
placeholder?: string;
label?: string;
error?: string;
disabled?: boolean;
size?: "sm" | "md" | "lg";
variant?: "default" | "filled" | "outlined";
className?: string;
}
const Select: React.FC<SelectProps> = ({
options,
value,
onChange,
multiple = false,
searchable = false,
placeholder = "Select an option",
label,
error,
disabled = false,
size = "md",
variant = "default",
className,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const selectRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selectId = useId();
const selectedValues = Array.isArray(value) ? value : value ? [value] : [];
const hasError = !!error;
// Filter options based on search term
const filteredOptions = searchable
? options.filter(option =>
option.label.toLowerCase().includes(searchTerm.toLowerCase())
)
: options;
const baseClasses = "relative w-full transition-all duration-200";
const triggerClasses = {
default: "border border-gray-300 bg-white focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:focus:border-blue-400",
filled: "border-transparent bg-gray-100 focus:bg-white focus:border-blue-500 focus:ring-blue-500/20 dark:bg-gray-800 dark:focus:bg-gray-900",
outlined: "border-2 border-gray-300 bg-transparent focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-600",
};
const sizeClasses = {
sm: "px-3 py-2 text-sm",
md: "px-4 py-3 text-base",
lg: "px-4 py-4 text-lg",
};
const errorClasses = "border-red-500 focus:border-red-500 focus:ring-red-500/20";
// Get display text for selected values
const getDisplayText = () => {
if (selectedValues.length === 0) return placeholder;
if (multiple) {
if (selectedValues.length === 1) {
const option = options.find(opt => opt.value === selectedValues[0]);
return option?.label || selectedValues[0];
}
return `${selectedValues.length} selected`;
}
const option = options.find(opt => opt.value === selectedValues[0]);
return option?.label || selectedValues[0];
};
// Handle option selection
const handleOptionSelect = (optionValue: string) => {
if (multiple) {
const newValues = selectedValues.includes(optionValue)
? selectedValues.filter(v => v !== optionValue)
: [...selectedValues, optionValue];
onChange?.(newValues);
} else {
onChange?.(optionValue);
setIsOpen(false);
}
setSearchTerm("");
setHighlightedIndex(-1);
};
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsOpen(true);
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex(prev =>
prev < filteredOptions.length - 1 ? prev + 1 : 0
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex(prev =>
prev > 0 ? prev - 1 : filteredOptions.length - 1
);
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
handleOptionSelect(filteredOptions[highlightedIndex].value);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setHighlightedIndex(-1);
break;
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
setIsOpen(false);
setHighlightedIndex(-1);
setSearchTerm("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Focus management
useEffect(() => {
if (isOpen && searchable && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen, searchable]);
return (
<div className={cn(baseClasses, className)}>
{/* Label */}
{label && (
<motion.label
htmlFor={selectId}
className={cn(
"block text-sm font-medium mb-2 transition-colors duration-200",
hasError ? "text-red-600 dark:text-red-400" :
"text-gray-700 dark:text-gray-300"
)}
animate={{
scale: isOpen || selectedValues.length > 0 ? 1 : 1,
y: isOpen || selectedValues.length > 0 ? 0 : 0,
}}
transition={{ duration: 0.2 }}
>
{label}
</motion.label>
)}
{/* Select Trigger */}
<div ref={selectRef}>
<motion.div
className={cn(
"relative flex items-center justify-between cursor-pointer rounded-lg transition-all duration-200",
triggerClasses[variant],
sizeClasses[size],
hasError && errorClasses,
disabled && "opacity-60 cursor-not-allowed bg-gray-50 dark:bg-gray-900",
isOpen && "ring-2 ring-blue-500/20"
)}
onClick={() => !disabled && setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
tabIndex={disabled ? -1 : 0}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-labelledby={label ? selectId : undefined}
animate={{
scale: isOpen ? 1.01 : 1,
boxShadow: isOpen
? "0 4px 12px rgba(0, 0, 0, 0.1)"
: "0 1px 3px rgba(0, 0, 0, 0.1)",
}}
transition={{ duration: 0.2 }}
>
{/* Selected Value Display */}
<span className={cn(
"flex-1 truncate",
selectedValues.length === 0 && "text-gray-500 dark:text-gray-400"
)}>
{getDisplayText()}
</span>
{/* Multi-select tags */}
{multiple && selectedValues.length > 1 && (
<div className="flex flex-wrap gap-1 mr-2">
{selectedValues.slice(0, 2).map(val => {
const option = options.find(opt => opt.value === val);
return (
<motion.span
key={val}
className="inline-flex items-center px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-md dark:bg-blue-900 dark:text-blue-200"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
>
{option?.label || val}
<button
onClick={(e) => {
e.stopPropagation();
handleOptionSelect(val);
}}
className="ml-1 hover:bg-blue-200 rounded dark:hover:bg-blue-800"
>
<X className="w-3 h-3" />
</button>
</motion.span>
);
})}
{selectedValues.length > 2 && (
<span className="text-xs text-gray-500">
+{selectedValues.length - 2} more
</span>
)}
</div>
)}
{/* Dropdown Arrow */}
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="ml-2"
>
<ChevronDown className="w-4 h-4 text-gray-400" />
</motion.div>
</motion.div>
{/* Dropdown Menu */}
<AnimatePresence>
{isOpen && (
<motion.div
className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700 max-h-60 overflow-hidden"
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
{/* Search Input */}
{searchable && (
<div className="p-2 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
ref={inputRef}
type="text"
placeholder="Search options..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") {
handleKeyDown(e);
}
}}
/>
</div>
</div>
)}
{/* Options List */}
<div className="max-h-48 overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 text-center">
No options found
</div>
) : (
filteredOptions.map((option, index) => {
const isSelected = selectedValues.includes(option.value);
const isHighlighted = index === highlightedIndex;
return (
<motion.div
key={option.value}
className={cn(
"px-4 py-3 cursor-pointer transition-colors duration-150 flex items-center justify-between",
option.disabled && "opacity-50 cursor-not-allowed",
isSelected && "bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-200",
isHighlighted && !isSelected && "bg-gray-100 dark:bg-gray-700",
!option.disabled && !isSelected && "hover:bg-gray-50 dark:hover:bg-gray-700"
)}
onClick={() => !option.disabled && handleOptionSelect(option.value)}
animate={{
backgroundColor: isHighlighted && !isSelected
? "rgba(0, 0, 0, 0.05)"
: isSelected
? "rgba(59, 130, 246, 0.1)"
: "transparent",
}}
transition={{ duration: 0.15 }}
>
<span className="flex-1">{option.label}</span>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.2 }}
>
<Check className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</motion.div>
)}
</motion.div>
);
})
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Error Message */}
<AnimatePresence>
{hasError && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
>
<X className="w-4 h-4 flex-shrink-0" />
{error}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default Select;4
Update the import paths to match your project setup
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | Array<{value: string, label: string, disabled?: boolean}> | [] | Array of options to display in the dropdown. |
| value | string | string[] | undefined | Selected value(s) for controlled component. |
| multiple | boolean | false | Enable multi-select mode. |
| searchable | boolean | false | Enable search functionality. |
| placeholder | string | 'Select an option' | Placeholder text when no option is selected. |
| label | string | undefined | Label text for the select. |
| error | string | undefined | Error message to display. |
| disabled | boolean | false | Disable the select component. |
| size | 'sm' | 'md' | 'lg' | 'md' | Size of the select component. |
| variant | 'default' | 'filled' | 'outlined' | 'default' | Visual style variant. |