learn-html-with-phoebe / Session 4 of 6
Learn HTML with Phoebe · Session 4 of 6

Rows and inputs: tables and forms

Two element families carry the working web: tables show data, forms collect it. Today your page learns both - a real data table with proper accessibility, and a contact form the browser validates for you, no code required.

🟡 Moderate Data folks' favorite session Total beginners welcome 45 min live + self-study depth
0-3 · Welcome 3-22 · Concepts (live cards) 22-40 · Three demos 40-45 · Q&A
Part 0

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.

Live - presented in session Self-study - read after class ★ Try it now Every example on this page is editable - type and watch
★ What you walk out with today A table you can read row by row with your eyes closed (caption, thead, scope - the accessibility trio), the iron rule about what tables are NOT for, the label habit that makes every form usable, an input-types tour where the browser does the validation, and a real skills table plus a real contact form on your own page.
Part 1 · covers MDN "Table basics" + web.dev "Tables"

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.

Cells sit in rows, rows sit in the table THE MARKUP THE GRID THE BROWSER DRAWS <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> Language Job Born HTML Structure 1991 CSS Style 1996 Every row needs the same number of cells - the browser draws the columns, you fill them.
🔍 Click to zoom - four elements, table, tr, td, th, build any grid of data
<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>
Spanning cells, sparingly 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.
Real world

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.

Part 2 · covers MDN "Table accessibility"

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.

Pipeline health - last 7 days <caption> Pipeline Runs Status <thead> + scope="col" orders_daily 7 healthy crm_sync 5 2 failed <th scope="row"> row headers read across scope="col" labels its column, reading down scope="row" labels its row, reading across tfoot: totals live below the data Caption names the table, thead names the columns, scope gives every cell an address.
🔍 Click to zoom - the accessibility trio on one table: caption on top, thead announced first, scope arrows showing what each header labels.
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.
Real world

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.

Part 3 · covers MDN "Forms and buttons" + web.dev "Forms"

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.

✗ placeholder, no label <input placeholder="Your name"> Screen reader announces nothing. Vanishes once typing starts. ✓ label wired with for/id <label for="n">Name</label><input id="n"> Screen reader announces on focus. Click the label text, focus jumps in. Label says what the field IS - always visible; placeholder is only an example, then it's gone.
🔍 Click to zoom - a placeholder is not a label, it disappears exactly when you need it
<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">
Placeholder is a hint, not a label Placeholder text vanishes the moment typing starts - a form with only placeholders becomes a memory test half-filled. Label says what the field IS (always visible); placeholder shows an example answer. You need the first; the second is optional garnish.
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:

typeYou get
textThe default one-line box
email / numberFormat checking + the matching phone keyboard
dateA calendar picker, localized for free
checkboxIndependent yes/no boxes
radioPick ONE of a group - grouped by sharing a name
range / colorA 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/max for numbers and dates, minlength/maxlength for text, pattern for 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 like autocomplete="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 its list attribute: a dropdown that still accepts anything. The relaxed cousin of <select>.
Real world

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.

Demo 1 of 3 · the running project grows

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.

Grid looks shifted? Count cells per row - every row needs the same number (a th counts as a cell). One missing td shifts everything after it left by one column.
Demo 2 of 3

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.

Demo 3 of 3

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.

Real world

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.

Homework

Try it yourself - this week ◐ 30-45 min total

Source material

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.

✓MDN: Table basicsPart 1 - table/tr/td/th, spanning, the layout ban
✓MDN: Table accessibilityParts 1-2 - caption, thead/tbody/tfoot, scope
✓MDN: Forms and buttonsPart 3 - form, label, input types, validation, button
✓web.dev: Tables + Forms chapterstaught alongside the MDN lessons throughout
◐fieldset/legend, autocomplete, datalistself-study card - read before doing the homework
◐Where form data actually goeshonest gap: needs a server; Session 6 wires a real destination
Check yourself

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.

Session 4 cheat sheet · pin this

Table anatomytable > tr > td/th. Same cell count in every row. th = "this cell names the others".
The accessibility triocaption (table's title, first inside table) + thead/tbody/tfoot + scope="col|row" on every th.
The iron ruleTables hold tabular data only - never layout. Side-by-side placement is CSS (Session 5).
Table smellsSingle-column table = a list in costume. Deep colspan/rowspan mazes = data that wants two tables.
Form envelopeform action="where" method="get|post". get = answers in URL; post = in the body. Data needs a server (Session 6).
The label habitEvery input labeled: for ↔ id, or wrap the input in the label. Placeholder is a hint, never the label.
Types + groupstype= text/email/number/date/checkbox/radio/range/color. Radios sharing one name = pick-one group.
Free validationrequired, type="email", min/max, minlength, pattern - browser enforces at submit, no code.