"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export interface FloatingLabelInputProps extends Omit<React.ComponentPropsWithoutRef<"input">, "size"> {
/** Visible label, rendered as a real <label htmlFor>. */
label: React.ReactNode;
/** Helper text below the field (linked via aria-describedby). */
hint?: React.ReactNode;
/** Error message. Any truthy value marks the field invalid; a node is also shown and announced. */
error?: React.ReactNode;
variant?: "outline" | "filled" | "underline";
/** Focus colour for the border / underline (any CSS colour). */
accent?: string;
/** Class for the <input> itself; `className` styles the outer wrapper. */
inputClassName?: string;
}
const VARIANTS = {
outline: {
field:
"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",
label: "left-3.5",
},
filled: {
field:
"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]",
label: "left-3.5",
},
underline: {
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",
label: "left-0",
},
} as const;
/**
* A text input whose label rests inside the field and floats up on focus or when filled.
* Floating is pure CSS (`:placeholder-shown`, `:focus`, `:autofill`), so it is correct for
* server-rendered values and browser autofill. The error message animates in and is announced.
*/
export const FloatingLabelInput = React.forwardRef<HTMLInputElement, FloatingLabelInputProps>(function FloatingLabelInput(
{
label,
hint,
error,
variant = "outline",
accent = "#FF7448",
id: idProp,
className = "",
inputClassName = "",
style,
placeholder,
disabled,
required,
...props
},
ref,
) {
const reduceMotion = useReducedMotion();
const autoId = React.useId();
const id = idProp ?? `fl-${autoId}`;
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const invalid = Boolean(error) || props["aria-invalid"] === true || props["aria-invalid"] === "true";
const hasErrorText = Boolean(error) && error !== true;
const describedBy =
[props["aria-describedby"], hint ? hintId : null, hasErrorText ? errorId : null].filter(Boolean).join(" ") || undefined;
const v = VARIANTS[variant];
const floated =
"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]";
return (
<div className={`w-full ${className}`} style={{ ["--fl-accent" as string]: accent, ...style }}>
<div className="relative">
<input
ref={ref}
id={id}
disabled={disabled}
required={required}
// A single space keeps :placeholder-shown meaningful when no placeholder is given.
// A custom placeholder is only revealed once the label has floated out of the way.
placeholder={placeholder ?? " "}
{...props}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
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} ${
invalid
? "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"
: variant === "underline"
? "focus:border-[var(--fl-accent)]"
: "focus:border-[var(--fl-accent)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--fl-accent)_22%,transparent)]"
} ${inputClassName}`}
/>
<label
htmlFor={id}
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} ${
invalid
? "text-rose-600 dark:text-rose-400"
: "text-zinc-500 peer-focus:text-zinc-900 dark:text-zinc-400 dark:peer-focus:text-zinc-100"
} ${disabled ? "opacity-50" : ""}`}
style={{ maxWidth: "calc(100% - 1.75rem)" }}
>
{label}
{required && (
<span aria-hidden="true" className="ml-0.5 text-[var(--fl-accent)]">
*
</span>
)}
</label>
{variant === "underline" && (
// Accent rule that draws out from the centre on focus.
<span
aria-hidden="true"
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 ${
invalid ? "bg-rose-500 dark:bg-rose-400" : "bg-[var(--fl-accent)]"
}`}
/>
)}
</div>
{hint && (
<p id={hintId} className={`mt-1.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400 ${variant === "underline" ? "" : "px-3.5"}`}>
{hint}
</p>
)}
{/* Persistent live region: content changes are announced without moving focus. */}
<div aria-live="polite">
<AnimatePresence initial={false}>
{hasErrorText && (
<motion.p
key="error"
id={errorId}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, height: "auto", y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}
transition={{ duration: reduceMotion ? 0 : 0.2, ease: [0.2, 0.7, 0.2, 1] }}
className={`overflow-hidden text-[13px] font-medium leading-snug text-rose-600 dark:text-rose-400 ${variant === "underline" ? "" : "px-3.5"}`}
>
<span className="flex items-start gap-1.5 pt-1.5">
<svg aria-hidden="true" viewBox="0 0 16 16" className="mt-px size-3.5 shrink-0" fill="none" stroke="currentColor" strokeWidth="1.6">
<circle cx="8" cy="8" r="6.25" />
<path d="M8 4.75v3.75M8 10.9v.1" strokeLinecap="round" />
</svg>
<span>{error}</span>
</span>
</motion.p>
)}
</AnimatePresence>
</div>
</div>
);
});