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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add floating-inputWrites 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.
1import { FloatingInput } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <FloatingInput />6 );7}Props
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
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}