{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"signup-form","type":"registry:block","title":"Sign Up Form","description":"Composed sign-up form that flags a field only once it is left, then clears the moment it is fixed, with a length-weighted strength meter, password reveal and an animated submit lifecycle.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/signup-form.tsx","type":"registry:component","target":"@components/motion/signup-form.tsx","content":"\"use client\";\n// beui.dev/components/blocks/signup-form\n\nimport { Eye, EyeOff, Lock, Mail, User } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type FormEvent,\n  type ReactNode,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { StatefulButton } from \"@/components/motion/button\";\nimport { Checkbox } from \"@/components/motion/checkbox\";\nimport { Input } from \"@/components/motion/input\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SignUpStatus = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport type SignUpValues = {\n  name: string;\n  email: string;\n  password: string;\n  confirmPassword: string;\n  terms: boolean;\n};\n\nexport type SignUpErrors = Partial<Record<keyof SignUpValues, string>>;\n\nexport type SignUpFormClassNames = {\n  root?: string;\n  header?: string;\n  title?: string;\n  description?: string;\n  fields?: string;\n  strength?: string;\n  terms?: string;\n  submit?: string;\n  footer?: string;\n};\n\nexport interface SignUpFormProps {\n  /** Controlled values. Omit for uncontrolled. */\n  values?: SignUpValues;\n  defaultValues?: Partial<SignUpValues>;\n  onValuesChange?: (values: SignUpValues) => void;\n  /** Called with valid values only. Return a promise to drive the button state. */\n  onSubmit?: (values: SignUpValues) => void | Promise<void>;\n  /** Replace the built-in rules — return a message per invalid field. */\n  validate?: (values: SignUpValues) => SignUpErrors;\n  /** Controlled submit state. Omit to let the form track it. */\n  status?: SignUpStatus;\n  /** Form-level failure message, shown above the submit button. */\n  errorMessage?: string;\n  title?: ReactNode;\n  description?: ReactNode;\n  submitLabel?: string;\n  footer?: ReactNode;\n  /** Show the password strength meter. */\n  strengthMeter?: boolean;\n  className?: string;\n  classNames?: SignUpFormClassNames;\n}\n\nconst EMPTY_VALUES: SignUpValues = {\n  name: \"\",\n  email: \"\",\n  password: \"\",\n  confirmPassword: \"\",\n  terms: false,\n};\n\n// Deliberately permissive. Full RFC 5322 matching is impractical in a regex and\n// rejects addresses that deliver fine; the only real check is sending mail.\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nconst MIN_PASSWORD_LENGTH = 8;\n\nconst STRENGTH_LABELS = [\"Too short\", \"Weak\", \"Fair\", \"Good\", \"Strong\"] as const;\n\nconst STRENGTH_COLORS = [\n  \"bg-destructive\",\n  \"bg-destructive\",\n  \"bg-amber-500\",\n  \"bg-amber-400\",\n  \"bg-(--color-success)\",\n] as const;\n\n/**\n * Length-weighted strength score, 0-4. NIST SP 800-63B advises against\n * composition requirements and treats length as the dominant factor, so extra\n * character classes only nudge the score — they can't rescue a short password.\n * This is a heuristic for feedback, not entropy estimation; pair it with a\n * breach-list check server-side for anything real.\n */\nexport function passwordStrength(password: string): number {\n  if (password.length < MIN_PASSWORD_LENGTH) return 0;\n\n  let score = 1;\n  if (password.length >= 12) score += 1;\n  if (password.length >= 16) score += 1;\n\n  const classes = [/[a-z]/, /[A-Z]/, /\\d/, /[^A-Za-z0-9]/].filter((pattern) =>\n    pattern.test(password),\n  ).length;\n  if (classes >= 3) score += 1;\n\n  return Math.min(score, 4);\n}\n\nfunction defaultValidate(values: SignUpValues): SignUpErrors {\n  const errors: SignUpErrors = {};\n\n  if (!values.name.trim()) {\n    errors.name = \"Enter your name.\";\n  }\n\n  if (!values.email.trim()) {\n    errors.email = \"Enter your email.\";\n  } else if (!EMAIL_PATTERN.test(values.email)) {\n    errors.email = \"That doesn't look like an email address.\";\n  }\n\n  if (!values.password) {\n    errors.password = \"Choose a password.\";\n  } else if (values.password.length < MIN_PASSWORD_LENGTH) {\n    errors.password = `Use at least ${MIN_PASSWORD_LENGTH} characters.`;\n  }\n\n  if (!values.confirmPassword) {\n    errors.confirmPassword = \"Confirm your password.\";\n  } else if (values.confirmPassword !== values.password) {\n    errors.confirmPassword = \"Passwords don't match.\";\n  }\n\n  if (!values.terms) {\n    errors.terms = \"Accept the terms to continue.\";\n  }\n\n  return errors;\n}\n\nexport function SignUpForm({\n  values: valuesProp,\n  defaultValues,\n  onValuesChange,\n  onSubmit,\n  validate,\n  status: statusProp,\n  errorMessage,\n  title = \"Create your account\",\n  description = \"Start building in under a minute.\",\n  submitLabel = \"Create account\",\n  footer,\n  strengthMeter = true,\n  className,\n  classNames,\n}: SignUpFormProps) {\n  const reduce = useReducedMotion();\n  const baseId = useId();\n\n  const controlled = valuesProp !== undefined;\n  const [internalValues, setInternalValues] = useState<SignUpValues>({\n    ...EMPTY_VALUES,\n    ...defaultValues,\n  });\n  const values = controlled ? valuesProp : internalValues;\n\n  const [internalStatus, setInternalStatus] = useState<SignUpStatus>(\"idle\");\n  const status = statusProp ?? internalStatus;\n\n  const [revealPassword, setRevealPassword] = useState(false);\n\n  // \"Reward early, punish late\": errors are computed on every change, but a\n  // field only *shows* its error once it has been blurred (or submit touched\n  // everything). So a first entry is never flagged mid-typing, while a field\n  // already in error clears the moment it becomes valid.\n  const [touched, setTouched] = useState<Partial<Record<keyof SignUpValues, boolean>>>(\n    {},\n  );\n\n  const errors = useMemo(\n    () => (validate ?? defaultValidate)(values),\n    [values, validate],\n  );\n\n  const setValue = useCallback(\n    <K extends keyof SignUpValues>(key: K, next: SignUpValues[K]) => {\n      const nextValues = { ...values, [key]: next };\n      if (!controlled) {\n        setInternalValues(nextValues);\n        if (statusProp === undefined) {\n          setInternalStatus((current) =>\n            current === \"success\" || current === \"error\" ? \"idle\" : current,\n          );\n        }\n      }\n      onValuesChange?.(nextValues);\n    },\n    [controlled, onValuesChange, statusProp, values],\n  );\n\n  const touch = useCallback((key: keyof SignUpValues) => {\n    setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));\n  }, []);\n\n  /** Error to render for a field — hidden until the field has been touched. */\n  const shownError = (key: keyof SignUpValues) =>\n    touched[key] ? errors[key] : undefined;\n\n  /** Success check draws only once a touched field is non-empty and valid. */\n  const isValid = (key: keyof SignUpValues) =>\n    Boolean(touched[key]) && !errors[key] && Boolean(values[key]);\n\n  const strength = passwordStrength(values.password);\n  const showStrength = strengthMeter && values.password.length > 0;\n  const isSubmitting = status === \"loading\";\n\n  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n\n    setTouched({\n      name: true,\n      email: true,\n      password: true,\n      confirmPassword: true,\n      terms: true,\n    });\n\n    if (Object.keys(errors).length > 0) return;\n    if (!onSubmit) return;\n\n    if (statusProp === undefined) setInternalStatus(\"loading\");\n    try {\n      await onSubmit(values);\n      if (statusProp === undefined) setInternalStatus(\"success\");\n    } catch {\n      if (statusProp === undefined) setInternalStatus(\"error\");\n    }\n  };\n\n  const termsErrorId = `${baseId}-terms-error`;\n  const formErrorId = `${baseId}-form-error`;\n\n  return (\n    <form\n      noValidate\n      onSubmit={handleSubmit}\n      className={cn(\n        \"flex w-full max-w-sm flex-col gap-5 rounded-3xl border border-border p-6\",\n        className,\n        classNames?.root,\n      )}\n    >\n      {title || description ? (\n        <div className={cn(\"flex flex-col gap-1\", classNames?.header)}>\n          {title ? (\n            <h2\n              className={cn(\n                \"text-xl font-semibold tracking-tight text-foreground\",\n                classNames?.title,\n              )}\n            >\n              {title}\n            </h2>\n          ) : null}\n          {description ? (\n            <p\n              className={cn(\n                \"text-sm text-muted-foreground\",\n                classNames?.description,\n              )}\n            >\n              {description}\n            </p>\n          ) : null}\n        </div>\n      ) : null}\n\n      <div className={cn(\"flex flex-col gap-4\", classNames?.fields)}>\n        <Input\n          label=\"Name\"\n          autoComplete=\"name\"\n          placeholder=\"Ada Lovelace\"\n          leftIcon={<User />}\n          disabled={isSubmitting}\n          value={values.name}\n          onChange={(next) => setValue(\"name\", next)}\n          onBlur={() => touch(\"name\")}\n          error={shownError(\"name\")}\n          success={isValid(\"name\")}\n        />\n\n        <Input\n          label=\"Email\"\n          type=\"email\"\n          inputMode=\"email\"\n          autoComplete=\"email\"\n          placeholder=\"you@example.com\"\n          leftIcon={<Mail />}\n          disabled={isSubmitting}\n          value={values.email}\n          onChange={(next) => setValue(\"email\", next)}\n          onBlur={() => touch(\"email\")}\n          error={shownError(\"email\")}\n          success={isValid(\"email\")}\n        />\n\n        <div className=\"flex flex-col gap-2\">\n          <Input\n            label=\"Password\"\n            type={revealPassword ? \"text\" : \"password\"}\n            autoComplete=\"new-password\"\n            placeholder=\"At least 8 characters\"\n            leftIcon={<Lock />}\n            rightIcon={\n              <button\n                type=\"button\"\n                disabled={isSubmitting}\n                onClick={() => setRevealPassword((prev) => !prev)}\n                aria-label={revealPassword ? \"Hide password\" : \"Show password\"}\n                className=\"text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:text-foreground\"\n              >\n                {revealPassword ? <EyeOff /> : <Eye />}\n              </button>\n            }\n            disabled={isSubmitting}\n            value={values.password}\n            onChange={(next) => setValue(\"password\", next)}\n            onBlur={() => touch(\"password\")}\n            error={shownError(\"password\")}\n          />\n\n          <AnimatePresence initial={false}>\n            {showStrength ? (\n              <motion.div\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n                transition={{ duration: 0.18, ease: EASE_OUT }}\n                className={cn(\"flex flex-col gap-1.5 px-1\", classNames?.strength)}\n              >\n                <div className=\"flex gap-1.5\" aria-hidden>\n                  {[0, 1, 2, 3].map((index) => (\n                    <span\n                      key={index}\n                      className=\"h-1 flex-1 overflow-hidden rounded-full bg-muted-foreground/20\"\n                    >\n                      {/* scaleX rather than width — transforms only, per the\n                          motion conventions, and it keeps the bar off layout. */}\n                      <motion.span\n                        initial={false}\n                        animate={{ scaleX: index < strength ? 1 : 0 }}\n                        transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                        className={cn(\n                          \"block h-full w-full origin-left rounded-full\",\n                          STRENGTH_COLORS[strength],\n                        )}\n                      />\n                    </span>\n                  ))}\n                </div>\n                <p\n                  aria-live=\"polite\"\n                  className=\"text-xs text-muted-foreground\"\n                >\n                  Password strength: {STRENGTH_LABELS[strength]}\n                </p>\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n        </div>\n\n        <Input\n          label=\"Confirm password\"\n          type={revealPassword ? \"text\" : \"password\"}\n          autoComplete=\"new-password\"\n          placeholder=\"Re-enter your password\"\n          leftIcon={<Lock />}\n          disabled={isSubmitting}\n          value={values.confirmPassword}\n          onChange={(next) => setValue(\"confirmPassword\", next)}\n          onBlur={() => touch(\"confirmPassword\")}\n          error={shownError(\"confirmPassword\")}\n          success={isValid(\"confirmPassword\")}\n        />\n      </div>\n\n      <div className={cn(\"flex flex-col gap-1.5\", classNames?.terms)}>\n        <Checkbox\n          checked={values.terms}\n          disabled={isSubmitting}\n          onCheckedChange={(next) => {\n            setValue(\"terms\", next);\n            touch(\"terms\");\n          }}\n          label=\"I agree to the Terms and Privacy Policy\"\n          aria-describedby={shownError(\"terms\") ? termsErrorId : undefined}\n        />\n        <AnimatePresence initial={false}>\n          {shownError(\"terms\") ? (\n            <motion.p\n              id={termsErrorId}\n              role=\"alert\"\n              initial={\n                reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n              }\n              transition={{ duration: 0.2 }}\n              className=\"px-1 text-xs text-destructive\"\n            >\n              {shownError(\"terms\")}\n            </motion.p>\n          ) : null}\n        </AnimatePresence>\n      </div>\n\n      <AnimatePresence initial={false}>\n        {errorMessage ? (\n          <motion.p\n            id={formErrorId}\n            role=\"alert\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n            transition={{ duration: 0.2 }}\n            className=\"rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive\"\n          >\n            {errorMessage}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n\n      <StatefulButton\n        type=\"submit\"\n        size=\"lg\"\n        state={status}\n        loadingText=\"Creating account\"\n        successText=\"Account created\"\n        errorText=\"Try again\"\n        aria-describedby={errorMessage ? formErrorId : undefined}\n        className={cn(\"w-full\", classNames?.submit)}\n      >\n        {submitLabel}\n      </StatefulButton>\n\n      {footer ? (\n        <div\n          className={cn(\n            \"text-center text-sm text-muted-foreground\",\n            classNames?.footer,\n          )}\n        >\n          {footer}\n        </div>\n      ) : null}\n    </form>\n  );\n}\n"},{"path":"components/motion/button/index.tsx","type":"registry:component","target":"@components/motion/button/index.tsx","content":"export type {\n  ButtonLinkProps,\n  ButtonProps,\n  ButtonSize,\n  ButtonVariant,\n} from \"./base\";\nexport { Button, ButtonLink } from \"./base\";\nexport type { MagneticButtonProps } from \"./magnetic\";\nexport { MagneticButton } from \"./magnetic\";\nexport type { ButtonState, StatefulButtonProps } from \"./stateful\";\nexport { StatefulButton } from \"./stateful\";\n"},{"path":"components/motion/checkbox.tsx","type":"registry:component","target":"@components/motion/checkbox.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId } from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nconst CHECK_PATH = \"M5 13l4 4L19 7\";\nconst INDETERMINATE_PATH = \"M6 12h12\";\n\nexport interface CheckboxProps {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  disabled?: boolean;\n  indeterminate?: boolean;\n  label?: string;\n  className?: string;\n  id?: string;\n  \"aria-label\"?: string;\n  /** Associates an external message (e.g. a form error) with the control. */\n  \"aria-describedby\"?: string;\n}\n\nexport function Checkbox({\n  checked,\n  onCheckedChange,\n  disabled,\n  indeterminate,\n  label,\n  className,\n  id: idProp,\n  \"aria-label\": ariaLabel,\n  \"aria-describedby\": ariaDescribedBy,\n}: CheckboxProps) {\n  const autoId = useId();\n  const id = idProp ?? autoId;\n  const reduce = useReducedMotion();\n  const showMark = checked || indeterminate;\n  const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;\n\n  return (\n    <label\n      htmlFor={id}\n      className={cn(\n        \"inline-flex items-center gap-3\",\n        disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n        className,\n      )}\n    >\n      <motion.button\n        id={id}\n        type=\"button\"\n        role=\"checkbox\"\n        aria-checked={indeterminate ? \"mixed\" : checked}\n        aria-label={ariaLabel}\n        aria-describedby={ariaDescribedBy}\n        disabled={disabled}\n        onClick={() => !disabled && onCheckedChange(!checked)}\n        whileTap={reduce || disabled ? undefined : { scale: 0.92 }}\n        transition={SPRING_PRESS}\n        data-state={\n          checked ? \"checked\" : indeterminate ? \"indeterminate\" : \"unchecked\"\n        }\n        className={cn(\n          \"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"disabled:cursor-not-allowed disabled:opacity-60\",\n          showMark\n            ? \"border-primary bg-primary text-primary-foreground\"\n            : \"border-muted-foreground/50 bg-background hover:border-muted-foreground\",\n        )}\n      >\n        <AnimatePresence initial={false}>\n          {showMark ? (\n            <motion.svg\n              key={indeterminate ? \"indeterminate\" : \"checked\"}\n              width=\"12\"\n              height=\"12\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={3}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}\n              animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}\n              exit={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, scale: 0.5, filter: \"blur(4px)\" }\n              }\n              transition={\n                reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }\n              }\n              aria-hidden\n            >\n              <title>{indeterminate ? \"Partially selected\" : \"Selected\"}</title>\n              <motion.path\n                d={path}\n                initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        duration: indeterminate ? 0.2 : 0.3,\n                        ease: EASE_OUT,\n                        delay: 0.04,\n                      }\n                }\n              />\n            </motion.svg>\n          ) : null}\n        </AnimatePresence>\n      </motion.button>\n      {label ? (\n        <span className={cn(\"select-none text-sm text-foreground\", disabled && \"opacity-60\")}>\n          {label}\n        </span>\n      ) : null}\n    </label>\n  );\n}\n"},{"path":"components/motion/input.tsx","type":"registry:component","target":"@components/motion/input.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type InputHTMLAttributes,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InputClassNames = {\n  root?: string;\n  label?: string;\n  field?: string;\n  input?: string;\n  leftIcon?: string;\n  rightIcon?: string;\n  successIcon?: string;\n  errorMessage?: string;\n};\n\nexport interface InputProps extends Omit<\n  InputHTMLAttributes<HTMLInputElement>,\n  \"value\" | \"defaultValue\" | \"onChange\"\n> {\n  label?: string;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  /** Truthy error triggers a shake, red border and (if a string) a message. */\n  error?: string | boolean;\n  success?: boolean;\n  leftIcon?: ReactNode;\n  rightIcon?: ReactNode;\n  className?: string;\n  classNames?: InputClassNames;\n}\n\nexport const Input = forwardRef<HTMLInputElement, InputProps>(function Input(\n  {\n    label,\n    value: valueProp,\n    defaultValue,\n    onChange,\n    onFocus,\n    onBlur,\n    error,\n    success,\n    leftIcon,\n    rightIcon,\n    className,\n    classNames,\n    disabled,\n    id: idProp,\n    type,\n    ...rest\n  },\n  ref,\n) {\n  const reactId = useId();\n  const id = idProp ?? reactId;\n  const reduce = useReducedMotion();\n\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const value = controlled ? (valueProp ?? \"\") : internal;\n\n  const [focused, setFocused] = useState(false);\n\n  const fieldRef = useRef<HTMLDivElement>(null);\n\n  const hasError = Boolean(error);\n  const errorMessage = typeof error === \"string\" ? error : null;\n\n  // Right edge shows the success check, otherwise the caller's right icon.\n  const rightSlot = success ? null : rightIcon;\n\n  // Shake the field when an error appears.\n  useEffect(() => {\n    if (!fieldRef.current || reduce || !hasError) return;\n    animate(\n      fieldRef.current,\n      { x: [0, -6, 6, -4, 4, -2, 0] },\n      { duration: 0.45 },\n    );\n  }, [hasError, reduce]);\n\n  const handleChange = (next: string) => {\n    if (!controlled) setInternal(next);\n    onChange?.(next);\n  };\n\n  return (\n    <div\n      className={cn(\"flex flex-col gap-1.5\", className, classNames?.root)}\n    >\n      {label ? (\n        <label\n          htmlFor={id}\n          className={cn(\n            \"px-1 text-sm font-medium text-foreground\",\n            classNames?.label,\n          )}\n        >\n          {label}\n        </label>\n      ) : null}\n\n      <div\n        ref={fieldRef}\n        data-state={\n          hasError\n            ? \"error\"\n            : success\n              ? \"success\"\n              : focused\n                ? \"focused\"\n                : \"idle\"\n        }\n        className={cn(\n          \"relative h-11 overflow-hidden rounded-full border transition-colors duration-200\",\n          \"border-border\",\n          focused && !hasError && \"border-foreground/40 ring-2 ring-ring/40\",\n          hasError && \"border-destructive ring-2 ring-destructive/25\",\n          disabled && \"opacity-60\",\n          classNames?.field,\n        )}\n      >\n        {leftIcon ? (\n          <span\n            className={cn(\n              \"pointer-events-none absolute left-3 top-1/2 flex -translate-y-1/2 items-center text-muted-foreground [&_svg]:h-4 [&_svg]:w-4\",\n              classNames?.leftIcon,\n            )}\n          >\n            {leftIcon}\n          </span>\n        ) : null}\n\n        <input\n          ref={ref}\n          id={id}\n          type={type}\n          value={value}\n          disabled={disabled}\n          aria-invalid={hasError || undefined}\n          aria-describedby={errorMessage ? `${id}-error` : undefined}\n          {...rest}\n          onChange={(e) => handleChange(e.target.value)}\n          onFocus={(event) => {\n            setFocused(true);\n            onFocus?.(event);\n          }}\n          onBlur={(event) => {\n            setFocused(false);\n            onBlur?.(event);\n          }}\n          className={cn(\n            \"peer h-full w-full bg-transparent text-base leading-6 text-foreground caret-foreground outline-none\",\n            \"placeholder:text-muted-foreground/60\",\n            leftIcon ? \"pl-10\" : \"pl-3.5\",\n            rightSlot || success ? \"pr-10\" : \"pr-3.5\",\n            disabled && \"cursor-not-allowed\",\n            classNames?.input,\n          )}\n        />\n\n        {success ? (\n          <motion.svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            className={cn(\n              \"absolute right-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-(--color-success)\",\n              classNames?.successIcon,\n            )}\n          >\n            <motion.path\n              d=\"M5 12.5l4.5 4.5L19 7.5\"\n              stroke=\"currentColor\"\n              strokeWidth={2.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n              animate={{ pathLength: 1 }}\n              transition={{ duration: 0.35, ease: \"easeOut\" }}\n            />\n          </motion.svg>\n        ) : rightSlot ? (\n          <span\n            className={cn(\n              \"absolute right-0 top-0 flex h-full items-center text-muted-foreground [&_button]:grid [&_button]:size-11 [&_button]:place-items-center [&_svg]:h-4 [&_svg]:w-4\",\n              classNames?.rightIcon,\n            )}\n          >\n            {rightSlot}\n          </span>\n        ) : null}\n      </div>\n\n      <AnimatePresence initial={false}>\n        {errorMessage ? (\n          <motion.p\n            id={`${id}-error`}\n            role=\"alert\"\n            initial={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            transition={{ duration: 0.2 }}\n            className={cn(\n              \"px-1 text-xs text-destructive\",\n              classNames?.errorMessage,\n            )}\n          >\n            {errorMessage}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n});\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/button/base.tsx","type":"registry:component","target":"@components/motion/button/base.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  /** Spawn a Material-style ripple from the press point. Off by default. */\n  ripple?: boolean;\n  children?: ReactNode;\n}\n\nexport interface ButtonLinkProps extends Omit<\n  HTMLMotionProps<\"a\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  children?: ReactNode;\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-primary/5\",\n  outline:\n    \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  function Button(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      ripple = false,\n      className,\n      children,\n      onPointerDown,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n    const [ripples, setRipples] = useState<Ripple[]>([]);\n    const nextId = useRef(0);\n\n    const handlePointerDown = useCallback(\n      (event: PointerEvent<HTMLButtonElement>) => {\n        if (ripple && !reduce) {\n          const rect = event.currentTarget.getBoundingClientRect();\n          const size = Math.max(rect.width, rect.height) * 2;\n          const id = nextId.current++;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id,\n              x: event.clientX - rect.left,\n              y: event.clientY - rect.top,\n              size,\n            },\n          ]);\n        }\n        onPointerDown?.(event);\n      },\n      [ripple, reduce, onPointerDown],\n    );\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        onPointerDown={handlePointerDown}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          ripple && \"relative overflow-hidden\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {ripple && !reduce ? (\n          <span className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <AnimatePresence>\n              {ripples.map((r) => (\n                <motion.span\n                  key={r.id}\n                  className=\"absolute rounded-full bg-current\"\n                  style={{\n                    left: r.x,\n                    top: r.y,\n                    width: r.size,\n                    height: r.size,\n                    x: \"-50%\",\n                    y: \"-50%\",\n                  }}\n                  initial={{ scale: 0.05, opacity: 0.3 }}\n                  animate={{ scale: 1, opacity: 0 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 1.6, ease: EASE_OUT }}\n                  onAnimationComplete={() =>\n                    setRipples((prev) => prev.filter((x) => x.id !== r.id))\n                  }\n                />\n              ))}\n            </AnimatePresence>\n          </span>\n        ) : null}\n        {children}\n      </motion.button>\n    );\n  },\n);\n\nexport const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(\n  function ButtonLink(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      className,\n      children,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n\n    return (\n      <motion.a\n        ref={ref}\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </motion.a>\n    );\n  },\n);\n"},{"path":"components/motion/button/magnetic.tsx","type":"registry:component","target":"@components/motion/button/magnetic.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { Magnetic } from \"../magnetic\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport interface MagneticButtonProps extends ButtonProps {\n  /** Magnetic pull strength. Default 0.25. */\n  strength?: number;\n  /** Class applied to the magnetic wrapper. */\n  magneticClassName?: string;\n}\n\nexport const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(\n  { strength = 0.25, magneticClassName, children, ...rest },\n  ref,\n) {\n  return (\n    <Magnetic strength={strength} className={magneticClassName}>\n      <Button ref={ref} {...rest}>\n        {children}\n      </Button>\n    </Magnetic>\n  );\n});\n"},{"path":"components/motion/button/stateful.tsx","type":"registry:component","target":"@components/motion/button/stateful.tsx","content":"\"use client\";\n\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type ReactNode,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"children\"> {\n  state?: ButtonState;\n  children: ReactNode;\n  loadingText?: ReactNode;\n  successText?: ReactNode;\n  errorText?: ReactNode;\n  icon?: ReactNode;\n}\n\nconst CASCADE_STAGGER = 0.025;\nconst ROLL_BLUR = \"blur(6px)\";\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst ICON_VARIANTS: Variants = {\n  // Width collapses too, so the icon adds/removes its own space smoothly\n  // instead of popping the row width in a single frame.\n  initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    width: \"1.5rem\",\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    width: 0,\n    scale: 0.7,\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT },\n  },\n};\n\nfunction IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      variants={ICON_VARIANTS}\n      initial={reduce ? { opacity: 0 } : \"initial\"}\n      animate={reduce ? { opacity: 1 } : \"animate\"}\n      exit={reduce ? { opacity: 0 } : \"exit\"}\n      transition={reduce ? { duration: 0.15 } : undefined}\n      className=\"inline-grid shrink-0 place-items-center overflow-hidden\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction TextSlot({\n  value,\n  children,\n}: {\n  value: string;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = label !== null && !reduce;\n\n  // Measure strings with the same per-letter layout as the cascade. Measuring\n  // the whole string preserves kerning, which can make it narrower than the\n  // inline-block letters and clip the final glyph during the width animation.\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((current) => (current === nextWidth ? current : nextWidth));\n  });\n\n  return (\n    <motion.span\n      initial={false}\n      animate={{ width }}\n      transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\n            ))\n          : children}\n      </span>\n\n      {cascade ? (\n        <>\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, index) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                  key={index}\n                  custom={index * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </motion.span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const isBusy = state === \"loading\";\n  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>\n      <span\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Button>\n  );\n});\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"components/motion/magnetic.tsx","type":"registry:component","target":"@components/motion/magnetic.tsx","content":"\"use client\";\n\nimport { motion, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { useRef, type ReactNode } from \"react\";\nimport { SPRING_MOUSE } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MagneticProps {\n  children: ReactNode;\n  strength?: number;\n  className?: string;\n}\n\nexport function Magnetic({ children, strength = 0.35, className }: MagneticProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  // Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.\n  const enabled = !reduce && canHover;\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const sx = useSpring(x, SPRING_MOUSE);\n  const sy = useSpring(y, SPRING_MOUSE);\n\n  const onMove = (e: React.MouseEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const rect = el.getBoundingClientRect();\n    x.set((e.clientX - rect.left - rect.width / 2) * strength);\n    y.set((e.clientY - rect.top - rect.height / 2) * strength);\n  };\n\n  const onLeave = () => {\n    x.set(0);\n    y.set(0);\n  };\n\n  return (\n    <motion.div\n      ref={ref}\n      onMouseMove={onMove}\n      onMouseLeave={onLeave}\n      style={{ x: sx, y: sy }}\n      className={cn(\"inline-block\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"}]}