All patterns

Required Field Mark

Unanswered fields mark themselves one after another down the form — a wave, not a jolt.

formscalmsubtleautomatic · finite · intermediate · ~1.1s
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.

385 lines · react + motion only
import { useEffect, useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Required Field Mark
 *
 * A submit that could not go through, reported as a wave rather than a
 * jolt. The unanswered fields mark themselves one after another, top to
 * bottom, at reading pace — no shake, no flash, and answered fields are
 * left completely alone.
 *
 * Filling a marked field retracts its mark, so the form keeps telling
 * the truth while it is being fixed.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color; the caution hue is literal
 * because it carries meaning.
 * Works with zero props; tune via `variant`, `fields`, `leadInMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type RequiredField = {
  /** Field label. */
  label: string;
  /** Starting value — empty means unanswered. */
  value: string;
  /** Whether an answer is needed before submitting. */
  required?: boolean;
  /** Native input type. */
  type?: string;
  /** Shown in the empty field. */
  placeholder?: string;
};

export type RequiredFieldMarkProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Fields in the order they appear. */
  fields?: RequiredField[];
  /** ms after mount before the failed submit is reported. */
  leadInMs?: number;
  /** Message shown next to the count of unanswered fields. */
  summaryLabel?: string;
  /** Line shown under each marked field. */
  fieldMessage?: string;
};

type VariantConfig = {
  /** ms between one field marking itself and the next. This is the
   *  wave: too small and it becomes a flash, too large and it stalls. */
  waveMs: number;
  /** Seconds the tint and message take to arrive on one field. */
  markSeconds: number;
  dotSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: no shake, no flash, nothing scaling that carries a
// letter. Only the dot beside the label scales, and its spring sits well
// above a 0.8 damping ratio so it arrives once and stops. The wave is
// the whole mechanism — a form that marks everything on the same frame
// is a form nobody reads.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A quick pass down the form. For a long form with many rules.
  subtle: {
    waveMs: 70,
    markSeconds: 0.22,
    dotSpring: { type: "spring", stiffness: 620, damping: 46 },
  },
  // Reading pace: each mark lands as the eye reaches it. ζ ≈ 0.89 — the
  // all-purpose setting.
  default: {
    waveMs: 110,
    markSeconds: 0.3,
    dotSpring: { type: "spring", stiffness: 500, damping: 40 },
  },
  // A slower, more deliberate pass, for a short form where the misses
  // matter.
  playful: {
    waveMs: 170,
    markSeconds: 0.4,
    dotSpring: { type: "spring", stiffness: 400, damping: 34 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this
 *  component inherits — near-black on a light page, near-white on a dark
 *  one — so mixing it with `transparent` yields a surface, border or fill
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const CAUTION = "#E08A3C";

// One answered field, one optional empty one and three required empty
// ones: enough for the pass to skip what is already done, ignore what
// was never compulsory, and still read as a wave rather than a pair.
const DEFAULT_FIELDS: RequiredField[] = [
  { label: "Full name", value: "Rowan Ellis", required: true, type: "text" },
  {
    label: "Work email",
    value: "",
    required: true,
    type: "email",
    placeholder: "name@company.com",
  },
  {
    label: "Team size",
    value: "",
    required: true,
    type: "text",
    placeholder: "How many seats?",
  },
  {
    label: "Purchase order reference",
    value: "",
    type: "text",
    placeholder: "Optional",
  },
  {
    label: "Billing country",
    value: "",
    required: true,
    type: "text",
    placeholder: "Where should we invoice?",
  },
];

export default function RequiredFieldMark({
  variant = "default",
  fields = DEFAULT_FIELDS,
  leadInMs = 620,
  summaryLabel = "still need an answer",
  fieldMessage = "This one is required",
}: RequiredFieldMarkProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [values, setValues] = useState(() => fields.map((field) => field.value));
  const [submitted, setSubmitted] = useState(false);
  /** Index of the field showing a keyboard ring, if any. */
  const [ring, setRing] = useState<number | null>(null);
  // Scoped per instance so two of these on one page keep their own
  // label-to-input associations.
  const formId = useId();

  useEffect(() => {
    const timer = setTimeout(() => setSubmitted(true), leadInMs);
    return () => clearTimeout(timer);
  }, [leadInMs]);

  const missing = fields
    .map((field, index) =>
      field.required && values[index].trim() === "" ? index : -1
    )
    .filter((index) => index !== -1);

  return (
    <div
      style={{
        position: "relative",
        width: 300,
        display: "flex",
        flexDirection: "column",
        gap: 10,
        color: "inherit",
      }}
    >
      {/* The visible summary keeps its box at every moment so the form
          below it never shifts when the pass begins; it is decoration
          only, and the live region at the end of the form is what
          actually reports the failure. */}
      <motion.div
        aria-hidden
        initial={false}
        animate={{ opacity: submitted && missing.length > 0 ? 1 : 0 }}
        transition={{ duration: reduceMotion ? 0 : 0.24, ease: "easeOut" }}
        style={{
          display: "flex",
          alignItems: "center",
          gap: 8,
          padding: "8px 11px",
          borderRadius: 9,
          fontSize: 11.5,
          fontWeight: 620,
          color: CAUTION,
          background: `color-mix(in srgb, ${CAUTION} 10%, transparent)`,
          border: `1px solid color-mix(in srgb, ${CAUTION} 26%, transparent)`,
        }}
      >
        <svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden>
          <circle cx="7" cy="7" r="5.9" stroke="currentColor" strokeWidth="1.3" />
          <path
            d="M7 4.1v3.4"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
          />
          <circle cx="7" cy="9.7" r="0.85" fill="currentColor" />
        </svg>
        {missing.length} {missing.length === 1 ? "field" : "fields"} {summaryLabel}
      </motion.div>

      {fields.map((field, index) => {
        const wavePosition = missing.indexOf(index);
        const marked = submitted && wavePosition !== -1;
        // The wave: each field waits for the ones above it. Answered
        // fields are not in the queue at all, so the pass runs down only
        // the things that are actually wrong.
        const delay =
          reduceMotion || wavePosition === -1 ? 0 : wavePosition * (cfg.waveMs / 1000);
        const arrive = {
          duration: reduceMotion ? 0 : cfg.markSeconds,
          delay: marked ? delay : 0,
          ease: "easeOut" as const,
        };

        return (
          <div key={field.label} style={{ position: "relative", paddingLeft: 9 }}>
            {/* The rail grows downward from the label, in the same
                direction the wave is travelling. */}
            <motion.span
              aria-hidden
              initial={false}
              animate={{ scaleY: marked ? 1 : 0, opacity: marked ? 1 : 0 }}
              transition={arrive}
              style={{
                position: "absolute",
                left: 0,
                top: 2,
                bottom: 2,
                width: 2.5,
                borderRadius: 2,
                background: CAUTION,
                transformOrigin: "top",
              }}
            />

            <label
              htmlFor={`${formId}-${index}`}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 5,
                marginBottom: 4,
                fontSize: 11,
                fontWeight: 650,
                letterSpacing: 0.2,
                opacity: 0.6,
              }}
            >
              {field.label}
              {/* The dot is a mark, not a letter, so it may arrive with a
                  scale. It settles once and stays put. */}
              {field.required && (
                <motion.span
                  aria-hidden
                  initial={false}
                  animate={{
                    scale: marked ? 1 : 0.4,
                    opacity: marked ? 1 : 0,
                  }}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : { ...cfg.dotSpring, delay: marked ? delay : 0 }
                  }
                  style={{
                    width: 5,
                    height: 5,
                    borderRadius: "50%",
                    background: CAUTION,
                  }}
                />
              )}
            </label>

            <div style={{ position: "relative" }}>
              <input
                id={`${formId}-${index}`}
                type={field.type ?? "text"}
                value={values[index]}
                placeholder={field.placeholder}
                aria-required={field.required || undefined}
                aria-invalid={marked || undefined}
                // The message under a marked field is part of what the
                // field means, so it is announced with it rather than
                // being left as decoration only sighted users get.
                aria-describedby={marked ? `${formId}-${index}-message` : undefined}
                // The ring is for keyboard users only. `:focus-visible`
                // is the browser's own answer to "was this focus
                // deliberate?" — read it instead of guessing.
                onFocus={(event) =>
                  setRing(event.currentTarget.matches(":focus-visible") ? index : null)
                }
                onBlur={() => setRing(null)}
                onChange={(event) => {
                  const next = event.target.value;
                  setValues((current) =>
                    current.map((value, position) =>
                      position === index ? next : value
                    )
                  );
                }}
                style={{
                  width: "100%",
                  boxSizing: "border-box",
                  padding: "8px 11px",
                  fontSize: 12.5,
                  fontFamily: "inherit",
                  color: "inherit",
                  background: tone(6),
                  border: `1px solid ${tone(14)}`,
                  borderRadius: 9,
                  // The ring replaces the default outline rather than
                  // removing it: a field nobody can see themselves
                  // standing in is worse than an unstyled one.
                  boxShadow: ring === index ? `0 0 0 3px ${tone(18)}` : "none",
                  outline: "none",
                }}
              />
              {/*
                color-mix() results cannot be interpolated, so the caution
                border is a second static outline fading in over the
                resting one rather than an animated colour.
              */}
              <motion.span
                aria-hidden
                initial={false}
                animate={{ opacity: marked ? 1 : 0 }}
                transition={arrive}
                style={{
                  position: "absolute",
                  inset: 0,
                  borderRadius: 9,
                  border: `1px solid ${CAUTION}`,
                  pointerEvents: "none",
                }}
              />
            </div>

            <motion.div
              id={`${formId}-${index}-message`}
              // Kept in the DOM so it can fade out with its own text
              // rather than vanishing first, but out of the accessibility
              // tree until the field is actually marked.
              aria-hidden={!marked}
              initial={false}
              animate={{ opacity: marked ? 1 : 0, height: marked ? 16 : 0 }}
              transition={arrive}
              style={{
                overflow: "hidden",
                fontSize: 10.5,
                lineHeight: "16px",
                color: CAUTION,
              }}
            >
              {fieldMessage}
            </motion.div>
          </div>
        );
      })}

      {/* A live region only says anything when its content changes, so
          the count is rendered into it at the moment the submit fails
          rather than sitting there at zero opacity from the start. */}
      <span
        role="status"
        style={{
          position: "absolute",
          width: 1,
          height: 1,
          margin: -1,
          padding: 0,
          overflow: "hidden",
          clipPath: "inset(50%)",
          whiteSpace: "nowrap",
        }}
      >
        {submitted && missing.length > 0
          ? `${missing.length} ${missing.length === 1 ? "field" : "fields"} ${summaryLabel}`
          : ""}
      </span>
    </div>
  );
}

About this pattern

What a form should do when a submit cannot go through. The unanswered fields mark themselves in document order at reading pace: a rail grows downward beside each one in the direction the wave is travelling, a dot settles next to the label, the caution outline fades in over the resting border and a short line appears underneath. Fields that were answered are left completely alone, so the pass runs only over what is actually wrong. Nothing shakes and nothing flashes — and filling a marked field retracts its mark, so the form keeps telling the truth while it is being fixed.

Submit blocked by empty fieldsMarking what is still neededLong form completeness passHighlight missing answers

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

    A summary of what is missing above the form, with each field marked in place.

Related patterns