Swish UI
All componentsFeedback and forms Free

Input shake on error

An invalid email makes the field shake and shows an inline error; a valid one draws a tick inside the field.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useId, useRef, useState } from "react";
import styles from "./demo.module.css";

type Status = "idle" | "error" | "success";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

export default function Demo() {
  const id = useId();
  const field = useRef<HTMLDivElement>(null);
  const input = useRef<HTMLInputElement>(null);
  const [status, setStatus] = useState<Status>("idle");

  const submit = (event: React.FormEvent) => {
    event.preventDefault();
    const value = input.current?.value.trim() ?? "";
    if (EMAIL.test(value)) {
      setStatus("success");
      return;
    }
    setStatus("error");
    input.current?.focus();
    // Restart the shake even when the field is already in the error state.
    const el = field.current;
    if (el) {
      el.classList.remove(styles.shake);
      void el.offsetWidth;
      el.classList.add(styles.shake);
    }
  };

  return (
    <div className={styles.root}>
      <form className={styles.form} onSubmit={submit} noValidate>
        <label htmlFor={`${id}-email`} className={styles.label}>
          Email for booking updates
        </label>
        <div ref={field} className={`${styles.field} ${styles[status]}`} onAnimationEnd={(e) => e.currentTarget.classList.remove(styles.shake)}>
          <input
            ref={input}
            id={`${id}-email`}
            name="email"
            type="email"
            autoComplete="email"
            placeholder="maya@example.com"
            defaultValue="maya@villa"
            aria-invalid={status === "error"}
            aria-describedby={`${id}-msg`}
            onChange={() => status !== "idle" && setStatus("idle")}
          />
          <svg className={styles.tick} viewBox="0 0 20 20" aria-hidden="true">
            <circle cx="10" cy="10" r="9" />
            <path pathLength="1" d="M6 10.5 8.8 13 14 7.5" />
          </svg>
        </div>
        <p id={`${id}-msg`} className={`${styles.msg} ${styles[status]}`} aria-live="polite">
          {status === "error" && (
            <>
              <svg viewBox="0 0 16 16" aria-hidden="true">
                <circle cx="8" cy="8" r="7" />
                <path d="M8 4.5v4.2M8 11.2v.1" />
              </svg>
              Enter a full email, like maya@example.com
            </>
          )}
          {status === "success" && "Saved. We will email you when Wed 14 opens."}
          {status === "idle" && "We only send booking changes."}
        </p>
        <button type="submit" className={styles.button}>
          Notify me
        </button>
      </form>
    </div>
  );
}

demo.module.css

.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.form { display: grid; gap: 8px; width: min(340px, 100%); }
.label { font-size: 14px; font-weight: 600; }
.field { position: relative; }
.field input { width: 100%; min-height: 48px; padding: 0 44px 0 14px; border: 1.5px solid var(--line); border-radius: 12px; background: var(--white); font: inherit; font-size: 16px; color: var(--ink); outline: none; transition: border-color .2s, box-shadow .2s; }
.field input:focus { border-color: var(--ink); box-shadow: 0 0 0 3px var(--surface); }
.field.error input { border-color: var(--ink); box-shadow: 0 0 0 3px var(--sand); }
.field.success input { border-color: var(--accent-text); box-shadow: 0 0 0 3px var(--accent-pale); }

.shake { animation: shake .42s var(--out); }
@keyframes shake {
  15% { translate: -7px 0; }
  35% { translate: 6px 0; }
  55% { translate: -4px 0; }
  75% { translate: 2px 0; }
}

.tick { position: absolute; right: 14px; top: 50%; width: 22px; height: 22px; translate: 0 -50%; opacity: 0; scale: .6; transition: opacity .2s, scale .35s var(--out); }
.tick circle { fill: var(--accent-text); }
.tick path { fill: none; stroke: var(--white); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset .35s var(--out) .12s; }
.success .tick { opacity: 1; scale: 1; }
.success .tick path { stroke-dashoffset: 0; }

.msg { display: flex; align-items: center; gap: 6px; min-height: 22px; font-size: 13px; line-height: 1.4; color: var(--muted); }
.msg.error { color: var(--ink); font-weight: 500; animation: rise .3s var(--out); }
.msg.success { color: var(--accent-text); font-weight: 500; animation: rise .3s var(--out); }
.msg svg { width: 16px; height: 16px; flex: none; }
.msg svg circle { fill: var(--ink); }
.msg svg path { stroke: var(--white); stroke-width: 1.8; stroke-linecap: round; }
@keyframes rise { from { opacity: 0; translate: 0 -4px; } }

.button { justify-self: start; min-height: 44px; margin-top: 4px; padding: 0 22px; border: 0; border-radius: 999px; background: var(--ink); color: var(--white); font-weight: 600; font-size: 15px; cursor: pointer; transition: background-color .18s, scale .18s var(--out); }
.button:hover { background: var(--ink-hover); }
.button:active { scale: .97; }

@media (prefers-reduced-motion: reduce) {
  .shake, .msg.error, .msg.success { animation: none; }
  .tick, .tick path { transition: none; }
}

Use it for

  • Short forms with one or two fields, such as a sign up or a notify me box
  • Submit time validation where the error needs to be noticed

Avoid

  • Shaking on every keystroke, which feels like scolding
  • Relying on the shake alone: always show the message

How it works

On submit the value is checked with a simple pattern. An invalid result sets the error state and restarts a short translate keyframe on the field wrapper (remove class, read offsetWidth, add class) so it shakes even on a second try. A valid result reveals an SVG tick whose stroke draws in with stroke-dashoffset. Typing again resets to the idle state.

Accessibility

The input has a visible label, aria-invalid in the error state and aria-describedby pointing at the message, which is a polite live region with an icon and text. Focus returns to the field on error. Reduced motion removes the shake but keeps the message.

Found on