{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"floating-label-input","type":"registry:component","title":"Floating Label Input","description":"A text field whose label springs from placeholder to caption, with a focus ring and an underline that grows from where you clicked, a pointer-lit hover, helper and error text that slide open (and shake once on invalid), a clear button, a live counter and a password reveal. Drops into forms as a plain input.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/floating-label-input.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { ChangeEvent, FocusEvent, InputHTMLAttributes, PointerEvent as ReactPointerEvent } from \"react\";\n\nexport type FloatingLabelInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, \"size\" | \"prefix\"> & {\n  /** The label that floats from placeholder position into a caption. */\n  label?: string;\n  /** Helper text under the field. Replaced by `error` while the field is invalid. */\n  helper?: string;\n  /** Error text. Marks the field invalid (aria-invalid) and shakes it once. */\n  error?: string;\n  /** \"light\" matches a white page (default), \"dark\" paints an OLED surface. */\n  variant?: \"light\" | \"dark\";\n  /** Focus colour: any CSS colour (hex, rgb(), hsl(), oklch() or a name). */\n  accent?: string;\n  /** Show a clear button while the field has a value (not for password). */\n  clearable?: boolean;\n  /** Password fields get a show/hide toggle. */\n  revealable?: boolean;\n  /** Show a live character counter (uses maxLength when set). */\n  showCount?: boolean;\n  /** Validate with the browser's constraint API when the field loses focus. */\n  validateOnBlur?: boolean;\n};\n\nconst SAFE_COLOR = /^(#[0-9a-f]{3,8}|[a-z]{3,20}|(rgb|hsl|oklch|oklab|lab|lch)a?\\([0-9a-z .,%/+-]{1,48}\\))$/i;\nconst DEFAULT_ACCENT = \"#ef4444\";\n\nconst CSS = [\n  \"@layer components {\",\n  \".fli-root{position:relative;width:18rem;max-width:100%;font-family:inherit;text-align:left;--fli-accent:#ef4444;--fli-bg:#ffffff;--fli-ring:#d4d4d4;--fli-ring-hover:#a3a3a3;--fli-text:#171717;--fli-muted:#737373;--fli-err:#dc2626;--fli-ph:#a3a3a3;--fli-pill:rgba(0,0,0,.06)}\",\n  '.fli-root[data-variant=\"dark\"]{--fli-bg:#0d0e11;--fli-ring:rgba(255,255,255,.13);--fli-ring-hover:rgba(255,255,255,.26);--fli-text:#f4f4f5;--fli-muted:#8b8d96;--fli-err:#f87171;--fli-ph:#6b6d76;--fli-pill:rgba(255,255,255,.08)}',\n  \".fli-box{position:relative;border-radius:14px;--mx:50%;--my:50%;--fx:50%}\",\n  \".fli-input{display:block;width:100%;height:56px;box-sizing:border-box;margin:0;padding:22px 16px 6px;border:0;border-radius:14px;background:var(--fli-bg);color:var(--fli-text);font:inherit;font-size:16px;line-height:1.2;box-shadow:inset 0 0 0 1px var(--fli-ring);outline:none!important;-webkit-appearance:none;appearance:none;transition:box-shadow .28s cubic-bezier(.22,1,.36,1)}\",\n  \".fli-input::-ms-reveal,.fli-input::-ms-clear{display:none}\",\n  \".fli-input::placeholder{color:transparent;transition:color .2s ease .05s}\",\n  \".fli-input:focus::placeholder{color:var(--fli-ph)}\",\n  \".fli-box:hover .fli-input:not(:focus){box-shadow:inset 0 0 0 1px var(--fli-ring-hover)}\",\n  \".fli-input:focus{box-shadow:inset 0 0 0 1.5px var(--fli-accent),0 0 0 4px color-mix(in srgb,var(--fli-accent) 17%,transparent),0 8px 24px -10px color-mix(in srgb,var(--fli-accent) 45%,transparent)!important}\",\n  '.fli-input[aria-invalid=\"true\"],.fli-box:hover .fli-input[aria-invalid=\"true\"]:not(:focus){box-shadow:inset 0 0 0 1.5px var(--fli-err)}',\n  '.fli-input[aria-invalid=\"true\"]:focus{box-shadow:inset 0 0 0 1.5px var(--fli-err),0 0 0 4px color-mix(in srgb,var(--fli-err) 18%,transparent),0 8px 24px -10px color-mix(in srgb,var(--fli-err) 45%,transparent)!important}',\n  \".fli-input:disabled{cursor:not-allowed;opacity:.5}\",\n  \".fli-input:-webkit-autofill{-webkit-text-fill-color:var(--fli-text);transition:background-color 99999s}\",\n  \".fli-glow{pointer-events:none;position:absolute;inset:0;border-radius:14px;opacity:0;transition:opacity .35s ease;background:radial-gradient(150px circle at var(--mx) var(--my),color-mix(in srgb,var(--fli-accent) 24%,transparent),transparent 74%)}\",\n  \"@media (hover:hover){.fli-box:hover .fli-input:not(:focus):not(:disabled)~.fli-glow{opacity:1}}\",\n  \".fli-label{pointer-events:none;position:absolute;left:16px;top:0;max-width:calc(100% - 32px);height:56px;display:flex;align-items:center;box-sizing:border-box;font-size:16px;line-height:1;color:var(--fli-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transform-origin:0 50%;transform:translate3d(0,0,0) scale(1);transition:transform .34s cubic-bezier(.34,1.32,.5,1),color .2s ease,max-width .2s ease}\",\n  \".fli-input:focus~.fli-label,.fli-input:not(:placeholder-shown)~.fli-label,.fli-input:-webkit-autofill~.fli-label,.fli-input[type=date]~.fli-label,.fli-input[type=time]~.fli-label,.fli-input[type=datetime-local]~.fli-label,.fli-input[type=month]~.fli-label,.fli-input[type=week]~.fli-label{transform:translate3d(0,-11px,0) scale(.76);max-width:calc((100% - 32px)/.76)}\",\n  \".fli-input:focus~.fli-label{color:var(--fli-accent)}\",\n  '.fli-input[aria-invalid=\"true\"]~.fli-label{color:var(--fli-err)}',\n  \".fli-bar{pointer-events:none;position:absolute;left:14px;right:14px;bottom:0;height:2px;border-radius:2px;background:var(--fli-accent);transform:scaleX(0);transform-origin:var(--fx) 50%;opacity:0;transition:transform .5s cubic-bezier(.22,1,.36,1),opacity .25s ease}\",\n  \".fli-input:focus~.fli-bar{transform:scaleX(1);opacity:1}\",\n  '.fli-input[aria-invalid=\"true\"]~.fli-bar{background:var(--fli-err)}',\n  \".fli-actions{position:absolute;top:0;right:8px;height:56px;display:flex;align-items:center;gap:2px}\",\n  \".fli-btn{position:relative;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;margin:0;padding:0;border:0;border-radius:10px!important;background:transparent;color:var(--fli-muted);cursor:pointer;-webkit-tap-highlight-color:transparent;transition:background-color .18s ease,color .18s ease,transform .18s cubic-bezier(.22,1,.36,1),opacity .2s ease}\",\n  \".fli-btn::before{content:'';position:absolute;inset:-4px}\",\n  \"@media (pointer:coarse){.fli-btn::before{inset:-8px}}\",\n  \"@media (hover:hover){.fli-btn:hover{background:var(--fli-pill);color:var(--fli-text)}}\",\n  \".fli-btn:active{transform:scale(.88)}\",\n  \".fli-btn:focus-visible{outline:2px solid var(--fli-accent)!important;outline-offset:1px!important}\",\n  \".fli-btn svg{width:16px;height:16px;display:block}\",\n  \".fli-clear{opacity:1;transform:scale(1)}\",\n  \".fli-input:placeholder-shown~.fli-actions .fli-clear{display:none}\",\n  \"@media (hover:hover){.fli-box:not(:hover):not(:focus-within) .fli-clear{opacity:0;pointer-events:none;transform:scale(.7)}}\",\n  \".fli-msgwrap{display:grid;grid-template-rows:0fr;transition:grid-template-rows .32s cubic-bezier(.22,1,.36,1)}\",\n  '.fli-msgwrap[data-open=\"true\"]{grid-template-rows:1fr}',\n  \".fli-msginner{overflow:hidden;min-height:0}\",\n  \".fli-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:7px 4px 0;font-size:12.5px;line-height:1.35}\",\n  \".fli-msg{display:flex;align-items:flex-start;gap:6px;min-width:0;color:var(--fli-muted);opacity:0;transform:translateY(-4px);transition:opacity .22s ease,transform .3s cubic-bezier(.22,1,.36,1)}\",\n  '.fli-msgwrap[data-open=\"true\"] .fli-msg{opacity:1;transform:none}',\n  '.fli-msg[data-kind=\"error\"]{color:var(--fli-err)}',\n  \".fli-msg svg{flex:none;width:14px;height:14px;margin-top:1px}\",\n  \".fli-count{flex:none;margin-left:auto;font-variant-numeric:tabular-nums;color:var(--fli-muted);transition:color .2s ease}\",\n  '.fli-count[data-near=\"true\"]{color:#d97706}',\n  '.fli-count[data-over=\"true\"]{color:var(--fli-err)}',\n  '.fli-root[data-shake=\"a\"] .fli-box{animation:fli-shake-a .42s cubic-bezier(.36,.07,.19,.97) both}',\n  '.fli-root[data-shake=\"b\"] .fli-box{animation:fli-shake-b .42s cubic-bezier(.36,.07,.19,.97) both}',\n  \"@keyframes fli-shake-a{10%,90%{transform:translateX(-1px)}20%,80%{transform:translateX(3px)}30%,50%,70%{transform:translateX(-5px)}40%,60%{transform:translateX(5px)}}\",\n  \"@keyframes fli-shake-b{10%,90%{transform:translateX(-1px)}20%,80%{transform:translateX(3px)}30%,50%,70%{transform:translateX(-5px)}40%,60%{transform:translateX(5px)}}\",\n  \"@media (prefers-reduced-motion:reduce){.fli-root *{transition-duration:.01s!important;animation:none!important}.fli-bar{transition:opacity .01s!important}.fli-label{transition:color .01s!important}.fli-msg{transform:none}}\",\n  \"}\",\n].join(\"\\n\");\n\nconst EyeIcon = ({ off }: { off: boolean }) => (\n  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <path d=\"M2.5 12s3.6-6.5 9.5-6.5S21.5 12 21.5 12s-3.6 6.5-9.5 6.5S2.5 12 2.5 12Z\" />\n    <circle cx=\"12\" cy=\"12\" r=\"3\" />\n    {off ? <path d=\"M4 4l16 16\" /> : null}\n  </svg>\n);\n\nconst ClearIcon = () => (\n  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" aria-hidden=\"true\">\n    <path d=\"M6 6l12 12M18 6L6 18\" />\n  </svg>\n);\n\nconst AlertIcon = () => (\n  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <circle cx=\"12\" cy=\"12\" r=\"9\" />\n    <path d=\"M12 7.5v5.2M12 16.4v.1\" />\n  </svg>\n);\n\nexport const FloatingLabelInput = forwardRef<HTMLInputElement, FloatingLabelInputProps>(function FloatingLabelInput(\n  {\n    label = \"Email address\",\n    type = \"email\",\n    helper,\n    error,\n    variant = \"light\",\n    accent = DEFAULT_ACCENT,\n    clearable = false,\n    revealable = true,\n    showCount = false,\n    validateOnBlur = true,\n    className,\n    style,\n    id: idProp,\n    onChange,\n    onBlur,\n    onPointerDown,\n    value,\n    defaultValue,\n    placeholder,\n    maxLength,\n    disabled,\n    \"aria-describedby\": describedByProp,\n    ...rest\n  },\n  forwardedRef,\n) {\n  const autoId = useId();\n  const id = idProp ?? autoId;\n  const msgId = id + \"-msg\";\n  const innerRef = useRef<HTMLInputElement | null>(null);\n  const boxRef = useRef<HTMLDivElement | null>(null);\n  const setRefs = useCallback(\n    (node: HTMLInputElement | null) => {\n      innerRef.current = node;\n      if (typeof forwardedRef === \"function\") forwardedRef(node);\n      else if (forwardedRef) forwardedRef.current = node;\n    },\n    [forwardedRef],\n  );\n\n  const controlled = value !== undefined;\n  const [typedLen, setTypedLen] = useState(String(defaultValue ?? \"\").length);\n  const len = controlled ? String(value).length : typedLen;\n\n  const [revealed, setRevealed] = useState(false);\n  const isPassword = type === \"password\";\n  const effectiveType = isPassword && revealed ? \"text\" : type;\n  const showReveal = isPassword && revealable;\n  const showClear = clearable && !isPassword;\n\n  const [nativeError, setNativeError] = useState(\"\");\n  const errorText = error || nativeError;\n  const helperText = helper ?? \"\";\n  const activeText = errorText || helperText;\n  const activeKind = errorText ? \"error\" : \"help\";\n\n  // Keep the last message mounted while the row collapses, so it fades out instead of vanishing.\n  const [lastMsg, setLastMsg] = useState({ text: activeText, kind: activeKind });\n  if (activeText && (activeText !== lastMsg.text || activeKind !== lastMsg.kind)) {\n    setLastMsg({ text: activeText, kind: activeKind });\n  }\n  const shownText = activeText || lastMsg.text;\n  const shownKind = activeText ? activeKind : lastMsg.kind;\n\n  // One shake each time a new error appears. Two keyframe names alternate so it restarts.\n  const [prevError, setPrevError] = useState(errorText);\n  const [shake, setShake] = useState(0);\n  if (errorText !== prevError) {\n    setPrevError(errorText);\n    if (errorText) setShake((n) => n + 1);\n  }\n\n  useEffect(() => {\n    const form = innerRef.current?.form;\n    if (!form || controlled) return;\n    const sync = () =>\n      window.setTimeout(() => {\n        setTypedLen(innerRef.current?.value.length ?? 0);\n        setNativeError(\"\");\n      }, 0);\n    form.addEventListener(\"reset\", sync);\n    return () => form.removeEventListener(\"reset\", sync);\n  }, [controlled]);\n\n  const countOn = showCount || maxLength !== undefined;\n  const hasMsg = !!shownText;\n  const rowOpen = !!activeText || countOn;\n  const near = maxLength !== undefined && len >= maxLength * 0.9;\n  const over = maxLength !== undefined && len > maxLength;\n\n  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n    if (!controlled) setTypedLen(e.target.value.length);\n    if (nativeError && e.target.checkValidity()) setNativeError(\"\");\n    onChange?.(e);\n  };\n  const handleBlur = (e: FocusEvent<HTMLInputElement>) => {\n    if (validateOnBlur && !error) {\n      const el = e.currentTarget;\n      if ((el.value || el.required) && !el.checkValidity()) setNativeError(el.validationMessage);\n    }\n    onBlur?.(e);\n  };\n  const handleBoxPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (e.pointerType !== \"mouse\") return;\n    const box = boxRef.current;\n    if (!box) return;\n    const r = box.getBoundingClientRect();\n    const k = r.width ? box.offsetWidth / r.width : 1;\n    box.style.setProperty(\"--mx\", (e.clientX - r.left) * k + \"px\");\n    box.style.setProperty(\"--my\", (e.clientY - r.top) * k + \"px\");\n  };\n  const handlePointerDown = (e: ReactPointerEvent<HTMLInputElement>) => {\n    const box = boxRef.current;\n    if (box) {\n      const r = box.getBoundingClientRect();\n      const k = r.width ? box.offsetWidth / r.width : 1;\n      box.style.setProperty(\"--fx\", Math.max(0, Math.min(box.offsetWidth, (e.clientX - r.left) * k)) + \"px\");\n    }\n    onPointerDown?.(e);\n  };\n  const clear = () => {\n    const el = innerRef.current;\n    if (!el) return;\n    const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, \"value\")?.set;\n    setter?.call(el, \"\");\n    el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n    el.focus();\n  };\n\n  const safeAccent = SAFE_COLOR.test(accent.trim()) ? accent.trim() : DEFAULT_ACCENT;\n  const actions = (showClear ? 1 : 0) + (showReveal ? 1 : 0);\n  const describedBy = [describedByProp, rowOpen ? msgId : \"\"].filter(Boolean).join(\" \") || undefined;\n\n  return (\n    <div\n      className={[\"fli-root\", className].filter(Boolean).join(\" \")}\n      data-variant={variant === \"dark\" ? \"dark\" : \"light\"}\n      data-shake={shake === 0 ? undefined : shake % 2 ? \"a\" : \"b\"}\n      style={{ ...style, [\"--fli-accent\" as string]: safeAccent }}\n    >\n      <style>{CSS}</style>\n      <div ref={boxRef} className=\"fli-box\" onPointerMove={handleBoxPointerMove}>\n        <input\n          {...rest}\n          ref={setRefs}\n          id={id}\n          type={effectiveType}\n          value={value}\n          defaultValue={defaultValue}\n          maxLength={maxLength}\n          disabled={disabled}\n          placeholder={placeholder || \" \"}\n          aria-invalid={errorText ? true : rest[\"aria-invalid\"]}\n          aria-describedby={describedBy}\n          className=\"fli-input\"\n          style={actions ? { paddingRight: 16 + actions * 34 } : undefined}\n          onChange={handleChange}\n          onBlur={handleBlur}\n          onPointerDown={handlePointerDown}\n        />\n        <span className=\"fli-glow\" aria-hidden=\"true\" />\n        <label htmlFor={id} className=\"fli-label\">\n          {label}\n        </label>\n        <span className=\"fli-bar\" aria-hidden=\"true\" />\n        {actions ? (\n          <div className=\"fli-actions\">\n            {showClear ? (\n              <button type=\"button\" className=\"fli-btn fli-clear\" aria-label={\"Clear \" + label} onClick={clear} disabled={disabled}>\n                <ClearIcon />\n              </button>\n            ) : null}\n            {showReveal ? (\n              <button\n                type=\"button\"\n                className=\"fli-btn\"\n                aria-label={revealed ? \"Hide password\" : \"Show password\"}\n                aria-pressed={revealed}\n                onClick={() => setRevealed((r) => !r)}\n                disabled={disabled}\n              >\n                <EyeIcon off={revealed} />\n              </button>\n            ) : null}\n          </div>\n        ) : null}\n      </div>\n      <div className=\"fli-msgwrap\" data-open={rowOpen}>\n        <div className=\"fli-msginner\">\n          <div className=\"fli-row\">\n            <div id={msgId} className=\"fli-msg\" data-kind={shownKind} aria-live=\"polite\" aria-atomic=\"true\">\n              {hasMsg && shownKind === \"error\" ? <AlertIcon /> : null}\n              <span>{shownText}</span>\n            </div>\n            {countOn ? (\n              <span className=\"fli-count\" data-near={near && !over} data-over={over} aria-hidden=\"true\">\n                {maxLength !== undefined ? len + \"/\" + maxLength : len}\n              </span>\n            ) : null}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n});\n","type":"registry:component","target":"components/viberdy/floating-label-input.tsx"}],"meta":{"tier":"free","tags":["input","form","label","focus","validation","password","counter"],"href":"https://viberdy.dev/library/floating-label-input","aiPrompt":"Build a dependency-free React \"FloatingLabelInput\" as one \"use client\" file (React only; a scoped <style> tag in @layer components carries the CSS, class names prefixed fli-). Export FloatingLabelInput built with forwardRef<HTMLInputElement> plus the FloatingLabelInputProps type.\n\nProps: all standard input attributes (value, defaultValue, onChange, onBlur, name, required, maxLength, disabled, placeholder, autoComplete, aria-*, ref) go to the real <input>; className and style go to the root. Own props: label = \"Email address\", type = \"email\", helper, error, variant \"light\" (default) | \"dark\", accent (any CSS colour, validated with a regex, default #ef4444), clearable = false, revealable = true, showCount = false, validateOnBlur = true. Works controlled and uncontrolled.\n\nStructure: root (width 18rem, max-width 100%, set inside @layer components so a Tailwind w-* class wins) > .fli-box (relative, radius 14) holding the input, a hover glow, the label, a focus bar and the action buttons; then a collapsing message row.\nInput: height 56, padding 22px 16px 6px, 16px font (no iOS zoom), background and 1px ring made with inset box-shadow (never border). Light: white bg, ring #d4d4d4, hover ring #a3a3a3, text #171717, label #737373. Dark: bg #0d0e11, ring rgba(255,255,255,.13), text #f4f4f5, label #8b8d96. placeholder is \" \" unless given; the real placeholder stays transparent until focus.\nLabel: absolute, 56px tall flex-centred, 16px. Float state is pure CSS: input:focus, :not(:placeholder-shown), :-webkit-autofill and date/time types move it with translate3d(0,-11px,0) scale(.76), origin left, 340ms cubic-bezier(.34,1.32,.5,1) (slight spring), colour to the accent on focus. Real <label for> with useId().\nFocus: ring becomes inset 1.5px accent plus 0 0 0 4px accent at 17% and a soft 24px glow; a 2px accent bar inset 14px from the bottom grows scaleX 0 to 1 over 500ms cubic-bezier(.22,1,.36,1) from the x where the pointer pressed (--fx, measured in layout pixels). On a mouse, a 150px radial accent glow (24%) follows the pointer inside the field while hovering.\nActions (right, 32px buttons, 40px+ hit area on coarse pointers): clear button (shown only while the field has a value; on hover-capable devices only while hovering or focused; clears through the native value setter + a bubbling input event so controlled forms see onChange, then refocuses) and for type=password a show/hide eye toggle with aria-pressed and a changing aria-label. Input padding-right grows 34px per button.\nMessage row under the field: a grid-template-rows 0fr to 1fr collapse (320ms). Shows error (red, alert icon) over helper (muted) at 12.5px; it is an aria-live polite region referenced by aria-describedby; the last text stays mounted while it collapses. Optional counter at the right (tabular numbers, amber from 90% of maxLength, red past it).\nErrors: error prop or, on blur, the browser constraint validation message (only when the field has a value or is required; cleared as soon as the value is valid). aria-invalid, red ring and label, and one 420ms horizontal shake (keyframes alternate between two names so every new error restarts it).\nForm reset clears the counter and error. Disabled: opacity .5, not-allowed cursor.\nReduced motion: transitions collapse to 10ms, no shake, label snaps. Site-style traps: outline and focus rings use !important inside the layer; colours never rely on page tokens.","license":"https://viberdy.dev/license"}}