All components

Select

Select is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.

SmoothUI MIT forms

What it is

Select is a React and Tailwind CSS form 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 lucide-react motion

Licence

This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Select

index.tsx
"use client";

import { cn } from "@/lib/utils";
import { Check, ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
  DURATION_INSTANT,
  SPRING_DEFAULT,
  SPRING_SNAPPY,
} from "@/components/smoothui/lib/animation";

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const CHEVRON_ROTATION = 180;
const DROPDOWN_OFFSET = 4;
const STAGGER_DELAY = 0.02;
const ITEM_HOVER_X = 2;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface SelectOptionProps {
  /** Whether the option is disabled */
  disabled?: boolean;
  /** The display label for the option */
  label: string;
  /** The value of the option */
  value: string;
}

export interface SelectGroupOption {
  /** Label for the group */
  label: string;
  /** Options within this group */
  options: SelectOptionProps[];
}

export interface SelectProps {
  /** Accessible label for the select */
  "aria-label"?: string;
  /** ID of element that labels this select */
  "aria-labelledby"?: string;
  /** Additional CSS class names for the trigger */
  className?: string;
  /** Additional CSS class names for the content dropdown */
  contentClassName?: string;
  /** The default value (uncontrolled) */
  defaultValue?: string;
  /** Whether the select is disabled */
  disabled?: boolean;
  /** Grouped options */
  groups?: SelectGroupOption[];
  /** The name attribute for form submission */
  name?: string;
  /** Callback when the value changes */
  onValueChange?: (value: string) => void;
  /** Flat list of options */
  options?: SelectOptionProps[];
  /** Placeholder text when no value is selected */
  placeholder?: string;
  /** Whether the select is required */
  required?: boolean;
  /** The size of the trigger */
  size?: "sm" | "default";
  /** The controlled value of the select */
  value?: string;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export default function Select({
  value: controlledValue,
  defaultValue,
  onValueChange,
  placeholder = "Select an option",
  disabled = false,
  required = false,
  name,
  options,
  groups,
  className,
  contentClassName,
  size = "default",
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
}: SelectProps) {
  const shouldReduceMotion = useReducedMotion();
  const [isOpen, setIsOpen] = useState(false);
  const [internalValue, setInternalValue] = useState(defaultValue ?? "");
  const [focusedIndex, setFocusedIndex] = useState(-1);
  const [position, setPosition] = useState({ left: 0, top: 0, width: 0 });

  const triggerRef = useRef<HTMLButtonElement>(null);
  const wrapperRef = useRef<HTMLDivElement>(null);
  const portalRef = useRef<HTMLDivElement>(null);

  const selectedValue =
    controlledValue === undefined ? internalValue : controlledValue;

  // Flatten all options for keyboard navigation
  const allOptions: SelectOptionProps[] = (() => {
    const flat: SelectOptionProps[] = [];
    if (options) {
      for (const opt of options) {
        flat.push(opt);
      }
    }
    if (groups) {
      for (const group of groups) {
        for (const opt of group.options) {
          flat.push(opt);
        }
      }
    }
    return flat;
  })();

  const selectedLabel = allOptions.find(
    (opt) => opt.value === selectedValue
  )?.label;

  // ---------------------------------------------------------------------------
  // Handlers
  // ---------------------------------------------------------------------------

  const handleSelect = useCallback(
    (opt: SelectOptionProps) => {
      if (opt.disabled) {
        return;
      }
      if (controlledValue === undefined) {
        setInternalValue(opt.value);
      }
      onValueChange?.(opt.value);
      setIsOpen(false);
      setFocusedIndex(-1);
      triggerRef.current?.focus();
    },
    [controlledValue, onValueChange]
  );

  const handleToggle = useCallback(() => {
    if (disabled) {
      return;
    }
    if (!isOpen && triggerRef.current) {
      const rect = triggerRef.current.getBoundingClientRect();
      setPosition({
        left: rect.left,
        top: rect.bottom + DROPDOWN_OFFSET,
        width: rect.width,
      });
    }
    setIsOpen((prev) => !prev);
    setFocusedIndex(-1);
  }, [disabled, isOpen]);

  // ---------------------------------------------------------------------------
  // Position updates on scroll/resize
  // ---------------------------------------------------------------------------

  useEffect(() => {
    if (!(isOpen && triggerRef.current)) {
      return;
    }

    const updatePosition = () => {
      if (triggerRef.current) {
        const rect = triggerRef.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]);

  // ---------------------------------------------------------------------------
  // Click outside to close
  // ---------------------------------------------------------------------------

  useEffect(() => {
    if (!isOpen) {
      return;
    }

    const handleClickOutside = (event: MouseEvent) => {
      const target = event.target as Node;
      if (
        wrapperRef.current &&
        !wrapperRef.current.contains(target) &&
        portalRef.current &&
        !portalRef.current.contains(target)
      ) {
        setIsOpen(false);
        setFocusedIndex(-1);
      }
    };

    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, [isOpen]);

  // ---------------------------------------------------------------------------
  // Keyboard navigation
  // ---------------------------------------------------------------------------

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (!isOpen) {
        if (
          (event.key === "Enter" || event.key === " ") &&
          document.activeElement === triggerRef.current
        ) {
          event.preventDefault();
          handleToggle();
        }
        return;
      }

      if (event.key === "Escape") {
        setIsOpen(false);
        setFocusedIndex(-1);
        triggerRef.current?.focus();
      } else if (event.key === "ArrowDown") {
        event.preventDefault();
        setFocusedIndex((prev) =>
          prev < allOptions.length - 1 ? prev + 1 : 0
        );
      } else if (event.key === "ArrowUp") {
        event.preventDefault();
        setFocusedIndex((prev) =>
          prev > 0 ? prev - 1 : allOptions.length - 1
        );
      } else if (event.key === "Enter" && focusedIndex >= 0) {
        event.preventDefault();
        const opt = allOptions[focusedIndex];
        if (opt) {
          handleSelect(opt);
        }
      } else if (event.key === "Home") {
        event.preventDefault();
        setFocusedIndex(0);
      } else if (event.key === "End") {
        event.preventDefault();
        setFocusedIndex(allOptions.length - 1);
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
    // biome-ignore lint/correctness/useExhaustiveDependencies: handlers stable via closure
  }, [isOpen, allOptions, focusedIndex, handleSelect, handleToggle]);

  // ---------------------------------------------------------------------------
  // Render helpers
  // ---------------------------------------------------------------------------

  /** Render a single option item with stagger animation */
  const renderItem = (opt: SelectOptionProps, itemIndex: number) => {
    const isSelected = opt.value === selectedValue;
    const isFocused = itemIndex === focusedIndex;

    return (
      <motion.div
        animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, x: 0 }}
        exit={
          shouldReduceMotion
            ? { opacity: 0, transition: { duration: 0 } }
            : { opacity: 0, x: -8 }
        }
        initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, x: -8 }}
        key={opt.value}
        transition={
          shouldReduceMotion
            ? DURATION_INSTANT
            : {
                ...SPRING_SNAPPY,
                delay: itemIndex * STAGGER_DELAY,
              }
        }
        whileHover={shouldReduceMotion ? {} : { x: ITEM_HOVER_X }}
      >
        <button
          aria-selected={isSelected}
          className={cn(
            "relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-left text-sm outline-hidden",
            "transition-colors",
            opt.disabled
              ? "pointer-events-none opacity-50"
              : "hover:bg-accent hover:text-white",
            isFocused && "bg-accent text-white",
            isSelected && "font-medium"
          )}
          disabled={opt.disabled}
          onClick={() => handleSelect(opt)}
          onMouseEnter={() => setFocusedIndex(itemIndex)}
          role="option"
          type="button"
        >
          <span className="flex-1 truncate">{opt.label}</span>

          {/* Animated checkmark */}
          <span className="absolute right-2 flex size-3.5 items-center justify-center">
            <AnimatePresence>
              {isSelected && (
                <motion.span
                  animate={shouldReduceMotion ? {} : { opacity: 1, scale: 1 }}
                  exit={
                    shouldReduceMotion
                      ? { opacity: 0, transition: { duration: 0 } }
                      : { opacity: 0, scale: 0 }
                  }
                  initial={shouldReduceMotion ? {} : { opacity: 0, scale: 0 }}
                  transition={
                    shouldReduceMotion
                      ? DURATION_INSTANT
                      : {
                          damping: 20,
                          duration: 0.2,
                          stiffness: 300,
                          type: "spring" as const,
                        }
                  }
                >
                  <Check className="size-4" />
                </motion.span>
              )}
            </AnimatePresence>
          </span>
        </button>
      </motion.div>
    );
  };

  // Global index counter for stagger across groups
  let globalIndex = 0;

  // ---------------------------------------------------------------------------
  // Dropdown content (portalled)
  // ---------------------------------------------------------------------------

  const dropdownContent = (
    <AnimatePresence>
      {isOpen ? (
        <div ref={portalRef}>
          <motion.div
            animate={
              shouldReduceMotion
                ? { opacity: 1 }
                : { opacity: 1, scale: 1, y: 0 }
            }
            className={cn(
              "fixed z-50 origin-top overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
              contentClassName
            )}
            exit={
              shouldReduceMotion
                ? { opacity: 0, transition: { duration: 0 } }
                : {
                    opacity: 0,
                    scale: 0.95,
                    transition: { duration: 0.15 },
                    y: -4,
                  }
            }
            initial={
              shouldReduceMotion
                ? { opacity: 1 }
                : { opacity: 0, scale: 0.95, y: -4 }
            }
            role="listbox"
            style={{
              left: `${position.left}px`,
              top: `${position.top}px`,
              width: `${position.width}px`,
            }}
            transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_DEFAULT}
          >
            <div className="max-h-60 overflow-y-auto p-1">
              {/* Flat options */}
              {options &&
                options.length > 0 &&
                (() => {
                  const items = options.map((opt) => {
                    const idx = globalIndex;
                    globalIndex += 1;
                    return renderItem(opt, idx);
                  });
                  return items;
                })()}

              {/* Grouped options */}
              {groups
                ? groups.map((group, groupIdx) => {
                    const groupItems = group.options.map((opt) => {
                      const idx = globalIndex;
                      globalIndex += 1;
                      return renderItem(opt, idx);
                    });

                    return (
                      <div key={group.label}>
                        {groupIdx > 0 && (
                          <div className="pointer-events-none -mx-1 my-1 h-px bg-border" />
                        )}
                        <div className="px-2 py-1.5 text-muted-foreground text-xs">
                          {group.label}
                        </div>
                        {groupItems}
                      </div>
                    );
                  })
                : null}
            </div>
          </motion.div>
        </div>
      ) : null}
    </AnimatePresence>
  );

  // ---------------------------------------------------------------------------
  // Main render
  // ---------------------------------------------------------------------------

  return (
    <>
      <div className="relative inline-block w-full" ref={wrapperRef}>
        {/* Hidden native input for form submission */}
        {name ? (
          <input
            aria-hidden="true"
            name={name}
            required={required}
            tabIndex={-1}
            type="hidden"
            value={selectedValue}
          />
        ) : null}

        <button
          aria-expanded={isOpen}
          aria-haspopup="listbox"
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy}
          aria-required={required || undefined}
          className={cn(
            "flex w-full cursor-pointer items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground",
            size === "default" ? "h-9" : "h-8",
            className
          )}
          data-placeholder={!selectedLabel || undefined}
          disabled={disabled}
          onClick={handleToggle}
          ref={triggerRef}
          role="combobox"
          type="button"
        >
          <span
            className={cn(
              "line-clamp-1 flex items-center gap-2 text-left",
              !selectedLabel && "text-muted-foreground"
            )}
          >
            {selectedLabel ?? placeholder}
          </span>

          {/* Animated chevron */}
          <motion.div
            animate={{ rotate: isOpen ? CHEVRON_ROTATION : 0 }}
            className="shrink-0"
            transition={
              shouldReduceMotion
                ? DURATION_INSTANT
                : { bounce: 0.05, duration: 0.25, type: "spring" as const }
            }
          >
            <ChevronDown className="size-4 opacity-50" />
          </motion.div>
        </button>
      </div>

      {typeof window === "undefined"
        ? null
        : createPortal(dropdownContent, document.body)}
    </>
  );
}

Similar components