## 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 (
);
}
```
### 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 (
{hint}
{maxLength !== undefined ? (
{maxLength} / {maxLength}
{length} / {maxLength}
) : null}
{hint ? (
{hint}
) : null}
);
}
```