Web InnoventixFreeCode

Validation States Input

Original · free

success/warning/error validated inputs

byWeb InnoventixReact + Tailwind
inpvalidationstatesinputs
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/inp-validation-states.json
inp-validation-states.tsx
"use client";

import { useId, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Status = "idle" | "success" | "warning" | "error";
type Result = { status: Status; message: string };

const RING: Record<Status, string> = {
  idle: "border-slate-300 dark:border-slate-700 focus-within:border-indigo-500 dark:focus-within:border-indigo-400 focus-within:ring-2 focus-within:ring-indigo-500/25",
  success:
    "border-emerald-400 dark:border-emerald-500/70 focus-within:ring-2 focus-within:ring-emerald-500/25",
  warning:
    "border-amber-400 dark:border-amber-500/70 focus-within:ring-2 focus-within:ring-amber-500/25",
  error:
    "border-rose-400 dark:border-rose-500/70 focus-within:ring-2 focus-within:ring-rose-500/25",
};

const MSG: Record<Status, string> = {
  idle: "text-slate-500 dark:text-slate-400",
  success: "text-emerald-600 dark:text-emerald-400",
  warning: "text-amber-600 dark:text-amber-400",
  error: "text-rose-600 dark:text-rose-400",
};

const ICON_TONE: Record<Status, string> = {
  idle: "text-slate-400",
  success: "text-emerald-500",
  warning: "text-amber-500",
  error: "text-rose-500",
};

function StatusIcon({ status }: { status: Status }) {
  if (status === "success") {
    return (
      <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="currentColor">
        <path
          fillRule="evenodd"
          d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.7-9.3a1 1 0 00-1.4-1.4L9 10.6 7.7 9.3a1 1 0 10-1.4 1.4l2 2a1 1 0 001.4 0l4-4z"
          clipRule="evenodd"
        />
      </svg>
    );
  }
  if (status === "warning") {
    return (
      <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="currentColor">
        <path
          fillRule="evenodd"
          d="M8.26 2.83a2 2 0 013.48 0l6 10.42A2 2 0 0116 16.25H4a2 2 0 01-1.74-3l6-10.42zM10 7a1 1 0 00-1 1v3a1 1 0 002 0V8a1 1 0 00-1-1zm0 7.4a1.15 1.15 0 100 2.3 1.15 1.15 0 000-2.3z"
          clipRule="evenodd"
        />
      </svg>
    );
  }
  if (status === "error") {
    return (
      <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="currentColor">
        <path
          fillRule="evenodd"
          d="M10 2a8 8 0 100 16 8 8 0 000-16zm0 3.6a1 1 0 011 1v4.2a1 1 0 01-2 0V6.6a1 1 0 011-1zm0 8.1a1.15 1.15 0 100 2.3 1.15 1.15 0 000-2.3z"
          clipRule="evenodd"
        />
      </svg>
    );
  }
  return null;
}

function EyeIcon({ off }: { off: boolean }) {
  return (
    <svg
      viewBox="0 0 24 24"
      aria-hidden="true"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {off ? (
        <>
          <path d="M3 3l18 18" />
          <path d="M10.6 10.7a3 3 0 004.2 4.2" />
          <path d="M9.9 5.2A9.6 9.6 0 0112 5c6.5 0 10 7 10 7a17 17 0 01-3.4 4.1" />
          <path d="M6.1 6.3A17 17 0 002 12s3.5 7 10 7a9.8 9.8 0 004.1-.9" />
        </>
      ) : (
        <>
          <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
          <circle cx="12" cy="12" r="3" />
        </>
      )}
    </svg>
  );
}

function validateEmail(v: string): Result {
  const t = v.trim();
  if (t === "") return { status: "idle", message: "We send your confirmation link here." };
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t))
    return { status: "error", message: "That does not look like a valid email address." };
  if (/@(gmail|yahoo|hotmail|outlook|icloud|proton)\./i.test(t))
    return {
      status: "warning",
      message: "Personal inbox detected. A work email unlocks team seats.",
    };
  return { status: "success", message: "Looks good. This address is free to use." };
}

const RESERVED = ["admin", "root", "support", "billing", "salman", "webinnoventix"];
function validateUsername(v: string): Result {
  if (v === "") return { status: "idle", message: "3 to 20 characters: letters, numbers, underscores." };
  if (v.length < 3) return { status: "error", message: "Handle must be at least 3 characters." };
  if (!/^[a-z0-9_]+$/i.test(v))
    return { status: "error", message: "Use only letters, numbers, and underscores." };
  if (RESERVED.includes(v.toLowerCase()))
    return { status: "warning", message: "That handle is reserved. Pick another one." };
  return { status: "success", message: "@" + v + " is available." };
}

function scorePassword(v: string): number {
  let s = 0;
  if (v.length >= 8) s += 1;
  if (/[a-z]/.test(v) && /[A-Z]/.test(v)) s += 1;
  if (/\d/.test(v)) s += 1;
  if (/[^A-Za-z0-9]/.test(v)) s += 1;
  return s;
}
function validatePassword(v: string, score: number): Result {
  if (v === "")
    return { status: "idle", message: "Use 8 or more characters with a mix of types." };
  if (v.length < 8) return { status: "error", message: "Too short. Use at least 8 characters." };
  if (score <= 2)
    return { status: "warning", message: "Weak. Add an uppercase letter, number, or symbol." };
  if (score === 3)
    return { status: "warning", message: "Getting stronger. Add one more character type." };
  return { status: "success", message: "Strong password. You are all set." };
}

function validateConfirm(pw: string, c: string): Result {
  if (c === "") return { status: "idle", message: "Re-enter your password to confirm." };
  if (c !== pw) return { status: "error", message: "These passwords do not match yet." };
  return { status: "success", message: "Passwords match." };
}

function validateWebsite(v: string): Result {
  const t = v.trim();
  if (t === "")
    return { status: "idle", message: "Optional. Helps us tailor your onboarding." };
  try {
    const url = new URL(/^https?:\/\//i.test(t) ? t : "https://" + t);
    if (!url.hostname.includes(".") || url.hostname.startsWith("."))
      return { status: "error", message: "Enter a valid URL, for example acme.com." };
    return { status: "success", message: "We will take a look at " + url.hostname + "." };
  } catch {
    return { status: "error", message: "Enter a valid URL, for example acme.com." };
  }
}

type FieldProps = {
  id: string;
  label: string;
  type?: string;
  value: string;
  onChange: (v: string) => void;
  placeholder?: string;
  autoComplete?: string;
  inputMode?: "text" | "email" | "url" | "numeric";
  result: Result;
  optional?: boolean;
  reduce: boolean;
  trailing?: ReactNode;
  children?: ReactNode;
};

function Field({
  id,
  label,
  type = "text",
  value,
  onChange,
  placeholder,
  autoComplete,
  inputMode,
  result,
  optional,
  reduce,
  trailing,
  children,
}: FieldProps) {
  const msgId = id + "-msg";
  const pop = reduce
    ? undefined
    : { animation: "ivs-pop 260ms cubic-bezier(0.34,1.56,0.64,1)" };
  return (
    <div>
      <div className="mb-1.5 flex items-center justify-between gap-2">
        <label
          htmlFor={id}
          className="text-sm font-medium text-slate-700 dark:text-slate-200"
        >
          {label}
        </label>
        {optional ? (
          <span className="text-[11px] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
            Optional
          </span>
        ) : null}
      </div>
      <div
        className={
          "flex items-center rounded-xl border bg-white/80 backdrop-blur-sm transition-[border-color,box-shadow] dark:bg-slate-900/70 " +
          RING[result.status]
        }
      >
        <input
          id={id}
          type={type}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          placeholder={placeholder}
          autoComplete={autoComplete}
          inputMode={inputMode}
          aria-invalid={result.status === "error"}
          aria-describedby={msgId}
          className="w-full bg-transparent px-4 py-2.5 text-sm text-slate-900 outline-none placeholder:text-slate-400 dark:text-slate-100 dark:placeholder:text-slate-500"
        />
        <div className="flex items-center gap-1 pr-2.5">
          {trailing}
          {result.status !== "idle" ? (
            <span
              key={result.status}
              style={pop}
              className={"ivs-anim inline-flex " + ICON_TONE[result.status]}
            >
              <StatusIcon status={result.status} />
            </span>
          ) : null}
        </div>
      </div>
      {children}
      <p
        id={msgId}
        aria-live="polite"
        className={"mt-1.5 text-xs leading-snug " + MSG[result.status]}
      >
        {result.message}
      </p>
    </div>
  );
}

function StrengthMeter({ score }: { score: number }) {
  const tone = [
    "bg-slate-200 dark:bg-slate-800",
    "bg-rose-500",
    "bg-amber-500",
    "bg-sky-500",
    "bg-emerald-500",
  ];
  const labels = ["", "Weak", "Fair", "Good", "Strong"];
  return (
    <div className="mt-2 flex items-center gap-2" aria-hidden="true">
      <div className="flex flex-1 gap-1.5">
        {[1, 2, 3, 4].map((i) => (
          <span
            key={i}
            className={
              "h-1.5 flex-1 rounded-full transition-all duration-300 " +
              (i <= score ? tone[score] ?? "" : "bg-slate-200 dark:bg-slate-800")
            }
          />
        ))}
      </div>
      <span className="w-10 shrink-0 text-right text-[11px] font-medium tabular-nums text-slate-500 dark:text-slate-400">
        {labels[score] ?? ""}
      </span>
    </div>
  );
}

const SAMPLES: { status: Status; label: string; value: string; message: string }[] = [
  {
    status: "success",
    label: "Coupon code",
    value: "LAUNCH25",
    message: "Applied. 25% off your first three months.",
  },
  {
    status: "warning",
    label: "Display name",
    value: "support",
    message: "Close to a reserved word. Consider another.",
  },
  {
    status: "error",
    label: "Card number",
    value: "4242 4242 0000",
    message: "This card number is incomplete.",
  },
  {
    status: "idle",
    label: "Referral code",
    value: "",
    message: "Enter a code if a teammate invited you.",
  },
];

function SampleField({
  status,
  label,
  value,
  message,
  index,
}: {
  status: Status;
  label: string;
  value: string;
  message: string;
  index: number;
}) {
  const id = "ivs-sample-" + index;
  return (
    <div className="rounded-xl border border-slate-200/70 bg-white/60 p-3 dark:border-slate-800/70 dark:bg-slate-900/40">
      <div className="mb-1.5 flex items-center justify-between">
        <label
          htmlFor={id}
          className="text-xs font-medium text-slate-600 dark:text-slate-300"
        >
          {label}
        </label>
        <span
          className={
            "text-[10px] font-semibold uppercase tracking-wide " + MSG[status]
          }
        >
          {status}
        </span>
      </div>
      <div
        className={
          "flex items-center rounded-lg border bg-white px-3 py-2 dark:bg-slate-900 " +
          RING[status]
        }
      >
        <input
          id={id}
          readOnly
          value={value}
          placeholder="—"
          aria-readonly="true"
          className="w-full bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400 dark:text-slate-100"
        />
        {status !== "idle" ? (
          <span className={"ml-2 inline-flex " + ICON_TONE[status]}>
            <StatusIcon status={status} />
          </span>
        ) : null}
      </div>
      <p className={"mt-1.5 text-[11px] leading-snug " + MSG[status]}>{message}</p>
    </div>
  );
}

export default function InpValidationStates() {
  const uid = useId();
  const reduce = !!useReducedMotion();

  const [email, setEmail] = useState("");
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [confirm, setConfirm] = useState("");
  const [website, setWebsite] = useState("");
  const [showPw, setShowPw] = useState(false);
  const [submitted, setSubmitted] = useState<null | "ok" | "err">(null);
  const [shakeKey, setShakeKey] = useState(0);

  const emailR = validateEmail(email);
  const userR = validateUsername(username);
  const pwScore = scorePassword(password);
  const pwR = validatePassword(password, pwScore);
  const confR = validateConfirm(password, confirm);
  const webR = validateWebsite(website);

  const required = [emailR, userR, pwR, confR];
  const formValid =
    required.every((r) => r.status === "success" || r.status === "warning") &&
    webR.status !== "error";

  const clear = () => {
    if (submitted) setSubmitted(null);
  };

  const enter = reduce
    ? { initial: false as const }
    : {
        initial: { opacity: 0, y: -6 },
        animate: { opacity: 1, y: 0 },
        exit: { opacity: 0, y: -6 },
      };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes ivs-pop {
          0% { transform: scale(0.4); opacity: 0; }
          60% { transform: scale(1.14); }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes ivs-shake {
          0%, 100% { transform: translateX(0); }
          20% { transform: translateX(-6px); }
          40% { transform: translateX(6px); }
          60% { transform: translateX(-4px); }
          80% { transform: translateX(4px); }
        }
        @media (prefers-reduced-motion: reduce) {
          .ivs-anim { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-300/30 via-violet-300/20 to-emerald-300/30 blur-3xl dark:from-indigo-600/20 dark:via-violet-600/10 dark:to-emerald-600/20"
      />

      <div className="relative mx-auto max-w-5xl">
        <header className="mx-auto max-w-2xl text-center">
          <span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Inputs
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            Inputs that tell you where you stand
          </h2>
          <p className="mx-auto mt-3 max-w-xl text-sm text-slate-600 sm:text-base dark:text-slate-400">
            Real-time validation with success, warning, and error states.
            Accessible, keyboard-ready, and legible in light or dark.
          </p>
        </header>

        <div className="mt-10 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
          {SAMPLES.map((s, i) => (
            <SampleField
              key={s.label}
              status={s.status}
              label={s.label}
              value={s.value}
              message={s.message}
              index={i}
            />
          ))}
        </div>

        <form
          noValidate
          onSubmit={(e) => {
            e.preventDefault();
            if (formValid) {
              setSubmitted("ok");
            } else {
              setSubmitted("err");
              setShakeKey((k) => k + 1);
            }
          }}
          className="mx-auto mt-10 w-full max-w-xl rounded-2xl border border-slate-200 bg-white/70 p-6 shadow-sm backdrop-blur-md sm:p-8 dark:border-slate-800 dark:bg-slate-900/50"
        >
          <h3 className="text-lg font-semibold">Create your workspace</h3>
          <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
            Start a 14-day trial. No card required.
          </p>

          <AnimatePresence mode="wait" initial={false}>
            {submitted === "ok" ? (
              <motion.div
                key="ok"
                role="status"
                {...enter}
                className="mt-5 flex items-start gap-2.5 rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300"
              >
                <span className="mt-0.5 text-emerald-500">
                  <StatusIcon status="success" />
                </span>
                <span>
                  Workspace ready. We sent a confirmation link to{" "}
                  <strong className="font-semibold">{email || "your email"}</strong>.
                </span>
              </motion.div>
            ) : null}
            {submitted === "err" ? (
              <motion.div
                key={"err-" + shakeKey}
                role="alert"
                initial={reduce ? false : { opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                className="mt-5"
              >
                <div
                  className="ivs-anim flex items-start gap-2.5 rounded-xl border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-300"
                  style={reduce ? undefined : { animation: "ivs-shake 380ms ease-in-out" }}
                >
                  <span className="mt-0.5 text-rose-500">
                    <StatusIcon status="error" />
                  </span>
                  <span>Please fix the highlighted fields before continuing.</span>
                </div>
              </motion.div>
            ) : null}
          </AnimatePresence>

          <div className="mt-6 space-y-5">
            <Field
              id={uid + "-email"}
              label="Work email"
              type="email"
              inputMode="email"
              autoComplete="email"
              placeholder="you@company.com"
              value={email}
              onChange={(v) => {
                setEmail(v);
                clear();
              }}
              result={emailR}
              reduce={reduce}
            />

            <Field
              id={uid + "-username"}
              label="Choose a handle"
              autoComplete="username"
              placeholder="acme_team"
              value={username}
              onChange={(v) => {
                setUsername(v);
                clear();
              }}
              result={userR}
              reduce={reduce}
            />

            <Field
              id={uid + "-password"}
              label="Create password"
              type={showPw ? "text" : "password"}
              autoComplete="new-password"
              placeholder="At least 8 characters"
              value={password}
              onChange={(v) => {
                setPassword(v);
                clear();
              }}
              result={pwR}
              reduce={reduce}
              trailing={
                <button
                  type="button"
                  onClick={() => setShowPw((s) => !s)}
                  aria-pressed={showPw}
                  aria-label={showPw ? "Hide password" : "Show password"}
                  className="inline-flex items-center justify-center rounded-md p-1 text-slate-400 transition-colors hover:text-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:text-slate-200"
                >
                  <EyeIcon off={showPw} />
                </button>
              }
            >
              {password ? <StrengthMeter score={pwScore} /> : null}
            </Field>

            <Field
              id={uid + "-confirm"}
              label="Confirm password"
              type={showPw ? "text" : "password"}
              autoComplete="new-password"
              placeholder="Re-enter your password"
              value={confirm}
              onChange={(v) => {
                setConfirm(v);
                clear();
              }}
              result={confR}
              reduce={reduce}
            />

            <Field
              id={uid + "-website"}
              label="Company website"
              type="url"
              inputMode="url"
              autoComplete="url"
              placeholder="acme.com"
              value={website}
              onChange={(v) => {
                setWebsite(v);
                clear();
              }}
              result={webR}
              optional
              reduce={reduce}
            />
          </div>

          <button
            type="submit"
            className="mt-7 inline-flex w-full items-center justify-center rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:bg-indigo-700 dark:focus-visible:ring-offset-slate-900"
          >
            Create workspace
          </button>
          <p className="mt-3 text-center text-xs text-slate-400 dark:text-slate-500">
            By continuing you agree to the Terms and Privacy Policy.
          </p>
        </form>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →