Why CSS-only validation is enough
For a contact form — name, email, message — every validation rule a user needs is built into the browser. required blocks empty submits. type="email" blocks malformed addresses. minlength and pattern handle anything else. The browser will refuse to submit the form until the rules pass, no JavaScript involved.
The thing JavaScript libraries actually give you is presentation — "turn the input red and show an error message". Tailwind already has those modifiers. invalid: applies a class when the field is invalid. peer-invalid: applies a class to a sibling when an input is invalid. user-invalid: applies only after the user has interacted. Compose those with placeholder tricks and you have full validation UX in pure CSS.
There's a real win to dropping the library: zero bundle weight, instant-on validation (no hydration delay), no library churn between major versions, and the form keeps working with JavaScript disabled. For a marketing site contact form going to splitformsor any other form backend, that's the right tradeoff.
HTML5 validation primitives
Five attributes cover essentially every common validation case:
required— field must be non-empty.type="email"— must look like an email (loose check; see FAQ).type="url"— must be a valid absolute URL.minlength/maxlength— character bounds.pattern— regex (without anchors; the engine wraps it in ^...$ for you).
You can also use min/max/step for numeric inputs and accept for file types, but for a typical contact form the five above are all you need.
Tailwind variants you need
All of these work in Tailwind v3 and v4 (Tailwind v4 just makes a few of them shorter):
invalid:— applies when the input is invalid for any reason. Fires immediately on emptyrequiredfields, which is usually too aggressive.user-invalid:— applies only after the user has interacted (typed, blurred, or attempted submit). This is the variant you actually want most of the time.placeholder-shown:— applies while the placeholder is visible (i.e. the field is empty). Combine withinvalid:as a fallback for browsers without:user-invalid.peer-*:— apply a state from one input to a sibling element. Used for the "input red → error message visible" pattern.has-[:invalid]:— apply a class to a parent when a descendant is invalid. Useful for marking the whole form group.
Example 1 — Minimal contact form
Three fields, full validation states, zero JavaScript:
// app/contact/page.tsx
export default function ContactForm() {
return (
<form
action="https://splitforms.com/api/submit"
method="POST"
className="mx-auto max-w-md space-y-4 p-8"
noValidate={false}
>
<input type="hidden" name="access_key" value="YOUR_KEY" />
{/* Name */}
<label className="block">
<span className="mb-1 block text-sm font-medium text-zinc-700">
Name
</span>
<input
type="text"
name="name"
required
minLength={2}
placeholder="Ada Lovelace"
className="
w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm
placeholder:text-zinc-400
focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20
user-invalid:border-red-500 user-invalid:ring-2 user-invalid:ring-red-500/20
"
/>
</label>
{/* Email */}
<label className="block">
<span className="mb-1 block text-sm font-medium text-zinc-700">
Email
</span>
<input
type="email"
name="email"
required
placeholder="ada@example.com"
className="
w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm
placeholder:text-zinc-400
focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20
user-invalid:border-red-500 user-invalid:ring-2 user-invalid:ring-red-500/20
"
/>
</label>
{/* Message */}
<label className="block">
<span className="mb-1 block text-sm font-medium text-zinc-700">
Message
</span>
<textarea
name="message"
required
minLength={10}
rows={4}
placeholder="Tell me a bit about the project..."
className="
w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm
placeholder:text-zinc-400
focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20
user-invalid:border-red-500 user-invalid:ring-2 user-invalid:ring-red-500/20
"
/>
</label>
<button
type="submit"
className="
inline-flex h-10 items-center rounded-full bg-indigo-600 px-5
text-sm font-semibold text-white transition
hover:bg-indigo-500
"
>
Send
</button>
</form>
);
}What's happening: the inputs use HTML5 attributes for the rules (required, minLength, type="email"). The user-invalid: Tailwind modifier paints them red only after the user touches the field — no jarring red on page load. Focus state still wins over invalid state because focus is added later in the cascade. The form posts to a form backend (here splitforms; substitute your own).
If you need to support older browsers without :user-invalid, swap the modifier for the longer fallback:
// Fallback for browsers without :user-invalid (~5% globally as of 2026)
className="
...
not-placeholder-shown:invalid:border-red-500
not-placeholder-shown:invalid:ring-2
not-placeholder-shown:invalid:ring-red-500/20
"The not-placeholder-shown:invalid:chain reads as "when the placeholder is NOT showing AND the field is invalid" — which is essentially the same heuristic as "the user typed something and it's wrong".
Example 2 — Inline error messages with peer
The minimal example above shows red borders but no explanatory text. For a real form you usually want an inline message under each field. Tailwind's peer modifier makes this trivial — mark the input as peer, then style the sibling <p>based on the input's state:
// Inline error pattern: input is the peer, <p> reacts to its state.
<label className="block">
<span className="mb-1 block text-sm font-medium text-zinc-700">
Email
</span>
<input
type="email"
name="email"
required
placeholder="ada@example.com"
className="
peer w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm
placeholder:text-zinc-400
focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20
user-invalid:border-red-500 user-invalid:ring-2 user-invalid:ring-red-500/20
"
/>
{/* Hidden by default; revealed when the input is user-invalid */}
<p
className="
mt-1 hidden text-xs text-red-600
peer-[:user-invalid]:block
"
>
Please enter a valid email address.
</p>
</label>Three things to notice. The peer class on the input flags it as the "source" element. The peer-[:user-invalid]:block on the <p> reads as "when the peer is in :user-invalid state, set me to display: block". The default hidden keeps it out of the flow until then — no layout shift on the field becoming valid.
You can stack multiple error messages for different rules. One common pattern: a generic "required" message and a specific "wrong format" message:
<input
type="email"
required
className="peer ..."
placeholder="ada@example.com"
/>
{/* Empty: shows when the placeholder is showing AND the user has blurred */}
<p className="hidden text-xs text-red-600 peer-placeholder-shown:peer-[:user-invalid]:block">
This field is required.
</p>
{/* Wrong format: user typed something but it's invalid */}
<p className="hidden text-xs text-red-600 peer-[:not(:placeholder-shown)]:peer-[:user-invalid]:block">
Please use a valid email address.
</p>The two messages are mutually exclusive because exactly one of placeholder-shown / not-placeholder-shown is true at any moment.
Custom error messages with setCustomValidity
The browser's default error tooltip is fine but it's not yours — it can't match your brand voice or your design system. setCustomValidity() lets you override the message:
<input
type="email"
name="email"
required
placeholder="you@company.com"
className="peer ..."
onInvalid={(e) => {
const t = e.currentTarget;
if (t.validity.valueMissing) {
t.setCustomValidity("We need an email so we can reply.");
} else if (t.validity.typeMismatch) {
t.setCustomValidity("Hmm — that doesn't look like a valid address.");
} else {
t.setCustomValidity("");
}
}}
onInput={(e) => e.currentTarget.setCustomValidity("")}
/>The pattern: in onInvalid, set a custom message based on which validity flag is true. In onInput, clear the message — otherwise the field will stay "invalid" even after the user fixes it, because the custom message is sticky until cleared.
You don't need this for the inline-text pattern above (your <p>already says everything). It's useful for the browser tooltip when a user attempts submit on a still-invalid field, and for accessibility — screen readers announce the custom message.
Submitting to a form backend
Once the form passes validation, the browser submits it. If your action points at a form backend like splitforms, you're done — the data lands in your dashboard, an email goes out, and any configured webhooks fire. No fetch handler needed.
If you want to stay on the page (no full reload), wrap the same form in a tiny submit handler:
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
if (!form.checkValidity()) {
form.reportValidity(); // triggers the browser tooltips
return;
}
const data = new FormData(form);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
body: data,
});
if (res.ok) form.reset();
};checkValidity() runs the same HTML5 rules in code, and reportValidity()shows the browser's native error tooltips on any invalid field — same UX, just under your control. Then you can drop a Tailwind toast/banner on success.
For more form patterns — multi-step, file upload, conditional fields, server-side validation responses — see the splitforms templates and the docs. The same Tailwind validation patterns work in all of them.
Tech support and troubleshooting
Five Tailwind validation gotchas that account for almost every "why isn't this working":
- Inputs are red on page load — You used invalid: instead of user-invalid:. Switch to user-invalid: or chain not-placeholder-shown:invalid: so styles wait until the user types.
- user-invalid: not applying — Tailwind v3.3 and older don't ship the variant. Either upgrade to v3.4+/v4 or define a custom variant in tailwind.config.js.
- Custom error message stays after fix — setCustomValidity is sticky until cleared. Add an onInput handler that calls setCustomValidity('') so the field can revalidate.
- type=email accepts foo@bar — Spec allows TLD-less addresses. Tighten with pattern='[^@\s]+@[^@\s]+\.[^@\s]+' for client-side, and revalidate server-side for real deliverability.
- peer error message doesn't show — The peer-style sibling must come after the input in the DOM. Wrap label > input + p, not p + input, otherwise the peer modifier can't reach it.
The splitforms docs cover the backend-side validation responses your form will receive after submit; the API reference documents the JSON shape. For account questions check the splitforms FAQ.
FAQ
Do I need a JavaScript validation library like React Hook Form or Formik?
No, not for a contact form or signup. HTML5 validation primitives (required, type, pattern, minlength) plus Tailwind's invalid: and peer modifiers cover 95% of cases without any JavaScript at all. Reach for a library when you need cross-field validation, async server-side checks during typing, or complex multi-step forms — not for a name/email/message form.
Why does invalid: by itself fire on page load before the user has typed anything?
Because the inputs are empty and required, so the browser considers them invalid immediately. The fix is to combine invalid: with placeholder-shown — when a placeholder is showing, the field is empty, so suppress the error styling. Use peer-[:not(:placeholder-shown)]:invalid: (or the shorter not-placeholder-shown:invalid: in Tailwind v4) to only apply the red ring after the user has typed at least one character.
What's the difference between :invalid and :user-invalid?
:user-invalid is a newer pseudo-class (Baseline 2024) that only fires after the user has interacted with the field — typed in it, blurred it, or attempted submit. It's purpose-built for this exact use case. Tailwind exposes it as user-invalid: in v3.4+ and v4. Browser support is now ~95% globally, so for most projects user-invalid: is the cleanest option, with placeholder-shown as a fallback.
How do I show a custom error message instead of the browser's default?
Use the setCustomValidity() method on the input. Call it from an oninvalid handler with your message, and clear it (with an empty string) from oninput so the user's next keystroke restores the default state. Pair this with the inline error <p> pattern shown in the second example — the <p> is hidden until the input becomes user-invalid.
Can I validate email format more strictly than type=email?
type=email accepts foo@bar (no TLD) because the spec defines a deliberately loose email regex. To require a TLD, add pattern='[^@\s]+@[^@\s]+\.[^@\s]+'. For real address validity (does the domain accept mail), validate server-side after submit — no client regex catches the actual deliverability question.
How do I disable the submit button until the form is valid?
Wrap the button in a peer chain or use form-level :invalid. The cleanest CSS-only pattern is to make the form a peer (<form class='peer'>) and the button uses peer-[:invalid]:opacity-50 peer-[:invalid]:cursor-not-allowed plus the disabled attribute applied via a hidden submit-blocker. In practice it's simpler to leave the button enabled and let the browser block submission via required — users get clearer feedback that way.
Does this work with React, Vue, Svelte, and plain HTML?
Yes — the techniques are pure CSS plus HTML5 attributes. The JSX example below is React, but you can drop the same className strings into a Vue template, a Svelte component, or a plain .html file. The validation logic lives in the markup, not in any framework.
Next steps
- Wire the validated form to a backend — receive submissions by email.
- Stop bots before they fill it out — stop contact form spam.
- Skip PHP entirely — send HTML form to email without PHP.
- Compare backend options — server actions vs form backend.