{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "floating-select",
  "title": "Floating Select",
  "description": "A single-button floating select that expands into an animated option list.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/base/ui/floating-select.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype FloatingSelectOptionBase = {\n  /** What renders for this option. Can be text or any node. */\n  label: React.ReactNode;\n  /** Optional leading node (icon, swatch, etc.) shown before the label. */\n  icon?: React.ReactNode;\n};\n\nexport type FloatingSelectOption = FloatingSelectOptionBase &\n  (\n    | {\n        /** Stable value passed back in callbacks. */\n        value: string;\n        /** @deprecated Use `value`. Kept for compatibility with earlier versions. */\n        id?: string;\n      }\n    | {\n        /** @deprecated Use `value`. Kept for compatibility with earlier versions. */\n        id: string;\n        /** Stable value passed back in callbacks. */\n        value?: string;\n      }\n  );\n\nexport interface FloatingSelectProps {\n  /** The choices revealed when the button is opened. */\n  options: FloatingSelectOption[];\n  /** Trigger label shown before the selected value. */\n  label?: React.ReactNode;\n  /** Optional leading icon for the trigger. */\n  icon?: React.ReactNode;\n  /** Uncontrolled initial selected option value. */\n  defaultValue?: string;\n  /** Controlled selected option value. */\n  value?: string;\n  /** Fires with the selected option whenever it changes. */\n  onValueChange?: (value: string, option: FloatingSelectOption) => void;\n  /** @deprecated Use `onValueChange`. Kept for compatibility with earlier versions. */\n  onChange?: (value: string, option: FloatingSelectOption) => void;\n  /** Close the panel after selecting an option. */\n  closeOnSelect?: boolean;\n  /** Show the selected value next to the trigger label. */\n  showSelectedValue?: boolean;\n  /** Pin the select to the viewport, or render it in normal document flow. */\n  placement?: \"fixed\" | \"inline\";\n  /** Pin the select to the top or bottom of the viewport. */\n  position?: \"top\" | \"bottom\";\n  /** Horizontal alignment of the floating select. */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Distance from the pinned edge, in pixels. */\n  offset?: number;\n  /** Play the entrance animation on mount. */\n  reveal?: boolean;\n  /** Classes applied to the floating shell. */\n  className?: string;\n  /** Classes applied to the trigger button. */\n  triggerClassName?: string;\n  /** Classes applied to the options panel. */\n  panelClassName?: string;\n}\n\nconst EASE_OUT = [0.22, 1, 0.36, 1] as const;\nconst SHELL_LAYOUT_DURATION = 0.26;\nconst PANEL_DURATION = 0.16;\nconst OPTION_STAGGER = 0.028;\nconst OPTION_DELAY = 0.04;\nconst OPTION_DURATION = 0.13;\nconst SHELL_TRANSITION = {\n  layout: { type: \"spring\", duration: SHELL_LAYOUT_DURATION, bounce: 0 },\n  opacity: { duration: PANEL_DURATION, ease: EASE_OUT },\n  y: { duration: 0.22, ease: EASE_OUT },\n} as const;\nconst PANEL_TRANSITION = { duration: PANEL_DURATION, ease: EASE_OUT };\nconst PANEL_EXIT_TRANSITION = { duration: 0.08, ease: EASE_OUT };\nconst ACTIVE_SURFACE_TRANSITION = {\n  type: \"spring\",\n  duration: 0.22,\n  bounce: 0,\n  opacity: { duration: 0.14, ease: EASE_OUT },\n} as const;\n\nfunction getCssPixels(value: string) {\n  const pixels = Number.parseFloat(value);\n\n  return Number.isFinite(pixels) ? pixels : 0;\n}\n\nfunction getOptionValue(option: FloatingSelectOption) {\n  return (option.value ?? option.id) as string;\n}\n\nfunction getOpenHoverLockDelay(optionCount: number) {\n  const listDuration =\n    OPTION_DELAY + Math.max(optionCount - 1, 0) * OPTION_STAGGER + OPTION_DURATION;\n\n  return Math.ceil(\n    Math.max(SHELL_LAYOUT_DURATION, PANEL_DURATION, listDuration) * 1000,\n  );\n}\n\nexport function FloatingSelect({\n  options,\n  label = \"Select\",\n  icon,\n  defaultValue,\n  value,\n  onValueChange,\n  onChange,\n  closeOnSelect = true,\n  showSelectedValue = true,\n  placement = \"fixed\",\n  position = \"bottom\",\n  align = \"center\",\n  offset = 16,\n  reveal = false,\n  className,\n  triggerClassName,\n  panelClassName,\n}: FloatingSelectProps) {\n  const shellRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLDivElement>(null);\n  const reactId = React.useId();\n  const listboxId = `${reactId}-listbox`;\n  const shouldReduceMotion = useReducedMotion();\n  const [open, setOpen] = React.useState(false);\n  const [optionHoverLocked, setOptionHoverLocked] = React.useState(false);\n  const [inlineAnchorSize, setInlineAnchorSize] = React.useState<{\n    width: number;\n    height: number;\n  } | null>(null);\n  const [internalSelected, setInternalSelected] = React.useState(\n    defaultValue ?? (options[0] ? getOptionValue(options[0]) : \"\"),\n  );\n\n  React.useEffect(() => {\n    if (!open) return;\n\n    function onKeyDown(event: KeyboardEvent) {\n      if (event.key === \"Escape\") {\n        setOpen(false);\n      }\n    }\n\n    window.addEventListener(\"keydown\", onKeyDown);\n\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [open]);\n\n  React.useEffect(() => {\n    if (!open) return;\n\n    function onPointerDown(event: PointerEvent) {\n      if (!shellRef.current?.contains(event.target as Node)) {\n        setOpen(false);\n      }\n    }\n\n    document.addEventListener(\"pointerdown\", onPointerDown);\n\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [open]);\n\n  React.useEffect(() => {\n    if (!open || shouldReduceMotion === true || !optionHoverLocked) return;\n\n    const timeout = window.setTimeout(() => {\n      setOptionHoverLocked(false);\n    }, getOpenHoverLockDelay(options.length));\n\n    return () => window.clearTimeout(timeout);\n  }, [open, optionHoverLocked, options.length, shouldReduceMotion]);\n\n  const selectedValue =\n    value ?? internalSelected ?? (options[0] ? getOptionValue(options[0]) : \"\");\n  const selectedOption = options.find(\n    (option) => getOptionValue(option) === selectedValue,\n  );\n  const alignClass =\n    align === \"start\"\n      ? \"justify-start\"\n      : align === \"end\"\n        ? \"justify-end\"\n        : \"justify-center\";\n  const transformOriginX =\n    align === \"start\" ? \"left\" : align === \"end\" ? \"right\" : \"center\";\n  const transformOrigin = `${transformOriginX} ${position}`;\n  const edgeOffset = (offset + 20) * (position === \"top\" ? -1 : 1);\n  const activeSurfaceLayoutId = shouldReduceMotion\n    ? undefined\n    : \"floating-select-active-surface\";\n  const optionHoverEnabled =\n    shouldReduceMotion === true || !optionHoverLocked;\n\n  const handleSelect = (option: FloatingSelectOption) => {\n    const nextValue = getOptionValue(option);\n\n    if (value === undefined) {\n      setInternalSelected(nextValue);\n    }\n\n    onValueChange?.(nextValue, option);\n\n    if (!onValueChange) {\n      onChange?.(nextValue, option);\n    }\n\n    if (closeOnSelect) {\n      setOpen(false);\n    }\n  };\n\n  const updateInlineAnchorSize = React.useCallback(\n    (nextSize: { width: number; height: number }) => {\n      setInlineAnchorSize((current) => {\n        if (\n          current?.width === nextSize.width &&\n          current.height === nextSize.height\n        ) {\n          return current;\n        }\n\n        return nextSize;\n      });\n    },\n    [],\n  );\n\n  const measureInlineAnchorSize = React.useCallback(() => {\n    if (placement !== \"inline\") return;\n\n    const trigger = triggerRef.current;\n\n    if (!trigger) return;\n\n    const { width, height } = trigger.getBoundingClientRect();\n    const shell = shellRef.current;\n    const shellStyles = shell ? window.getComputedStyle(shell) : null;\n    const borderX = shellStyles\n      ? getCssPixels(shellStyles.borderLeftWidth) +\n        getCssPixels(shellStyles.borderRightWidth)\n      : 0;\n    const borderY = shellStyles\n      ? getCssPixels(shellStyles.borderTopWidth) +\n        getCssPixels(shellStyles.borderBottomWidth)\n      : 0;\n\n    updateInlineAnchorSize({\n      width: width + borderX,\n      height: height + borderY,\n    });\n  }, [placement, updateInlineAnchorSize]);\n\n  const handleOpen = () => {\n    measureInlineAnchorSize();\n    setOptionHoverLocked(shouldReduceMotion !== true);\n    setOpen(true);\n  };\n\n  const reduceMotionTransition = shouldReduceMotion ? { duration: 0 } : {};\n  const listVariants = {\n    hidden: {},\n    visible: {\n      transition: {\n        staggerChildren: OPTION_STAGGER,\n        staggerDirection: position === \"bottom\" ? -1 : 1,\n        delayChildren: OPTION_DELAY,\n      },\n    },\n  };\n  const optionVariants = {\n    hidden: { opacity: 0, y: position === \"bottom\" ? 3 : -3 },\n    visible: {\n      opacity: 1,\n      y: 0,\n      transition: { duration: OPTION_DURATION, ease: EASE_OUT },\n    },\n  };\n\n  const shell = (\n    <LayoutGroup id={reactId}>\n      <motion.div\n        ref={shellRef}\n        layout\n        data-slot=\"floating-select\"\n        data-state={open ? \"open\" : \"closed\"}\n        initial={\n          reveal && !shouldReduceMotion\n            ? { opacity: 0, y: edgeOffset }\n            : false\n        }\n        animate={{ opacity: 1, y: 0 }}\n        transition={shouldReduceMotion ? { duration: 0 } : SHELL_TRANSITION}\n        className={cn(\n          \"pointer-events-auto flex w-fit flex-col overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-sm\",\n          className,\n        )}\n        style={{ transformOrigin }}\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {open ? (\n            <motion.div\n              key=\"floating-select-options\"\n              id={listboxId}\n              role=\"listbox\"\n              aria-label={typeof label === \"string\" ? label : \"Options\"}\n              data-slot=\"floating-select-listbox\"\n              initial={shouldReduceMotion ? false : { opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={\n                shouldReduceMotion\n                  ? undefined\n                  : { opacity: 0, transition: PANEL_EXIT_TRANSITION }\n              }\n              transition={{ ...PANEL_TRANSITION, ...reduceMotionTransition }}\n              className={cn(\"w-fit\", panelClassName)}\n            >\n              <motion.div\n                data-slot=\"floating-select-options\"\n                className=\"flex flex-col gap-1.5 p-2\"\n                variants={shouldReduceMotion ? undefined : listVariants}\n                initial={shouldReduceMotion ? false : \"hidden\"}\n                animate={shouldReduceMotion ? undefined : \"visible\"}\n              >\n                {options.map((option) => {\n                  const optionValue = getOptionValue(option);\n                  const active = optionValue === selectedValue;\n\n                  return (\n                    <motion.button\n                      key={optionValue}\n                      role=\"option\"\n                      aria-selected={active}\n                      type=\"button\"\n                      data-slot=\"floating-select-option\"\n                      data-active={active ? \"\" : undefined}\n                      variants={shouldReduceMotion ? undefined : optionVariants}\n                      onClick={() => handleSelect(option)}\n                      className={cn(\n                        \"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\",\n                        active\n                          ? \"text-primary-foreground\"\n                          : cn(\n                              \"text-muted-foreground\",\n                              optionHoverEnabled &&\n                                \"hover:bg-accent hover:text-accent-foreground\",\n                            ),\n                      )}\n                    >\n                      {active ? (\n                        <motion.span\n                          layoutId={activeSurfaceLayoutId}\n                          aria-hidden=\"true\"\n                          initial={\n                            shouldReduceMotion\n                              ? false\n                              : { opacity: 0, scale: 0.98 }\n                          }\n                          animate={{ opacity: 1, scale: 1 }}\n                          transition={ACTIVE_SURFACE_TRANSITION}\n                          className=\"absolute inset-0 rounded-md bg-primary\"\n                        />\n                      ) : null}\n                      <span className=\"relative z-10 flex items-center gap-2.5\">\n                        {option.icon ? (\n                          <span className=\"flex shrink-0 items-center justify-center\">\n                            {option.icon}\n                          </span>\n                        ) : null}\n                        <span>{option.label}</span>\n                      </span>\n                    </motion.button>\n                  );\n                })}\n              </motion.div>\n            </motion.div>\n          ) : (\n            <motion.div\n              ref={triggerRef}\n              key=\"floating-select-trigger\"\n              data-slot=\"floating-select-trigger-wrapper\"\n              initial={shouldReduceMotion ? false : { opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={shouldReduceMotion ? undefined : { opacity: 0 }}\n              transition={{ ...PANEL_TRANSITION, ...reduceMotionTransition }}\n              className=\"flex w-fit items-center p-1\"\n            >\n              <button\n                type=\"button\"\n                aria-haspopup=\"listbox\"\n                aria-expanded={open}\n                aria-controls={listboxId}\n                data-slot=\"floating-select-trigger\"\n                onClick={handleOpen}\n                className={cn(\n                  \"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\",\n                  triggerClassName,\n                )}\n              >\n                {icon ? (\n                  <span className=\"relative z-10 flex shrink-0 items-center justify-center\">\n                    {icon}\n                  </span>\n                ) : null}\n                {label ? (\n                  <span className=\"relative z-10 text-foreground\">\n                    {label}\n                  </span>\n                ) : null}\n                {showSelectedValue && selectedOption ? (\n                  <span className=\"relative z-10 text-muted-foreground\">\n                    {selectedOption.label}\n                  </span>\n                ) : null}\n              </button>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </motion.div>\n    </LayoutGroup>\n  );\n\n  if (placement === \"inline\") {\n    return (\n      <div\n        className=\"relative flex justify-center\"\n        style={open && inlineAnchorSize ? inlineAnchorSize : undefined}\n      >\n        <div\n          className={cn(\n            open && \"absolute left-1/2 -translate-x-1/2\",\n            open && (position === \"top\" ? \"top-0\" : \"bottom-0\"),\n          )}\n        >\n          {shell}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none fixed inset-x-0 z-50 flex translate-z-0 px-4\",\n        position === \"top\" ? \"top-0\" : \"bottom-0\",\n        alignClass,\n      )}\n      style={\n        position === \"top\"\n          ? { paddingTop: `max(${offset}px, env(safe-area-inset-top))` }\n          : { paddingBottom: `max(${offset}px, env(safe-area-inset-bottom))` }\n      }\n    >\n      {shell}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/floating-select.tsx"
    }
  ],
  "meta": {
    "tags": [
      "floating",
      "select",
      "single-trigger",
      "listbox"
    ],
    "effects": [
      "shared-layout",
      "stagger",
      "fade"
    ]
  },
  "categories": [
    "select",
    "button"
  ],
  "type": "registry:ui"
}