Skip to content

Label rests inside as the placeholder and floats up on focus, value or autofill.

Text inputs@base-ui/react

01Preview

Billing details

Pro plan · $24 per month

Receipts and invoices go here

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/react

03Usage

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

PropTypeDefaultDescription
label*stringSits inside the empty field, then floats above the text. Always the accessible name.
valuestringThe text, when controlled. Pair with onValueChange.
defaultValuestring""The starting text, when uncontrolled. A starting value renders with the label already up.
onValueChange(value: string) => voidCalled on every edit.
placeholderstringAn example value, shown only once the label has floated and the field is focused and empty.
descriptionReactNodeA hint under the field. The error takes its place while there is one.
errorstringMarks the field invalid and slides the message in under it. Clear it to slide it out.
suffixReactNodeTrailing content inside the field, e.g. a reveal button or a status icon.
validateField.Root validateBase 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.
classNamestringClasses for the Field root.
inputClassNamestringClasses for the input.
...propsComponentProps<"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.