{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "checkbox-group",
  "title": "Checkbox Group",
  "description": "Animated checkbox group with fluid hover, contiguous selection merging, and spring-animated check marks. Built on Radix UI Checkbox.",
  "dependencies": [
    "framer-motion",
    "@radix-ui/react-checkbox"
  ],
  "registryDependencies": [
    "utils",
    "https://www.fluidfunctionalism.com/r/springs.json",
    "https://www.fluidfunctionalism.com/r/font-weight.json",
    "https://www.fluidfunctionalism.com/r/shape-context.json",
    "https://www.fluidfunctionalism.com/r/use-fluid-hover.json",
    "https://www.fluidfunctionalism.com/r/use-merge-split.json",
    "https://www.fluidfunctionalism.com/r/size-context.json",
    "https://www.fluidfunctionalism.com/r/tokens.json"
  ],
  "files": [
    {
      "path": "registry/radix/checkbox-group.tsx",
      "content": "\"use client\";\n\nimport {\n  useRef,\n  useState,\n  useEffect,\n  createContext,\n  useContext,\n  forwardRef,\n  type ReactNode,\n  type HTMLAttributes,\n} from \"react\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\";\nimport { cn } from \"@/lib/utils\";\nimport { spring } from \"@/lib/springs\";\nimport { fontWeights } from \"@/lib/font-weight\";\nimport { useFluidHover, useRegisterFluidHoverItem } from \"@/hooks/use-fluid-hover\";\nimport { useMergeSplitBlocks, SelectionBackgrounds } from \"@/hooks/use-merge-split\";\nimport { useShape } from \"@/lib/shape-context\";\nimport { SizeProvider, useSize, type SizeVariant } from \"@/lib/size-context\";\nimport { FluidHoverHighlight } from \"@/components/ui/fluid-hover-highlight\";\n\ninterface CheckboxGroupContextValue {\n  registerItem: (index: number, element: HTMLElement | null) => void;\n  activeIndex: number | null;\n}\n\nconst CheckboxGroupContext = createContext<CheckboxGroupContextValue | null>(\n  null\n);\n\nfunction useCheckboxGroup() {\n  const ctx = useContext(CheckboxGroupContext);\n  if (!ctx)\n    throw new Error(\"useCheckboxGroup must be used within a CheckboxGroup\");\n  return ctx;\n}\n\ninterface CheckboxGroupProps extends HTMLAttributes<HTMLDivElement> {\n  children: ReactNode;\n  checkedIndices: Set<number>;\n  /** Pins the group's rows to one step of the size ladder (default 36px,\n   *  compact 28px — see /docs/sizes). Omitted, it follows the surrounding\n   *  SizeProvider. */\n  size?: SizeVariant;\n}\n\nconst CheckboxGroup = forwardRef<HTMLDivElement, CheckboxGroupProps>(\n  ({ children, checkedIndices, size, className, ...props }, ref) => {\n    const containerRef = useRef<HTMLDivElement>(null);\n    const groupIdCounter = useRef(0);\n    const prevGroupMap = useRef(new Map<number, number>());\n\n    const hover = useFluidHover(containerRef);\n    const {\n      activeIndex,\n      setActiveIndex,\n      itemRects,\n      handlers,\n      registerItem,\n    } = hover;\n\n    // Group contiguous checked indices into runs with stable IDs\n    const runs: { start: number; end: number }[] = [];\n    const sortedChecked = [...checkedIndices].sort((a, b) => a - b);\n    for (const idx of sortedChecked) {\n      const last = runs[runs.length - 1];\n      if (last && idx === last.end + 1) {\n        last.end = idx;\n      } else {\n        runs.push({ start: idx, end: idx });\n      }\n    }\n\n    // Assign stable IDs: reuse previous ID if any member overlaps\n    const usedIds = new Set<number>();\n    const newGroupMap = new Map<number, number>();\n    const checkedGroups = runs.map((run) => {\n      let stableId: number | null = null;\n      for (let i = run.start; i <= run.end; i++) {\n        const prevId = prevGroupMap.current.get(i);\n        if (prevId !== undefined && !usedIds.has(prevId)) {\n          stableId = prevId;\n          break;\n        }\n      }\n      const id = stableId ?? ++groupIdCounter.current;\n      usedIds.add(id);\n      for (let i = run.start; i <= run.end; i++) {\n        newGroupMap.set(i, id);\n      }\n      return { ...run, id };\n    });\n    prevGroupMap.current = newGroupMap;\n\n    const [focusedIndex, setFocusedIndex] = useState<number | null>(null);\n\n    const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;\n    const shape = useShape();\n\n    // Selected backgrounds, with the merge/split boundary animation when one\n    // unchecked row bridges or splits two checked runs.\n    const blocks = useMergeSplitBlocks(checkedGroups, itemRects, shape.mergedRadius);\n\n    const group = (\n      <CheckboxGroupContext.Provider value={{ registerItem, activeIndex }}>\n        <div\n          ref={(node) => {\n            (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;\n            if (typeof ref === \"function\") ref(node);\n            else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\n          }}\n          onMouseEnter={handlers.onMouseEnter}\n          onMouseMove={handlers.onMouseMove}\n          onMouseLeave={handlers.onMouseLeave}\n          onClick={handlers.onClick}\n          onFocus={(e) => {\n            const indexAttr = (e.target as HTMLElement)\n              .closest(\"[data-fluid-hover-index]\")\n              ?.getAttribute(\"data-fluid-hover-index\");\n            if (indexAttr != null) {\n              const idx = Number(indexAttr);\n              setActiveIndex(idx);\n              setFocusedIndex(\n                (e.target as HTMLElement).matches(\":focus-visible\") ? idx : null\n              );\n            }\n          }}\n          onBlur={(e) => {\n            // Don't clear hover when focus moves to another item within the group\n            if (containerRef.current?.contains(e.relatedTarget as Node)) return;\n            setFocusedIndex(null);\n            setActiveIndex(null);\n          }}\n          onKeyDown={(e) => {\n            // Scope to row wrappers only. The inner checkbox primitive also\n            // carries role=\"checkbox\", so a bare [role=\"checkbox\"] selector\n            // matches twice per row and arrows skip onto the hidden control.\n            const items = Array.from(\n              containerRef.current?.querySelectorAll(\"[data-fluid-hover-index]\") ?? []\n            ) as HTMLElement[];\n            const currentIdx = items.indexOf(e.target as HTMLElement);\n            if (currentIdx === -1) return;\n\n            if ([\"ArrowDown\", \"ArrowUp\"].includes(e.key)) {\n              e.preventDefault();\n              const next = e.key === \"ArrowDown\"\n                ? (currentIdx + 1) % items.length\n                : (currentIdx - 1 + items.length) % items.length;\n              items[next].focus();\n            } else if (e.key === \"Home\") {\n              e.preventDefault();\n              items[0]?.focus();\n            } else if (e.key === \"End\") {\n              e.preventDefault();\n              items[items.length - 1]?.focus();\n            }\n          }}\n          role=\"group\"\n          className={cn(\n            \"relative flex flex-col w-72 max-w-full select-none\",\n            className\n          )}\n          {...props}\n        >\n          {/* Selected backgrounds (merged for contiguous checked items).\n              A run is normally one block; mid merge/split it is drawn as two\n              abutting halves — see useMergeSplitBlocks. */}\n          <SelectionBackgrounds blocks={blocks} />\n\n          {/* Hover background */}\n          <FluidHoverHighlight\n            hover={hover}\n            className={shape.bg}\n          />\n\n          {/* Focus ring */}\n          <AnimatePresence>\n            {focusRect && (\n              <motion.div\n                className={`absolute ${shape.focusRing} pointer-events-none z-20 border border-[color:var(--focus-ring,#6B97FF)]`}\n                initial={false}\n                animate={{\n                  left: focusRect.left - 2,\n                  top: focusRect.top - 2,\n                  width: focusRect.width + 4,\n                  height: focusRect.height + 4,\n                }}\n                exit={{ opacity: 0, transition: spring.fast.exit }}\n                transition={{\n                  ...spring.fast,\n                  opacity: { duration: 0.08 },\n                }}\n              />\n            )}\n          </AnimatePresence>\n\n          {children}\n        </div>\n      </CheckboxGroupContext.Provider>\n    );\n\n    // A size prop pins every row in the group to one ladder step.\n    return size ? <SizeProvider size={size}>{group}</SizeProvider> : group;\n  }\n);\n\nCheckboxGroup.displayName = \"CheckboxGroup\";\n\ninterface CheckboxItemProps extends HTMLAttributes<HTMLDivElement> {\n  label: string;\n  index: number;\n  checked: boolean;\n  onToggle: () => void;\n}\n\nconst CheckboxItem = forwardRef<HTMLDivElement, CheckboxItemProps>(\n  ({ label, index, checked, onToggle, className, ...props }, ref) => {\n    const internalRef = useRef<HTMLDivElement>(null);\n    const hasMounted = useRef(false);\n    const { registerItem, activeIndex } = useCheckboxGroup();\n\n    useRegisterFluidHoverItem(registerItem, index, internalRef);\n\n    useEffect(() => {\n      hasMounted.current = true;\n    }, []);\n\n    const isActive = activeIndex === index;\n    const skipAnimation = !hasMounted.current;\n    const shape = useShape();\n    const sizeClasses = useSize();\n    const compact = sizeClasses.variant === \"compact\";\n\n    return (\n      <div\n        ref={(node) => {\n          (internalRef as React.MutableRefObject<HTMLDivElement | null>).current = node;\n          if (typeof ref === \"function\") ref(node);\n          else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\n        }}\n        data-fluid-hover-index={index}\n        tabIndex={0}\n        role=\"checkbox\"\n        aria-checked={checked}\n        aria-label={label}\n        onClick={onToggle}\n        onMouseDown={(e) => {\n          // Clicking the 15px checkbox square would natively focus the hidden\n          // primitive (nearest focusable ancestor of the click target), after\n          // which arrow-key nav dead-zones: the group keydown handler can't\n          // find the target among the row wrappers. Prevent the native focus\n          // move (click still fires) and land focus on the row instead. Skip\n          // genuinely interactive children so we don't hijack their focus.\n          const interactive = (e.target as HTMLElement).closest(\n            'button:not([tabindex=\"-1\"]), a[href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n          );\n          if (interactive && interactive !== e.currentTarget) return;\n          e.preventDefault();\n          e.currentTarget.focus();\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \" \" || e.key === \"Enter\") {\n            e.preventDefault();\n            onToggle();\n          }\n        }}\n        className={cn(\n          // Fixed height (was py-1.5 around a 19.5px line box ≈ 31.5px) so the\n          // text-box trim on the label doesn't shrink the row.\n          `relative z-10 flex ${sizeClasses.control} items-center ${sizeClasses.gap} ${shape.item} ${sizeClasses.px} cursor-pointer outline-none`,\n          className\n        )}\n        {...props}\n      >\n        {/* Checkbox — Radix primitive for accessibility */}\n        <CheckboxPrimitive.Root\n          checked={checked}\n          onCheckedChange={() => onToggle()}\n          tabIndex={-1}\n          aria-hidden\n          className={cn(\n            \"relative shrink-0 appearance-none bg-transparent p-0 border-0 outline-none cursor-pointer\",\n            compact ? \"w-[14px] h-[14px]\" : \"w-[16px] h-[16px]\"\n          )}\n          onClick={(e) => e.stopPropagation()}\n        >\n          {/* Border */}\n          <div\n            className={cn(\n              \"absolute inset-0 border-solid transition-all duration-80\",\n              compact ? \"rounded-[4px]\" : \"rounded-[5px]\",\n              checked\n                ? \"border-[1.5px] border-transparent\"\n                : isActive\n                ? \"border-[1.5px] border-neutral-400 dark:border-neutral-500\"\n                : \"border-[1.5px] border-border\"\n            )}\n          />\n          {/* Check mark */}\n          <AnimatePresence>\n            {checked && (\n              <CheckboxPrimitive.Indicator forceMount asChild>\n                <motion.svg\n                  width={compact ? 16 : 18}\n                  height={compact ? 16 : 18}\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth={2}\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  className=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-foreground\"\n                  initial={{ opacity: 1 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 1 }}\n                >\n                  <motion.path\n                    d=\"M6 12L10 16L18 8\"\n                    initial={{\n                      pathLength: skipAnimation ? 1 : 0,\n                    }}\n                    animate={{\n                      pathLength: 1,\n                      transition: {\n                        duration: 0.08,\n                        ease: \"easeOut\",\n                      },\n                    }}\n                    exit={{\n                      pathLength: 0,\n                      transition: {\n                        duration: 0.04,\n                        ease: \"easeIn\",\n                      },\n                    }}\n                  />\n                </motion.svg>\n              </CheckboxPrimitive.Indicator>\n            )}\n          </AnimatePresence>\n        </CheckboxPrimitive.Root>\n\n        {/* Label */}\n        {/* Both stacked spans carry the text-box trim so the invisible bold\n            sizer and the visible label keep identical boxes. */}\n        <span className={cn(\"inline-grid\", sizeClasses.text)}>\n          <span\n            className=\"col-start-1 row-start-1 invisible [text-box:trim-both_cap_alphabetic]\"\n            style={{ fontVariationSettings: fontWeights.semibold }}\n            aria-hidden=\"true\"\n          >\n            {label}\n          </span>\n          <span\n            className={cn(\n              \"col-start-1 row-start-1 transition-[color,font-variation-settings] duration-80 [text-box:trim-both_cap_alphabetic]\",\n              checked || isActive\n                ? \"text-foreground\"\n                : \"text-muted-foreground\"\n            )}\n            style={{\n              fontVariationSettings: checked\n                ? fontWeights.semibold\n                : fontWeights.normal,\n            }}\n          >\n            {label}\n          </span>\n        </span>\n      </div>\n    );\n  }\n);\n\nCheckboxItem.displayName = \"CheckboxItem\";\n\nexport { CheckboxGroup, CheckboxItem };\nexport default CheckboxGroup;\n",
      "type": "registry:ui",
      "target": "components/ui/checkbox-group.tsx"
    }
  ],
  "docs": "Docs & live playground: https://www.fluidfunctionalism.com/docs/checkbox-group.",
  "categories": [
    "components"
  ],
  "type": "registry:ui"
}
