{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"floating-label","type":"registry:ui","title":"Floating Label","description":"The label makes room instead of disappearing.","dependencies":["motion"],"categories":["input"],"docs":"https://www.interior.dev/docs/floating-label","files":[{"path":"registry/interior/floating-label.tsx","type":"registry:ui","target":"components/interior/floating-label.tsx","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst INSTANT = { duration: 0 } as const;\n\nconst LIFT = { type: \"spring\", stiffness: 760, damping: 46, mass: 0.5 } as const;\n\nconst RAISE = -32;\nconst SLIDE = -12;\nconst SHRINK = 0.92;\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport type UseFloatingLabelOptions = {\n  value?: string;\n  defaultValue?: string;\n  disabled?: boolean;\n};\n\nexport type UseFloatingLabelReturn = {\n  ref: React.RefObject<HTMLInputElement | null>;\n  raised: boolean;\n  focused: boolean;\n  filled: boolean;\n  length: number;\n  instant: boolean;\n  fieldProps: {\n    onFocus: () => void;\n    onBlur: () => void;\n    onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;\n  };\n};\n\ntype Fill = { length: number; instant: boolean };\n\nexport function useFloatingLabel({\n  value,\n  defaultValue,\n  disabled = false,\n}: UseFloatingLabelOptions = {}): UseFloatingLabelReturn {\n  const ref = useRef<HTMLInputElement | null>(null);\n  const mounted = useRef(false);\n\n  const [focused, setFocused] = useState(false);\n  const [fill, setFill] = useState<Fill>({\n    length: (value ?? defaultValue ?? \"\").length,\n    instant: true,\n  });\n\n  const settle = useCallback((next: number, instant: boolean) => {\n    setFill((prev) =>\n      prev.length === next && prev.instant === instant ? prev : { length: next, instant },\n    );\n  }, []);\n\n  useIsomorphicLayoutEffect(() => {\n    const el = ref.current;\n    const next = value !== undefined ? value.length : el ? el.value.length : 0;\n    settle(next, !mounted.current);\n    mounted.current = true;\n  }, [value, settle]);\n\n  useEffect(() => {\n    setFill((prev) => (prev.instant ? { ...prev, instant: false } : prev));\n  }, []);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || value !== undefined) return;\n    const read = () => settle(el.value.length, false);\n    el.addEventListener(\"input\", read);\n    el.addEventListener(\"change\", read);\n    return () => {\n      el.removeEventListener(\"input\", read);\n      el.removeEventListener(\"change\", read);\n    };\n  }, [value, settle]);\n\n  useEffect(() => {\n    if (disabled) setFocused(false);\n  }, [disabled]);\n\n  const onFocus = useCallback(() => setFocused(true), []);\n  const onBlur = useCallback(() => setFocused(false), []);\n  const onChange = useCallback(\n    (event: React.ChangeEvent<HTMLInputElement>) =>\n      settle(event.currentTarget.value.length, false),\n    [settle],\n  );\n\n  return {\n    ref,\n    raised: focused || fill.length > 0,\n    focused,\n    filled: fill.length > 0,\n    length: fill.length,\n    instant: fill.instant && !focused,\n    fieldProps: { onFocus, onBlur, onChange },\n  };\n}\n\nexport type FloatingLabelInputProps = {\n  label: string;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string, event: React.ChangeEvent<HTMLInputElement>) => void;\n  onFocus?: () => void;\n  onBlur?: () => void;\n  hint?: string;\n  invalid?: boolean;\n  id?: string;\n  name?: string;\n  type?: \"text\" | \"email\" | \"password\" | \"search\" | \"tel\" | \"url\";\n  autoComplete?: string;\n  inputMode?: React.ComponentProps<\"input\">[\"inputMode\"];\n  maxLength?: number;\n  required?: boolean;\n  disabled?: boolean;\n  readOnly?: boolean;\n  inputRef?: React.Ref<HTMLInputElement>;\n  className?: string;\n};\n\nexport function FloatingLabelInput({\n  label,\n  value,\n  defaultValue,\n  onChange,\n  onFocus,\n  onBlur,\n  hint,\n  invalid = false,\n  id,\n  name,\n  type = \"text\",\n  autoComplete,\n  inputMode,\n  maxLength,\n  required = false,\n  disabled = false,\n  readOnly = false,\n  inputRef,\n  className = \"\",\n}: FloatingLabelInputProps) {\n  const auto = useId();\n  const fieldId = id ?? `${auto}-field`;\n  const hintId = `${auto}-hint`;\n\n  const reduced = useReducedMotion();\n  const { ref, raised, focused, length, instant, fieldProps } = useFloatingLabel({\n    value,\n    defaultValue,\n    disabled,\n  });\n\n  const move = reduced || instant ? INSTANT : LIFT;\n\n  const attach = useCallback(\n    (node: HTMLInputElement | null) => {\n      ref.current = node;\n      if (typeof inputRef === \"function\") inputRef(node);\n      else if (inputRef) inputRef.current = node;\n    },\n    [ref, inputRef],\n  );\n\n  return (\n    <div className={`w-full ${className}`}>\n      <div className=\"relative pt-[20px]\">\n        <div\n          className={`relative h-10 rounded-[10px] border-2 transition-[background-color,border-color,box-shadow] duration-150 ${\n            invalid\n              ? \"border-red-500 bg-white dark:border-red-400 dark:bg-[#252522]\"\n              : focused\n                ? \"border-[#4568FF] bg-white dark:border-[#93B0FF] dark:bg-[#252522]\"\n                : \"border-stone-200 bg-stone-100/70 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:border-white/[0.08] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]\"\n          } ${disabled ? \"opacity-55\" : \"\"}`}\n        >\n          <input\n          ref={attach}\n          id={fieldId}\n          name={name}\n          type={type}\n          value={value}\n          defaultValue={defaultValue}\n          autoComplete={autoComplete}\n          inputMode={inputMode}\n          maxLength={maxLength}\n          required={required}\n          disabled={disabled}\n          readOnly={readOnly}\n          aria-required={required || undefined}\n          aria-invalid={invalid || undefined}\n          aria-describedby={hint ? hintId : undefined}\n          onFocus={() => {\n            fieldProps.onFocus();\n            onFocus?.();\n          }}\n          onBlur={() => {\n            fieldProps.onBlur();\n            onBlur?.();\n          }}\n          onChange={(event) => {\n            fieldProps.onChange(event);\n            onChange?.(event.currentTarget.value, event);\n          }}\n            className=\"absolute inset-0 h-full w-full rounded-[9px] bg-transparent px-3 py-0 text-[13px] leading-[20px] text-stone-700 outline-none focus-visible:outline-none disabled:cursor-not-allowed dark:text-stone-200\"\n          />\n        </div>\n\n        <motion.label\n          htmlFor={fieldId}\n          initial={false}\n          animate={{\n            y: raised ? RAISE : 0,\n            x: raised ? SLIDE : 0,\n            scale: raised ? SHRINK : 1,\n          }}\n          transition={move}\n          style={{ originX: 0, originY: 0, willChange: \"transform\" }}\n          className={`absolute left-3 top-[32px] block cursor-text select-none text-[13px] leading-[16px] ${\n            invalid\n              ? \"text-red-600 dark:text-red-400\"\n              : raised\n                ? \"text-stone-600 dark:text-stone-300\"\n                : \"text-stone-400 dark:text-stone-500\"\n          }`}\n        >\n          {label}\n          {required ? (\n            <span aria-hidden className=\"ml-0.5 text-stone-400 dark:text-stone-500\">\n              *\n            </span>\n          ) : null}\n        </motion.label>\n      </div>\n\n      <div className=\"mt-1.5 flex h-[16px] items-start gap-3\">\n        <p\n          aria-hidden\n          className={`min-w-0 flex-1 truncate text-[11.5px] leading-[16px] ${\n            invalid\n              ? \"text-red-600 dark:text-red-400\"\n              : \"text-stone-500 dark:text-stone-400\"\n          }`}\n        >\n          {hint}\n        </p>\n\n        {maxLength !== undefined ? (\n          <span\n            aria-hidden\n            className=\"grid shrink-0 justify-items-end font-mono text-[10.5px] leading-[16px] tabular-nums text-stone-400 dark:text-stone-500\"\n          >\n            <span className=\"invisible col-start-1 row-start-1\">\n              {maxLength} / {maxLength}\n            </span>\n            <span className=\"col-start-1 row-start-1\">\n              {length} / {maxLength}\n            </span>\n          </span>\n        ) : null}\n\n        {hint ? (\n          <span id={hintId} className=\"sr-only\">\n            {hint}\n          </span>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n"}]}