Docs
Dropdown

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:

PropTypeDefaultDescription
childrenReact.ReactNode-The dropdown button and content components.
dropdownAnimation{}Slide down animationAnimation variant for the Dropdown container. You can pass an animation object here to get a custom animation.
itemAnimation{}Slide up animationAnimation variant for the Dropdown Items.You can pass an animation object here to get a custom animation.
staggerItemsbooleanfalseWhether to stagger the children items.

DropdownButton Props

The DropdownButton component accepts the following props:

PropTypeDefaultDescription
defaultTextstring-Default text displayed on the button.
classNameClassValue-Additional CSS classes for the button.

DropdownContent Props

The DropdownContent component accepts the following props:

PropTypeDefaultDescription
childrenReact.ReactNode-The dropdown items and labels.

DropdownItem Props

The DropdownItem component accepts the following props:

PropTypeDefaultDescription
valuestring-The value of the item.
childrenReact.ReactNode-The text or content of the item.
classNameClassValue-Additional CSS classes for the item.

DropdownLabel Props

The DropdownLabel component accepts the following props:

PropTypeDefaultDescription
childrenReact.ReactNode-The label text.

DropdownList Props

The DropdownList component is a wrapper for the dropdown items. It accepts the following props:

PropTypeDefaultDescription
classNamestring-Additional CSS classes for the list.

FlaminUI

© 2024 FlaminUI. All rights reserved.

Made with ❤️ by FlaminUI Team