Label rests inside as the placeholder and floats up on focus, value or autofill.
Text inputs@base-ui/react
01Preview
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react03Usage
import { FloatingLabelField } from "@/components/ui/floating-label";
<FloatingLabelField
label="Email"
type="email"
autoComplete="email"
description="Receipts and invoices go here"
error={submitted && !valid ? "Enter a full address, like maya@northwind.com" : undefined}
/>04Source
"use client";
import { Field } from "@base-ui/react/field";
import { Input as BaseInput } from "@base-ui/react/input";
import { useState } from "react";
import { cn } from "@/lib/cn";
import { Alert } from "@/lib/icons";
import { useControllableState } from "@/lib/use-controllable-state";
export type FloatingLabelFieldProps = Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "size" | "children"> & {
/** Sits inside the field like a placeholder, then floats above the text. */
label: string;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** A hint under the field. The error takes its place while there is one. */
description?: React.ReactNode;
/** An error message. Setting it marks the field invalid and slides the message in. */
error?: string;
/** Trailing content inside the field, e.g. a reveal button or a status icon. */
suffix?: React.ReactNode;
/** Passed to the Base UI Field for its own validation, when you don't manage `error` yourself. */
validate?: React.ComponentProps<typeof Field.Root>["validate"];
validationMode?: React.ComponentProps<typeof Field.Root>["validationMode"];
/** Classes for the outer Field root; `inputClassName` styles the input. */
inputClassName?: string;
};
export function FloatingLabelField({
label,
value: valueProp,
defaultValue = "",
onValueChange,
description,
error,
suffix,
validate,
validationMode,
disabled,
readOnly,
name,
placeholder,
className,
inputClassName,
ref,
...rest
}: FloatingLabelFieldProps) {
const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });
// Keep the last message on screen while it animates out, instead of emptying mid-exit.
const [lastError, setLastError] = useState(error);
if (error && error !== lastError) setLastError(error);
return (
<Field.Root
name={name}
disabled={disabled}
invalid={error ? true : undefined}
validate={validate}
validationMode={validationMode}
className={cn("flex w-full min-w-0 flex-col", className)}
>
<div
data-slot="floating-label"
className={cn(
"group/float relative h-13 w-full rounded-lg border border-line-2 bg-raised text-fg shadow-[var(--shadow)]",
"transition-[border-color,box-shadow,background-color] duration-150 ease-out",
"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3",
"has-[input[data-invalid]]:border-danger/70 has-[input[data-invalid]]:hover:border-danger has-[input[data-invalid]]:focus-within:border-danger has-[input[data-invalid]]:focus-within:ring-danger/15",
"has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 has-[input:disabled]:shadow-none has-[input:disabled]:hover:border-line-2",
"has-[input:read-only]:bg-frame has-[input:read-only]:shadow-none has-[input:read-only]:hover:border-line-2",
)}
>
<BaseInput
ref={ref}
value={value}
onValueChange={(next) => setValue(next)}
readOnly={readOnly}
// A real placeholder is required for :placeholder-shown, which is how the
// label knows the field is empty without JavaScript (and through autofill).
placeholder={placeholder ?? " "}
className={cn(
"peer absolute inset-0 size-full rounded-[inherit] bg-transparent px-3 pb-1.5 pt-[21px] text-base leading-5 text-fg outline-none sm:text-[14px]",
"placeholder:text-transparent placeholder:transition-colors placeholder:duration-150 focus:placeholder:text-fg-4 focus:placeholder:delay-75",
"disabled:cursor-not-allowed",
"autofill:shadow-[inset_0_0_0_1000px_var(--raised)] autofill:[-webkit-text-fill-color:var(--fg)]",
suffix != null && "pr-11",
inputClassName,
)}
{...rest}
/>
<Field.Label
className={cn(
"pointer-events-none absolute left-3 top-4 max-w-[calc(100%-1.5rem)] origin-top-left truncate text-base leading-5 text-fg-3 sm:text-[14px]",
// Transform only: the label moves and shrinks, nothing around it reflows.
"transition-[translate,scale,color] duration-200 ease-out-quart motion-reduce:transition-[color]",
"peer-focus:-translate-y-[9px] peer-focus:scale-[0.85] peer-focus:text-fg-2",
"peer-[:not(:placeholder-shown)]:-translate-y-[9px] peer-[:not(:placeholder-shown)]:scale-[0.85]",
"peer-autofill:-translate-y-[9px] peer-autofill:scale-[0.85]",
"data-invalid:text-danger peer-focus:data-invalid:text-danger",
suffix != null && "max-w-[calc(100%-3.5rem)]",
)}
>
{label}
</Field.Label>
{suffix != null && <div className="absolute inset-y-0 right-1.5 flex items-center text-fg-3">{suffix}</div>}
</div>
{/* The message row grows from nothing on the first error, so the form below
eases down instead of jumping; a hint and an error share one slot. */}
<div className="grid grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out-quart has-[[data-msg]]:grid-rows-[1fr] motion-reduce:transition-none">
<div className="min-h-0 overflow-hidden">
<div className="grid pl-3 pt-1.5">
{description != null && (
<Field.Description
data-msg=""
className="col-start-1 row-start-1 text-[12px] leading-4 text-fg-3 transition-opacity duration-150 data-invalid:opacity-0"
>
{description}
</Field.Description>
)}
<Field.Error
data-msg=""
match={error ? true : undefined}
className={cn(
"col-start-1 row-start-1 flex items-start gap-1.5 text-[12px] leading-4 text-danger",
"transition-[opacity,translate] duration-200 ease-out-expo",
"data-starting-style:-translate-y-1 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100",
"motion-reduce:translate-y-0",
)}
>
<Alert size={14} className="mt-px size-3.5 shrink-0" />
<span className="min-w-0">{error || lastError || <Field.Validity>{(s) => s.error}</Field.Validity>}</span>
</Field.Error>
</div>
</div>
</div>
</Field.Root>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | string | — | Sits inside the empty field, then floats above the text. Always the accessible name. |
| value | string | — | The text, when controlled. Pair with onValueChange. |
| defaultValue | string | "" | The starting text, when uncontrolled. A starting value renders with the label already up. |
| onValueChange | (value: string) => void | — | Called on every edit. |
| placeholder | string | — | An example value, shown only once the label has floated and the field is focused and empty. |
| description | ReactNode | — | A hint under the field. The error takes its place while there is one. |
| error | string | — | Marks the field invalid and slides the message in under it. Clear it to slide it out. |
| suffix | ReactNode | — | Trailing content inside the field, e.g. a reveal button or a status icon. |
| validate | Field.Root validate | — | Base UI Field validation, when you'd rather not manage error yourself. Use one or the other. |
| validationMode | "onSubmit" | "onBlur" | "onChange" | "onSubmit" | When the Base UI validation runs. |
| className | string | — | Classes for the Field root. |
| inputClassName | string | — | Classes for the input. |
| ...props | ComponentProps<"input"> | — | name, type, autoComplete, inputMode, disabled, readOnly, ref and the rest go to the input. |
06Notes
Behavior
- Whether the label floats is decided in CSS from :focus, :placeholder-shown and :autofill, so it is already up in the server render when there's a value and stays up when the browser fills the field before any script runs.
- The real placeholder is hidden until the label has floated, so the two never overlap; it fades in 75ms after focus.
- The message row grows from zero the first time an error appears and the form below eases down; with a description, the error replaces it in the same slot and nothing moves.
- The last error stays rendered while it animates out, so the message never empties mid-exit. Long labels truncate; long errors wrap.
Motion
- The label moves 9px up and scales to 0.85 from its top-left corner in 200ms on a soft ease-out, using translate and scale only, so nothing reflows.
- The error drops in from 4px above over 200ms on the expo ease-out while its row opens in 200ms; it fades out in 100ms.
- Reduced motion keeps the color change, snaps the label into place and opens the row without the slide.
Accessibility
- Built on Base UI Field, so the label is a real label for the input and the description and error are linked with aria-describedby.
- An error sets aria-invalid on the input and turns the label red; the message has an icon and words, never color alone.
- The label stays in the DOM when floated, so screen readers always get the name, never the placeholder in its place.