Why this session exists
Your page can already talk (Sessions 1-3: headings, links, images, structure). Today it learns to show data honestly and to listen. If you work with data for a living, this is the session where HTML starts feeling like home: a table element is a tiny dataset, and a form is a tiny data-collection pipeline - schema, types, validation and all.
Tables - for data, never layout 7 min live
A table is rows of cells. Four elements build one: <table> wraps it, <tr> makes a row, <td> holds data, <th> holds a header. Everything else is refinement on those four.
LiveBuild one from four elements3 min▶
Read it inside out: cells sit in rows, rows sit in the table. The browser draws the grid for you - column widths, alignment, all of it. Your only job is to put the right content in the right cell, one row at a time.
<table>
<tr>
<th>Language</th>
<th>Job</th>
<th>Born</th>
</tr>
<tr>
<td>HTML</td>
<td>Structure</td>
<td>1991</td>
</tr>
<tr>
<td>CSS</td>
<td>Style</td>
<td>1996</td>
</tr>
</table>
- th is not "bold td". It declares "this cell names the others" - machines lean on that claim, so make it honestly.
- Every row carries the same number of cells. Miss one and the grid quietly shifts - the table version of an unclosed tag.
LiveThe accessible upgrade: caption, thead, scope3 min▶
A sighted reader scans a table in two dimensions at once. A screen reader visits one cell at a time - so the markup has to say what each cell means. Three additions do it:
<caption>- the table's title, first thing inside<table>. Announced before any cell, so listeners can skip tables they don't need.<thead>and<tbody>- name which rows are headers and which are data.<tfoot>holds totals.scope="col"on column headers,scope="row"on row headers - now every data cell can be announced with its full address: "HTML, Born, 1991".
<table>
<caption>Languages of the web</caption>
<thead>
<tr>
<th scope="col">Language</th>
<th scope="col">Job</th>
<th scope="col">Born</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">HTML</th>
<td>Structure</td>
<td>1991</td>
</tr>
<tr>
<th scope="row">CSS</th>
<td>Style</td>
<td>1996</td>
</tr>
</tbody>
</table>
colspan="2" makes a cell cover two columns, rowspan="2" two rows. Fine for the occasional merged header - but every span makes the table harder to announce, sort, and copy into a spreadsheet. If you need many, your data probably wants to be two tables.
The 1998 horror story: before CSS matured, whole websites were built as invisible tables - navigation in one giant cell, content in another, spacer images padding the gaps. Screen readers announced them as data ("row 1 of 47, column 3...") and redesigns meant rebuilding every page. The industry spent a decade digging out. Hence the iron rule: tables hold tabular data, never layout. Rows in a row problem? Table. Things placed side by side? That is Session 5's job.
Reading tables like a data person 5 min live
You already know when data belongs in a table: multiple records, same fields, comparison matters. HTML tables obey the same instincts as a good dataframe - and have the same smells when misused.
LiveA metrics table, done properly3 min▶
Here is the diagram above as living markup - a monitoring table any data team would recognize. Every technique from Part 1 in one place, plus <tfoot> for the totals row.
<table>
<caption>Pipeline health - last 7 days</caption>
<thead>
<tr>
<th scope="col">Pipeline</th>
<th scope="col">Runs</th>
<th scope="col">Rows/day</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">orders_daily</th>
<td>7</td><td>1.2M</td><td>healthy</td>
</tr>
<tr>
<th scope="row">events_stream</th>
<td>168</td><td>40M</td><td>healthy</td>
</tr>
<tr>
<th scope="row">crm_sync</th>
<td>5</td><td>80k</td><td>2 failed</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td>180</td><td>41.3M</td><td></td>
</tr>
</tfoot>
</table>
Self-studyWhen a table beats a list, and table smells2 min read▶
The decision is the same one you make before reaching for a dataframe: multiple records, shared fields, comparison across them? Table. One field per item, order or grouping is the point? List (Session 2). Two smells tell you the choice went wrong:
- The single-column table - one field means it is a list wearing a table costume. Simpler markup, same meaning: use
<ul>. - The merged-cell maze - colspan and rowspan stacked three deep means the data is not really rectangular. Split it into two honest tables, or rethink the shape.
Every BI export, HTML email report, and "download as web page" from a spreadsheet is this exact markup. A data person who can read thead/tbody/scope can also scrape confidently - well-structured tables are what makes pandas.read_html() feel like magic.
Forms - the input side of the web 8 min live
Everything you have written so far talks AT the visitor. A form listens: it is a collection of typed inputs wrapped in an envelope that knows where to send itself. Schema design, types, validation - data thinking, in markup.
LiveThe form element - an envelope with an address2 min▶
<form action="/where" method="post"> wraps the inputs. action says where the data goes when submitted; method says how (get puts answers in the URL - fine for searches; post sends them in the request body - right for anything personal).
The honest part: receiving form data needs a server. Your page on GitHub Pages (Session 6) has no server - so a form there either opens the visitor's email app (action="mailto:you@example.com", clunky but zero setup) or points at a free form-handling service. We wire that up in Session 6; today we build the form itself, which is most of the work anyway.
Livelabel + for - THE accessibility habit2 min▶
Every input gets a <label>, and the label's for attribute holds the input's id. That one wire does three jobs: screen readers announce the label when the input gets focus, clicking the label focuses the input (a huge tap target on phones), and your form documents itself.
<label for="name">Your name</label>
<input id="name" type="text">
<label for="topic">Favorite data topic</label>
<input id="topic" type="text"
placeholder="e.g. dbt, DuckDB, evals">
LiveThe input types tour - one element, many costumes2 min▶
One element, <input>, changes personality with its type attribute. Declaring the right type buys you the right keyboard on phones, the right picker widgets, and free validation:
| type | You get |
|---|---|
text | The default one-line box |
email / number | Format checking + the matching phone keyboard |
date | A calendar picker, localized for free |
checkbox | Independent yes/no boxes |
radio | Pick ONE of a group - grouped by sharing a name |
range / color | A slider / a color swatch picker |
<p>How do you take your data?</p> <label><input type="radio" name="fmt" checked> CSV</label> <label><input type="radio" name="fmt"> Parquet</label> <label><input type="radio" name="fmt"> JSON</label> <p>Extras - each checkbox is independent:</p> <label><input type="checkbox" checked> Send charts</label> <label><input type="checkbox"> Weekly digest</label> <label for="d">Start date</label> <input id="d" type="date">
Wrapping the input inside its label (as above) is the second legal way to pair them - no for/id needed. Handy for radios and checkboxes.
LiveValidation - the browser does the work2 min▶
Sprinkle constraint attributes on inputs and the browser enforces them at submit time, with its own error bubbles, in the visitor's own language. Zero code:
required- may not be empty.type="email"- must look like an address.min/maxfor numbers and dates,minlength/maxlengthfor text,patternfor regex-shaped rules.
Two more elements complete the kit: <textarea> for multi-line text, and <select> with <option> children for dropdown lists. A <button> inside a form submits it by default.
<form action="#" method="post">
<label for="n">Name</label>
<input id="n" type="text" required>
<label for="e">Email</label>
<input id="e" type="email" required>
<label for="how">How did you find me?</label>
<select id="how">
<option>LinkedIn</option>
<option>GitHub</option>
<option>Search</option>
</select>
<label for="msg">Message</label>
<textarea id="msg" rows="4"
minlength="10" required></textarea>
<button>Send</button>
</form>
Self-studyfieldset, autocomplete, datalist3 min read▶
<fieldset>+<legend>- group related inputs under a caption. For radio groups it is the polite move: the legend ("How do you take your data?") gets announced with every option, so listeners always know which question they are answering.autocomplete- values likeautocomplete="name"or"email"let the browser offer its saved answers. One attribute, dramatically faster forms - and an accessibility win for anyone who finds typing hard.<datalist>- attach a list of suggestions to a free text input via itslistattribute: a dropdown that still accepts anything. The relaxed cousin of<select>.
Forms are where the web makes its money - every checkout, signup, and survey. Industry studies keep finding the same top abandonment causes: unclear labels and surprise validation errors. The label habit and honest input types you just learned are, unglamorously, conversion optimization.
A real table on your page ★ 8 min · follow along
Your Projects section (outlined in Session 1, structured in Session 3) gets an upgrade: a proper data table of what you have built or learned - caption, thead, scope, the works.
Open your index.html. Inside the Projects section, start a <table> with a <caption>: "Things I have built" or "My skills" - your call.
Add a <thead> with one row of four <th scope="col"> headers: Name, What it is, Year, Status.
Add a <tbody> with 3-4 rows. First cell of each row is a <th scope="row"> with the project name; the rest are <td>.
Data people: make it your REAL project list - the dashboard, the pipeline, the model. This page becomes your portfolio in Session 6; real rows beat placeholder rows.
Save, reload. Unstyled tables look austere - resist the urge to fix that with markup. Session 5 dresses it properly.
A contact form that validates itself ★ 7 min · build your own
The Contact section stops being a placeholder. Name, email, a message box, one dropdown, one button - with the browser guarding the door.
In your Contact section, open a <form action="#" method="post">. The # is an honest placeholder address - Session 6 gives it a real one.
Add three labeled inputs: Name (type="text" required), Email (type="email" required), and a <textarea> for the message. Every input gets an id; every label gets the matching for.
Add a <select id="how"> - "How did you find me?" - with 3-4 <option>s (LinkedIn, GitHub, search, a friend).
Finish with <button>Send</button>. Save, reload, click Send with everything empty - watch the browser refuse politely, in your system language, for free.
Honest check: it validates but does not send yet - there is no server behind it. That is correct and expected until Session 6.
The label audit ★ 3 min · build your own
A three-minute quality gate you will reuse on every form you ever ship: labels are wired or they are not, and one click tells you which.
On your reloaded page, click the TEXT of every label on your form - the word "Name", not the box.
Focus jumps into the matching input? Wired. Nothing happens? That for/id pair is broken - typo, missing id, or duplicate id.
Fix every dead label and re-click. When all of them jump, your form works for screen readers, phone thumbs, and shaky mice alike.
Accessibility auditors literally do this exact click-test on client sites - it is the fastest form check that exists. You now perform a professional audit technique after four HTML lessons.
Try it yourself - this week ◐ 30-45 min total
- Fill your table with real rows - at least four projects or skills, honest statuses. Add a
<tfoot>row if a total makes sense. - Extend the form: add one radio group (wrapped in
<fieldset>+<legend>, from the self-study card) andautocomplete="name"/autocomplete="email"on the first two inputs. - Find one data table in the wild (Wikipedia is full of them), view source, and check: caption? thead? scope? Grade it like the auditor you now are.
- The smell hunt: find one single-column table or one merged-cell maze anywhere on the web. Decide what markup it should have used.
- Bring to Session 5: your page with table and form in place - it is the "before" photo. Session 5 is the makeover.
Official sources covered
This session teaches the working content of the table and form lessons from MDN's Structuring content with HTML and the Tables and Forms chapters of web.dev Learn HTML. Their interactive challenges stay with the originals - our exercises are equivalents, not copies.
Three questions before you go 🎯 ◐ 90 seconds
1 · What does scope="col" on a th element actually do?
Scope is pure meaning, zero looks: it gives every data cell an announced address ("crm_sync, Status, 2 failed"). Width and sorting are other tools' jobs.
2 · How do a label and its input get wired together?
for ↔ id is the wire (or wrap the input inside the label). Proximity does nothing - that is why the click-the-label audit catches broken forms.
3 · You want the browser to check that visitors enter a real-looking email address. Which input?
type="email" brings format checking and the @ keyboard on phones; required refuses emptiness. A placeholder checks nothing, and type="mail" is not a thing.