{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"interactive-input","type":"registry:ui","title":"Interactive Input","description":"A text field with a floating label, a focus ring that blooms out of the border, a spring-in clear button, a character-count ring and a shake on error.","dependencies":["motion","lucide-react"],"registryDependencies":["utils"],"files":[{"path":"registry/ui/interactive-input.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useRef,\n  useState,\n  type InputHTMLAttributes,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { AnimatePresence, motion, useAnimationControls, useReducedMotion } from \"motion/react\";\nimport { CircleAlert, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface InteractiveInputProps\n  extends Omit<InputHTMLAttributes<HTMLInputElement>, \"value\" | \"defaultValue\" | \"size\"> {\n  /** Floating label. Sits inside the field and lifts on focus or when filled. */\n  label: string;\n  value?: string;\n  defaultValue?: string;\n  /** Fires with the new value on typing and on clear. */\n  onValueChange?: (value: string) => void;\n  /** Helper text under the field. Replaced by `error` when set. */\n  hint?: ReactNode;\n  /** Error message. Setting (or changing) it shakes the field. */\n  error?: string;\n  /** Show the x button when the field has a value. */\n  clearable?: boolean;\n  /** Show \"used / maxLength\" with a progress ring. Needs `maxLength`. */\n  showCounter?: boolean;\n  /** Icon at the start of the field. */\n  icon?: ReactNode;\n  ref?: Ref<HTMLInputElement>;\n}\n\nexport function InteractiveInput({\n  label,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onChange,\n  onFocus,\n  onBlur,\n  hint,\n  error,\n  clearable = true,\n  showCounter = true,\n  maxLength,\n  icon,\n  disabled,\n  className,\n  id,\n  ref,\n  ...props\n}: InteractiveInputProps) {\n  const auto = useId();\n  const inputId = id ?? auto;\n  const inner = useRef<HTMLInputElement>(null);\n  useImperativeHandle(ref, () => inner.current!, []);\n  const reduce = useReducedMotion();\n  const shake = useAnimationControls();\n  const [own, setOwn] = useState(defaultValue);\n  const [focused, setFocused] = useState(false);\n  const val = value ?? own;\n  const floated = focused || val.length > 0;\n\n  useEffect(() => {\n    if (error && !reduce) shake.start({ x: [0, -8, 7, -5, 3, 0], transition: { duration: 0.38 } });\n  }, [error, reduce, shake]);\n\n  const update = (v: string) => {\n    if (value === undefined) setOwn(v);\n    onValueChange?.(v);\n  };\n\n  const counter = showCounter && maxLength ? val.length / maxLength : null;\n  const counterColor = counter === null ? \"\" : counter >= 1 ? \"#ef4444\" : counter >= 0.85 ? \"#f59e0b\" : \"currentColor\";\n  const describedBy = [error ? `${inputId}-err` : hint ? `${inputId}-hint` : \"\", counter !== null ? `${inputId}-count` : \"\"]\n    .filter(Boolean)\n    .join(\" \");\n\n  return (\n    <div className={cn(\"w-full\", className)}>\n      <motion.div\n        animate={shake}\n        className={cn(\n          \"group relative flex h-14 items-center rounded-xl border bg-background/60 shadow-xs transition-[border-color,background-color] duration-200\",\n          error ? \"border-destructive\" : \"border-input hover:border-foreground/25 focus-within:border-ring\",\n          focused && \"bg-background\",\n          disabled && \"pointer-events-none opacity-50\",\n        )}\n      >\n        {/* focus ring: grows out of the border and fades in */}\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute -inset-px rounded-[inherit] opacity-0 ring-4 transition-[opacity,scale] duration-300 ease-out motion-reduce:transition-none\",\n            \"scale-[0.97] group-focus-within:scale-100 group-focus-within:opacity-100\",\n            error ? \"ring-destructive/20\" : \"ring-ring/25\",\n          )}\n        />\n        {/* underline sweep from the centre */}\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute inset-x-4 -bottom-px h-0.5 origin-center scale-x-0 rounded-full transition-transform duration-500 ease-[cubic-bezier(.2,.8,.2,1)] group-focus-within:scale-x-100 motion-reduce:transition-none\",\n            error ? \"bg-destructive\" : \"bg-ring\",\n          )}\n        />\n\n        {icon && (\n          <span\n            aria-hidden\n            className={cn(\n              \"ml-3.5 grid size-5 shrink-0 place-items-center transition-colors [&_svg]:size-4.5\",\n              error ? \"text-destructive\" : focused ? \"text-foreground\" : \"text-muted-foreground\",\n            )}\n          >\n            {icon}\n          </span>\n        )}\n\n        <div className=\"relative h-full min-w-0 flex-1\">\n          <label\n            htmlFor={inputId}\n            className={cn(\n              \"pointer-events-none absolute left-3.5 origin-left truncate transition-[top,translate,scale,color] duration-200 ease-out motion-reduce:transition-none\",\n              floated ? \"top-2 translate-y-0 scale-[0.78] font-medium\" : \"top-1/2 -translate-y-1/2 scale-100\",\n              error ? \"text-destructive\" : focused ? \"text-foreground\" : \"text-muted-foreground\",\n              \"right-2 text-sm\",\n            )}\n          >\n            {label}\n          </label>\n          <input\n            {...props}\n            ref={inner}\n            id={inputId}\n            value={val}\n            maxLength={maxLength}\n            disabled={disabled}\n            aria-invalid={!!error || undefined}\n            aria-describedby={describedBy || undefined}\n            onChange={(e) => {\n              update(e.target.value);\n              onChange?.(e);\n            }}\n            onFocus={(e) => {\n              setFocused(true);\n              onFocus?.(e);\n            }}\n            onBlur={(e) => {\n              setFocused(false);\n              onBlur?.(e);\n            }}\n            className={cn(\n              \"size-full bg-transparent px-3.5 pt-5 pb-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground/60\",\n              \"placeholder:opacity-0 focus:placeholder:opacity-100 placeholder:transition-opacity\",\n            )}\n          />\n        </div>\n\n        <AnimatePresence initial={false}>\n          {clearable && val && !disabled && (\n            <motion.button\n              type=\"button\"\n              aria-label={`Clear ${label}`}\n              initial={{ opacity: 0, scale: 0.5, rotate: -90 }}\n              animate={{ opacity: 1, scale: 1, rotate: 0 }}\n              exit={{ opacity: 0, scale: 0.5, rotate: 90 }}\n              transition={{ type: \"spring\", stiffness: 500, damping: 30 }}\n              onClick={() => {\n                update(\"\");\n                inner.current?.focus();\n              }}\n              className=\"mr-1.5 grid size-7 shrink-0 place-items-center rounded-full text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\"\n            >\n              <X className=\"size-3.5\" strokeWidth={2.5} aria-hidden />\n            </motion.button>\n          )}\n        </AnimatePresence>\n\n        {counter !== null && (\n          <span className=\"mr-3 flex shrink-0 items-center gap-1.5 text-muted-foreground\" style={{ color: counter >= 0.85 ? counterColor : undefined }}>\n            <svg viewBox=\"0 0 20 20\" className=\"size-4.5 -rotate-90\" aria-hidden>\n              <circle cx=\"10\" cy=\"10\" r=\"8\" fill=\"none\" stroke=\"currentColor\" strokeOpacity={0.2} strokeWidth=\"2.5\" />\n              <circle\n                cx=\"10\"\n                cy=\"10\"\n                r=\"8\"\n                fill=\"none\"\n                stroke={counterColor}\n                strokeWidth=\"2.5\"\n                strokeLinecap=\"round\"\n                pathLength={100}\n                strokeDasharray={`${Math.min(1, counter) * 100} 100`}\n                className=\"transition-[stroke-dasharray,stroke] duration-300\"\n              />\n            </svg>\n            <span id={`${inputId}-count`} className=\"text-xs tabular-nums\">\n              <span className=\"sr-only\">Characters used: </span>\n              {val.length}/{maxLength}\n            </span>\n          </span>\n        )}\n      </motion.div>\n\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {error ? (\n          <motion.p\n            key=\"e\"\n            id={`${inputId}-err`}\n            role=\"alert\"\n            initial={{ opacity: 0, y: -4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: -4 }}\n            className=\"mt-1.5 flex items-center gap-1.5 px-1 text-xs text-destructive\"\n          >\n            <CircleAlert className=\"size-3.5 shrink-0\" aria-hidden />\n            {error}\n          </motion.p>\n        ) : hint ? (\n          <motion.p\n            key=\"h\"\n            id={`${inputId}-hint`}\n            initial={{ opacity: 0, y: -4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: -4 }}\n            className=\"mt-1.5 px-1 text-xs text-muted-foreground\"\n          >\n            {hint}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n"}]}