Floating Label Input
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.
DOM + CSS0 deps1 file · 16 KB TSXreduced-motion aware
Props
"use client";
import { forwardRef, useCallback, useEffect, useId, useRef, useState } from "react";
import type { ChangeEvent, FocusEvent, InputHTMLAttributes, PointerEvent as ReactPointerEvent } from "react";
export type FloatingLabelInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "prefix"> & {
/** The label that floats from placeholder position into a caption. */
label?: string;
/** Helper text under the field. Replaced by `error` while the field is invalid. */
helper?: string;
/** Error text. Marks the field invalid (aria-invalid) and shakes it once. */
error?: string;
/** "light" matches a white page (default), "dark" paints an OLED surface. */
variant?: "light" | "dark";
/** Focus colour: any CSS colour (hex, rgb(), hsl(), oklch() or a name). */
accent?: string;
/** Show a clear button while the field has a value (not for password). */
clearable?: boolean;
/** Password fields get a show/hide toggle. */
revealable?: boolean;
/** Show a live character counter (uses maxLength when set). */
showCount?: boolean;
/** Validate with the browser's constraint API when the field loses focus. */
validateOnBlur?: boolean;
};
const SAFE_COLOR = /^(#[0-9a-f]{3,8}|[a-z]{3,20}|(rgb|hsl|oklch|oklab|lab|lch)a?\([0-9a-z .,%/+-]{1,48}\))$/i;
const DEFAULT_ACCENT = "#ef4444";
const CSS = [
"@layer components {",
".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)}",
'.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)}',
".fli-box{position:relative;border-radius:14px;--mx:50%;--my:50%;--fx:50%}",
".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)}",
".fli-input::-ms-reveal,.fli-input::-ms-clear{display:none}",
".fli-input::placeholder{color:transparent;transition:color .2s ease .05s}",
".fli-input:focus::placeholder{color:var(--fli-ph)}",
".fli-box:hover .fli-input:not(:focus){box-shadow:inset 0 0 0 1px var(--fli-ring-hover)}",
".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}",
'.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)}',
'.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}',
".fli-input:disabled{cursor:not-allowed;opacity:.5}",
".fli-input:-webkit-autofill{-webkit-text-fill-color:var(--fli-text);transition:background-color 99999s}",
".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%)}",
"@media (hover:hover){.fli-box:hover .fli-input:not(:focus):not(:disabled)~.fli-glow{opacity:1}}",
".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}",
".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)}",
".fli-input:focus~.fli-label{color:var(--fli-accent)}",
'.fli-input[aria-invalid="true"]~.fli-label{color:var(--fli-err)}',
".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}",
".fli-input:focus~.fli-bar{transform:scaleX(1);opacity:1}",
'.fli-input[aria-invalid="true"]~.fli-bar{background:var(--fli-err)}',
".fli-actions{position:absolute;top:0;right:8px;height:56px;display:flex;align-items:center;gap:2px}",
".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}",
".fli-btn::before{content:'';position:absolute;inset:-4px}",
"@media (pointer:coarse){.fli-btn::before{inset:-8px}}",
"@media (hover:hover){.fli-btn:hover{background:var(--fli-pill);color:var(--fli-text)}}",
".fli-btn:active{transform:scale(.88)}",
".fli-btn:focus-visible{outline:2px solid var(--fli-accent)!important;outline-offset:1px!important}",
".fli-btn svg{width:16px;height:16px;display:block}",
".fli-clear{opacity:1;transform:scale(1)}",
".fli-input:placeholder-shown~.fli-actions .fli-clear{display:none}",
"@media (hover:hover){.fli-box:not(:hover):not(:focus-within) .fli-clear{opacity:0;pointer-events:none;transform:scale(.7)}}",
".fli-msgwrap{display:grid;grid-template-rows:0fr;transition:grid-template-rows .32s cubic-bezier(.22,1,.36,1)}",
'.fli-msgwrap[data-open="true"]{grid-template-rows:1fr}',
".fli-msginner{overflow:hidden;min-height:0}",
".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}",
".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)}",
'.fli-msgwrap[data-open="true"] .fli-msg{opacity:1;transform:none}',
'.fli-msg[data-kind="error"]{color:var(--fli-err)}',
".fli-msg svg{flex:none;width:14px;height:14px;margin-top:1px}",
".fli-count{flex:none;margin-left:auto;font-variant-numeric:tabular-nums;color:var(--fli-muted);transition:color .2s ease}",
'.fli-count[data-near="true"]{color:#d97706}',
'.fli-count[data-over="true"]{color:var(--fli-err)}',
'.fli-root[data-shake="a"] .fli-box{animation:fli-shake-a .42s cubic-bezier(.36,.07,.19,.97) both}',
'.fli-root[data-shake="b"] .fli-box{animation:fli-shake-b .42s cubic-bezier(.36,.07,.19,.97) both}',
"@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)}}",
"@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)}}",
"@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}}",
"}",
].join("\n");
const EyeIcon = ({ off }: { off: boolean }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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" />
<circle cx="12" cy="12" r="3" />
{off ? <path d="M4 4l16 16" /> : null}
</svg>
);
const ClearIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
const AlertIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 7.5v5.2M12 16.4v.1" />
</svg>
);
export const FloatingLabelInput = forwardRef<HTMLInputElement, FloatingLabelInputProps>(function FloatingLabelInput(
{
label = "Email address",
type = "email",
helper,
error,
variant = "light",
accent = DEFAULT_ACCENT,
clearable = false,
revealable = true,
showCount = false,
validateOnBlur = true,
className,
style,
id: idProp,
onChange,
onBlur,
onPointerDown,
value,
defaultValue,
placeholder,
maxLength,
disabled,
"aria-describedby": describedByProp,
...rest
},
forwardedRef,
) {
const autoId = useId();
const id = idProp ?? autoId;
const msgId = id + "-msg";
const innerRef = useRef<HTMLInputElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
const setRefs = useCallback(
(node: HTMLInputElement | null) => {
innerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
const controlled = value !== undefined;
const [typedLen, setTypedLen] = useState(String(defaultValue ?? "").length);
const len = controlled ? String(value).length : typedLen;
const [revealed, setRevealed] = useState(false);
const isPassword = type === "password";
const effectiveType = isPassword && revealed ? "text" : type;
const showReveal = isPassword && revealable;
const showClear = clearable && !isPassword;
const [nativeError, setNativeError] = useState("");
const errorText = error || nativeError;
const helperText = helper ?? "";
const activeText = errorText || helperText;
const activeKind = errorText ? "error" : "help";
// Keep the last message mounted while the row collapses, so it fades out instead of vanishing.
const [lastMsg, setLastMsg] = useState({ text: activeText, kind: activeKind });
if (activeText && (activeText !== lastMsg.text || activeKind !== lastMsg.kind)) {
setLastMsg({ text: activeText, kind: activeKind });
}
const shownText = activeText || lastMsg.text;
const shownKind = activeText ? activeKind : lastMsg.kind;
// One shake each time a new error appears. Two keyframe names alternate so it restarts.
const [prevError, setPrevError] = useState(errorText);
const [shake, setShake] = useState(0);
if (errorText !== prevError) {
setPrevError(errorText);
if (errorText) setShake((n) => n + 1);
}
useEffect(() => {
const form = innerRef.current?.form;
if (!form || controlled) return;
const sync = () =>
window.setTimeout(() => {
setTypedLen(innerRef.current?.value.length ?? 0);
setNativeError("");
}, 0);
form.addEventListener("reset", sync);
return () => form.removeEventListener("reset", sync);
}, [controlled]);
const countOn = showCount || maxLength !== undefined;
const hasMsg = !!shownText;
const rowOpen = !!activeText || countOn;
const near = maxLength !== undefined && len >= maxLength * 0.9;
const over = maxLength !== undefined && len > maxLength;
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!controlled) setTypedLen(e.target.value.length);
if (nativeError && e.target.checkValidity()) setNativeError("");
onChange?.(e);
};
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
if (validateOnBlur && !error) {
const el = e.currentTarget;
if ((el.value || el.required) && !el.checkValidity()) setNativeError(el.validationMessage);
}
onBlur?.(e);
};
const handleBoxPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.pointerType !== "mouse") return;
const box = boxRef.current;
if (!box) return;
const r = box.getBoundingClientRect();
const k = r.width ? box.offsetWidth / r.width : 1;
box.style.setProperty("--mx", (e.clientX - r.left) * k + "px");
box.style.setProperty("--my", (e.clientY - r.top) * k + "px");
};
const handlePointerDown = (e: ReactPointerEvent<HTMLInputElement>) => {
const box = boxRef.current;
if (box) {
const r = box.getBoundingClientRect();
const k = r.width ? box.offsetWidth / r.width : 1;
box.style.setProperty("--fx", Math.max(0, Math.min(box.offsetWidth, (e.clientX - r.left) * k)) + "px");
}
onPointerDown?.(e);
};
const clear = () => {
const el = innerRef.current;
if (!el) return;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(el, "");
el.dispatchEvent(new Event("input", { bubbles: true }));
el.focus();
};
const safeAccent = SAFE_COLOR.test(accent.trim()) ? accent.trim() : DEFAULT_ACCENT;
const actions = (showClear ? 1 : 0) + (showReveal ? 1 : 0);
const describedBy = [describedByProp, rowOpen ? msgId : ""].filter(Boolean).join(" ") || undefined;
return (
<div
className={["fli-root", className].filter(Boolean).join(" ")}
data-variant={variant === "dark" ? "dark" : "light"}
data-shake={shake === 0 ? undefined : shake % 2 ? "a" : "b"}
style={{ ...style, ["--fli-accent" as string]: safeAccent }}
>
<style>{CSS}</style>
<div ref={boxRef} className="fli-box" onPointerMove={handleBoxPointerMove}>
<input
{...rest}
ref={setRefs}
id={id}
type={effectiveType}
value={value}
defaultValue={defaultValue}
maxLength={maxLength}
disabled={disabled}
placeholder={placeholder || " "}
aria-invalid={errorText ? true : rest["aria-invalid"]}
aria-describedby={describedBy}
className="fli-input"
style={actions ? { paddingRight: 16 + actions * 34 } : undefined}
onChange={handleChange}
onBlur={handleBlur}
onPointerDown={handlePointerDown}
/>
<span className="fli-glow" aria-hidden="true" />
<label htmlFor={id} className="fli-label">
{label}
</label>
<span className="fli-bar" aria-hidden="true" />
{actions ? (
<div className="fli-actions">
{showClear ? (
<button type="button" className="fli-btn fli-clear" aria-label={"Clear " + label} onClick={clear} disabled={disabled}>
<ClearIcon />
</button>
) : null}
{showReveal ? (
<button
type="button"
className="fli-btn"
aria-label={revealed ? "Hide password" : "Show password"}
aria-pressed={revealed}
onClick={() => setRevealed((r) => !r)}
disabled={disabled}
>
<EyeIcon off={revealed} />
</button>
) : null}
</div>
) : null}
</div>
<div className="fli-msgwrap" data-open={rowOpen}>
<div className="fli-msginner">
<div className="fli-row">
<div id={msgId} className="fli-msg" data-kind={shownKind} aria-live="polite" aria-atomic="true">
{hasMsg && shownKind === "error" ? <AlertIcon /> : null}
<span>{shownText}</span>
</div>
{countOn ? (
<span className="fli-count" data-near={near && !over} data-over={over} aria-hidden="true">
{maxLength !== undefined ? len + "/" + maxLength : len}
</span>
) : null}
</div>
</div>
</div>
</div>
);
});
Free. Pro unlocks everything, from $27/month.
Details
About this component
FloatingLabelInput is a forwardRef input that accepts every standard input prop (value, defaultValue, onChange, name, required, maxLength, autoComplete and so on), so it drops into any form, controlled or uncontrolled. The label floats with a small spring when the field is focused or holds a value; that state comes from :focus and :not(:placeholder-shown) rather than JavaScript, which is why browser autofill, a form reset or a value set from outside never leave the label stuck over the text. Focus draws an accent ring and a 2px underline that grows outward from the point you pressed, and a mouse hover lights the field softly under the cursor. Below the field, helper text and error text share one collapsing row: an error replaces the helper, slides open, and shakes the field once. Errors come from the error prop, or from the browser's own constraint validation when the field loses focus (type, required, pattern, maxLength). Set clearable to add a clear button, which appears while there is a value and clears through a real input event so controlled parents see it. Type password adds a show/hide toggle, and showCount adds a live counter that turns amber at 90% of maxLength. The default look is a white field with the original red focus colour, so it works on a light page; set variant to dark for a near-black surface and accent to any CSS colour. className and style apply to the root, which keeps its 18rem default width. Dependencies: React only.
Curator’s note
Rebuilt in October as a real form field: the float is pure CSS (:placeholder-shown), so autofill, form reset and controlled values all just work, and the same file now handles errors, a counter, clearing and password reveal.

