HTML Form Generator
Form markup with real labels, the autocomplete tokens browsers actually read, the right inputmode per field, and an error pattern that announces itself.
<!--
Form markup.
Four things carry most of the value here:
- a real <label for>, which is clickable, visible and translated
- autocomplete tokens from the HTML spec, which are what let a browser or a
password manager fill the form correctly
- inputmode and type, which decide the keyboard on a phone
- novalidate off: the browser's own validation is better than most of what
gets written to replace it
-->
<form class="form" action="/subscribe" method="post">
<!--
Error summary. role="alert" announces it when it appears, which is why it is
in the markup from the start and empty rather than inserted on submit.
-->
<div class="form-errors" role="alert" aria-live="assertive" hidden>
<h2>Please check the form</h2>
<ul></ul>
</div>
<fieldset>
<legend>Sign up for the newsletter</legend>
<div class="field">
<label for="email">Email address <span aria-hidden="true">*</span></label>
<input
type="email"
autocomplete="email"
inputmode="email"
spellcheck="false"
id="email"
name="email"
required
/>
<p class="error" id="email-error" hidden></p>
</div>
<!--
Honeypot. Hidden with CSS rather than type="hidden", because a bot filling
every field will fill a hidden input too. The label tells a screen reader
user to leave it, and tabindex="-1" keeps it out of the tab order.
Reject the submission server side when it has a value. This stops the
simple bots and nothing else; it is not a substitute for a rate limit.
-->
<div class="field honeypot" aria-hidden="true">
<label for="website-url">Leave this field empty</label>
<input type="text" id="website-url" name="website_url" tabindex="-1" autocomplete="off" />
</div>
<button type="submit">Subscribe</button>
</fieldset>
</form>
<style>
.field {
margin-block-end: 1rem;
}
.field label {
display: block;
margin-block-end: 0.25rem;
font-weight: 600;
}
.field input,
.field select,
.field textarea {
/* 16px or more: iOS zooms the page for anything smaller, and the layout goes
with it. */
font: inherit;
font-size: max(1rem, 16px);
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #767676;
border-radius: 4px;
}
.field-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
}
.field-checkbox input {
width: auto;
/* A tap target of at least 24 by 24 CSS pixels, which is WCAG 2.5.8. */
min-width: 24px;
min-height: 24px;
}
.hint {
margin-block: 0.25rem 0;
font-size: 0.875rem;
color: #545454;
}
.error {
margin-block: 0.25rem 0;
font-weight: 600;
color: #b3261e;
}
.form-errors {
margin-block-end: 1.5rem;
padding: 1rem;
border-inline-start: 4px solid #b3261e;
background: #fdecea;
}
/* Never colour alone: the border and the message carry it too, for anyone who
cannot see the red. */
[aria-invalid='true'] {
border-color: #b3261e;
border-width: 2px;
}
/* Off screen rather than display:none, so a bot that reads the CSS is less
likely to notice. */
.honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
Output is valid and updates as you type.
Fix the highlighted fields to update the output.
Most of what makes a form pleasant to fill in is four attributes, and they are the four most often left out.
A real <label for>, which is clickable, visible and translated. The autocomplete token
from the HTML spec, which is what lets a browser or a password manager fill the field
correctly. inputmode and type, which decide the keyboard on a phone. And
aria-describedby for the hint, so it is announced after the label rather than instead of
it.
This generates the markup with all of them, per field kind, plus an error pattern that announces itself and a honeypot that does not trap screen reader users.
How to use
- Set the action, the method and the legend.
- Add a field per row, choosing the kind: it sets the type, the autocomplete token and the keyboard.
- Keep the error pattern unless you have one already.
- Copy the markup and the CSS.
Example
<form class="form" action="/subscribe" method="post">
<fieldset>
<legend>Sign up for the newsletter</legend>
<div class="field">
<label for="email">Email address <span aria-hidden="true">*</span></label>
<input
type="email"
autocomplete="email"
inputmode="email"
spellcheck="false"
id="email"
name="email"
required
aria-describedby="email-hint"
/>
<p class="hint" id="email-hint">We send one email a month and nothing else.</p>
<p class="error" id="email-error" hidden></p>
</div>
<button type="submit">Subscribe</button>
</fieldset>
</form>
autocomplete="email" is the line that matters most. It is why the browser offers the
address it already knows, and a form without it makes everybody type what their phone could
have filled in.
Pitfalls
A placeholder is not a label. It disappears when the user types, it fails contrast requirements in most implementations, and a form full of placeholders is unfillable the moment somebody is interrupted. Use both if you like; never the placeholder alone.
autocomplete tokens are a fixed list. email, name, given-name, tel,
postal-code, cc-number, current-password, new-password, one-time-code. Making one
up, or writing autocomplete="on", gets you nothing. The full list is in the HTML
specification and it is shorter than you think.
autocomplete="off" is mostly ignored, and it is usually wrong anyway. Browsers
deliberately disregard it on login forms because password managers make people safer. The
one place it earns its keep is a field that genuinely should never be remembered, such as a
one-time code, and there autocomplete="one-time-code" is better still.
current-password and new-password are different tokens. Getting them the right way
round is what makes a password manager offer to save the new password rather than fill in the
old one.
16px minimum on inputs. iOS zooms the page in for anything smaller, and the layout goes
with it. font-size: max(1rem, 16px) keeps it responsive and immune.
type="number" is for quantities, not for numbers. A card number, a phone number and a
postcode are not quantities: they can have spaces, leading zeros and letters, and the spinner
is nonsense on all three. Use type="text" with inputmode="numeric".
A <legend> has no visual substitute. It names the group for a screen reader. A heading
above the fieldset looks the same and is not associated with anything.
Colour alone is not an error state. The generated CSS uses a thicker border, a message
and aria-invalid together. Red on its own fails for the eight percent of men with a colour
vision deficiency.
A honeypot hidden with type="hidden" catches nothing. Bots skip hidden inputs and fill
visible ones, so the field has to be visible to the parser and invisible to people. It is
also why the generated one carries a label and tabindex="-1": a screen reader user should
be told to leave it alone rather than find an unlabelled field.
Validate on the server too. Everything here is a convenience for the person filling the
form in. required and pattern are trivially bypassed, and a form that trusts them is a
form with a vulnerability.
Compatibility
Everything runs in the browser: nothing is uploaded and nothing is stored.
The autocomplete tokens are from the HTML specification’s autofill section and are
implemented by Chrome, Safari, Firefox and Edge, plus the major password managers. The
inputmode attribute has been in every mobile browser since around 2019 and is ignored
harmlessly on a desktop.
The error pattern uses role="alert" with aria-live="assertive" on an element that is in
the markup from the start and empty. That matters: a live region inserted at the same moment
as its content is often not announced, because the screen reader never saw the empty
container to start watching it.
aria-invalid="true" on the field plus the message inside the element aria-describedby
points at is the combination that gets announced when focus lands on a bad field. Setting
one without the other gets you half of it.
The generated CSS uses logical properties, margin-block-end and border-inline-start,
which work in every current browser and behave correctly in a right-to-left layout.
Frequently asked questions
Should I use the browser’s own validation?
Where do the error messages come from?
aria-invalid flag and the summary. Filling them in is application code, and the comments in
the output say what each part expects.Is a honeypot enough against spam?
How do I lay this out in two columns?
grid-column: span 2 on the fields that should be full width. Keep
the DOM order the reading order, because that is the tab order.Do I need a fieldset for a single field?
<legend> earns its place when several fields belong
together, such as an address or a set of radio buttons.