All components

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.

Open in the catalogue

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

index.tsx
"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)}
    </>
  );
}

Similar components