{
  "name": "team-selector",
  "type": "registry:component",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Team Selector\n * @version: 3.0.0\n * @date: 2026-04-23\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { Minus, Plus } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport Image from \"next/image\";\nimport { useRef, useState } from \"react\";\n\nconst AVATAR_OVERLAP = 12;\nconst DICEBEAR_STYLE = \"notionists-neutral\";\nconst dicebearUrl = (seed: string) =>\n  `https://api.dicebear.com/9.x/${DICEBEAR_STYLE}/svg?seed=${encodeURIComponent(seed)}&backgroundColor=f4f4f5,e4e4e7,d4d4d8,a1a1aa`;\n\ninterface TeamMember {\n  id: string;\n  name: string;\n  avatarUrl: string;\n}\n\nconst DEFAULT_MEMBERS: TeamMember[] = [\n  { id: \"member-1\", name: \"Alex Rivera\", avatarUrl: dicebearUrl(\"Alex\") },\n  { id: \"member-2\", name: \"Blair Kim\", avatarUrl: dicebearUrl(\"Blair\") },\n  { id: \"member-3\", name: \"Casey Lin\", avatarUrl: dicebearUrl(\"Casey\") },\n  { id: \"member-4\", name: \"Devon Marsh\", avatarUrl: dicebearUrl(\"Devon\") },\n];\n\nconst animations = {\n  avatar: {\n    visible: {\n      opacity: 1,\n      scale: 1,\n      transition: {\n        type: \"spring\",\n        stiffness: 260,\n        damping: 22,\n        mass: 0.6,\n      },\n    },\n    hidden: {\n      opacity: 0,\n      scale: 0.85,\n      transition: { duration: 0.18, ease: \"easeOut\" },\n    },\n  } satisfies Variants,\n  vibration: {\n    idle: { x: 0 },\n    shake: {\n      x: [-3, 3, -2, 2, 0] as const,\n      transition: { duration: 0.28, ease: \"easeOut\" },\n    },\n  } satisfies Variants,\n} as const;\n\ninterface TeamSelectorProps {\n  members?: TeamMember[];\n  defaultValue?: number;\n  onChange?: (size: number) => void;\n  label?: string;\n  className?: string;\n}\n\nexport default function TeamSelector({\n  members = DEFAULT_MEMBERS,\n  defaultValue = 1,\n  onChange,\n  label = \"Team Size\",\n  className = \"\",\n}: TeamSelectorProps) {\n  const maxTeamSize = members.length;\n  const [peopleCount, setPeopleCount] = useState(defaultValue);\n  const [isVibrating, setIsVibrating] = useState(false);\n  const directionRef = useRef<1 | -1>(1);\n  const prefersReducedMotion = useReducedMotion();\n\n  const triggerVibration = () => {\n    if (prefersReducedMotion) {\n      return;\n    }\n    setIsVibrating(true);\n    setTimeout(() => setIsVibrating(false), 280);\n  };\n\n  const handleIncrement = (e: React.MouseEvent | React.KeyboardEvent) => {\n    e.preventDefault();\n    if (peopleCount < maxTeamSize) {\n      directionRef.current = 1;\n      const newCount = peopleCount + 1;\n      setPeopleCount(newCount);\n      onChange?.(newCount);\n    } else {\n      triggerVibration();\n    }\n  };\n\n  const handleDecrement = (e: React.MouseEvent | React.KeyboardEvent) => {\n    e.preventDefault();\n    if (peopleCount > 1) {\n      directionRef.current = -1;\n      const newCount = peopleCount - 1;\n      setPeopleCount(newCount);\n      onChange?.(newCount);\n    } else {\n      triggerVibration();\n    }\n  };\n\n  const handleKeyDown = (\n    e: React.KeyboardEvent,\n    action: \"increment\" | \"decrement\"\n  ) => {\n    if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      if (action === \"increment\") {\n        handleIncrement(e);\n      } else {\n        handleDecrement(e);\n      }\n    }\n  };\n\n  const counterDistance = prefersReducedMotion ? 0 : 10;\n\n  return (\n    <div\n      className={`flex w-full flex-col items-center justify-center ${className}`}\n    >\n      <div className=\"w-full max-w-xs rounded-2xl border border-zinc-200/80 bg-white p-5 shadow-[0_1px_4px_rgba(0,0,0,0.04)] dark:border-white/8 dark:bg-zinc-900/60\">\n        <fieldset>\n          <legend className=\"mb-5 w-full font-medium text-[11px] text-zinc-400 uppercase tracking-[0.14em] dark:text-zinc-500\">\n            {label}\n          </legend>\n\n          <div className=\"mb-7 flex justify-center\">\n            <div className=\"flex items-center\">\n              {members.map((member, index) => (\n                <motion.div\n                  animate={index < peopleCount ? \"visible\" : \"hidden\"}\n                  className=\"flex items-center justify-center\"\n                  initial={index < defaultValue ? \"visible\" : \"hidden\"}\n                  key={member.id}\n                  style={{\n                    marginLeft: index === 0 ? 0 : -AVATAR_OVERLAP,\n                    zIndex: maxTeamSize - index,\n                  }}\n                  variants={animations.avatar}\n                >\n                  <Image\n                    alt={member.name}\n                    className=\"size-11 rounded-full border-2 border-white bg-zinc-100 object-cover shadow-[0_2px_8px_rgba(0,0,0,0.08)] ring-1 ring-black/5 dark:border-zinc-900 dark:bg-zinc-800 dark:ring-white/5\"\n                    height={88}\n                    src={member.avatarUrl}\n                    unoptimized\n                    width={88}\n                  />\n                </motion.div>\n              ))}\n            </div>\n          </div>\n\n          <motion.div\n            animate={isVibrating ? \"shake\" : \"idle\"}\n            className=\"flex items-center justify-center gap-5\"\n            initial=\"idle\"\n            variants={animations.vibration}\n          >\n            <button\n              aria-label=\"Decrease team size\"\n              className=\"flex size-9 items-center justify-center rounded-xl border border-zinc-200/80 bg-white text-zinc-500 transition-all duration-150 hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400/40 focus-visible:ring-offset-2 active:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white dark:border-white/8 dark:bg-zinc-900 dark:text-zinc-400 dark:active:bg-zinc-800/80 dark:focus-visible:ring-zinc-500/40 dark:focus-visible:ring-offset-zinc-900 dark:hover:border-white/16 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:disabled:hover:bg-zinc-900\"\n              disabled={peopleCount <= 1}\n              onClick={handleDecrement}\n              onKeyDown={(e) => handleKeyDown(e, \"decrement\")}\n              type=\"button\"\n            >\n              <Minus aria-hidden=\"true\" className=\"size-3.5\" strokeWidth={2} />\n            </button>\n\n            <div className=\"flex min-w-16 flex-col items-center\">\n              <div className=\"relative h-9 overflow-hidden\">\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.output\n                    animate={{ opacity: 1, y: 0 }}\n                    aria-live=\"polite\"\n                    className=\"block select-none font-semibold text-3xl text-zinc-900 tabular-nums dark:text-zinc-100\"\n                    exit={{\n                      opacity: 0,\n                      y: directionRef.current * -counterDistance,\n                      transition: { duration: 0.14, ease: \"easeIn\" },\n                    }}\n                    initial={{\n                      opacity: 0,\n                      y: directionRef.current * counterDistance,\n                    }}\n                    key={peopleCount}\n                    transition={{ duration: 0.2, ease: \"easeOut\" }}\n                  >\n                    {peopleCount}\n                  </motion.output>\n                </AnimatePresence>\n              </div>\n              <span className=\"text-[11px] text-zinc-400 dark:text-zinc-500\">\n                {peopleCount === 1 ? \"member\" : \"members\"}\n              </span>\n            </div>\n\n            <button\n              aria-label=\"Increase team size\"\n              className=\"flex size-9 items-center justify-center rounded-xl border border-zinc-200/80 bg-white text-zinc-500 transition-all duration-150 hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400/40 focus-visible:ring-offset-2 active:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white dark:border-white/8 dark:bg-zinc-900 dark:text-zinc-400 dark:active:bg-zinc-800/80 dark:focus-visible:ring-zinc-500/40 dark:focus-visible:ring-offset-zinc-900 dark:hover:border-white/16 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:disabled:hover:bg-zinc-900\"\n              disabled={peopleCount >= maxTeamSize}\n              onClick={handleIncrement}\n              onKeyDown={(e) => handleKeyDown(e, \"increment\")}\n              type=\"button\"\n            >\n              <Plus aria-hidden=\"true\" className=\"size-3.5\" strokeWidth={2} />\n            </button>\n          </motion.div>\n        </fieldset>\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/team-selector.tsx",
      "target": "components/kokonutui/team-selector.tsx"
    }
  ]
}