Skip to main content
Published / updated

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>
AttributePurpose
actionWhere the form posts
methodget or post
nameThe key the server receives — required
idLinks the input to its <label>
novalidateDisables 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:

TypeMobile keyboardValidates
textStandardNothing
emailWith @ and .Basic email shape
telNumeric keypadNothing
numberNumericmin, max, step
dateDate pickerA real date
urlWith / 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>
AttributeEnforces
requiredNot empty
minlength / maxlengthText length
min / maxNumeric or date range
stepIncrement
patternA regular expression
typeFormat 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 seeCauseFix
Clicking a label does not focus the inputfor does not match the input's idMatch them exactly
Form submits and the page reloadsDefault form behaviourevent.preventDefault() in the handler
Nothing arrives at the serverInputs have no name attributename is what gets submitted, not id
Table headers are not announcedUsed <td> where <th> belongsUse <th> with scope
required does not stop submissionForm has novalidate, or JavaScript submits itCheck 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 id but no name, submitting nothing
  • Placeholder used instead of a label
  • A <caption> that is not the first child of <table>
  • No scope on table headers
  • Tables used for layout
  • type="number" for a phone number
  • Radio buttons with different name values
  • No empty first <option>, so required on a select does nothing
  • Expecting an unchecked checkbox to submit false
  • A Cancel button without type="button"
  • pattern with no title, 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.

  1. Build the student results table with <caption>, <thead>, <tbody>, <tfoot> and correct scope on every header.
  2. Test it with a screen reader (NVDA on Windows, VoiceOver on Mac). Navigate cell by cell and confirm headers are announced.
  3. Remove every scope and repeat. Record the difference.
  4. Wrap the table in a horizontally scrolling container and confirm the page itself does not scroll sideways at 375px.
  5. Build the full student form above. Submit it empty and record what the browser says for each field.
  6. Replace a label with a placeholder. Fill the form half-way, then try to remember what the field was for.
  7. Use type="number" for the phone, enter 09951510727, and record what the value becomes.
  8. Give two radio buttons different name values and confirm both can be selected.
  9. Remove the empty first <option> from the class select and confirm required no longer does anything.
  10. Submit the form with the hostel checkbox unchecked and inspect the request body in DevTools Network. Confirm the key is absent.
  11. Remove type="button" from Cancel and click it.
  12. In DevTools, delete the required attribute 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 name matters and id does not, for submission
  • Prevent a form's default reload

Review questions

  1. What does an input with id but no name submit?
  2. Why is type="tel" correct for a phone number and type="number" wrong?
  3. What makes a set of radio buttons mutually exclusive?
  4. What does an unchecked checkbox send to the server?

Next: Semantic layout and accessibility