Basic Dropdown
Basic Dropdown is a React and Tailwind CSS overlay component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
overlays
What it is
Basic Dropdown is a React and Tailwind CSS overlay component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion lucide-react
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Basic Dropdown
"use client";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
const ROTATION_ANGLE_OPEN = 180;
const DROPDOWN_OFFSET = 4;
export interface DropdownItem {
icon?: React.ReactNode;
id: string | number;
label: string;
}
export interface BasicDropdownProps {
className?: string;
items: DropdownItem[];
label: string;
onChange?: (item: DropdownItem) => void;
}
export default function BasicDropdown({
label,
items,
onChange,
className = "",
}: BasicDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<DropdownItem | null>(null);
const [focusedIndex, setFocusedIndex] = useState(-1);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const portalRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: 0, top: 0, width: 0 });
const shouldReduceMotion = useReducedMotion();
const handleItemSelect = (item: DropdownItem) => {
setSelectedItem(item);
setIsOpen(false);
onChange?.(item);
};
const handleToggle = () => {
if (!isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setPosition({
left: rect.left,
top: rect.bottom + DROPDOWN_OFFSET,
width: rect.width,
});
}
setIsOpen(!isOpen);
};
// Update position on scroll/resize when open
useEffect(() => {
if (!(isOpen && buttonRef.current)) {
return;
}
const updatePosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setPosition({
left: rect.left,
top: rect.bottom + DROPDOWN_OFFSET,
width: rect.width,
});
}
};
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
return () => {
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
};
}, [isOpen]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
isOpen &&
dropdownRef.current &&
!dropdownRef.current.contains(target) &&
portalRef.current &&
!portalRef.current.contains(target)
) {
setIsOpen(false);
setFocusedIndex(-1);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) {
// Open dropdown on Enter or Space when button is focused
if (
(event.key === "Enter" || event.key === " ") &&
document.activeElement === buttonRef.current
) {
event.preventDefault();
handleToggle();
}
return;
}
if (event.key === "Escape") {
setIsOpen(false);
setFocusedIndex(-1);
buttonRef.current?.focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setFocusedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
} else if (event.key === "Enter" && focusedIndex >= 0) {
event.preventDefault();
const item = items[focusedIndex];
if (item) {
handleItemSelect(item);
}
} else if (event.key === "Home") {
event.preventDefault();
setFocusedIndex(0);
} else if (event.key === "End") {
event.preventDefault();
setFocusedIndex(items.length - 1);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
// biome-ignore lint/correctness/useExhaustiveDependencies: Handlers are stable via closure
}, [isOpen, items, focusedIndex, handleItemSelect, handleToggle]);
// Reset focused index when items change
useEffect(() => {
setFocusedIndex(-1);
}, []);
const dropdownContent = (
<AnimatePresence>
{isOpen ? (
<div ref={portalRef}>
<motion.div
animate={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 1, scaleY: 1, y: 0 }
}
className="fixed z-50 origin-top rounded-lg border bg-background shadow-lg"
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
scaleY: 0.8,
transition: { duration: 0.15 },
y: -10,
}
}
initial={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 0, scaleY: 0.8, y: -10 }
}
style={{
left: `${position.left}px`,
top: `${position.top}px`,
width: `${position.width}px`,
}}
transition={
shouldReduceMotion
? { duration: 0 }
: { bounce: 0.1, duration: 0.25, type: "spring" as const }
}
>
<ul
aria-label="Dropdown options"
className="py-2"
id="dropdown-items"
>
{items.map((item, index) => (
<motion.li
animate={
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, x: 0 }
}
aria-selected={
selectedItem?.id === item.id || index === focusedIndex
}
className="block"
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: { opacity: 0, x: -10 }
}
initial={
shouldReduceMotion ? { opacity: 1 } : { opacity: 0, x: -10 }
}
key={item.id}
role="option"
transition={
shouldReduceMotion
? { duration: 0 }
: {
damping: 30,
duration: 0.2,
stiffness: 300,
type: "spring" as const,
}
}
whileHover={shouldReduceMotion ? {} : { x: 5 }}
>
<button
aria-label={item.label}
className={`flex min-h-[44px] w-full items-center px-4 py-2 text-left text-sm transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
selectedItem?.id === item.id
? "font-medium text-brand"
: ""
} ${index === focusedIndex ? "bg-muted" : ""}`}
onClick={() => handleItemSelect(item)}
onMouseEnter={() => setFocusedIndex(index)}
type="button"
>
{item.icon ? (
<span className="mr-2">{item.icon}</span>
) : null}
{item.label}
{selectedItem?.id === item.id && (
<motion.span
animate={shouldReduceMotion ? {} : { scale: 1 }}
className="ml-auto"
initial={shouldReduceMotion ? {} : { scale: 0 }}
transition={
shouldReduceMotion
? { duration: 0 }
: {
damping: 20,
duration: 0.2,
stiffness: 300,
type: "spring" as const,
}
}
>
<svg
className="h-4 w-4 text-brand"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<title>Selected</title>
<path
d="M5 13l4 4L19 7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</motion.span>
)}
</button>
</motion.li>
))}
</ul>
</motion.div>
</div>
) : null}
</AnimatePresence>
);
return (
<>
<div className={`relative inline-block ${className}`} ref={dropdownRef}>
<button
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={selectedItem ? `${label}: ${selectedItem.label}` : label}
className="flex min-h-[44px] w-full cursor-pointer items-center justify-between gap-2 rounded-lg border bg-background px-4 py-2 text-left transition-colors hover:bg-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
id="dropdown-button"
onClick={handleToggle}
ref={buttonRef}
type="button"
>
<span className="block truncate">
{String(selectedItem ? selectedItem.label : label)}
</span>
<motion.div
animate={{ rotate: isOpen ? ROTATION_ANGLE_OPEN : 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
>
<ChevronDown className="h-4 w-4" />
</motion.div>
</button>
</div>
{typeof window === "undefined"
? null
: createPortal(dropdownContent, document.body)}
</>
);
}