Dropdown
Displays a menu to the user, such as a set of actions or functions. Triggered by a button.
Usage
Installation
Dropdown.tsx
1"use client";
2import React, { useEffect, useRef, useState } from "react";
3import { Variants } from "framer-motion";
4import { motion, AnimatePresence } from "framer-motion";
5import { cn } from "@/utils/cn";
6import { ClassValue } from "clsx";
7import { ChevronDown } from "lucide-react";
8import { createContext, useContext } from "react";
9
10interface DropdownContextType {
11 isOpen: boolean;
12 setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
13 selectedOption: string;
14 setSelectedOption: React.Dispatch<React.SetStateAction<string>>;
15 dropdownPosition: "top" | "bottom";
16 containerRef: React.RefObject<HTMLDivElement>;
17 buttonRef: React.RefObject<HTMLButtonElement>;
18 contentRef: React.RefObject<HTMLDivElement>;
19 itemAnimation: Variants | "default" | "slideUp";
20 dropdownAnimation: Variants | "default" | "slideUp";
21 setDropdownAnimation: React.Dispatch<
22 React.SetStateAction<Variants | "default" | "slideUp">
23 >;
24 setItemAnimation: React.Dispatch<
25 React.SetStateAction<Variants | "default" | "slideUp">
26 >;
27 staggerChildren: boolean;
28 setStaggerChildren: React.Dispatch<React.SetStateAction<boolean>>;
29}
30
31const DropdownContext = createContext<DropdownContextType | undefined>(
32 undefined
33);
34
35const useDropdown = () => {
36 const context = useContext(DropdownContext);
37 if (context === undefined) {
38 throw new Error("useDropdown must be used within a Dropdown component");
39 }
40 return context;
41};
42
43interface DropdownButtonProps {
44 defaultText: string;
45 className?: ClassValue;
46}
47
48const DropdownButton: React.FC<DropdownButtonProps> = ({
49 defaultText,
50 className,
51}) => {
52 const { isOpen, setIsOpen, selectedOption, buttonRef } = useDropdown();
53
54 const textVariants = {
55 initial: { y: 20, opacity: 0 },
56 animate: { y: 0, opacity: 1 },
57 exit: { y: -20, opacity: 0 },
58 };
59
60 return (
61 <motion.button
62 ref={buttonRef}
63 className={cn(
64 "relative flex h-full w-full min-w-52 items-center justify-between overflow-hidden rounded-md border border-white/20 bg-zinc-950 px-4 py-2 font-semibold text-white",
65 className
66 )}
67 aria-haspopup="listbox"
68 aria-expanded={isOpen}
69 onClick={() => setIsOpen(!isOpen)}
70 >
71 <AnimatePresence mode="wait">
72 <motion.div
73 key={selectedOption ?? defaultText}
74 variants={textVariants}
75 initial="initial"
76 animate="animate"
77 exit="exit"
78 transition={{ duration: 0.2 }}
79 >
80 {selectedOption ?? defaultText}
81 </motion.div>
82 </AnimatePresence>
83
84 <ChevronDown
85 size={24}
86 className={
87 "transition-transform duration-200 ease-in-out " +
88 (isOpen ? "-rotate-180" : "rotate-0")
89 }
90 />
91 </motion.button>
92 );
93};
94
95interface DropdownProps {
96 children: React.ReactNode;
97 dropdownAnimation?: "default" | "slideUp" | Variants;
98 itemAnimation?: "default" | "slideUp" | Variants;
99 staggerItems?: boolean;
100}
101
102interface DropdownContentProps {
103 children: React.ReactNode;
104}
105
106const DropdownContent: React.FC<DropdownContentProps> = ({ children }) => {
107 const {
108 isOpen,
109 dropdownPosition,
110 contentRef,
111 dropdownAnimation,
112 staggerChildren,
113 } = useDropdown();
114
115 const [Animation, setAnimation] = useState<Variants>();
116 useEffect(() => {
117 const baseVariants: Variants = {
118 hidden: { opacity: 0, y: dropdownPosition === "top" ? -10 : 10 },
119 visible: { opacity: 1, y: 0 },
120 exit: { opacity: 0, y: dropdownPosition === "top" ? -10 : 10 },
121 };
122
123 const staggeredVariants: Variants = {
124 ...baseVariants,
125 visible: {
126 ...baseVariants.visible,
127 transition: { staggerChildren: 0.1, delayChildren: 0.1 },
128 },
129 exit: {
130 ...baseVariants.exit,
131 transition: { staggerChildren: 0.1, staggerDirection: -1 },
132 },
133 };
134
135 if (dropdownAnimation === "slideUp") {
136 setAnimation(staggerChildren ? staggeredVariants : baseVariants);
137 } else if (dropdownAnimation === "default") {
138 setAnimation({
139 hidden: { opacity: 0, scale: 0.98 },
140 visible: {
141 opacity: 1,
142 scale: 1,
143 transition: staggerChildren
144 ? { staggerChildren: 0.1, delayChildren: 0.1 }
145 : { duration: 0.2 },
146 },
147 exit: {
148 opacity: 0,
149 scale: 0.98,
150 transition: staggerChildren
151 ? { staggerChildren: 0.1, staggerDirection: -1 }
152 : { duration: 0.2 },
153 },
154 });
155 } else {
156 setAnimation(dropdownAnimation);
157 }
158 }, [dropdownAnimation, dropdownPosition, staggerChildren]);
159
160 return (
161 <AnimatePresence>
162 {isOpen && (
163 <motion.div
164 ref={contentRef}
165 className={
166 "w-full absolute rounded-md" +
167 (dropdownPosition === "top" ? "bottom-full mb-1" : "top-full mt-1")
168 }
169 variants={Animation}
170 initial="hidden"
171 animate="visible"
172 exit="exit"
173 >
174 <ul
175 className="overflow-y-auto rounded-md border border-white/20 bg-zinc-950 p-1.5 font-semibold text-white shadow-lg"
176 style={{
177 scrollbarWidth: "none",
178 msOverflowStyle: "none",
179 }}
180 >
181 {children}
182 </ul>
183 </motion.div>
184 )}
185 </AnimatePresence>
186 );
187};
188
189interface DropdownItemProps {
190 value: string;
191 children: React.ReactNode;
192 className?: ClassValue;
193}
194
195const DropdownItem: React.FC<DropdownItemProps> = ({
196 value,
197 children,
198 className,
199}) => {
200 const { setSelectedOption, setIsOpen, dropdownPosition, itemAnimation } =
201 useDropdown();
202 const handleSelect = () => {
203 setSelectedOption(value);
204 setIsOpen(false);
205 };
206
207 const defaultVariants = {
208 hidden: { opacity: 0 },
209 visible: { opacity: 1 },
210 exit: { opacity: 0 },
211 };
212
213 const slideUpVariants = {
214 hidden: { opacity: 0, y: dropdownPosition === "top" ? -10 : 10 },
215 visible: { opacity: 1, y: 0 },
216 exit: { opacity: 0, y: dropdownPosition === "top" ? -10 : 10 },
217 };
218
219 const [Animation, setAnimation] = useState<Variants>(defaultVariants);
220 React.useEffect(() => {
221 if (itemAnimation === "slideUp") {
222 setAnimation(slideUpVariants);
223 } else if (itemAnimation === "default") {
224 setAnimation(defaultVariants);
225 } else {
226 setAnimation(itemAnimation);
227 }
228 //eslint-disable-next-line
229 }, [itemAnimation]);
230
231 return (
232 <div className="overflow-hidden">
233 <motion.li
234 className={cn(
235 "w-full cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-gray-100 text-opacity-90 hover:bg-zinc-900 focus:bg-zinc-900 focus:outline-none",
236 className
237 )}
238 role="option"
239 tabIndex={0}
240 onClick={handleSelect}
241 onKeyDown={(e) => {
242 if (e.key === "Enter" || e.key === " ") {
243 e.preventDefault();
244 handleSelect();
245 }
246 }}
247 variants={Animation}
248 >
249 {children}
250 </motion.li>
251 </div>
252 );
253};
254
255interface DropdownLabelProps {
256 children: React.ReactNode;
257 className?: ClassValue;
258}
259
260const DropdownLabel: React.FC<DropdownLabelProps> = ({
261 children,
262 className,
263}) => {
264 const { itemAnimation, dropdownPosition } = useDropdown();
265
266 const defaultVariants: Variants = {
267 hidden: { opacity: 0 },
268 visible: { opacity: 1 },
269 exit: { opacity: 0 },
270 };
271
272 const slideUpVariants: Variants = {
273 hidden: { opacity: 0, y: dropdownPosition === "top" ? -10 : 10 },
274 visible: { opacity: 1, y: 0 },
275 exit: { opacity: 0, y: dropdownPosition === "top" ? -10 : 10 },
276 };
277
278 const [Animation, setAnimation] = useState<Variants>(defaultVariants);
279 React.useEffect(() => {
280 if (itemAnimation === "slideUp") {
281 setAnimation(slideUpVariants);
282 } else if (itemAnimation === "default") {
283 setAnimation(defaultVariants);
284 } else {
285 setAnimation(itemAnimation);
286 }
287 //eslint-disable-next-line
288 }, [itemAnimation, dropdownPosition]);
289 return (
290 <div className="overflow-hidden">
291 <motion.li
292 className={cn(
293 "w-full cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-gray-100 text-opacity-90 focus:outline-none",
294 className
295 )}
296 variants={Animation}
297 initial="hidden"
298 animate="visible"
299 exit="exit"
300 >
301 {children}
302 </motion.li>
303 </div>
304 );
305};
306
307export const Dropdown: React.FC<DropdownProps> = ({
308 children,
309 dropdownAnimation,
310 itemAnimation,
311 staggerItems,
312}) => {
313 const [isOpen, setIsOpen] = useState(false);
314 const [selectedOption, setSelectedOption] = useState("Select an option");
315 const [dropdownPosition, setDropdownPosition] = useState<"top" | "bottom">(
316 "bottom"
317 );
318 const [currentDropdownAnimation, setDropdownAnimation] = useState<
319 Variants | "default" | "slideUp"
320 >("default");
321 const [currentItemAnimation, setItemAnimation] = useState<
322 Variants | "default" | "slideUp"
323 >("default");
324 const containerRef = useRef<HTMLDivElement>(null);
325 const buttonRef = useRef<HTMLButtonElement>(null);
326 const contentRef = useRef<HTMLDivElement>(null);
327 const [staggerChildren, setStaggerChildren] = useState(false);
328
329 useEffect(() => {
330 if (dropdownAnimation) {
331 setDropdownAnimation(dropdownAnimation);
332 }
333 if (itemAnimation) {
334 setItemAnimation(itemAnimation);
335 }
336 if (staggerItems) {
337 setStaggerChildren(staggerItems);
338 }
339 }, [dropdownAnimation, itemAnimation, staggerItems]);
340
341 const updatePosition = () => {
342 if (buttonRef.current && contentRef.current) {
343 const buttonRect = buttonRef.current.getBoundingClientRect();
344 const contentHeight = contentRef.current.scrollHeight;
345 const windowHeight = window.innerHeight;
346 const spaceBelow = windowHeight - buttonRect.bottom;
347 setDropdownPosition(
348 spaceBelow < contentHeight && buttonRect.top > spaceBelow
349 ? "top"
350 : "bottom"
351 );
352 }
353 };
354
355 useEffect(() => {
356 const handleClickOutside = (event: MouseEvent) => {
357 if (
358 containerRef.current &&
359 !containerRef.current.contains(event.target as Node)
360 ) {
361 setIsOpen(false);
362 }
363 };
364 document.addEventListener("mousedown", handleClickOutside);
365 return () => document.removeEventListener("mousedown", handleClickOutside);
366 }, []);
367
368 useEffect(() => {
369 if (isOpen) {
370 updatePosition();
371 window.addEventListener("resize", updatePosition);
372 return () => window.removeEventListener("resize", updatePosition);
373 }
374 }, [isOpen]);
375
376 return (
377 <DropdownContext.Provider
378 value={{
379 isOpen,
380 setIsOpen,
381 selectedOption,
382 setSelectedOption,
383 dropdownPosition,
384 containerRef,
385 buttonRef,
386 contentRef,
387 dropdownAnimation: currentDropdownAnimation,
388 itemAnimation: currentItemAnimation,
389 setDropdownAnimation,
390 setItemAnimation,
391 staggerChildren,
392 setStaggerChildren,
393 }}
394 >
395 <div ref={containerRef} className="relative">
396 {children}
397 </div>
398 </DropdownContext.Provider>
399 );
400};
401
402export { DropdownButton, DropdownContent, DropdownItem, DropdownLabel };
Props
Dropdown Props
The Dropdown
component accepts the following props:
Prop | Type | Default | Description |
---|---|---|---|
children | React.ReactNode | - | The dropdown button and content components. |
dropdownAnimation | {} | Slide down animation | Animation variant for the Dropdown container. You can pass an animation object here to get a custom animation. |
itemAnimation | {} | Slide up animation | Animation variant for the Dropdown Items.You can pass an animation object here to get a custom animation. |
staggerItems | boolean | false | Whether to stagger the children items. |
DropdownButton Props
The DropdownButton
component accepts the following props:
Prop | Type | Default | Description |
---|---|---|---|
defaultText | string | - | Default text displayed on the button. |
className | ClassValue | - | Additional CSS classes for the button. |
DropdownContent Props
The DropdownContent
component accepts the following props:
Prop | Type | Default | Description |
---|---|---|---|
children | React.ReactNode | - | The dropdown items and labels. |
DropdownItem Props
The DropdownItem
component accepts the following props:
Prop | Type | Default | Description |
---|---|---|---|
value | string | - | The value of the item. |
children | React.ReactNode | - | The text or content of the item. |
className | ClassValue | - | Additional CSS classes for the item. |
DropdownLabel Props
The DropdownLabel
component accepts the following props:
Prop | Type | Default | Description |
---|---|---|---|
children | React.ReactNode | - | The label text. |
DropdownList Props
The DropdownList
component is a wrapper for the dropdown items. It accepts the following props:
Prop | Type | Default | Description |
---|---|---|---|
className | string | - | Additional CSS classes for the list. |