{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-multi-select",
  "type": "registry:ui",
  "title": "Animated Multi Select",
  "description": "An Animated Multi Select component for displaying a multi-select dropdown.",
  "files": [
    {
      "path": "src/registry/ui/animated-multi-select.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, ChevronDown, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport {\n  Popover,\n  PopoverAnchor,\n  PopoverContent,\n} from \"@/components/ui/popover\";\n\nexport type Option = {\n  value: string;\n  label: string;\n  disabled?: boolean;\n};\n\nexport type AnimatedMultiSelectProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\" | \"value\"\n> & {\n  options: Option[];\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  placeholder?: string;\n  emptyText?: React.ReactNode;\n  /** Number of chips shown before collapsing the rest into a \"+N\" chip. */\n  maxCount?: number;\n  disabled?: boolean;\n};\n\nexport function AnimatedMultiSelect({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  placeholder = \"Search...\",\n  emptyText = \"No results found.\",\n  maxCount,\n  disabled = false,\n  className,\n  ...props\n}: AnimatedMultiSelectProps) {\n  const reduceMotion = useReducedMotion();\n  const anchorRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [open, setOpen] = useState(false);\n  const [search, setSearch] = useState(\"\");\n  const [uncontrolledValue, setUncontrolledValue] = useState<string[]>(\n    defaultValue ?? []\n  );\n  const selected = value ?? uncontrolledValue;\n\n  const setSelected = (next: string[]) => {\n    if (value === undefined) setUncontrolledValue(next);\n    onValueChange?.(next);\n  };\n\n  const toggle = (optionValue: string) => {\n    setSelected(\n      selected.includes(optionValue)\n        ? selected.filter((v) => v !== optionValue)\n        : [...selected, optionValue]\n    );\n    setSearch(\"\");\n  };\n\n  const remove = (optionValue: string) => {\n    setSelected(selected.filter((v) => v !== optionValue));\n    inputRef.current?.focus();\n  };\n\n  const labelFor = (optionValue: string) =>\n    options.find((option) => option.value === optionValue)?.label ??\n    optionValue;\n\n  const visible =\n    maxCount === undefined ? selected : selected.slice(0, maxCount);\n  const hiddenCount = selected.length - visible.length;\n\n  const chipMotion = reduceMotion\n    ? // Reduced motion drops the scale but keeps the fade, so changes still read.\n      {\n        initial: { opacity: 0 },\n        animate: { opacity: 1 },\n        exit: { opacity: 0 },\n        transition: { duration: 0.15 },\n      }\n    : {\n        initial: { opacity: 0, scale: 0.95 },\n        animate: { opacity: 1, scale: 1 },\n        exit: { opacity: 0, scale: 0.95 },\n        transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] as const },\n      };\n\n  return (\n    <Command\n      data-slot=\"animated-multi-select\"\n      loop\n      className={cn(\n        \"h-auto w-full overflow-visible bg-transparent text-foreground\",\n        className\n      )}\n      {...props}\n    >\n      <Popover open={open && !disabled} onOpenChange={setOpen}>\n        <PopoverAnchor asChild>\n          <div\n            ref={anchorRef}\n            data-disabled={disabled || undefined}\n            onClick={() => {\n              if (disabled) return;\n              setOpen(true);\n              inputRef.current?.focus();\n            }}\n            className={cn(\n              \"flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-2 py-1.5 text-sm shadow-xs transition-[color,box-shadow]\",\n              \"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50\",\n              \"data-disabled:cursor-not-allowed data-disabled:opacity-50\"\n            )}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {visible.map((optionValue) => {\n                const label = labelFor(optionValue);\n                return (\n                  <motion.span\n                    key={optionValue}\n                    layout={!reduceMotion}\n                    {...chipMotion}\n                    className=\"inline-flex items-center gap-1 rounded-md bg-secondary py-0.5 pr-1 pl-2 text-xs font-medium text-secondary-foreground\"\n                  >\n                    {label}\n                    <button\n                      type=\"button\"\n                      aria-label={`Remove ${label}`}\n                      disabled={disabled}\n                      onClick={(event) => {\n                        event.stopPropagation();\n                        remove(optionValue);\n                      }}\n                      className=\"rounded-sm p-0.5 text-muted-foreground outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                    >\n                      <X className=\"size-3\" aria-hidden />\n                    </button>\n                  </motion.span>\n                );\n              })}\n              {hiddenCount > 0 && (\n                <motion.span\n                  key=\"overflow\"\n                  layout={!reduceMotion}\n                  {...chipMotion}\n                  className=\"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium text-muted-foreground\"\n                >\n                  +{hiddenCount}\n                </motion.span>\n              )}\n            </AnimatePresence>\n\n            <CommandPrimitive.Input\n              ref={inputRef}\n              value={search}\n              onValueChange={(next) => {\n                setSearch(next);\n                setOpen(true);\n              }}\n              onFocus={() => setOpen(true)}\n              onKeyDown={(event) => {\n                if (\n                  event.key === \"Backspace\" &&\n                  search === \"\" &&\n                  selected.length > 0\n                ) {\n                  setSelected(selected.slice(0, -1));\n                }\n              }}\n              disabled={disabled}\n              aria-label={placeholder}\n              placeholder={selected.length === 0 ? placeholder : undefined}\n              className=\"min-w-24 flex-1 bg-transparent px-1 py-0.5 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\"\n            />\n\n            <button\n              type=\"button\"\n              aria-label={open ? \"Hide options\" : \"Show options\"}\n              disabled={disabled}\n              onClick={(event) => {\n                event.stopPropagation();\n                setOpen(!open);\n              }}\n              className=\"ml-auto rounded-sm p-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              <ChevronDown\n                aria-hidden\n                className={cn(\n                  \"size-4 transition-transform motion-reduce:transition-none\",\n                  open && \"rotate-180\"\n                )}\n              />\n            </button>\n          </div>\n        </PopoverAnchor>\n\n        <PopoverContent\n          align=\"start\"\n          className=\"w-(--radix-popover-trigger-width) p-0\"\n          // Keep focus in the search input so typing and arrow keys keep working.\n          onOpenAutoFocus={(event) => event.preventDefault()}\n          onInteractOutside={(event) => {\n            if (anchorRef.current?.contains(event.target as Node)) {\n              event.preventDefault();\n            }\n          }}\n        >\n          <CommandList>\n            <CommandEmpty>{emptyText}</CommandEmpty>\n            <CommandGroup>\n              {options.map((option) => {\n                const isSelected = selected.includes(option.value);\n                return (\n                  <CommandItem\n                    key={option.value}\n                    value={option.value}\n                    keywords={[option.label]}\n                    disabled={option.disabled}\n                    aria-checked={isSelected}\n                    onSelect={() => toggle(option.value)}\n                  >\n                    <span className=\"flex-1 truncate\">{option.label}</span>\n                    <Check\n                      aria-hidden\n                      className={cn(\n                        \"text-primary\",\n                        isSelected ? \"opacity-100\" : \"opacity-0\"\n                      )}\n                    />\n                  </CommandItem>\n                );\n              })}\n            </CommandGroup>\n          </CommandList>\n        </PopoverContent>\n      </Popover>\n    </Command>\n  );\n}\n"
    }
  ],
  "dependencies": [
    "cmdk",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "command",
    "popover"
  ]
}
