All patterns

Input Focus Underline

The label lifts out of the field and an underline grows outward from the center on focus.

formssubtleelegantinteraction · finite · starter · ~0.3s
Interactive · click to play
Variant

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.

197 lines · react + motion only
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.

Text field focusCheckout formSettings formInline edit field

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Add a supplierTwo fields now, the rest later
    Legal name
    Ridgeline Supply Co.
    Country
    Sweden
    VAT number
    SE556031820101
    Save supplier
    Form

    The floating-label idea, kept here without the glyph scaling.

Related patterns