Tables and Forms
Before you start
You need: HTML structure (Article 01).
Time: about 45 minutes, plus the practice.
Learning objective
Build a data table a screen reader can navigate and a form that is usable, accessible and validated before any JavaScript runs.
Topics
- Table structure and
<th scope> <caption>and accessibility- Form structure and submission
- Labels — the rule with no exceptions
- Input types and what each gives you free
- Built-in validation attributes
- Select, radio, checkbox and grouping
- Error messages and
aria-describedby
Tables
<table>
<caption>Class 10 Section A — Mid Term results</caption>
<thead>
<tr>
<th scope="col">Roll number</th>
<th scope="col">Name</th>
<th scope="col">Mathematics</th>
<th scope="col">Science</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">NCA-2024-0012</th>
<td>Ravi Kumar</td>
<td>87</td>
<td>72</td>
<td>159</td>
</tr>
<tr>
<th scope="row">NCA-2024-0018</th>
<td>Priya Sharma</td>
<td>91</td>
<td>88</td>
<td>179</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row" colspan="2">Class average</th>
<td>84.2</td>
<td>79.6</td>
<td>163.8</td>
</tr>
</tfoot>
</table>
<thead>, <tbody> and <tfoot> group the rows. The browser can repeat the header when printing across pages, and CSS can style the sections independently.
scope is what makes a table navigable. scope="col" says this header labels its column; scope="row" says it labels its row. A screen reader then announces "Mathematics, 87" as the user moves across, instead of reading a wall of numbers.
Without scope, a data table is unusable non-visually. It costs one attribute per header.
<caption> names the table for everyone, and is announced first by a screen reader. It must be the first child of <table>.
Tables are for data, not layout
<!-- Never: this was 2003 practice -->
<table>
<tr><td>Sidebar</td><td>Main content</td></tr>
</table>
A screen reader announces "table with 1 row and 2 columns" and starts reading cells, which is meaningless for a layout. Use CSS Grid or Flexbox — the next articles cover both.
Use a table when the data has rows and columns that relate to each other. A list of students with their marks is a table; a page layout is not.
Complex headers
<th scope="colgroup" colspan="2">First term</th>
<th scope="rowgroup" rowspan="3">Class 10</th>
For headers spanning several cells. When a table needs more than this, it is usually two tables.
Responsive tables
A wide table on a phone either overflows or squashes into unreadability. The simplest fix that keeps the table a table:
<div class="table-scroll">
<table>...</table>
</div>
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
The table scrolls horizontally inside its container, and the page itself does not.
Forms
<form action="/students/create" method="post">
<label for="name">Student name</label>
<input type="text" id="name" name="name" required maxlength="100">
<button type="submit">Save</button>
</form>
| Attribute | Purpose |
|---|---|
action | Where the form posts |
method | get or post |
name | The key the server receives — required |
id | Links the input to its <label> |
novalidate | Disables browser validation |
name is what the server receives. An input with an id and no name submits nothing at all — a silent failure that looks like a backend bug.
method="get" puts values in the query string: bookmarkable, cacheable, and correct for a search. method="post" puts them in the body: correct for anything that changes data, and the only option for a password.
Labels
<!-- Explicit: for matches id -->
<label for="rollNumber">Roll number</label>
<input type="text" id="rollNumber" name="rollNumber">
<!-- Implicit: input inside the label -->
<label>
Roll number
<input type="text" name="rollNumber">
</label>
Every input needs a label. There are no exceptions worth taking.
A label does three things: a screen reader announces it when the field is focused, clicking it focuses the field, and the larger click target matters on a phone.
Placeholder text is not a label:
<!-- Wrong: the hint vanishes the moment the user types -->
<input type="text" name="rollNumber" placeholder="Roll number">
<!-- Right: the label persists, the placeholder shows the format -->
<label for="rollNumber">Roll number</label>
<input type="text" id="rollNumber" name="rollNumber" placeholder="NCA-2024-0012">
A user who is half-way through a long form and cannot remember what a field was for has no way to find out. Placeholder text also fails contrast requirements in most designs.
To hide a label visually while keeping it for screen readers:
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
Use display: none and the screen reader skips it too — which defeats the purpose.
Input types
<input type="text" name="name">
<input type="email" name="email">
<input type="tel" name="parentPhone">
<input type="number" name="marks" min="0" max="100" step="1">
<input type="date" name="dateOfBirth">
<input type="password" name="password">
<input type="search" name="term">
<input type="url" name="website">
<input type="file" name="photo" accept="image/*">
<input type="hidden" name="schoolId" value="1">
<input type="checkbox" name="isHosteller" value="true">
<input type="radio" name="section" value="A">
The type is not cosmetic. It changes the mobile keyboard, the browser's built-in validation, and the input's behaviour:
| Type | Mobile keyboard | Validates |
|---|---|---|
text | Standard | Nothing |
email | With @ and . | Basic email shape |
tel | Numeric keypad | Nothing |
number | Numeric | min, max, step |
date | Date picker | A real date |
url | With / and . | URL shape |
type="tel" rather than type="number" for a phone number. number strips leading zeros, allows e and -, shows spinner arrows, and cannot hold +91. tel gives the numeric keypad and treats the value as text, which is what a phone number is.
Use pattern for the actual format:
<label for="parentPhone">Parent phone</label>
<input type="tel" id="parentPhone" name="parentPhone"
inputmode="numeric"
pattern="[6-9][0-9]{9}"
maxlength="10"
required
aria-describedby="phoneHelp">
<small id="phoneHelp">10 digits, starting 6 to 9.</small>
aria-describedby links the hint to the input, so a screen reader reads it after the label. Without it the hint is invisible non-visually.
Built-in validation
<label for="rollNumber">Roll number</label>
<input type="text" id="rollNumber" name="rollNumber"
required
pattern="NCA-\d{4}-\d{4}"
maxlength="20"
title="Format: NCA-2024-0012">
<label for="marks">Marks obtained</label>
<input type="number" id="marks" name="marks" min="0" max="100" step="0.5" required>
<label for="dateOfBirth">Date of birth</label>
<input type="date" id="dateOfBirth" name="dateOfBirth" min="2000-01-01" max="2020-12-31" required>
| Attribute | Enforces |
|---|---|
required | Not empty |
minlength / maxlength | Text length |
min / max | Numeric or date range |
step | Increment |
pattern | A regular expression |
type | Format for email, url, date |
The browser blocks submission and shows a message, with no JavaScript at all. title supplies the text shown when pattern fails — without it the browser says only "Please match the requested format", which tells the user nothing.
input:invalid { border-color: #b91c1c; }
input:valid { border-color: #15803d; }
input:required { border-left: 3px solid #2563eb; }
Style :invalid carefully — it applies before the user has typed anything, so an empty required field is red on page load. Use :user-invalid where supported, or add the styling class with JavaScript after a blur.
None of this is a security control. Every attribute here is removable in DevTools, and a request can be sent without the page at all. Server-side validation is the actual check; this only saves the user a round trip.
Select, radio and checkbox
<label for="className">Class</label>
<select id="className" name="className" required>
<option value="">-- Select class --</option>
<option value="9th">9th</option>
<option value="10th" selected>10th</option>
</select>
The empty first option makes required meaningful — without it the first real option is pre-selected and the field can never be empty.
<fieldset>
<legend>Section</legend>
<label for="sectionA">
<input type="radio" id="sectionA" name="section" value="A" required> A
</label>
<label for="sectionB">
<input type="radio" id="sectionB" name="section" value="B"> B
</label>
</fieldset>
Radio buttons in a group share one name — that is what makes them mutually exclusive. Different name values give you several independent radios that can all be selected, which is a puzzling bug the first time.
<fieldset> and <legend> group related controls, and a screen reader announces the legend before each option — so the user hears "Section, A" rather than an unexplained "A".
<label for="isHosteller">
<input type="checkbox" id="isHosteller" name="isHosteller" value="true"> Hostel resident
</label>
An unchecked checkbox submits nothing at all — the key is absent from the request, not present as false. Server code reading it must treat "missing" as false. A hidden field before it is the usual workaround:
<input type="hidden" name="isHosteller" value="false">
<input type="checkbox" name="isHosteller" value="true">
The checkbox's value overrides the hidden one when checked.
A complete form
<form action="/students/create" method="post" novalidate>
<fieldset>
<legend>Student details</legend>
<div class="field">
<label for="name">Full name</label>
<input type="text" id="name" name="name" required maxlength="100"
autocomplete="name" aria-describedby="nameError">
<p id="nameError" class="error" role="alert" hidden></p>
</div>
<div class="field">
<label for="rollNumber">Roll number</label>
<input type="text" id="rollNumber" name="rollNumber" required
pattern="NCA-\d{4}-\d{4}" maxlength="20"
placeholder="NCA-2024-0012" title="Format: NCA-2024-0012">
</div>
<div class="field">
<label for="dateOfBirth">Date of birth</label>
<input type="date" id="dateOfBirth" name="dateOfBirth" required>
</div>
</fieldset>
<fieldset>
<legend>Parent contact</legend>
<div class="field">
<label for="parentName">Parent name</label>
<input type="text" id="parentName" name="parentName" required maxlength="100">
</div>
<div class="field">
<label for="parentPhone">Parent phone</label>
<input type="tel" id="parentPhone" name="parentPhone" required
inputmode="numeric" pattern="[6-9][0-9]{9}" maxlength="10"
autocomplete="tel" aria-describedby="phoneHelp">
<small id="phoneHelp">10 digits, starting 6 to 9.</small>
</div>
</fieldset>
<button type="submit">Save student</button>
<button type="button" onclick="history.back()">Cancel</button>
</form>
Two details worth noting.
type="button" on Cancel. A <button> inside a form defaults to type="submit", so a Cancel button without it submits the form. This catches nearly everyone once.
role="alert" on the error element. A screen reader announces its content the moment it appears, so a user who cannot see the message still hears it.
autocomplete values (name, tel, email, street-address) let the browser fill fields from the user's saved profile. It is a genuine usability win and costs one attribute.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| Clicking a label does not focus the input | for does not match the input's id | Match them exactly |
| Form submits and the page reloads | Default form behaviour | event.preventDefault() in the handler |
| Nothing arrives at the server | Inputs have no name attribute | name is what gets submitted, not id |
| Table headers are not announced | Used <td> where <th> belongs | Use <th> with scope |
required does not stop submission | Form has novalidate, or JavaScript submits it | Check both |
id is for labels and CSS; name is what gets submitted. An input with no name is invisible to the server, and there is no error to tell you.
Common mistakes
- An input with
idbut noname, submitting nothing - Placeholder used instead of a label
- A
<caption>that is not the first child of<table> - No
scopeon table headers - Tables used for layout
type="number"for a phone number- Radio buttons with different
namevalues - No empty first
<option>, sorequiredon a select does nothing - Expecting an unchecked checkbox to submit
false - A Cancel button without
type="button" patternwith notitle, giving a useless error message- Hiding a label with
display: none - Trusting client-side validation as a control
Practice
The course exercise is build a semantic form.
- Build the student results table with
<caption>,<thead>,<tbody>,<tfoot>and correctscopeon every header. - Test it with a screen reader (NVDA on Windows, VoiceOver on Mac). Navigate cell by cell and confirm headers are announced.
- Remove every
scopeand repeat. Record the difference. - Wrap the table in a horizontally scrolling container and confirm the page itself does not scroll sideways at 375px.
- Build the full student form above. Submit it empty and record what the browser says for each field.
- Replace a label with a placeholder. Fill the form half-way, then try to remember what the field was for.
- Use
type="number"for the phone, enter09951510727, and record what the value becomes. - Give two radio buttons different
namevalues and confirm both can be selected. - Remove the empty first
<option>from the class select and confirmrequiredno longer does anything. - Submit the form with the hostel checkbox unchecked and inspect the request body in DevTools Network. Confirm the key is absent.
- Remove
type="button"from Cancel and click it. - In DevTools, delete the
requiredattribute and submit. Confirm it goes through — this is why the server must validate.
Exercise 12 is the point. Every attribute in this article is a convenience; none of them is a control.
You can now
- Build a data table a screen reader can navigate
- Bind every label to its input
- Use the right input types and validation attributes
- Say why
namematters andiddoes not, for submission - Prevent a form's default reload
Review questions
- What does an input with
idbut nonamesubmit? - Why is
type="tel"correct for a phone number andtype="number"wrong? - What makes a set of radio buttons mutually exclusive?
- What does an unchecked checkbox send to the server?