Back to components

Floating Select

A single-button floating select that expands into an animated option list.

formshared-layout
"use client";

import * as React from "react";
import {
  AnimatePresence,
  LayoutGroup,
  motion,
  useReducedMotion,
} from "motion/react";

import { cn } from "@/lib/utils";

type FloatingSelectOptionBase = {
  /** What renders for this option. Can be text or any node. */
  label: React.ReactNode;
  /** Optional leading node (icon, swatch, etc.) shown before the label. */
  icon?: React.ReactNode;
};

export type FloatingSelectOption = FloatingSelectOptionBase &
  (
    | {
        /** Stable value passed back in callbacks. */
        value: string;
        /** @deprecated Use `value`. Kept for compatibility with earlier versions. */
        id?: string;
      }
    | {
        /** @deprecated Use `value`. Kept for compatibility with earlier versions. */
        id: string;
        /** Stable value passed back in callbacks. */
        value?: string;
      }
  );

export interface FloatingSelectProps
  extends Omit<
    React.ComponentProps<"div">,
    "defaultValue" | "onChange" | "children"
  > {
  /** The choices revealed when the button is opened. */
  options: FloatingSelectOption[];
  /** Trigger label shown before the selected value. */
  label?: React.ReactNode;
  /** Optional leading icon for the trigger. */
  icon?: React.ReactNode;
  /** Uncontrolled initial selected option value. */
  defaultValue?: string;
  /** Controlled selected option value. */
  value?: string;
  /** Fires with the selected option whenever it changes. */
  onValueChange?: (value: string, option: FloatingSelectOption) => void;
  /** @deprecated Use `onValueChange`. Kept for compatibility with earlier versions. */
  onChange?: (value: string, option: FloatingSelectOption) => void;
  /** Controlled open state of the options panel. */
  open?: boolean;
  /** Uncontrolled initial open state. */
  defaultOpen?: boolean;
  /** Fires whenever the panel requests an open-state change. */
  onOpenChange?: (open: boolean) => void;
  /** Close the panel after selecting an option. */
  closeOnSelect?: boolean;
  /** Show the selected value next to the trigger label. */
  showSelectedValue?: boolean;
  /** Pin the select to the viewport, or render it in normal document flow. */
  placement?: "fixed" | "inline";
  /** Pin the select to the top or bottom of the viewport. */
  position?: "top" | "bottom";
  /** Horizontal alignment of the floating select. */
  align?: "start" | "center" | "end";
  /** Distance from the pinned edge, in pixels. */
  offset?: number;
  /** Play the entrance animation on mount. */
  reveal?: boolean;
  /** Classes applied to the floating shell. */
  className?: string;
  /** Classes applied to the trigger button. */
  triggerClassName?: string;
  /** Classes applied to the options panel. */
  panelClassName?: string;
  /**
   * Classes applied to the outer positioning wrapper (the element carrying
   * `fixed`/`z-50` in `placement="fixed"`), so the stacking context can be
   * overridden without forking the component.
   */
  wrapperClassName?: string;
}

const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const SHELL_LAYOUT_DURATION = 0.26;
const PANEL_DURATION = 0.16;
const OPTION_STAGGER = 0.028;
const OPTION_DELAY = 0.04;
const OPTION_DURATION = 0.13;
const SHELL_TRANSITION = {
  layout: { type: "spring", duration: SHELL_LAYOUT_DURATION, bounce: 0 },
  opacity: { duration: PANEL_DURATION, ease: EASE_OUT },
  y: { duration: 0.22, ease: EASE_OUT },
} as const;
const PANEL_TRANSITION = { duration: PANEL_DURATION, ease: EASE_OUT };
const PANEL_EXIT_TRANSITION = { duration: 0.08, ease: EASE_OUT };
const ACTIVE_SURFACE_TRANSITION = {
  type: "spring",
  duration: 0.22,
  bounce: 0,
  opacity: { duration: 0.14, ease: EASE_OUT },
} as const;

function getCssPixels(value: string) {
  const pixels = Number.parseFloat(value);

  return Number.isFinite(pixels) ? pixels : 0;
}

function getOptionValue(option: FloatingSelectOption) {
  return (option.value ?? option.id) as string;
}

function getOpenHoverLockDelay(optionCount: number) {
  const listDuration =
    OPTION_DELAY + Math.max(optionCount - 1, 0) * OPTION_STAGGER + OPTION_DURATION;

  return Math.ceil(
    Math.max(SHELL_LAYOUT_DURATION, PANEL_DURATION, listDuration) * 1000,
  );
}

export function FloatingSelect({
  options,
  label = "Select",
  icon,
  defaultValue,
  value,
  onValueChange,
  onChange,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  closeOnSelect = true,
  showSelectedValue = true,
  placement = "fixed",
  position = "bottom",
  align = "center",
  offset = 16,
  reveal = false,
  className,
  triggerClassName,
  panelClassName,
  wrapperClassName,
  ...props
}: FloatingSelectProps) {
  const shellRef = React.useRef<HTMLDivElement>(null);
  const triggerRef = React.useRef<HTMLDivElement>(null);
  const reactId = React.useId();
  const listboxId = `${reactId}-listbox`;
  const shouldReduceMotion = useReducedMotion();
  const openControlled = openProp !== undefined;
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
  const open = openControlled ? openProp : uncontrolledOpen;
  const setOpen = React.useCallback(
    (nextOpen: boolean) => {
      if (!openControlled) {
        setUncontrolledOpen(nextOpen);
      }

      onOpenChange?.(nextOpen);
    },
    [onOpenChange, openControlled],
  );
  const [optionHoverLocked, setOptionHoverLocked] = React.useState(false);
  const [inlineAnchorSize, setInlineAnchorSize] = React.useState<{
    width: number;
    height: number;
  } | null>(null);
  const [internalSelected, setInternalSelected] = React.useState(
    defaultValue ?? (options[0] ? getOptionValue(options[0]) : ""),
  );

  // The trigger and the listbox are swapped in and out of the DOM by
  // AnimatePresence, so focus must be managed manually: into the selected
  // option when the listbox mounts, back onto the trigger after keyboard or
  // selection closes (pointer dismissals leave focus where the user clicked).
  const pendingTriggerFocusRef = React.useRef(false);

  React.useEffect(() => {
    const shellElement = shellRef.current;

    if (!shellElement) return;

    if (open) {
      const target =
        shellElement.querySelector<HTMLButtonElement>(
          '[role="option"][aria-selected="true"]',
        ) ?? shellElement.querySelector<HTMLButtonElement>('[role="option"]');

      target?.focus();
      return;
    }

    if (pendingTriggerFocusRef.current) {
      pendingTriggerFocusRef.current = false;
      shellElement
        .querySelector<HTMLButtonElement>('[data-slot="floating-select-trigger"]')
        ?.focus();
    }
  }, [open]);

  React.useEffect(() => {
    if (!open) return;

    function onKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        pendingTriggerFocusRef.current = true;
        setOpen(false);
      }
    }

    window.addEventListener("keydown", onKeyDown);

    return () => window.removeEventListener("keydown", onKeyDown);
  }, [open, setOpen]);

  React.useEffect(() => {
    if (!open) return;

    function onPointerDown(event: PointerEvent) {
      if (!shellRef.current?.contains(event.target as Node)) {
        setOpen(false);
      }
    }

    document.addEventListener("pointerdown", onPointerDown);

    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open, setOpen]);

  React.useEffect(() => {
    if (!open || shouldReduceMotion === true || !optionHoverLocked) return;

    const timeout = window.setTimeout(() => {
      setOptionHoverLocked(false);
    }, getOpenHoverLockDelay(options.length));

    return () => window.clearTimeout(timeout);
  }, [open, optionHoverLocked, options.length, shouldReduceMotion]);

  const selectedValue =
    value ?? internalSelected ?? (options[0] ? getOptionValue(options[0]) : "");
  const selectedOption = options.find(
    (option) => getOptionValue(option) === selectedValue,
  );
  const alignClass =
    align === "start"
      ? "justify-start"
      : align === "end"
        ? "justify-end"
        : "justify-center";
  const transformOriginX =
    align === "start" ? "left" : align === "end" ? "right" : "center";
  const transformOrigin = `${transformOriginX} ${position}`;
  const edgeOffset = (offset + 20) * (position === "top" ? -1 : 1);
  const activeSurfaceLayoutId = shouldReduceMotion
    ? undefined
    : "floating-select-active-surface";
  const optionHoverEnabled =
    shouldReduceMotion === true || !optionHoverLocked;

  const handleSelect = (option: FloatingSelectOption) => {
    const nextValue = getOptionValue(option);

    if (value === undefined) {
      setInternalSelected(nextValue);
    }

    onValueChange?.(nextValue, option);

    if (!onValueChange) {
      onChange?.(nextValue, option);
    }

    if (closeOnSelect) {
      pendingTriggerFocusRef.current = true;
      setOpen(false);
    }
  };

  // role="listbox" promises arrow-key navigation; options are real buttons,
  // so roving focus is enough (no aria-activedescendant needed).
  const handleListboxKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;

    const optionElements = Array.from(
      event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="option"]'),
    );

    if (optionElements.length === 0) return;

    event.preventDefault();

    const currentIndex = optionElements.indexOf(
      document.activeElement as HTMLButtonElement,
    );
    const lastIndex = optionElements.length - 1;
    let nextIndex = 0;

    if (event.key === "ArrowDown") {
      nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % optionElements.length;
    } else if (event.key === "ArrowUp") {
      nextIndex =
        currentIndex < 0
          ? lastIndex
          : (currentIndex - 1 + optionElements.length) % optionElements.length;
    } else if (event.key === "End") {
      nextIndex = lastIndex;
    }

    optionElements[nextIndex]?.focus();
  };

  const updateInlineAnchorSize = React.useCallback(
    (nextSize: { width: number; height: number }) => {
      setInlineAnchorSize((current) => {
        if (
          current?.width === nextSize.width &&
          current.height === nextSize.height
        ) {
          return current;
        }

        return nextSize;
      });
    },
    [],
  );

  const measureInlineAnchorSize = React.useCallback(() => {
    if (placement !== "inline") return;

    const trigger = triggerRef.current;

    if (!trigger) return;

    const { width, height } = trigger.getBoundingClientRect();
    const shell = shellRef.current;
    const shellStyles = shell ? window.getComputedStyle(shell) : null;
    const borderX = shellStyles
      ? getCssPixels(shellStyles.borderLeftWidth) +
        getCssPixels(shellStyles.borderRightWidth)
      : 0;
    const borderY = shellStyles
      ? getCssPixels(shellStyles.borderTopWidth) +
        getCssPixels(shellStyles.borderBottomWidth)
      : 0;

    updateInlineAnchorSize({
      width: width + borderX,
      height: height + borderY,
    });
  }, [placement, updateInlineAnchorSize]);

  const handleOpen = () => {
    measureInlineAnchorSize();
    setOptionHoverLocked(shouldReduceMotion !== true);
    setOpen(true);
  };

  const reduceMotionTransition = shouldReduceMotion ? { duration: 0 } : {};
  const listVariants = {
    hidden: {},
    visible: {
      transition: {
        staggerChildren: OPTION_STAGGER,
        staggerDirection: position === "bottom" ? -1 : 1,
        delayChildren: OPTION_DELAY,
      },
    },
  };
  const optionVariants = {
    hidden: { opacity: 0, y: position === "bottom" ? 3 : -3 },
    visible: {
      opacity: 1,
      y: 0,
      transition: { duration: OPTION_DURATION, ease: EASE_OUT },
    },
  };

  const shell = (
    <LayoutGroup id={reactId}>
      <motion.div
        ref={shellRef}
        layout
        data-slot="floating-select"
        data-state={open ? "open" : "closed"}
        initial={
          reveal && !shouldReduceMotion
            ? { opacity: 0, y: edgeOffset }
            : false
        }
        animate={{ opacity: 1, y: 0 }}
        transition={shouldReduceMotion ? { duration: 0 } : SHELL_TRANSITION}
        className={cn(
          "pointer-events-auto flex w-fit flex-col overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-sm",
          className,
        )}
        style={{ transformOrigin }}
      >
        <AnimatePresence mode="popLayout" initial={false}>
          {open ? (
            <motion.div
              key="floating-select-options"
              id={listboxId}
              role="listbox"
              aria-label={typeof label === "string" ? label : "Options"}
              data-slot="floating-select-listbox"
              onKeyDown={handleListboxKeyDown}
              initial={shouldReduceMotion ? false : { opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={
                shouldReduceMotion
                  ? undefined
                  : { opacity: 0, transition: PANEL_EXIT_TRANSITION }
              }
              transition={{ ...PANEL_TRANSITION, ...reduceMotionTransition }}
              className={cn("w-fit", panelClassName)}
            >
              <motion.div
                data-slot="floating-select-options"
                className="flex flex-col gap-1.5 p-2"
                variants={shouldReduceMotion ? undefined : listVariants}
                initial={shouldReduceMotion ? false : "hidden"}
                animate={shouldReduceMotion ? undefined : "visible"}
              >
                {options.map((option) => {
                  const optionValue = getOptionValue(option);
                  const active = optionValue === selectedValue;

                  return (
                    <motion.button
                      key={optionValue}
                      role="option"
                      aria-selected={active}
                      type="button"
                      data-slot="floating-select-option"
                      data-active={active ? "" : undefined}
                      variants={shouldReduceMotion ? undefined : optionVariants}
                      onClick={() => handleSelect(option)}
                      className={cn(
                        "relative flex h-8 w-full items-center gap-6 overflow-hidden rounded-md px-2.5 text-left text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
                        active
                          ? "text-primary-foreground"
                          : cn(
                              "text-muted-foreground",
                              optionHoverEnabled &&
                                "hover:bg-accent hover:text-accent-foreground",
                            ),
                      )}
                    >
                      {active ? (
                        <motion.span
                          layoutId={activeSurfaceLayoutId}
                          aria-hidden="true"
                          initial={
                            shouldReduceMotion
                              ? false
                              : { opacity: 0, scale: 0.98 }
                          }
                          animate={{ opacity: 1, scale: 1 }}
                          transition={ACTIVE_SURFACE_TRANSITION}
                          className="absolute inset-0 rounded-md bg-primary"
                        />
                      ) : null}
                      <span className="relative z-10 flex items-center gap-2.5">
                        {option.icon ? (
                          <span className="flex shrink-0 items-center justify-center">
                            {option.icon}
                          </span>
                        ) : null}
                        <span>{option.label}</span>
                      </span>
                    </motion.button>
                  );
                })}
              </motion.div>
            </motion.div>
          ) : (
            <motion.div
              ref={triggerRef}
              key="floating-select-trigger"
              data-slot="floating-select-trigger-wrapper"
              initial={shouldReduceMotion ? false : { opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={shouldReduceMotion ? undefined : { opacity: 0 }}
              transition={{ ...PANEL_TRANSITION, ...reduceMotionTransition }}
              className="flex w-fit items-center p-1"
            >
              <button
                type="button"
                aria-haspopup="listbox"
                aria-expanded={open}
                data-slot="floating-select-trigger"
                onClick={handleOpen}
                className={cn(
                  "group relative flex h-8 items-center gap-2 overflow-hidden rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
                  triggerClassName,
                )}
              >
                {icon ? (
                  <span className="relative z-10 flex shrink-0 items-center justify-center">
                    {icon}
                  </span>
                ) : null}
                {label ? (
                  <span className="relative z-10 text-foreground">
                    {label}
                  </span>
                ) : null}
                {showSelectedValue && selectedOption ? (
                  <span className="relative z-10 text-muted-foreground">
                    {selectedOption.label}
                  </span>
                ) : null}
              </button>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>
    </LayoutGroup>
  );

  if (placement === "inline") {
    return (
      <div
        {...props}
        className={cn("relative flex justify-center", wrapperClassName)}
        style={{
          ...(open && inlineAnchorSize ? inlineAnchorSize : undefined),
          ...props.style,
        }}
      >
        <div
          className={cn(
            open && "absolute left-1/2 -translate-x-1/2",
            open && (position === "top" ? "top-0" : "bottom-0"),
          )}
        >
          {shell}
        </div>
      </div>
    );
  }

  return (
    <div
      {...props}
      className={cn(
        "pointer-events-none fixed inset-x-0 z-50 flex translate-z-0 px-4",
        position === "top" ? "top-0" : "bottom-0",
        alignClass,
        wrapperClassName,
      )}
      style={{
        ...(position === "top"
          ? { paddingTop: `max(${offset}px, env(safe-area-inset-top))` }
          : { paddingBottom: `max(${offset}px, env(safe-area-inset-bottom))` }),
        ...props.style,
      }}
    >
      {shell}
    </div>
  );
}

Installation