Bismillâh, kardeşim. Checkout forms are where a lot of trust breaks. A user types their name, address, maybe a phone number, and hits Submit, expecting the site to tell them clearly if something is wrong before calling the server. The cheapest path to a careful checkout is the one browsers already ship: HTML5 validation. No React Hook Form, no Zod, no library. Just the attributes you already know.

The attributes that do the work

Every <input> and <textarea> can carry validation rules right in the markup:

<input type="text"     name="name"  required minlength="2" autocomplete="name">
<input type="email"    name="email" required                autocomplete="email">
<input type="tel"      name="phone"          pattern="^[+0-9\s\-()]{7,20}$">
<textarea name="addr"  required minlength="10" maxlength="300" rows="3"></textarea>
<input type="checkbox" name="terms" required>
  • required blocks submit if empty.
  • type="email" requires "a@b.c" shape.
  • minlength / maxlength enforce length bounds.
  • pattern accepts a regex; everything must match.
  • autocomplete is the single most skipped attribute in checkout. It gives the browser permission to fill fields from the user's saved profile. A 2-second checkout instead of a 40-second one. Always add it.

The :user-invalid pseudo-class

CSS has a selector that activates only when the user has touched the field and left it in an invalid state:

input:user-invalid, textarea:user-invalid {
  border-color: #f87171;
  box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.15);
}

Compared with :invalid, which triggers the moment the page loads (an empty required field is technically invalid), :user-invalid waits until the user interacts. The difference is enormous: no red borders on a form nobody has touched yet.

The JavaScript layer

HTML5 validation blocks the submit if any field is invalid, but browsers show a single generic tooltip that reads badly. A small script can reveal a friendlier message per-field:

const ERRORS = {
  valueMissing:    'Required.',
  typeMismatch:    'Looks wrong, check the format.',
  tooShort:        'A bit longer please.',
  patternMismatch: 'Unexpected characters.'
}

function messageFor(input) {
  const v = input.validity
  if (v.valueMissing)    return ERRORS.valueMissing
  if (v.typeMismatch)    return ERRORS.typeMismatch
  if (v.tooShort)        return ERRORS.tooShort
  if (v.patternMismatch) return ERRORS.patternMismatch
  return ''
}

form.querySelectorAll('input, textarea').forEach(el => {
  el.addEventListener('blur', () => updateError(el))
  el.addEventListener('input', () => updateError(el))
})

The ValidityState object on every input tells you exactly which rule failed. Eight boolean flags:

  • valueMissing: required but empty
  • typeMismatch: wrong shape for type (email, url, etc.)
  • patternMismatch: doesn't match the regex
  • tooShort / tooLong: below/above length bounds
  • rangeUnderflow / rangeOverflow: below/above numeric bounds
  • stepMismatch: not on the allowed step
  • badInput: unparseable input
  • customError: you set it via setCustomValidity

The submit handler

form.addEventListener('submit', e => {
  e.preventDefault()
  let allValid = true
  form.querySelectorAll('input, textarea').forEach(el => {
    updateError(el)
    if (!el.validity.valid) allValid = false
  })
  if (!allValid) {
    const first = form.querySelector(':invalid')
    first && first.focus()
    return
  }
  // submit to backend
})

Two details most submit handlers miss:

  • Focus the first invalid field. The user shouldn't have to scroll up looking for the problem. form.querySelector(':invalid') returns it for free.
  • Show errors for all invalid fields before the first failure, not just one at a time. Users hate fixing one problem only to discover there are two more.

The novalidate attribute

When you add novalidate to the form, the browser won't show its native bubble popup. Your JavaScript is in charge of all messaging. Always use it when you want custom errors; never use it when you're shipping the form without JavaScript (the browser bubble is better than nothing).

Honest defaults

Every field in the demo has a plain-language placeholder and an aria-label when the visible label wouldn't be read. Don't hide the label inside the placeholder alone; placeholders disappear when the field has text, and screen readers sometimes skip them. Always pair a visible label with the input.

On asking only what you need

A checkout form is a request the user is filling out for you. Every extra field is a small emanet weighing on them. Ask for: name, email, shipping address, terms. Anything else (phone, tax ID, second email, preferred delivery time) should be optional or wait until after the first order. The Qur'an talks about mizan (Mutaffifîn 83:1–3): give full measure, don't demand more than you return. The same ethic applies to forms: ask only what you'll use.

Tricks worth carrying

  • autocomplete tokens matter. Use the standard ones: name, email, tel, street-address, postal-code, country. The browser (and 1Password, LastPass, etc.) auto-fills from the user's profile.
  • inputmode on top of type: numeric for postal code, tel for phone. Better mobile keyboards.
  • Never use a regex that rejects valid emails. Real-world emails include +, long TLDs, apostrophes. type="email" does RFC-ish validation for you. Don't add pattern to it.
  • Labels on the checkbox too. A checkbox without a label is unreachable by assistive tech. Wrap the checkbox inside the <label> and all is well.

Takeaways

  1. HTML5 validation does 80% of checkout validation for free.
  2. :user-invalid is the CSS pseudo-class you've been waiting for.
  3. Read input.validity to know exactly which rule failed.
  4. Focus the first invalid field. Don't make the user hunt.
  5. Ask only what you need. Every optional field honors the user's time.

Next post: "Honest pricing: Mutaffifîn 83, full measure in e-commerce."

Validate kindly. Redirect the form to good, kardeşim.