{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "floating-label-input",
  "type": "registry:ui",
  "title": "Floating Label Input",
  "description": "A premium text field whose label floats on focus, with hint, validation and autofill support.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/floating-label-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nexport interface FloatingLabelInputProps extends Omit<React.ComponentPropsWithoutRef<\"input\">, \"size\"> {\n  /** Visible label, rendered as a real <label htmlFor>. */\n  label: React.ReactNode;\n  /** Helper text below the field (linked via aria-describedby). */\n  hint?: React.ReactNode;\n  /** Error message. Any truthy value marks the field invalid; a node is also shown and announced. */\n  error?: React.ReactNode;\n  variant?: \"outline\" | \"filled\" | \"underline\";\n  /** Focus colour for the border / underline (any CSS colour). */\n  accent?: string;\n  /** Class for the <input> itself; `className` styles the outer wrapper. */\n  inputClassName?: string;\n}\n\nconst VARIANTS = {\n  outline: {\n    field:\n      \"rounded-xl border border-zinc-900/15 bg-white px-3.5 hover:border-zinc-900/30 dark:border-white/15 dark:bg-zinc-950 dark:hover:border-white/30\",\n    label: \"left-3.5\",\n  },\n  filled: {\n    field:\n      \"rounded-xl border border-transparent bg-zinc-900/[0.05] px-3.5 hover:bg-zinc-900/[0.07] dark:bg-white/[0.06] dark:hover:bg-white/[0.09]\",\n    label: \"left-3.5\",\n  },\n  underline: {\n    field: \"rounded-none border-0 border-b border-zinc-900/20 bg-transparent px-0 hover:border-zinc-900/40 dark:border-white/20 dark:hover:border-white/40\",\n    label: \"left-0\",\n  },\n} as const;\n\n/**\n * A text input whose label rests inside the field and floats up on focus or when filled.\n * Floating is pure CSS (`:placeholder-shown`, `:focus`, `:autofill`), so it is correct for\n * server-rendered values and browser autofill. The error message animates in and is announced.\n */\nexport const FloatingLabelInput = React.forwardRef<HTMLInputElement, FloatingLabelInputProps>(function FloatingLabelInput(\n  {\n    label,\n    hint,\n    error,\n    variant = \"outline\",\n    accent = \"#FF7448\",\n    id: idProp,\n    className = \"\",\n    inputClassName = \"\",\n    style,\n    placeholder,\n    disabled,\n    required,\n    ...props\n  },\n  ref,\n) {\n  const reduceMotion = useReducedMotion();\n  const autoId = React.useId();\n  const id = idProp ?? `fl-${autoId}`;\n  const hintId = `${id}-hint`;\n  const errorId = `${id}-error`;\n  const invalid = Boolean(error) || props[\"aria-invalid\"] === true || props[\"aria-invalid\"] === \"true\";\n  const hasErrorText = Boolean(error) && error !== true;\n\n  const describedBy =\n    [props[\"aria-describedby\"], hint ? hintId : null, hasErrorText ? errorId : null].filter(Boolean).join(\" \") || undefined;\n\n  const v = VARIANTS[variant];\n  const floated =\n    \"peer-focus:translate-y-[calc(-50%-11px)] peer-focus:scale-[0.78] peer-[:not(:placeholder-shown)]:translate-y-[calc(-50%-11px)] peer-[:not(:placeholder-shown)]:scale-[0.78] peer-autofill:translate-y-[calc(-50%-11px)] peer-autofill:scale-[0.78]\";\n\n  return (\n    <div className={`w-full ${className}`} style={{ [\"--fl-accent\" as string]: accent, ...style }}>\n      <div className=\"relative\">\n        <input\n          ref={ref}\n          id={id}\n          disabled={disabled}\n          required={required}\n          // A single space keeps :placeholder-shown meaningful when no placeholder is given.\n          // A custom placeholder is only revealed once the label has floated out of the way.\n          placeholder={placeholder ?? \" \"}\n          {...props}\n          aria-invalid={invalid || undefined}\n          aria-describedby={describedBy}\n          className={`peer block h-14 w-full pb-2 pt-6 text-[15px] text-zinc-950 outline-none transition-[border-color,background-color,box-shadow] duration-200 placeholder:text-zinc-400 placeholder:opacity-0 focus:placeholder:opacity-100 disabled:cursor-not-allowed disabled:opacity-50 autofill:[transition:background-color_600000s_0s,color_600000s_0s] dark:text-zinc-50 dark:placeholder:text-zinc-500 motion-reduce:transition-none ${v.field} ${\n            invalid\n              ? \"border-rose-500/80 hover:border-rose-500 focus:border-rose-500 dark:border-rose-400/80 dark:hover:border-rose-400 dark:focus:border-rose-400\"\n              : variant === \"underline\"\n                ? \"focus:border-[var(--fl-accent)]\"\n                : \"focus:border-[var(--fl-accent)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--fl-accent)_22%,transparent)]\"\n          } ${inputClassName}`}\n        />\n        <label\n          htmlFor={id}\n          className={`pointer-events-none absolute top-1/2 origin-left -translate-y-1/2 select-none truncate text-[15px] leading-5 transition-[transform,color] duration-200 ease-[cubic-bezier(0.2,0.7,0.2,1)] motion-reduce:transition-none ${v.label} ${floated} ${\n            invalid\n              ? \"text-rose-600 dark:text-rose-400\"\n              : \"text-zinc-500 peer-focus:text-zinc-900 dark:text-zinc-400 dark:peer-focus:text-zinc-100\"\n          } ${disabled ? \"opacity-50\" : \"\"}`}\n          style={{ maxWidth: \"calc(100% - 1.75rem)\" }}\n        >\n          {label}\n          {required && (\n            <span aria-hidden=\"true\" className=\"ml-0.5 text-[var(--fl-accent)]\">\n              *\n            </span>\n          )}\n        </label>\n        {variant === \"underline\" && (\n          // Accent rule that draws out from the centre on focus.\n          <span\n            aria-hidden=\"true\"\n            className={`pointer-events-none absolute inset-x-0 bottom-0 h-[2px] origin-center scale-x-0 transition-transform duration-300 ease-out peer-focus:scale-x-100 motion-reduce:transition-none ${\n              invalid ? \"bg-rose-500 dark:bg-rose-400\" : \"bg-[var(--fl-accent)]\"\n            }`}\n          />\n        )}\n      </div>\n\n      {hint && (\n        <p id={hintId} className={`mt-1.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400 ${variant === \"underline\" ? \"\" : \"px-3.5\"}`}>\n          {hint}\n        </p>\n      )}\n\n      {/* Persistent live region: content changes are announced without moving focus. */}\n      <div aria-live=\"polite\">\n        <AnimatePresence initial={false}>\n          {hasErrorText && (\n            <motion.p\n              key=\"error\"\n              id={errorId}\n              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}\n              animate={reduceMotion ? { opacity: 1 } : { opacity: 1, height: \"auto\", y: 0 }}\n              exit={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}\n              transition={{ duration: reduceMotion ? 0 : 0.2, ease: [0.2, 0.7, 0.2, 1] }}\n              className={`overflow-hidden text-[13px] font-medium leading-snug text-rose-600 dark:text-rose-400 ${variant === \"underline\" ? \"\" : \"px-3.5\"}`}\n            >\n              <span className=\"flex items-start gap-1.5 pt-1.5\">\n                <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"mt-px size-3.5 shrink-0\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\">\n                  <circle cx=\"8\" cy=\"8\" r=\"6.25\" />\n                  <path d=\"M8 4.75v3.75M8 10.9v.1\" strokeLinecap=\"round\" />\n                </svg>\n                <span>{error}</span>\n              </span>\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/floating-label-input"
}
