UIForms and Inputsmotion/react

FloatingInput

A text field whose label glides up and shrinks when focused or filled, with an animated accent focus ring, helper or error text that cross-fades in place, and an optional clear button that pops in once there is a value.

Preview

We'll only use this to send you receipts.

Customize

11 props

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add floating-input

Writes components/ui/floating-input.tsx and components/styles/floating-input.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { FloatingInput } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <FloatingInput />6  );7}

Props

PropTypeDefault
value

Controlled value.

stringundefined
defaultValue

Initial value when uncontrolled.

string''
onValueChange

Called with the next value on every edit and with an empty string when cleared.

(value: string) => voidundefined
label

Floating label text; also the accessible name.

string'Email address'
type

Native input type.

'text' | 'email' | 'url' | 'tel' | 'search''email'
helperText

Helper text shown under the field.

string"We'll only use this to send you receipts."
errorText

Message shown instead of the helper when invalid is true.

string'Please enter a valid email address.'
invalid

Switches to the error ring, label color and message, and sets aria-invalid.

booleanfalse
variant

Visual style of the field.

'outline' | 'filled' | 'underline''outline'
size

Field height and font-size preset.

'sm' | 'md' | 'lg''md'
accentColor

Color of the focus ring, underline bar and floated label.

string'#6366f1'
clearable

Show an animated clear button when the field has a value (Escape also clears).

booleantrue
disabled

Disable the field.

booleanfalse
required

Adds a required asterisk and aria-required.

booleanfalse
name

Native name attribute for form submission.

stringundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

Uses a native <input> with a real <label htmlFor> as its accessible name. Helper/error text is linked with aria-describedby inside a polite live region; invalid sets aria-invalid and required sets aria-required (the visual asterisk is aria-hidden). The clear button is a labelled native button ('Clear <label>') and Escape also clears the value; focus returns to the input. Under prefers-reduced-motion the label and messages change without movement.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/floating-input.tsx
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/floating-input.module.css";6 7export interface FloatingInputProps {8  /** Controlled value */9  value?: string;10  /** Initial value when uncontrolled */11  defaultValue?: string;12  /** Called with the next value on every edit (and with "" when cleared) */13  onValueChange?: (value: string) => void;14  /** Floating label text (also the accessible name) */15  label?: string;16  /** Native input type */17  type?: "text" | "email" | "url" | "tel" | "search";18  /** Helper text shown under the field */19  helperText?: string;20  /** Error message shown (instead of the helper) when `invalid` is true */21  errorText?: string;22  /** Marks the field invalid: red ring, aria-invalid and the error message */23  invalid?: boolean;24  /** Visual style */25  variant?: "outline" | "filled" | "underline";26  /** Field height preset */27  size?: "sm" | "md" | "lg";28  /** Color of the focus ring and floated label */29  accentColor?: string;30  /** Show a clear (x) button when the field has a value */31  clearable?: boolean;32  /** Disable the field */33  disabled?: boolean;34  /** Mark as required (adds an asterisk and aria-required) */35  required?: boolean;36  /** Native name attribute for forms */37  name?: string;38  /** Optional className merged onto the root */39  className?: string;40  /** Optional inline styles merged onto the root */41  style?: React.CSSProperties;42}43 44export function FloatingInput({45  value: valueProp,46  defaultValue = "",47  onValueChange,48  label = "Email address",49  type = "email",50  helperText = "We'll only use this to send you receipts.",51  errorText = "Please enter a valid email address.",52  invalid = false,53  variant = "outline",54  size = "md",55  accentColor = "#6366f1",56  clearable = true,57  disabled = false,58  required = false,59  name,60  className = "",61  style,62}: FloatingInputProps) {63  const reduce = useReducedMotion();64  const [inner, setInner] = React.useState(defaultValue);65  const controlled = valueProp !== undefined;66  const value = controlled ? valueProp : inner;67  const [focused, setFocused] = React.useState(false);68  const inputRef = React.useRef<HTMLInputElement>(null);69  const id = React.useId();70  const msgId = `${id}-msg`;71  const message = invalid ? errorText : helperText;72  const floated = focused || value.length > 0;73 74  const setValue = (next: string) => {75    if (!controlled) setInner(next);76    onValueChange?.(next);77  };78 79  const clear = () => {80    setValue("");81    inputRef.current?.focus();82  };83 84  const fade = reduce ? { duration: 0.1 } : { duration: 0.18, ease: [0.16, 1, 0.3, 1] as const };85 86  return (87    <div88      className={`${styles.root} ${className}`.trim()}89      data-variant={variant}90      data-size={size}91      data-floated={floated ? "" : undefined}92      data-focused={focused ? "" : undefined}93      data-invalid={invalid ? "" : undefined}94      data-disabled={disabled ? "" : undefined}95      style={{ "--mf-fi-accent": accentColor, ...style } as React.CSSProperties}96    >97      <div className={styles.field}>98        <input99          ref={inputRef}100          id={id}101          name={name}102          type={type}103          value={value}104          disabled={disabled}105          required={required}106          aria-required={required || undefined}107          aria-invalid={invalid || undefined}108          aria-describedby={message ? msgId : undefined}109          placeholder=" "110          autoComplete="off"111          className={styles.input}112          onChange={(e) => setValue(e.target.value)}113          onFocus={() => setFocused(true)}114          onBlur={() => setFocused(false)}115          onKeyDown={(e) => {116            if (e.key === "Escape" && clearable && value) {117              e.preventDefault();118              clear();119            }120          }}121        />122        <label htmlFor={id} className={styles.label}>123          {label}124          {required && (125            <span aria-hidden="true" className={styles.req}>126              *127            </span>128          )}129        </label>130        <span aria-hidden="true" className={styles.bar} />131        <AnimatePresence initial={false}>132          {clearable && value && !disabled && (133            <motion.button134              key="clear"135              type="button"136              aria-label={`Clear ${label}`}137              className={styles.clear}138              onClick={clear}139              initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}140              animate={{ opacity: 1, scale: 1 }}141              exit={{ opacity: 0, scale: reduce ? 1 : 0.6 }}142              transition={fade}143            >144              <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round">145                <path d="M18 6 6 18M6 6l12 12" />146              </svg>147            </motion.button>148          )}149        </AnimatePresence>150      </div>151      <div className={styles.msgSlot} id={msgId} aria-live="polite">152        <AnimatePresence mode="wait" initial={false}>153          {message && (154            <motion.p155              key={invalid ? "error" : "helper"}156              className={invalid ? `${styles.msg} ${styles.error}` : styles.msg}157              initial={{ opacity: 0, y: reduce ? 0 : -4 }}158              animate={{ opacity: 1, y: 0 }}159              exit={{ opacity: 0, y: reduce ? 0 : -4 }}160              transition={fade}161            >162              {invalid && (163                <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round">164                  <circle cx="12" cy="12" r="9" />165                  <path d="M12 8v5M12 16.5v.01" />166                </svg>167              )}168              {message}169            </motion.p>170          )}171        </AnimatePresence>172      </div>173    </div>174  );175}