Mert Özen Further With Every Line
Frontend HTML5 and Semantic HTML — #05

HTML Forms 2: Native Validation, Constraint Attributes, :user-invalid and the Split with the Server

Mert Özen Nov 14, 2026 13 min 4 views
HTML Forms 2: Native Validation, Constraint Attributes, :user-invalid and the Split with the Server

The browser enforces required, pattern, min/max and input type rules without JavaScript, but this validation is for user experience, not security; for error styling, use :user-invalid, which matches only after the user interacts, instead of :invalid. In this post we measure in Chromium and WebKit that native messages follow the browser's language, formnovalidate, the pattern that rejects "Şule" and the v flag trap, and a textarea that grows with field-sizing.

Version note: This post follows the HTML (HyperText Markup Language) Living Standard and Baseline as of November 2026; the examples were run with Chrome 158, WebKit 27.2 (Playwright 1.65) and Nu Html Checker 26.11.9.

Guess First

In Bookstore's checkout form, someone wrote pattern="[a-zA-Z ]+" for the full name field, saying "letters and spaces only". Can Şule Yılmaz from Ankara complete her order? Second question: to show required fields with a red border, the CSS (Cascading Style Sheets) uses input:invalid. The moment the customer opens the page, before touching anything, how many fields look red?

In the previous post we built the structure of the checkout form. In this post we add rules to it: the native validation the browser does without JavaScript, the modern way to show those rules without annoying the user, and where the browser stops and the server takes over. Customizing validation messages with JavaScript, that is the Constraint Validation API (Application Programming Interface), is in the "advanced forms" post of the JavaScript Orta series; here there is only HTML and CSS.

Constraint Attributes

The checkout form with its validation rules added:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Checkout | Bookstore</title>
    <style>
        input:user-invalid, textarea:user-invalid {
            border-color: crimson;
        }
        input:user-valid {
            border-color: seagreen;
        }
        textarea {
            field-sizing: content;
            min-block-size: 3lh;
        }
    </style>
</head>
<body>
    <form id="checkout" action="/orders" method="post">
        <label for="full-name">Full name</label>
        <input type="text" id="full-name" name="fullName" autocomplete="shipping name"
               required minlength="2" maxlength="80" pattern="[\p{L}\p{M} '\-]+">
        <label for="email">Email</label>
        <input type="email" id="email" name="email" autocomplete="email" required>
        <label for="postal-code">Postal code</label>
        <input type="text" id="postal-code" name="postalCode" autocomplete="shipping postal-code"
               inputmode="numeric" required pattern="[0-9]{5}">
        <label for="quantity">Quantity</label>
        <input type="number" id="quantity" name="quantity" value="1" min="1" max="10" step="1">
        <label for="gift-note">Gift note</label>
        <textarea id="gift-note" name="giftNote" maxlength="300"></textarea>
        <button type="submit" formnovalidate formaction="/orders/draft">Save draft</button>
        <button type="submit">Place order</button>
    </form>
</body>
</html>

The rules sit in attributes. required says the field cannot be empty. minlength and maxlength limit the length of the text; maxlength already stops the user from typing past the limit, while minlength is only checked if the user edited the field. pattern requires the value to match a regular expression. min, max and step set the range and step of number and date fields: the quantity must be a whole number between 1 and 10.

On top of these, the input type itself is a rule. type="email" requires the value to be in email format, type="url" requires an absolute URL (Uniform Resource Locator); you do not need an extra pattern for that. When any rule is violated, the field is invalid and the form is not submitted.

The Nu Html Checker reports a single error for this form, and it is not about HTML:

:14.27-14.33: error: CSS: “field-sizing”: Property “field-sizing” doesn't exist.

The checker's CSS validator does not yet know field-sizing, which became Baseline in June 2026. I will come back to this in the In Production section.

What Does the Browser Say? Native Messages

I ran the same page in two Chromium instances, one with an English and one with a Turkish user interface, with a script that types a few values into each field and reads the browser's verdict and message. The page's lang is en in both runs:

--- English UI
#full-name = "Şule Yılmaz" -> valid
#full-name = "Zoë O'Neil-Åberg" -> valid
#full-name = "Ada 2" -> Please match the requested format.
#postal-code = "06100" -> valid
#postal-code = "061000" -> Please match the requested format.
#email = "ada@" -> Please enter a part following '@'. 'ada@' is incomplete.
#quantity = "12" -> Value must be less than or equal to 10.
#quantity = "1.5" -> Please enter a valid value. The two nearest valid values are 1 and 2.
--- Turkish UI
#full-name = "Şule Yılmaz" -> valid
#full-name = "Zoë O'Neil-Åberg" -> valid
#full-name = "Ada 2" -> Lütfen istenen biçimi eşleştirin.
#postal-code = "06100" -> valid
#postal-code = "061000" -> Lütfen istenen biçimi eşleştirin.
#email = "ada@" -> Lütfen "@" işaretinden sonra gelen kısmı ekleyin. "ada@", tam bir adres değil.
#quantity = "12" -> Değer 10 veya daha küçük olmalıdır.
#quantity = "1.5" -> Lütfen geçerli bir değer girin. En yakın iki geçerli değer şunlardır: 1 ve 2.

The first two lines give a hint for the guessing question: our form's pattern accepts both "Şule Yılmaz" and "Zoë O'Neil-Åberg"; we will get to the reason in the pattern section. What stands out is the language of the messages: although the page is English, the browser with a Turkish interface shows Turkish messages. Native messages speak the browser's language, not the page's. A customer using a browser with an English interface on a Turkish site sees English messages, and you cannot change that with HTML.

The content of the messages is not under your control either. "Please match the requested format." is not clear enough for a postal code; the user does not know what they did wrong. The simplest HTML fix is to state the expected format next to the field up front: a help text such as "5 digits, e.g. 06100" below the label. Replacing the message entirely is a job for JavaScript with setCustomValidity(). The look of the bubble also belongs to the browser and cannot be styled with CSS.

At Submit Time: invalid Events, Focus and formnovalidate

When the user presses "Place order", the browser checks every field before submitting the form. An invalid event fires for each invalid field, focus moves to the first invalid field, and its message is shown. To save a draft, though, we want to skip validation: saving a half-filled form is exactly what a draft is for. The script that measures this:

const { chromium, webkit } = require('playwright');
const fs = require('fs');

const html = fs.readFileSync('checkout.html', 'utf8');

(async () => {
  for (const engine of [chromium, webkit]) {
    const browser = await engine.launch();
    const page = await browser.newPage();
    await page.setContent(html);
    page.on('console', m => console.log(`${engine.name()}: ${m.text()}`));
    await page.evaluate(() => {
      document.forms.checkout.addEventListener('submit', e => {
        e.preventDefault();
        console.log(`submitted by "${e.submitter.textContent}", formaction=${e.submitter.getAttribute("formaction") ?? "(form action)"}`);
      });
      document.forms.checkout.addEventListener('invalid', e => console.log(`invalid: #${e.target.id}`), true);
    });
    await page.click('text=Place order');
    await page.click('text=Save draft');

    // With field-sizing: content the textarea grows with its content
    const note = page.locator('#gift-note');
    const before = await note.evaluate(el => el.offsetHeight);
    await note.fill('Happy birthday!\nEnjoy the book.\nSee you soon.\nAda\n\nP.S. Chapter 22.');
    console.log(`${engine.name()}: gift-note height ${before}px -> ${await note.evaluate(el => el.offsetHeight)}px`);
    await browser.close();
  }
})();
chromium: invalid: #full-name
chromium: invalid: #email
chromium: invalid: #postal-code
chromium: submitted by "Save draft", formaction=/orders/draft
chromium: gift-note height 51px -> 96px
webkit: invalid: #full-name
webkit: invalid: #email
webkit: invalid: #postal-code
webkit: submitted by "Save draft", formaction=/orders/draft
webkit: gift-note height 45px -> 84px

"Place order" produced three invalid events and no submit event at all. "Save draft" submitted the same invalid form, because it has formnovalidate; with formaction it also sent the form to a different endpoint. To turn off validation for the whole form you write novalidate on the form element; to skip validation for a single button you put formnovalidate on that button. Draft, "back" and "cancel" are typical uses of the latter.

I will come back to the last lines of the output in the field-sizing section.

Not :invalid but :user-invalid

The second half of the guessing question. The :invalid pseudo-class reflects the current state of a field; an empty required field is invalid the moment the page opens. So a form that draws a red border with input:invalid tells the customer "you made a mistake" before they have typed anything. For years this was solved with JavaScript by tracking a "touched" state. Now CSS has two pseudo-classes for it: :user-invalid and :user-valid. Both match only after the user has interacted with the field or tried to submit the form. The script that measures the difference:

const { chromium, webkit } = require('playwright');
const fs = require('fs');

const html = fs.readFileSync('checkout.html', 'utf8');

(async () => {
  for (const engine of [chromium, webkit]) {
    const browser = await engine.launch();
    const page = await browser.newPage();
    await page.setContent(html);
    const count = sel => page.locator(sel).count();
    console.log(`--- ${engine.name()} ${browser.version()}`);
    console.log('on load      :invalid', await count(':invalid'), '| :user-invalid', await count(':user-invalid'));

    await page.fill('#email', 'ada@');
    await page.locator('#email').blur();
    console.log('after email  :invalid', await count(':invalid'), '| :user-invalid', await count(':user-invalid'));

    await page.click('text=Place order');
    console.log('after submit :invalid', await count(':invalid'), '| :user-invalid', await count(':user-invalid'),
      '| focused:', await page.evaluate(() => document.activeElement.id));
    await browser.close();
  }
})();
--- chromium 158.0.8127.18
on load      :invalid 4 | :user-invalid 0
after email  :invalid 4 | :user-invalid 1
after submit :invalid 4 | :user-invalid 3 | focused: full-name
--- webkit 27.2
on load      :invalid 4 | :user-invalid 0
after email  :invalid 4 | :user-invalid 1
after submit :invalid 4 | :user-invalid 3 | focused: full-name

On page load, :invalid matches four elements: the three required fields and the form itself, because a form containing an invalid field is also :invalid. That is the answer to the guessing question: three fields are red without being touched. :user-invalid matches zero. When the user types ada@ into the email field and leaves it, only that field matches; the other empty fields stay quiet. After "Place order" is pressed, all three fields match and focus goes to the first invalid one, the name. Chromium and WebKit give the same result; :user-valid and :user-invalid became Baseline newly available in November 2023 and moved to widely available in May 2026, so today they can be used without a fallback.

That is why the form's CSS gives a red border to :user-invalid, not :invalid, and a green one to :user-valid. A suggestion: do not make colour the only signal. For a colour-blind user a red and a green border can look the same; show the error in text as well.

pattern Pitfalls: Turkish Characters, the v Flag and Unneeded ^/$

To see how pattern works, I wrote a small script that tries different patterns with different values:

const { chromium } = require('playwright');

const tests = [
  ['[a-zA-Z ]+', 'Şule Yılmaz'],
  ['\\w+( \\w+)*', 'Şule Yılmaz'],
  ['[a-z-]+', 'not-a-name 123'],
  ['[\\p{L}\\p{M} \'\\-]+', 'Şule Yılmaz'],
  ['^[0-9]{5}$', '06100'],
  ['[0-9]{5}', '061000'],
];

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  page.on('console', m => console.log('console:', m.text()));
  for (const [pattern, value] of tests) {
    const valid = await page.evaluate(([p, v]) => {
      const input = document.createElement('input');
      input.pattern = p;
      input.value = v;
      document.body.append(input);
      return input.validity.valid;
    }, [pattern, value]);
    console.log(`pattern="${pattern}" value="${value}" -> ${valid ? 'valid' : 'patternMismatch'}`);
  }
  await browser.close();
})();
pattern="[a-zA-Z ]+" value="Şule Yılmaz" -> patternMismatch
pattern="\w+( \w+)*" value="Şule Yılmaz" -> patternMismatch
console: Pattern attribute value [a-z-]+ is not a valid regular expression: Uncaught SyntaxError: Failed to read the 'valid' property from 'ValidityState': Invalid regular expression: /[a-z-]+/v: Invalid character class
pattern="[a-z-]+" value="not-a-name 123" -> valid
pattern="[\p{L}\p{M} '\-]+" value="Şule Yılmaz" -> valid
pattern="^[0-9]{5}$" value="06100" -> valid
pattern="[0-9]{5}" value="061000" -> patternMismatch

The first line answers the guessing question: [a-zA-Z ]+ rejects "Şule Yılmaz", because "Ş" and "ı" are outside the ASCII (American Standard Code for Information Interchange) range. \w is not the fix either; in JavaScript it only covers ASCII letters, digits and the underscore. The right way is Unicode property escapes: \p{L} covers letters in every language, and \p{M} covers combining marks added to letters. The [\p{L}\p{M} '\-]+ in our form accepts Turkish, German and Scandinavian names, the apostrophe and the hyphen, but rejects the digit in "Ada 2". For a field like a full name, a pattern is of limited use anyway; the world's names are more varied than you can predict. If in doubt, not using a pattern on this field at all is a legitimate decision.

The console message in the third line shows a lesser-known rule. The spec compiles pattern with JavaScript's v flag; the /[a-z-]+/v in Chromium's error message says so explicitly. In v mode some characters inside a character class, such as a lone -, must be escaped. For an invalid pattern the browser throws no error; it writes a warning to the console and ignores the pattern, so a value like "not-a-name 123" counts as valid. The Nu Html Checker does not catch this error either. That is why the hyphen in the form's pattern is written as \-.

The last two lines show that ^ and $ are unnecessary. The browser always compiles the pattern anchored at the start and end so that it matches the whole value; [0-9]{5} rejects the six-digit 061000. Writing ^...$ does no harm, but adds nothing either.

A textarea That Grows with Its Content: field-sizing

The gift note field starts small but should grow if the customer writes a long note. For years we wrote JavaScript that listened to the input event and set the height from scrollHeight. Now there is a single CSS property: field-sizing: content. In the form's CSS I also gave it a lower bound of three lines with min-block-size: 3lh. The last line of the draft.js output shows the difference: after a six-line note, the field grew from 51 to 96 pixels in Chromium and from 45 to 84 pixels in WebKit. field-sizing became Baseline newly available in June 2026; in an older browser without it the field keeps a fixed size and shows a scrollbar, so it is a safe progressive enhancement. The rest of modern CSS is in the upcoming CSS3: Flexbox, Grid ve Modern CSS series.

Client Validation Is Not Security: Splitting the Work with the Server

The "Save draft" experiment above proved something important as well: a single attribute skips every rule. A user can do the same by adding novalidate to the form in DevTools or by sending the request with curl. Validation in HTML is for user experience: it shows the error instantly, before anything goes to the server. For security and data integrity, every rule must also be enforced on the server. In ASP.NET Core that is done with DataAnnotations and ModelState; the details are in the "Server-Side and Client-Side Validation" post of the ASP.NET Core MVC (Model-View-Controller) and Razor Pages series.

A practical rule for splitting the work: format rules (required, length, range, pattern) live on both sides, in HTML for instant feedback and on the server for security. Business rules (is the coupon valid, is there enough stock, is the postal code inside the delivery area) live only on the server; HTML cannot know them.

Frameworks strike this balance in their own way. Angular adds novalidate to its forms by default and validates with its own validators; keeping the native behaviour requires ngNativeValidate. ASP.NET Core's unobtrusive validation runs on jQuery Validation, which also adds novalidate to the form. So if you do not see the native messages from this post in a framework project, that is probably why; but constraint attributes and :user-invalid are still useful. Angular's form validation is covered in the form posts of the Angular series.

In Production

If you put the Nu Html Checker into CI (Continuous Integration), the field-sizing error will break your build one day. This will repeat with new CSS features: the checker's CSS validator lags behind the browsers. There are two fixes: move the styles into an external CSS file (the checker validates only the HTML and does not look at external CSS), or filter this known message with a regular expression using the checker's --filterpattern option. If you choose the second, keep the filter narrow; do not silence every CSS error.

Measure validation errors. Knowing which field users get stuck on most is the most direct way to improve a form. Because the invalid event fires for every invalid field, sending it to analytics takes a few lines of JavaScript; if you see a high rate on the postal code field, the problem is probably not the user but the rule or the help text.

Finally, the price of keeping the same rule in two places is inconsistency. With maxlength="80" in HTML and [StringLength(60)] on the server, a user can type a 70-character name but cannot save it. ASP.NET Core Tag Helpers generate data-val-* attributes from DataAnnotations themselves; check what they generate in the rendered HTML and try to derive the rules from a single source.

Common Pitfalls

First, [a-zA-Z] or \w on a name field. It rejects letters from Turkish and other languages; use \p{L} or no pattern at all.

Second, styling errors with :invalid. Required fields turn red as soon as the page opens; use :user-invalid.

Third, writing a pattern that is invalid under the v flag. The browser throws no error and silently ignores the pattern; check the console and write the hyphen as \-.

Fourth, assuming the native message will appear in your language and your design. The message is in the browser's language and the bubble cannot be styled; state the expected format next to the label up front.

Fifth, mistaking HTML validation for security. A single novalidate skips it; every rule must also be on the server.

A Small Experiment

★☆☆ Open checkout.html from this post in Chrome. Without touching anything, press "Place order" and note which field gets focus and which message appears. Then change :user-invalid in the CSS to :invalid, reload the page and see the difference.

★★☆ Set the full name pattern first to [a-zA-Z ]+, then to [\p{L} -]+, and type "Şule Yılmaz" and "Ayşe Nur-Kaya" into each. Why does the second pattern not work at all? Read the warning in the DevTools Console and fix the pattern according to the v flag rules.

★★★ Compare the HTML rules of a form in your own project with the rules on the server: does every required, maxlength, min/max and pattern have a counterpart on the server, and are the values the same? For each inconsistency you find, decide which one is right and think about how to generate both from the same source.

Forms end here. In the next post we move to the interactions the browser gives you without JavaScript: an accordion with details/summary, a modal with dialog, the popover attribute, and opening and closing a "Delete book" confirmation with Invoker Commands without writing a single line of JavaScript.