## Floating Label — Input The label makes room instead of disappearing. Docs: https://www.interior.dev/docs/floating-label Reference: https://www.interior.dev/reference/floating-label License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/floating-label.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { FloatingLabelInput } from "@/components/interior/floating-label"; export function BillingContact() { const [email, setEmail] = useState(""); const [reference, setReference] = useState(""); const [touched, setTouched] = useState(false); const bad = touched && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return (
setTouched(true)} invalid={bad} hint={bad ? "Needs to look like name@company.com" : "Receipts are sent here."} /> ); } ``` ### Props - `label` (`string`) The field's name. It moves into a reserved slot above the text; it is never swapped out for a placeholder. - `value` (`string`) Controlled value. Omit for an uncontrolled field and the label still tracks the DOM node. - `defaultValue` (`string`) Uncontrolled starting value. Counted on the first render, so a pre-filled field never animates its label on load. - `onChange` (`(value: string, event: React.ChangeEvent) => void`) Receives the value first so `onChange={setEmail}` is the whole handler. - `hint` (`string`) Secondary line under the field. Read once by screen readers through aria-describedby, not re-announced per keystroke. - `invalid` (`boolean`) — default: `false` Recolors the border and the label and sets aria-invalid. The message itself stays the caller's job. - `maxLength` (`number`) Enables the counter. Its width is reserved at the largest string it can ever show, so digits rolling over never nudge the row. - `required` (`boolean`) — default: `false` Sets the native constraint and marks the label. The asterisk is aria-hidden because the input already announces required. - `disabled` (`boolean`) — default: `false` Dims the field and drops focus state, so a field disabled mid-focus does not keep a lit border. - `readOnly` (`boolean`) — default: `false` Keeps the field focusable and the label raised while refusing edits. - `type` (`"text" | "email" | "password" | "search" | "tel" | "url"`) — default: `"text"` Single-line types only; the geometry is built around one 17px line. - `inputRef` (`React.Ref`) Merged with the internal ref, so form libraries can focus or scroll to the field. - `className` (`string`) — default: `""` Appended last on the wrapper. Width and margins are yours. ### Behavior notes - The label makes room instead of disappearing: the field reserves the raised row and the hint row at mount, so it stands 52px tall in every reachable state and a counter, an error color or a hint arriving on blur cannot push the submit button down the page. - The label travels on transform only — y and scale, origin pinned to its left edge — so raising it costs no layout and the spring resumes from wherever the label currently is when you refocus a field you were leaving. - A value the browser restores on back-navigation, or one a password manager writes without a React change event, still raises the label: the field reads its own node on mount and listens for native input and change, so text is never printed underneath the label. - The mount-time raise is applied with zero duration, so a field that arrives pre-filled from the server presents its label already raised rather than animating on page load. - Under prefers-reduced-motion the label still occupies the raised slot and the hint still changes; only the trip is skipped, and nothing is hidden. - Screen readers get the hint once through aria-describedby and never hear the character counter, which is aria-hidden — the native maxLength attribute carries that information instead of sixty live-region updates. ### Source (`components/interior/floating-label.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { motion, useReducedMotion } from "motion/react"; const INSTANT = { duration: 0 } as const; const LIFT = { type: "spring", stiffness: 760, damping: 46, mass: 0.5 } as const; const RAISE = -32; const SLIDE = -12; const SHRINK = 0.92; const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; export type UseFloatingLabelOptions = { value?: string; defaultValue?: string; disabled?: boolean; }; export type UseFloatingLabelReturn = { ref: React.RefObject; raised: boolean; focused: boolean; filled: boolean; length: number; instant: boolean; fieldProps: { onFocus: () => void; onBlur: () => void; onChange: (event: React.ChangeEvent) => void; }; }; type Fill = { length: number; instant: boolean }; export function useFloatingLabel({ value, defaultValue, disabled = false, }: UseFloatingLabelOptions = {}): UseFloatingLabelReturn { const ref = useRef(null); const mounted = useRef(false); const [focused, setFocused] = useState(false); const [fill, setFill] = useState({ length: (value ?? defaultValue ?? "").length, instant: true, }); const settle = useCallback((next: number, instant: boolean) => { setFill((prev) => prev.length === next && prev.instant === instant ? prev : { length: next, instant }, ); }, []); useIsomorphicLayoutEffect(() => { const el = ref.current; const next = value !== undefined ? value.length : el ? el.value.length : 0; settle(next, !mounted.current); mounted.current = true; }, [value, settle]); useEffect(() => { setFill((prev) => (prev.instant ? { ...prev, instant: false } : prev)); }, []); useEffect(() => { const el = ref.current; if (!el || value !== undefined) return; const read = () => settle(el.value.length, false); el.addEventListener("input", read); el.addEventListener("change", read); return () => { el.removeEventListener("input", read); el.removeEventListener("change", read); }; }, [value, settle]); useEffect(() => { if (disabled) setFocused(false); }, [disabled]); const onFocus = useCallback(() => setFocused(true), []); const onBlur = useCallback(() => setFocused(false), []); const onChange = useCallback( (event: React.ChangeEvent) => settle(event.currentTarget.value.length, false), [settle], ); return { ref, raised: focused || fill.length > 0, focused, filled: fill.length > 0, length: fill.length, instant: fill.instant && !focused, fieldProps: { onFocus, onBlur, onChange }, }; } export type FloatingLabelInputProps = { label: string; value?: string; defaultValue?: string; onChange?: (value: string, event: React.ChangeEvent) => void; onFocus?: () => void; onBlur?: () => void; hint?: string; invalid?: boolean; id?: string; name?: string; type?: "text" | "email" | "password" | "search" | "tel" | "url"; autoComplete?: string; inputMode?: React.ComponentProps<"input">["inputMode"]; maxLength?: number; required?: boolean; disabled?: boolean; readOnly?: boolean; inputRef?: React.Ref; className?: string; }; export function FloatingLabelInput({ label, value, defaultValue, onChange, onFocus, onBlur, hint, invalid = false, id, name, type = "text", autoComplete, inputMode, maxLength, required = false, disabled = false, readOnly = false, inputRef, className = "", }: FloatingLabelInputProps) { const auto = useId(); const fieldId = id ?? `${auto}-field`; const hintId = `${auto}-hint`; const reduced = useReducedMotion(); const { ref, raised, focused, length, instant, fieldProps } = useFloatingLabel({ value, defaultValue, disabled, }); const move = reduced || instant ? INSTANT : LIFT; const attach = useCallback( (node: HTMLInputElement | null) => { ref.current = node; if (typeof inputRef === "function") inputRef(node); else if (inputRef) inputRef.current = node; }, [ref, inputRef], ); return (
{ fieldProps.onFocus(); onFocus?.(); }} onBlur={() => { fieldProps.onBlur(); onBlur?.(); }} onChange={(event) => { fieldProps.onChange(event); onChange?.(event.currentTarget.value, event); }} 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" />
{label} {required ? ( * ) : null}

{hint}

{maxLength !== undefined ? ( {maxLength} / {maxLength} {length} / {maxLength} ) : null} {hint ? ( {hint} ) : null}
); } ```