All patterns

Field Valid Check

A small mark strokes itself in at the right edge of a field the moment its value becomes acceptable.

feedbacksubtlefriendlyautomatic · finite · intermediate · ~0.4s
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.

253 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Field Valid Check
 *
 * The quiet half of validation: the moment a field's value becomes
 * acceptable, a tick strokes itself in at the right edge and a soft
 * ring settles over the border. Nothing is announced, nothing shouts.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `label`, `isValid`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type FieldValidCheckProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Field label. */
  label?: string;
  /** Placeholder for the empty field. */
  placeholder?: string;
  /** Drive the field from outside. Omit and it holds its own value. */
  value?: string;
  /** Starting value when the field holds its own. */
  defaultValue?: string;
  /** Decide acceptability. Defaults to a simple address shape. */
  isValid?: (value: string) => boolean;
  /** Fires whenever acceptability flips. */
  onValidChange?: (valid: boolean) => void;
};

type VariantConfig = {
  /** Spring behind the mark appearing. */
  spring: { type: "spring"; stiffness: number; damping: number };
  /** How small the mark starts. */
  scaleFrom: number;
  /** How long the tick takes to stroke itself in. */
  drawDuration: number;
  /** How long the border ring takes to settle on. */
  ringDuration: number;
};

// The mark is 16px across, so its spring can be quick and still land
// softly — every ratio here is at or above 0.8. Variants differ in how
// far the mark travels into place, never in bounce count.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // No travel to speak of: the mark simply resolves.
  subtle: {
    spring: { type: "spring", stiffness: 620, damping: 44 },
    scaleFrom: 0.85,
    drawDuration: 0.18,
    ringDuration: 0.24,
  },
  default: {
    spring: { type: "spring", stiffness: 520, damping: 38 },
    scaleFrom: 0.62,
    drawDuration: 0.22,
    ringDuration: 0.28,
  },
  // A longer approach — for one-off fields where the pass is the news.
  playful: {
    spring: { type: "spring", stiffness: 440, damping: 36 },
    scaleFrom: 0.4,
    drawDuration: 0.26,
    ringDuration: 0.32,
  },
};

const VALID = "#10B981";
const FIELD_HEIGHT = 42;

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_SHAPE = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;

export default function FieldValidCheck({
  variant = "default",
  label = "Billing email",
  placeholder = "name@company.com",
  value,
  defaultValue = "",
  isValid,
  onValidChange,
}: FieldValidCheckProps) {
  const [held, setHeld] = useState(defaultValue);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const controlled = value !== undefined;
  const current = controlled ? value : held;
  const valid = isValid ? isValid(current) : DEFAULT_SHAPE.test(current.trim());

  // The callback lives in a ref so an inline arrow from the parent can't
  // re-fire the effect on every render of the form around it.
  const onValidChangeRef = useRef(onValidChange);
  useEffect(() => {
    onValidChangeRef.current = onValidChange;
  }, [onValidChange]);
  useEffect(() => {
    onValidChangeRef.current?.(valid);
  }, [valid]);

  return (
    <div style={{ width: 300 }}>
      <label
        style={{
          display: "block",
          fontSize: 11.5,
          opacity: 0.55,
          marginBottom: 5,
        }}
      >
        {label}
      </label>

      <div
        style={{
          position: "relative",
          display: "flex",
          alignItems: "center",
          height: FIELD_HEIGHT,
          borderRadius: 10,
          background: tone(4),
          border: `1px solid ${tone(14)}`,
        }}
      >
        {/* The border "changes color" by fading a ring over it rather than
            tweening the real border: the change stays on the compositor
            and lands on the exact hue instead of drifting through gray. */}
        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: valid ? 1 : 0 }}
          transition={{ duration: cfg.ringDuration, ease: "easeOut" }}
          style={{
            position: "absolute",
            inset: -1,
            borderRadius: 10,
            border: `1px solid ${VALID}`,
            pointerEvents: "none",
          }}
        />

        <input
          type="email"
          inputMode="email"
          value={current}
          readOnly={controlled}
          onChange={(event) => setHeld(event.target.value)}
          placeholder={placeholder}
          aria-invalid={current.length > 0 && !valid}
          style={{
            flex: 1,
            minWidth: 0,
            height: "100%",
            // The slot on the right is reserved whether or not the mark
            // is showing, so arriving at a valid value never re-flows the
            // text the user just typed.
            padding: "0 34px 0 12px",
            fontSize: 13.5,
            fontFamily: "inherit",
            color: "inherit",
            background: "transparent",
            border: 0,
            borderRadius: 10,
            outline: "none",
          }}
        />

        <span
          aria-hidden
          style={{
            position: "absolute",
            right: 11,
            width: 16,
            height: 16,
            display: "grid",
            placeItems: "center",
            pointerEvents: "none",
          }}
        >
          <AnimatePresence initial={false}>
            {valid && (
              <motion.svg
                key="mark"
                width="16"
                height="16"
                viewBox="0 0 16 16"
                fill="none"
                // Icons may scale; the text beside them may not. This is
                // an icon, so it is allowed to arrive with a little size.
                initial={{ opacity: 0, scale: reduceMotion ? 1 : cfg.scaleFrom }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{
                  opacity: 0,
                  scale: reduceMotion ? 1 : 0.8,
                  transition: { duration: 0.12, ease: "easeIn" },
                }}
                transition={
                  reduceMotion
                    ? { duration: 0.16, ease: "easeOut" }
                    : { ...cfg.spring, opacity: { duration: 0.14, ease: "easeOut" } }
                }
              >
                <circle
                  cx="8"
                  cy="8"
                  r="8"
                  fill={`color-mix(in srgb, ${VALID} 16%, transparent)`}
                />
                <motion.path
                  d="M4.4 8.3 6.9 10.8 11.7 5.6"
                  stroke={VALID}
                  strokeWidth="1.9"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  initial={{ pathLength: reduceMotion ? 1 : 0 }}
                  animate={{ pathLength: 1 }}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : { duration: cfg.drawDuration, ease: "easeOut", delay: 0.03 }
                  }
                />
              </motion.svg>
            )}
          </AnimatePresence>
        </span>
      </div>

      {/* One polite announcement, so the pass is not silent for anyone
          who cannot see the mark. */}
      <span
        role="status"
        aria-live="polite"
        style={{
          position: "absolute",
          width: 1,
          height: 1,
          overflow: "hidden",
          clip: "rect(0 0 0 0)",
          whiteSpace: "nowrap",
        }}
      >
        {valid ? `${label} accepted` : ""}
      </span>
    </div>
  );
}

About this pattern

Validation usually only speaks up when something is wrong, which leaves the user guessing on the way in. This is the other half: the keystroke that completes a value is answered by a mark resolving at the right edge and a soft ring settling over the border. The slot for the mark is reserved whether or not it is showing, so arriving at an acceptable value never re-flows the text that was just typed, and the border changes color by fading a ring over it rather than tweening the real border — the change stays on the compositor and lands on the exact hue instead of drifting through gray. The mark is an icon, so it may arrive with a little size; nothing typographic moves at all.

Email field acceptedCoupon code recognisedUsername availableCard number complete

Where it shows up

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

  • Payment
    Card number4242 4242 4242 4242Name on cardN. Bergström
    Expiry04 / 28CVC•••
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Pay $169.65
    Checkout

    Card fields mark themselves complete at the right edge while you type.

Related patterns