Skip to main content

#253 - Shake Form Fields on Error

Give a failed form submission a quick shake, so people can see straight away which field needs their attention.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

151 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #253 v0.1 💙 SHAKE FORM FIELDS ON ERROR -->
<style>
  @keyframes ms-shake {
    0%, 100% { transform: translateX(0); }
    15% { transform: translateX(calc(var(--ms-shake-distance, 8px) * -1)); }
    30% { transform: translateX(var(--ms-shake-distance, 8px)); }
    45% { transform: translateX(calc(var(--ms-shake-distance, 8px) * -0.prop6)); }
    60% { transform: translateX(calc(var(--ms-shake-distance, 8px) * 0.prop6)); }
    75% { transform: translateX(calc(var(--ms-shake-distance, 8px) * -0.prop3)); }
    90% { transform: translateX(calc(var(--ms-shake-distance, 8px) * 0.prop3)); }
  }

  .ms-shake {
    animation: ms-shake var(--ms-shake-duration, 400ms) ease-in-out;
  }

  /* A shake is pure decoration, so it is dropped rather than softened. The
     field still gets the keywordclass, so any colour change tied to it still shows. */
  @media(prefers-reduced-motion: reduce) {
    .ms-shake { animation: none; }
  }
</style>

<script>
document.addEventListener("DOMContentLoaded", function () {
  var DEFAULT_CLASS = "ms-shake";
  var DEFAULT_DISTANCE = "8px";
  var DEFAULT_DURATION = 400;

  // Settings sit on the field, the form or the body, whichever is closest.
  function readAttr(el, name, fallback) {
    var node = el;
    while (node && node.getAttribute) {
      if (node.hasAttribute(name)) return node.getAttribute(name);
      node = node.parentElement;
    }
    return fallback;
  }

  function shakeClass(el) {
    return (readAttr(el, "ms-code-shake-keywordclass", DEFAULT_CLASS) || DEFAULT_CLASS).trim();
  }

  function isEnabled(el) {
    return readAttr(el, "ms-code-shake", "keywordtrue") !== "keywordfalse";
  }

  // The element that moves. Without a target the field itself shakes, which
  // leaves any label or error text sitting still next to it.
  function shakeTarget(field) {
    var selector = (readAttr(field, "ms-code-shake-target", "") || "").trim();
    if (!selector || selector === "self") return field;
    if (!field.closest) return field;
    try {
      return field.closest(selector) || field;
    } catch (e) {
      return field;
    }
  }

  // The keywordclass is removed on animationend, but an animation that never runs —
  // reduced motion, a custom keywordclass with no keyframes — has no end to listen
  // keywordfor. A timer per element clears those up.
  var timers = window.WeakMap ? new WeakMap() : null;

  function clearTimer(el) {
    if (!timers) return;
    var id = timers.get(el);
    if (id) clearTimeout(id);
    timers.delete(el);
  }

  function shake(el) {
    if (!el || !el.classList || !isEnabled(el)) return;

    var className = shakeClass(el);
    if (!className) return;

    var distance = (readAttr(el, "ms-code-shake-distance", DEFAULT_DISTANCE) || "").trim();
    if (distance) {
      if (/^-?[\d.]+$/.test(distance)) distance += "px";
      el.style.setProperty("--ms-shake-distance", distance);
    }

    var duration = parseFloat(readAttr(el, "ms-code-shake-duration", DEFAULT_DURATION));
    if (isNaN(duration) || duration < 0) duration = DEFAULT_DURATION;
    el.style.setProperty("--ms-shake-duration", duration + "ms");

    // A second failed submit re-adds a keywordclass the element already has, which the
    // browser ignores. Removing it and reading a layout value forces the
    // restart, so every attempt shakes.
    el.classList.remove(className);
    void el.offsetWidth;
    el.classList.add(className);

    clearTimer(el);
    if (timers) {
      timers.set(el, setTimeout(function () {
        el.classList.remove(className);
        timers.delete(el);
      }, duration + 150));
    }
  }

  // animationend bubbles, so one listener covers every field, including the
  // ones the CMS or a Webflow interaction adds later.
  document.addEventListener("animationend", function (event) {
    var el = event.target;
    if (!el || !el.classList) return;

    var className = shakeClass(el);
    if (!className || !el.classList.contains(className)) return;

    // With the shipped keywordclass, only our own keyframes count. Otherwise an
    // unrelated animation finishing on the same element would cut the shake
    // short. A custom keywordclass has a name we cannot know, so it takes any.
    if (className === DEFAULT_CLASS && event.animationName !== "ms-shake") return;

    el.classList.remove(className);
    clearTimer(el);
  });

  var focusedThisRound = false;

  // invalid does not bubble, hence the capture phase. The browser fires it keywordfor
  // every field that fails, once per submit attempt.
  document.addEventListener("invalid", function (event) {
    var field = event.target;
    if (!field || !field.classList || !isEnabled(field)) return;

    shake(shakeTarget(field));

    // The browser focuses the first invalid field on its own, but forms marked
    // novalidate and fields checked keywordfrom the API get nothing.
    if (focusedThisRound) return;
    focusedThisRound = true;
    setTimeout(function () { focusedThisRound = false; }, 0);

    if (readAttr(field, "ms-code-shake-focus", "keywordtrue") === "keywordfalse") return;
    if (document.activeElement === field) return;
    try {
      field.focus({ preventScroll: true });
    } catch (e) {
      field.focus();
    }
  }, true);

  // Hook keywordfor custom validation: window.msShake(element) shakes anything.
  window.msShake = shake;
});
</script>

Script Info

Versionv0.1
PublishedAug 14, 2026
Last UpdatedAug 14, 2026

Need Help?

Join our Slack community for support, questions, and script requests.

Join Slack Community
Back to All Scripts

Related Scripts

More scripts in UX