Input Focus Underline
The label lifts out of the field and an underline grows outward from the center on focus.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Input Focus Underline
*
* The label lifts out of the field and an underline draws outward from
* the center the moment the input takes focus.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `label`, `type`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type InputFocusUnderlineProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Doubles as the resting placeholder, so keep it short. */
label?: string;
/** Any native input type. */
type?: string;
/** Starting text. The label stays lifted whenever the field has a value. */
defaultValue?: string;
/** Focus color for the label and the underline. */
accent?: string;
/** Field width. */
width?: number | string;
/** Fires on every keystroke. */
onValueChange?: (value: string) => void;
};
type VariantConfig = {
/**
* Where the label sits when the field is empty. It doubles as the
* placeholder, so this barely moves between variants — the lift
* distance is set by the layout, not by taste.
*/
labelRest: number;
labelSpring: { type: "spring"; stiffness: number; damping: number };
/** How long the underline takes to reach both ends. */
draw: number;
underlineHeight: number;
};
// Quality rule: the label is text, so its spring sits at or above a 0.8
// damping ratio and its font size never changes — a floating label that
// scales is the cheapest tell in form design. Variants differ in tempo
// and in the weight of the line, not in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Snaps into place with no settle at all. For dense forms where a
// dozen of these fire as the user tabs through.
subtle: {
labelRest: 22,
labelSpring: { type: "spring", stiffness: 560, damping: 48 },
draw: 0.14,
underlineHeight: 1.5,
},
// A single soft settle on the label. The all-purpose setting.
default: {
labelRest: 24,
labelSpring: { type: "spring", stiffness: 460, damping: 40 },
draw: 0.24,
underlineHeight: 2,
},
// Slower draw, heavier line, a touch more travel — for a short,
// deliberate form where each field is a moment.
playful: {
labelRest: 27,
labelSpring: { type: "spring", stiffness: 370, damping: 32 },
draw: 0.34,
underlineHeight: 2.8,
},
};
export default function InputFocusUnderline({
variant = "default",
label = "Work email",
type = "email",
defaultValue = "",
accent = "#5B5BD6",
width = 260,
onValueChange,
}: InputFocusUnderlineProps) {
const [value, setValue] = useState(defaultValue);
const [focused, setFocused] = useState(false);
const inputId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// A filled field keeps its label lifted: dropping it back over the
// user's own text is the one thing a floating label must never do.
const lifted = focused || value.length > 0;
return (
<div style={{ position: "relative", width, height: 44 }}>
<motion.label
htmlFor={inputId}
animate={{ y: lifted ? 0 : cfg.labelRest }}
// Reduced motion: the label still moves, because its position
// carries the state — it just gets there without travel.
transition={reduceMotion ? { duration: 0 } : cfg.labelSpring}
style={{
position: "absolute",
left: 0,
top: 0,
// Constant size in both states. The lift is the whole signal;
// shrinking the glyphs would only deform them.
fontSize: 12.5,
lineHeight: "13px",
fontWeight: 600,
letterSpacing: 0.2,
color: focused ? accent : "inherit",
opacity: focused ? 1 : 0.55,
pointerEvents: "none",
// Color and opacity settle on a CSS transition so the
// animation loop stays transform-only.
transition: "color 180ms ease-out, opacity 180ms ease-out",
}}
>
{label}
</motion.label>
<input
id={inputId}
type={type}
value={value}
onChange={(event) => {
setValue(event.target.value);
onValueChange?.(event.target.value);
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{
position: "absolute",
left: 0,
top: 20,
width: "100%",
height: 22,
boxSizing: "border-box",
padding: 0,
fontSize: 15,
fontFamily: "inherit",
lineHeight: "22px",
color: "inherit",
background: "transparent",
border: "none",
// The drawn underline and the lifted label are the focus
// indicator — louder than the default ring, and always visible.
outline: "none",
}}
/>
{/* Resting hairline: the field reads as a field before anything moves. */}
<span
aria-hidden
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: cfg.underlineHeight,
borderRadius: cfg.underlineHeight,
background: "rgba(127,127,140,0.32)",
}}
/>
<motion.span
aria-hidden
initial={reduceMotion ? { opacity: 0 } : { scaleX: 0 }}
// Reduced motion keeps the line — it just arrives at full width
// instead of growing to it.
animate={
reduceMotion
? { opacity: focused ? 1 : 0 }
: { scaleX: focused ? 1 : 0 }
}
transition={{
duration: reduceMotion ? 0.12 : cfg.draw,
ease: "easeOut",
}}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: cfg.underlineHeight,
borderRadius: cfg.underlineHeight,
background: accent,
// Center origin: the line grows to both ends at once, which
// reads as the field opening rather than as a wipe with a
// direction the user has to follow.
transformOrigin: "50% 50%",
}}
/>
</div>
);
}About this pattern
Focus feedback for text fields, sized for forms where a dozen of these fire while the user tabs through. The label travels up out of the input at a constant font size — a floating label that shrinks its glyphs is the cheapest tell in form design — and the accent line grows from the middle to both ends at once, which reads as the field opening rather than as a wipe the eye has to follow. A field that already holds a value keeps its label lifted after blur, so the state is never ambiguous.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Form
The floating-label idea, kept here without the glyph scaling.
Related patterns
- Search Input ExpandA search icon opens into a full field while the controls beside it give up the space.
- Date Picker OpenThe calendar unfolds from the field and the days arrive in a wave, today already marked.
- Inline Edit SwapA value becomes a field where it sits: the surface arrives around the words and the row never changes size.