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

HTML Forms 1: Input Types, label, fieldset, autocomplete and inputmode

Mert Özen Nov 14, 2026 12 min 4 views
HTML Forms 1: Input Types, label, fieldset, autocomplete and inputmode

The foundation of an accessible, quickly filled form is a visible label on every field, the right input type and the autocomplete tokens from the spec; type="number" loses the leading zero of a postal code, and a button without a type submits the form. In this post we build Bookstore's checkout form with fieldset/legend, inputmode, enterkeyhint, autocorrect (Baseline 2026-09) and the form attribute, measuring every behaviour with a real test in Chromium.

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, the postal code field is written as <input type="number">; someone said "it takes digits, make it a number". A customer in Ankara types 06100, then, with the field still focused, accidentally presses the up arrow key. What value goes to the server? The same form has a <button>Apply coupon</button> next to the coupon code, with no type attribute. What happens when the customer types a coupon and presses this button?

This post and the next one are about Bookstore's checkout form. Here we build the structure of the form: label, fieldset, the right input type, autocomplete so the browser can fill in the address, and the attributes that steer the mobile keyboard. Validation is the topic of the next post. Reading and submitting a form with JavaScript, that is FormData and the Constraint Validation API (Application Programming Interface), is covered in the JavaScript Temel and Orta series; here we focus on what the browser gives you without JavaScript.

The Skeleton of the Checkout Form

This is the form we will end up with. See all of it first, then we will go through the reasons piece by piece:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Checkout | Bookstore</title>
</head>
<body>
    <main>
        <h1>Checkout</h1>
        <form id="checkout" action="/orders" method="post">
            <fieldset>
                <legend>Contact</legend>
                <label for="email">Email</label>
                <input type="email" id="email" name="email" autocomplete="email">
                <label for="phone">Mobile phone</label>
                <input type="tel" id="phone" name="phone" autocomplete="tel">
            </fieldset>
            <fieldset>
                <legend>Shipping address</legend>
                <label for="full-name">Full name</label>
                <input type="text" id="full-name" name="fullName" autocomplete="shipping name">
                <label for="street">Street address</label>
                <textarea id="street" name="street" autocomplete="shipping street-address"></textarea>
                <label for="postal-code">Postal code</label>
                <input type="text" id="postal-code" name="postalCode"
                       autocomplete="shipping postal-code" inputmode="numeric">
                <label for="city">City</label>
                <input type="text" id="city" name="city" autocomplete="shipping address-level2">
            </fieldset>
            <fieldset>
                <legend>Shipping method</legend>
                <label><input type="radio" name="shipping" value="standard" checked> Standard (3-5 days)</label>
                <label><input type="radio" name="shipping" value="express"> Express (next day)</label>
            </fieldset>
            <label for="coupon">Coupon code</label>
            <input type="text" id="coupon" name="code" form="coupon-form"
                   autocomplete="off" autocorrect="off" enterkeyhint="done">
            <button type="submit" form="coupon-form">Apply coupon</button>
        </form>
        <form id="coupon-form" action="/cart/coupon" method="post"></form>
        <button type="submit" form="checkout">Place order</button>
    </main>
</body>
</html>

The Nu Html Checker does not print a single line for this form. Running the script from the previous posts, this time showing form roles, gives this accessibility tree in Chromium:

main
  heading "Checkout" level=1
  form
    group "Contact"
      textbox "Email"
      textbox "Mobile phone"
    group "Shipping address"
      textbox "Full name"
      textbox "Street address"
      textbox "Postal code"
      textbox "City"
    group "Shipping method"
      radio "Standard (3-5 days)"
      radio "Express (next day)"
    textbox "Coupon code"
    button "Apply coupon"
  form
  button "Place order"

Every field has a name, and that name comes from the visible label. Three fieldset elements became three named group nodes; when a screen reader user enters the "Shipping address" group they hear it and know that the "City" field inside belongs to the shipping address. The empty form below the form is the coupon form; we will get to it shortly.

label: for/id, Wrapping, and Why a Placeholder Is Not a Label

There are two ways to tie an input to a label. The first is the for/id pair: <label for="email"> and <input id="email">. The second is putting the input inside the label; I chose this for the radio buttons, because the text and the radio sit side by side and no separate id is needed. Either way, clicking the label focuses the input or selects the radio; on mobile, this spreads the click area of a small radio across the whole row.

A common shortcut is writing a placeholder instead of a label. I tried it on a form that looks clean in the design but is problematic:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Checkout pitfalls | Bookstore</title>
</head>
<body>
    <form id="checkout" action="/orders" method="post">
        <input type="email" name="email" placeholder="Email">
        <input type="number" id="postal-code" name="postalCode" autocomplete="shipping zip">
        <input type="text" id="coupon" name="coupon">
        <button id="apply-coupon">Apply coupon</button>
        <button type="submit" id="place-order">Place order</button>
    </form>
</body>
</html>
const { chromium } = require('playwright');
const fs = require('fs');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(fs.readFileSync('pitfalls.html', 'utf8'));

  // Accessible name of an input that only has a placeholder
  const email = page.locator('input[name=email]');
  console.log('email name:', await page.getByRole('textbox', { name: 'Email' }).count(), 'match');
  await email.fill('ada@example.com');
  console.log('placeholder visible after typing:', await email.evaluate(el => el.matches(':placeholder-shown')));

  // Postal code with type="number"
  const postal = page.locator('#postal-code');
  await postal.fill('06100');
  console.log('value:', await postal.inputValue(), '| valueAsNumber:', await postal.evaluate(el => el.valueAsNumber));
  await postal.press('ArrowUp');
  console.log('after ArrowUp:', await postal.inputValue());

  // A button without a type, and implicit submission with Enter
  await page.evaluate(() => {
    document.forms.checkout.addEventListener('submit', e => {
      e.preventDefault();
      console.log(`submit by #${e.submitter.id}`);
    });
  });
  page.on('console', m => console.log('page:', m.text()));
  await page.click('#apply-coupon');
  await page.locator('#coupon').press('Enter');
  console.log('apply-coupon type:', await page.locator('#apply-coupon').evaluate(b => b.type));
  await browser.close();
})();
email name: 1 match
placeholder visible after typing: false
value: 06100 | valueAsNumber: 6100
after ArrowUp: 6101
page: submit by #apply-coupon
page: submit by #apply-coupon
apply-coupon type: submit

The first line can be misleading: Chromium gives an input without a label its placeholder as a fallback name, so getByRole('textbox', { name: 'Email' }) finds a match. But the second line shows the real problem: the placeholder disappears as soon as the user starts typing. A user who leaves the form halfway and comes back, wants to check what they typed, or has weak short-term memory can no longer see what the field asks for. The default placeholder colour also has low contrast in most designs. The rule is simple: every field gets a visible label; a placeholder, if present, only shows an example format ("e.g. 06100").

In ASP.NET Core, the <label asp-for="Email"> and <input asp-for="Email"> Tag Helpers generate for and id from the same property, so the link is made automatically; the details are in the Tag Helper and form posts of the ASP.NET Core MVC (Model-View-Controller) and Razor Pages series. In Angular you write the id yourself; if the same component appears twice on a page, make sure the id stays unique.

fieldset and legend: Naming Groups

In a checkout, fields with the same name can repeat in different places: the shipping address and the billing address each have a "City". fieldset groups the fields, legend names the group, and that name shows up in the accessibility tree as group "Shipping address". For radio buttons the group matters even more: "Standard (3-5 days)" on its own is an answer, but the question is "Shipping method".

Radios that share a name behave as a single stop on the keyboard. I measured this on the checkout form: tabbing from the city field into the radio group and pressing the down arrow changes the selection. The same script also shows that clicking a label moves focus to the input, along with other form behaviour:

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

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(fs.readFileSync('checkout.html', 'utf8'));
  page.on('console', m => console.log(m.text()));
  await page.evaluate(() => {
    document.addEventListener('submit', e => {
      e.preventDefault();
      console.log(`#${e.target.id} submitted by "${e.submitter.textContent}"`);
    });
  });

  // Enter in the coupon field submits the coupon form, Enter in the city field submits the order
  await page.locator('#coupon').press('Enter');
  await page.locator('#city').press('Enter');

  // A radio group is a single Tab stop, arrow keys change the selection
  await page.focus('#city');
  await page.keyboard.press('Tab');
  await page.keyboard.press('ArrowDown');
  console.log('focused:', await page.evaluate(() => document.activeElement.value));

  // Clicking the label focuses the input
  await page.click('label[for=coupon]');
  console.log('focused:', await page.evaluate(() => document.activeElement.id));

  // Thanks to the form attribute, a button outside the form submits it too
  await page.click('text=Place order');
  await browser.close();
})();
#coupon-form submitted by "Apply coupon"
#checkout submitted by "Place order"
focused: express
focused: coupon
#checkout submitted by "Place order"

The third line shows the radio group: Tab entered the group at the selected radio, the down arrow selected "express". A user does not press Tab seven times through a seven-option shipping list; they move inside the group with arrows and leave it with Tab. Building this behaviour with divs takes a serious amount of JavaScript. We will come back to the first two lines and the last line in the button section.

The Right Input Type: email, tel, url, date and the number Trap

The input type decides three things: how the browser validates the value, which keyboard opens on mobile, and how a screen reader introduces the field. email checks the value against the email format and opens a keyboard with an @ key on mobile. url similarly brings / and .com keys forward. tel does no format validation at all, because phone number formats differ from country to country; but it opens the phone keypad on mobile. date brings up the browser's date picker, and although it shows the date formatted in the user's language, it always submits the value as yyyy-mm-dd; a good choice for a real date, such as the delivery date of a gift package.

The first half of the guessing question is number. The third and fourth lines of the output above: after typing 06100, the input's string value is still 06100, but its numeric value is 6100. The up arrow incremented the value to 6101 and the leading zero was gone; that is the value sent to the server. On desktop, it is also possible to change the value of a focused number field with the mouse wheel while scrolling the page. In the accessibility tree the field shows up as spinbutton, not textbox; it tells a screen reader user "a number you can increase and decrease".

number is for values where incrementing and decrementing makes sense: the quantity in the cart, the number of people. Values made of digits that are not numbers, namely postal codes, phone numbers, card numbers and order numbers, are written with type="text" and inputmode="numeric". In ASP.NET Core this trap has a server side too: if you keep the postal code as an int in the model, the Input Tag Helper generates type="number" for the field on its own; the postal code should be a string in the model as well.

autocomplete Tokens and Autofill

The moment a checkout form is abandoned most often is when the user types their address by hand for the eleventh time. Browsers can fill in a saved address and contact details with one tap, but they need to know which field is which. The autocomplete attribute tells them. Its value is not free text but tokens defined in the spec: name, email, tel, street-address, postal-code, address-level2 (city), country and so on. Prefixing them with shipping or billing states which address they belong to; with two address forms on one page, the browser does not mix them up.

The tokens are strict. The checker rejected autocomplete="shipping zip", copied from an old source:

:10.9-10.92: error: Bad value “shipping zip” for attribute “autocomplete” on element “input”: The string “zip” is not a valid autofill field name.

The right one is postal-code. A few more tokens are worth knowing: one-time-code for a one-time code sent by SMS (Short Message Service), new-password for a new password field, current-password for an existing password. I used autocomplete="off" on the coupon field, because the browser suggesting previously entered values is useless there. Do not put off on address and contact fields: you would switch off the place where the user saves the most time.

A note: name and id mean nothing for autofill by definition; browsers also look at them heuristically, but the reliable signal is autocomplete. Keeping field names English and consistent still helps both backend model binding and those heuristics.

inputmode, enterkeyhint and autocorrect: The Mobile Keyboard

Where the input type is not enough, three attributes fine-tune the mobile keyboard. inputmode says which keyboard to open without changing validation: on the postal code, inputmode="numeric" opens the digit keyboard, but because the field stays text, the leading zero is kept and the arrow key does not change the value. Other values include decimal (decimal number), tel, email, url and search.

enterkeyhint sets the label of the Enter key on the virtual keyboard: done, go, next, search, send and so on. I gave the coupon field enterkeyhint="done"; the key shows a label such as "Done". It is only a hint: it does not change what happens when Enter is pressed; the structure of the form decides that. inputmode and enterkeyhint have been Baseline widely available since 2024.

The newest is autocorrect. Safari supported it for years, Firefox got it in 136 and Chrome last, in 153; it became Baseline newly available in September 2026. Both in Chrome 158 and in WebKit 27.2, 'autocorrect' in HTMLInputElement.prototype returns true. Set autocorrect="off" on values that are not in a dictionary, such as coupon codes, usernames and ISBNs (International Standard Book Number); otherwise the mobile keyboard may "correct" the BOOKS25 coupon into a word. You do not need to turn it off on name and address fields, but if complaints come in about foreign names being corrected, this is the first place to look.

I cannot see the effect of these attributes on the mobile keyboard in a headless browser; the behaviour depends on the operating system's keyboard. To verify what you see, open the form on a real phone rather than in Chrome DevTools' device mode.

The Default type of button and the form Attribute

The second half of the guessing question. A button without a type inside a form is a submit button; the last line of the pitfalls.js output says so: apply-coupon type: submit. When the customer presses "Apply coupon", the coupon is not applied, the order form is submitted: submit by #apply-coupon. The next line is sneakier: pressing Enter in the coupon field also submits the form, and the submitter is "Apply coupon" again. On Enter, the browser treats the form's first submit button as "pressed"; this is called implicit submission.

The first step of the fix is writing a type on every button: a button that does not submit gets type="button", one that does gets type="submit". But in the checkout form that is not enough. A user who presses Enter in the coupon field expects the coupon to be applied, not the order to be placed. The coupon is a separate operation, so it should be a separate form. Forms cannot be nested; the solution is the form attribute. The coupon form sits outside the checkout form as an empty <form id="coupon-form">; the coupon input and the "Apply coupon" button are visually inside the checkout but tied to it with form="coupon-form". The same attribute let me move the "Place order" button outside the form, to the bottom of the page.

The first two lines of the checkout.js output show the result: Enter in the coupon field submits the coupon form with "Apply coupon", Enter in the city field submits the order with "Place order". In the last line, the "Place order" button outside the form submits the checkout form as well.

In Production

The best way to measure autofill is to open the form in a real browser profile with a saved address and see which fields fill with one click. Every field that stays empty is a place where the autocomplete token is missing or wrong. Putting this on Bookstore's manual checklist for every checkout change rules out the first suspect in advance when analytics show the abandonment rate rising at the address step.

A point specific to Turkey: the spec defines address-level1 as the top administrative level of a country and address-level2 as the city or district level; in Turkey that roughly maps to province (il) and district (ilçe). Because browsers' saved address models differ from country to country, always try which field fills with what using a browser profile with a Turkish address. Store the postal code as a string on the server as well and enforce the five-digit rule there too; HTML-side validation is in the next post.

Finally, if the team has a "form field" component (a component in Angular, a partial or Tag Helper in Razor), make label, id, autocomplete and inputmode required parameters of that component. Making these decisions right once, instead of again in every form, is what makes the difference in an application with twenty forms. Angular's form APIs are covered in the form posts of the Angular series.

Common Pitfalls

First, a placeholder instead of a label. It disappears when typing starts and the purpose of the field is forgotten.

Second, type="number" for postal codes, phone numbers or card numbers. It loses the leading zero, and the arrow keys and mouse wheel change the value. Use type="text" and inputmode="numeric".

Third, a button without a type. Inside a form it is submit; buttons such as "Apply coupon", "Show password" or "Add another address" submit the form.

Fourth, made-up or outdated autocomplete values (zip, phone) and autocomplete="off" on address fields. The first breaks autofill, the second turns it off completely.

Fifth, putting unrelated operations into the same form. If a coupon, a newsletter signup or a search sits inside a checkout form, the Enter key submits the wrong thing; use a separate form and the form attribute.

A Small Experiment

★☆☆ Save checkout.html from this post and open it in your own Chrome or Edge profile with a saved address. Click the email field, choose the autofill suggestion and see which fields get filled. Then delete one field's autocomplete and see the difference.

★★☆ Add a "Billing address is the same as shipping" checkbox to the form and a billing address fieldset below it. Write the billing fields' autocomplete tokens with billing, get it through the Nu Html Checker with zero errors, and check in the accessibility tree which groups the two "City" fields appear in.

★★★ Audit the checkout or signup form in your own project against this post's list of pitfalls: does every field have a visible label, are numeric identifiers number fields, does every button have a type, do the autocomplete tokens match the list in the spec? Confirm your findings with the checker output and a Playwright script like checkout.js.

The structure of the form is ready. In the next post we add rules to it: required, pattern, min/max and the browser's native validation; :user-invalid so fields do not turn red before the user touches them, and the pattern trap that rejects the name "Şule".