ComponentsBasic Dropdown
Basic Dropdown
An elegant dropdown menu with smooth animations, hover effects, and proper keyboard accessibility for enhanced user interaction.
Select a size
Code
Install with shadcn Beta
Terminal
npx shadcn@latest add "https://smoothui.dev/r/basic-dropdown.json"
Manual install
Terminal
npm install motion lucide-react
BasicDropdown.tsx
"use client"
import { useEffect, useRef, useState } from "react"
import { ChevronDown } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
interface DropdownItem {
id: string | number
label: string
icon?: React.ReactNode
}
interface BasicDropdownProps {
label: string
items: DropdownItem[]
onChange?: (item: DropdownItem) => void
className?: string
}
export default function BasicDropdown({
label,
items,
onChange,
className = "",
}: BasicDropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [selectedItem, setSelectedItem] = useState<DropdownItem | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const handleItemSelect = (item: DropdownItem) => {
setSelectedItem(item)
setIsOpen(false)
onChange?.(item)
}
// Close dropdown when clicking outside
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)
}, [])
return (
<div ref={dropdownRef} className={`relative inline-block ${className}`}>
<button
onClick={() => setIsOpen(!isOpen)}
className="border-light-300 bg-light-100 hover:bg-light-200 dark:border-dark-300 dark:bg-dark-100 dark:hover:bg-dark-200 flex w-full items-center justify-between gap-2 rounded-lg border px-4 py-2 text-left transition-colors"
>
<span className="block truncate">
{selectedItem ? selectedItem.label : label}
</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="h-4 w-4" />
</motion.div>
</button>
<AnimatePresence>
{isOpen && (
<motion.div
className="border-light-300 bg-light-100 dark:border-dark-300 dark:bg-dark-100 absolute left-0 z-10 mt-1 w-full origin-top rounded-lg border shadow-lg"
initial={{ opacity: 0, y: -10, scaleY: 0.8 }}
animate={{ opacity: 1, y: 0, scaleY: 1 }}
exit={{
opacity: 0,
y: -10,
scaleY: 0.8,
transition: { duration: 0.2 },
}}
transition={{ type: "spring", bounce: 0.15, duration: 0.4 }}
>
<ul
className="py-2"
role="menu"
aria-orientation="vertical"
aria-labelledby="dropdown-button"
>
{items.map((item) => (
<motion.li
key={item.id}
role="menuitem"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="block"
whileHover={{ x: 5 }}
>
<button
onClick={() => handleItemSelect(item)}
className={`hover:bg-light-200 dark:hover:bg-dark-200 flex w-full items-center px-4 py-2 text-left text-sm ${
selectedItem?.id === item.id
? "font-medium text-pink-500"
: ""
}`}
>
{item.icon && <span className="mr-2">{item.icon}</span>}
{item.label}
{selectedItem?.id === item.id && (
<motion.span
className="ml-auto"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<svg
className="h-4 w-4 text-pink-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</motion.span>
)}
</button>
</motion.li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function DropdownDemo() {
const items = [
{ id: 1, label: "Small" },
{ id: 2, label: "Medium" },
{ id: 3, label: "Large" },
{ id: 4, label: "Extra Large" },
]
return (
<div className="flex w-full max-w-xs flex-col gap-4 p-8">
<h3 className="text-lg font-medium">Select a size</h3>
<BasicDropdown
label="Choose a size"
items={items}
onChange={(item) => console.log("Selected:", item.label)}
/>
</div>
)
}