{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-checkbox",
  "type": "registry:ui",
  "title": "Animated Checkbox",
  "description": "A real checkbox with a drawn check, an indeterminate dash morph, a spring press and a tri-state select-all group.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/animated-checkbox.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport type CheckboxSize = \"sm\" | \"md\" | \"lg\";\n\nconst SIZES: Record<CheckboxSize, { box: string; text: string; gap: string; desc: string }> = {\n  sm: { box: \"size-4 rounded-[5px]\", text: \"text-[13px]\", gap: \"gap-2.5\", desc: \"text-[12px]\" },\n  md: { box: \"size-5 rounded-md\", text: \"text-[14px]\", gap: \"gap-3\", desc: \"text-[13px]\" },\n  lg: { box: \"size-6 rounded-[7px]\", text: \"text-[15px]\", gap: \"gap-3.5\", desc: \"text-[13px]\" },\n};\n\n// Both paths share one command structure (M, L, L), so Motion can morph between them.\nconst CHECK_PATH = \"M4.2 8.4 L6.9 11 L11.8 5.2\";\nconst DASH_PATH = \"M4.5 8 L8 8 L11.5 8\";\n\nexport interface AnimatedCheckboxProps\n  extends Omit<React.ComponentPropsWithoutRef<\"input\">, \"type\" | \"size\" | \"children\" | \"checked\" | \"defaultChecked\"> {\n  /** Controlled checked state. */\n  checked?: boolean;\n  /** Initial state when uncontrolled. */\n  defaultChecked?: boolean;\n  /** Mixed state. Written to the input's DOM `indeterminate` property (it has no HTML attribute). */\n  indeterminate?: boolean;\n  /** Called with the next checked state. The native `onChange` still fires too. */\n  onCheckedChange?: (checked: boolean) => void;\n  /** Visible label; clicking it toggles the box. */\n  label?: React.ReactNode;\n  /** Secondary text, linked to the input with aria-describedby. */\n  description?: React.ReactNode;\n  size?: CheckboxSize;\n  /** Fill colour of the checked box (any CSS colour). */\n  accent?: string;\n  /** Duration of the check stroke draw, in ms. */\n  duration?: number;\n  /** Scale of the box while pressed (1 disables the press spring). */\n  pressScale?: number;\n  /** Class names for the outer label element. */\n  className?: string;\n}\n\n/**\n * A checkbox built on a real, visually hidden <input type=\"checkbox\"> laid over a styled box,\n * so clicks, Space, form submission, validation and assistive tech all behave natively.\n * The check is drawn with an animated stroke (pathLength → stroke-dashoffset) and morphs into\n * a dash for the indeterminate state; pressing the box gives it a small spring.\n */\nexport function AnimatedCheckbox({\n  checked,\n  defaultChecked = false,\n  indeterminate = false,\n  onCheckedChange,\n  onChange,\n  label,\n  description,\n  size = \"md\",\n  accent = \"#FF7448\",\n  duration = 280,\n  pressScale = 0.85,\n  disabled,\n  id: idProp,\n  className = \"\",\n  style,\n  ...props\n}: AnimatedCheckboxProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const id = idProp ?? `cbx-${uid}`;\n  const descId = `${id}-desc`;\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const [inner, setInner] = React.useState(defaultChecked);\n  const isChecked = checked ?? inner;\n  const on = isChecked || indeterminate;\n  const s = SIZES[size];\n\n  // `indeterminate` exists only as a DOM property, so it is synced after every render\n  // (a native click clears it, and the prop must win again).\n  React.useEffect(() => {\n    if (inputRef.current) inputRef.current.indeterminate = indeterminate;\n  });\n\n  const seconds = reduceMotion ? 0 : duration / 1000;\n  const spring = { type: \"spring\" as const, stiffness: 600, damping: 22, mass: 0.6 };\n\n  return (\n    <motion.label\n      htmlFor={id}\n      initial={false}\n      whileTap={disabled || reduceMotion ? undefined : \"pressed\"}\n      className={`group/cbx inline-flex cursor-pointer items-start ${s.gap} has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 ${className}`}\n      style={{ [\"--cbx-accent\" as string]: accent, ...style }}\n    >\n      <span className=\"relative inline-grid shrink-0 place-items-center pt-px\">\n        <input\n          {...props}\n          ref={inputRef}\n          id={id}\n          type=\"checkbox\"\n          checked={isChecked}\n          disabled={disabled}\n          aria-describedby={description ? [descId, props[\"aria-describedby\"]].filter(Boolean).join(\" \") : props[\"aria-describedby\"]}\n          onChange={(event) => {\n            if (checked === undefined) setInner(event.target.checked);\n            onCheckedChange?.(event.target.checked);\n            onChange?.(event);\n          }}\n          className=\"peer absolute inset-0 z-10 m-0 size-full cursor-[inherit] appearance-none opacity-0\"\n        />\n        <motion.span\n          aria-hidden=\"true\"\n          variants={{ pressed: { scale: pressScale } }}\n          animate={{ scale: 1 }}\n          transition={spring}\n          className={`relative grid ${s.box} place-items-center border transition-[border-color,background-color,box-shadow] duration-150 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-solid peer-focus-visible:outline-[var(--cbx-accent)] motion-reduce:transition-none ${\n            on\n              ? \"border-[var(--cbx-accent)] bg-[var(--cbx-accent)]\"\n              : \"border-zinc-900/25 bg-white group-hover/cbx:border-zinc-900/45 dark:border-white/25 dark:bg-zinc-950 dark:group-hover/cbx:border-white/45\"\n          }`}\n        >\n          {/* A soft ring that blooms outward each time the box turns on. */}\n          <motion.span\n            className=\"pointer-events-none absolute inset-0 rounded-[inherit] border border-[var(--cbx-accent)]\"\n            initial={false}\n            animate={on && !reduceMotion ? { scale: [1, 1.7], opacity: [0.55, 0] } : { scale: 1, opacity: 0 }}\n            transition={{ duration: 0.45, ease: \"easeOut\" }}\n          />\n          <svg viewBox=\"0 0 16 16\" className=\"size-full text-white dark:text-zinc-950\" fill=\"none\">\n            <motion.path\n              initial={false}\n              animate={{ d: indeterminate ? DASH_PATH : CHECK_PATH, pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}\n              transition={{\n                pathLength: { duration: on ? seconds : seconds * 0.5, ease: [0.65, 0, 0.35, 1], delay: on && !reduceMotion ? 0.04 : 0 },\n                d: reduceMotion ? { duration: 0 } : spring,\n                opacity: { duration: on ? 0.01 : seconds * 0.5 },\n              }}\n              stroke=\"currentColor\"\n              strokeWidth={size === \"sm\" ? 2.2 : 2}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n        </motion.span>\n      </span>\n\n      {(label || description) && (\n        <span className=\"flex min-w-0 flex-col gap-0.5\">\n          {label && <span className={`${s.text} font-medium leading-tight text-zinc-900 dark:text-zinc-100`}>{label}</span>}\n          {description && (\n            <span id={descId} className={`${s.desc} leading-snug text-zinc-500 dark:text-zinc-400`}>\n              {description}\n            </span>\n          )}\n        </span>\n      )}\n    </motion.label>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*  CheckboxGroup                                                             */\n/* -------------------------------------------------------------------------- */\n\nexport interface CheckboxGroupOption {\n  value: string;\n  label: React.ReactNode;\n  description?: React.ReactNode;\n  disabled?: boolean;\n}\n\nexport interface CheckboxGroupProps\n  extends Omit<React.ComponentPropsWithoutRef<\"fieldset\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /** Group legend (rendered as a <legend>). */\n  legend: React.ReactNode;\n  options: CheckboxGroupOption[];\n  /** Controlled list of checked values. */\n  value?: string[];\n  /** Initial checked values when uncontrolled. */\n  defaultValue?: string[];\n  onChange?: (values: string[]) => void;\n  /** Label of the tri-state parent. Pass `false` to hide it. */\n  selectAll?: React.ReactNode | false;\n  /** Name submitted for each checked value in native forms. */\n  name?: string;\n  size?: CheckboxSize;\n  accent?: string;\n  duration?: number;\n  pressScale?: number;\n}\n\n/**\n * A fieldset of AnimatedCheckboxes with an optional tri-state \"select all\" parent: it is checked\n * when every enabled option is, indeterminate when some are, and toggles all enabled options.\n * Disabled options keep their state.\n */\nexport function CheckboxGroup({\n  legend,\n  options,\n  value,\n  defaultValue,\n  onChange,\n  selectAll = \"Select all\",\n  name,\n  size = \"md\",\n  accent = \"#FF7448\",\n  duration = 280,\n  pressScale = 0.85,\n  disabled,\n  className = \"\",\n  ...props\n}: CheckboxGroupProps) {\n  const uid = React.useId();\n  const [inner, setInner] = React.useState<string[]>(() => defaultValue ?? []);\n  const values = value ?? inner;\n  const set = (next: string[]) => {\n    if (value === undefined) setInner(next);\n    onChange?.(next);\n  };\n\n  const enabled = options.filter((o) => !o.disabled);\n  const checkedEnabled = enabled.filter((o) => values.includes(o.value)).length;\n  const all = enabled.length > 0 && checkedEnabled === enabled.length;\n  const some = checkedEnabled > 0 && !all;\n  const childId = (i: number) => `cbg-${uid}-${i}`;\n  const shared = { size, accent, duration, pressScale };\n\n  const toggleAll = () => {\n    const locked = values.filter((v) => options.some((o) => o.disabled && o.value === v));\n    set(all ? locked : [...locked, ...enabled.map((o) => o.value)]);\n  };\n\n  return (\n    <fieldset disabled={disabled} className={`m-0 min-w-0 border-0 p-0 ${className}`} {...props}>\n      <legend className=\"mb-3 p-0 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">{legend}</legend>\n      {selectAll !== false && (\n        <div className=\"mb-2 border-b border-zinc-900/10 pb-3 dark:border-white/10\">\n          <AnimatedCheckbox\n            {...shared}\n            label={selectAll}\n            checked={all}\n            indeterminate={some}\n            aria-controls={options.map((_, i) => childId(i)).join(\" \")}\n            description={\n              <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums\">\n                {checkedEnabled + values.filter((v) => options.some((o) => o.disabled && o.value === v)).length} of {options.length} on\n              </span>\n            }\n            onCheckedChange={toggleAll}\n          />\n        </div>\n      )}\n      <div className={`flex flex-col gap-3 ${selectAll !== false ? (size === \"sm\" ? \"pl-[1.625rem]\" : size === \"lg\" ? \"pl-[2.375rem]\" : \"pl-8\") : \"\"}`}>\n        {options.map((o, i) => (\n          <AnimatedCheckbox\n            key={o.value}\n            {...shared}\n            id={childId(i)}\n            name={name}\n            value={o.value}\n            label={o.label}\n            description={o.description}\n            disabled={o.disabled}\n            checked={values.includes(o.value)}\n            onCheckedChange={(on) => set(on ? [...values, o.value] : values.filter((v) => v !== o.value))}\n          />\n        ))}\n      </div>\n    </fieldset>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/animated-checkbox"
}
