Components
Dropdown
Dropdown
Animated dropdown menu with smooth transitions, keyboard navigation, and customizable options.
Installation
1
Install the packages
npm i motion clsx tailwind-merge2
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
dropdown.tsx
"use client";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useRef, useEffect } from "react";
import { FiChevronDown, FiCheck } from "react-icons/fi";
interface DropdownItem {
label: string;
value: string;
icon?: React.ReactNode;
}
interface DropdownProps {
items?: DropdownItem[];
placeholder?: string;
onSelect?: (item: DropdownItem) => void;
defaultValue?: string;
}
const Dropdown: React.FC<DropdownProps> = ({
items = [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
{ label: "Option 3", value: "option3" },
],
placeholder = "Select an option",
onSelect = () => {},
defaultValue
}) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<DropdownItem | null>(
items.find(item => item.value === defaultValue) || null
);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSelect = (item: DropdownItem) => {
setSelectedItem(item);
setIsOpen(false);
onSelect(item);
};
return (
<div ref={dropdownRef} className="relative w-full max-w-xs">
{/* Trigger */}
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full px-4 py-3 text-left",
"bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg",
"flex items-center justify-between",
"hover:border-neutral-400 dark:hover:border-neutral-500 transition-colors",
"focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
)}
>
<span className={cn(
"text-neutral-900 dark:text-white",
!selectedItem && "text-neutral-500 dark:text-neutral-400"
)}>
{selectedItem ? selectedItem.label : placeholder}
</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<FiChevronDown className="h-4 w-4 text-neutral-400" />
</motion.div>
</button>
{/* Dropdown Menu */}
<AnimatePresence>
{isOpen && (
<motion.div
className={cn(
"absolute top-full left-0 right-0 z-50 mt-1",
"bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg",
"shadow-lg max-h-60 overflow-y-auto"
)}
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, ease: "easeOut" }}
>
{items.map((item, index) => (
<motion.button
key={item.value}
onClick={() => handleSelect(item)}
className={cn(
"w-full px-4 py-3 text-left flex items-center justify-between",
"hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors",
"first:rounded-t-lg last:rounded-b-lg",
selectedItem?.value === item.value && "bg-cyan-50 dark:bg-cyan-900/20"
)}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05, duration: 0.2 }}
>
<div className="flex items-center gap-3">
{item.icon && (
<span className="text-neutral-400">{item.icon}</span>
)}
<span className={cn(
"text-neutral-900 dark:text-white",
selectedItem?.value === item.value && "text-cyan-600 dark:text-cyan-400"
)}>
{item.label}
</span>
</div>
{selectedItem?.value === item.value && (
<FiCheck className="h-4 w-4 text-cyan-500" />
)}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default Dropdown;"use client";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useRef, useEffect } from "react";
import { FiChevronDown, FiCheck } from "react-icons/fi";
interface DropdownItem {
label: string;
value: string;
icon?: React.ReactNode;
}
interface DropdownProps {
items?: DropdownItem[];
placeholder?: string;
onSelect?: (item: DropdownItem) => void;
defaultValue?: string;
}
const Dropdown: React.FC<DropdownProps> = ({
items = [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
{ label: "Option 3", value: "option3" },
],
placeholder = "Select an option",
onSelect = () => {},
defaultValue
}) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<DropdownItem | null>(
items.find(item => item.value === defaultValue) || null
);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSelect = (item: DropdownItem) => {
setSelectedItem(item);
setIsOpen(false);
onSelect(item);
};
return (
<div ref={dropdownRef} className="relative w-full max-w-xs">
{/* Trigger */}
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full px-4 py-3 text-left",
"bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg",
"flex items-center justify-between",
"hover:border-neutral-400 dark:hover:border-neutral-500 transition-colors",
"focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
)}
>
<span className={cn(
"text-neutral-900 dark:text-white",
!selectedItem && "text-neutral-500 dark:text-neutral-400"
)}>
{selectedItem ? selectedItem.label : placeholder}
</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<FiChevronDown className="h-4 w-4 text-neutral-400" />
</motion.div>
</button>
{/* Dropdown Menu */}
<AnimatePresence>
{isOpen && (
<motion.div
className={cn(
"absolute top-full left-0 right-0 z-50 mt-1",
"bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg",
"shadow-lg max-h-60 overflow-y-auto"
)}
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, ease: "easeOut" }}
>
{items.map((item, index) => (
<motion.button
key={item.value}
onClick={() => handleSelect(item)}
className={cn(
"w-full px-4 py-3 text-left flex items-center justify-between",
"hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors",
"first:rounded-t-lg last:rounded-b-lg",
selectedItem?.value === item.value && "bg-cyan-50 dark:bg-cyan-900/20"
)}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05, duration: 0.2 }}
>
<div className="flex items-center gap-3">
{item.icon && (
<span className="text-neutral-400">{item.icon}</span>
)}
<span className={cn(
"text-neutral-900 dark:text-white",
selectedItem?.value === item.value && "text-cyan-600 dark:text-cyan-400"
)}>
{item.label}
</span>
</div>
{selectedItem?.value === item.value && (
<FiCheck className="h-4 w-4 text-cyan-500" />
)}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default Dropdown;4
Update the import paths to match your project setup
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | DropdownItem[] | [] | Array of dropdown items with label, value, and optional icon. |
| placeholder | string | Select an option | Placeholder text when no item is selected. |
| onSelect | function | () => {} | Callback function when an item is selected. |
| defaultValue | string | undefined | Default selected value. |