Build an accessible, responsive event page: semantic HTML, Grid and a form with clear errors
Build a one-page event site from scratch with semantic HTML, a mobile-first Flexbox and Grid layout, an accessible sign-up form with error messages, and checks for keyboard, screen readers and contrast.
Accessibility is easiest when it is built in from the first line rather than patched on at the end. In this tutorial you build a small event page for a free workshop, using plain HTML, CSS and a few lines of JavaScript. Along the way you learn why each element was chosen, how a mobile-first layout works, and how to make a form whose errors everyone can find, including people who use a keyboard or a screen reader.
It is for beginners who know what HTML tags and CSS rules look like. The finished page was validated locally with the html-validate tool (recommended and accessibility presets), contrast ratios were calculated with a small script, and keyboard behavior and the mobile layout were checked in a Chromium-based browser.
What you will build
- A single page with a skip link, header and navigation, a hero section, three feature cards, a schedule table, a sign-up form and a footer.
- A layout that is one column on phones and switches to a three-column card grid on wider screens, using Flexbox and CSS Grid with mobile-first media queries.
- A form with real labels, a hint, a fieldset for radio buttons, inline error messages and an error summary that receives focus.
- A color palette whose text contrast meets WCAG level AA, and a visible keyboard focus style.
Before you start
- A modern browser (Firefox, Chrome, Edge or Safari) and a text editor.
- Python 3 or any other way to serve a folder over HTTP locally (opening the file directly also works for most of this tutorial).
- Optional: Node.js 20 or newer to run the html-validate checker and the contrast script.
- Time: about 60-90 minutes.
- Knowledge: basic HTML tags and CSS selectors. The ideas you will use are semantic HTML (elements that describe meaning), the accessibility tree (what assistive technology reads), and responsive design.
Step 1: Start with the document skeleton
Why: three lines matter more than they look. lang="en" tells screen readers which pronunciation to use; the viewport meta tag makes phones render at real device width instead of a zoomed-out desktop view; a unique, descriptive <title> is the first thing a screen reader announces and what appears in tabs and bookmarks.
mkdir workshop
cd workshop
touch index.html styles.css form.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Intro to Git workshop – Riverside Code Club</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
</body>
</html>
Step 2: Write the semantic structure
Why: elements like <header>, <nav>, <main> and <footer> become landmarks that screen reader users can jump between. Headings form an outline: one <h1> for the page, <h2> for each section, without skipping levels. A real <table> with <caption> and scope lets a screen reader announce “Time” and “Session” with each cell.
Here is the complete index.html. Read it top to bottom; the form is explained in step 5.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Intro to Git workshop – Riverside Code Club</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to main content</a>
<header class="site-header">
<p class="logo">Riverside Code Club</p>
<nav aria-label="Main">
<ul>
<li><a href="#about">About</a></li>
<li><a href="#schedule">Schedule</a></li>
<li><a href="#signup">Sign up</a></li>
</ul>
</nav>
</header>
<main id="main">
<section class="hero" aria-labelledby="page-title">
<h1 id="page-title">Intro to Git workshop</h1>
<p>A free, hands-on evening for complete beginners. Bring a laptop; we bring the coffee.</p>
<a class="button" href="#signup">Reserve a seat</a>
</section>
<section id="about" aria-labelledby="about-title">
<h2 id="about-title">What you will learn</h2>
<ul class="cards">
<li class="card">
<h3>Save your work</h3>
<p>Make commits and read the history of a project.</p>
</li>
<li class="card">
<h3>Try ideas safely</h3>
<p>Use branches to experiment without breaking anything.</p>
</li>
<li class="card">
<h3>Work with others</h3>
<p>Push to GitHub and open your first pull request.</p>
</li>
</ul>
</section>
<section id="schedule" aria-labelledby="schedule-title">
<h2 id="schedule-title">Schedule</h2>
<table>
<caption>Thursday evening, all times local</caption>
<thead>
<tr><th scope="col">Time</th><th scope="col">Session</th></tr>
</thead>
<tbody>
<tr><td>18:00</td><td>Welcome and setup</td></tr>
<tr><td>18:30</td><td>Your first commits</td></tr>
<tr><td>19:30</td><td>Branches and pull requests</td></tr>
</tbody>
</table>
</section>
<section id="signup" aria-labelledby="signup-title">
<h2 id="signup-title">Sign up</h2>
<div id="error-summary" class="error-summary" tabindex="-1" hidden>
<h3>Please fix the following:</h3>
<ul></ul>
</div>
<form id="signup-form" novalidate>
<div class="field">
<label for="name">Full name</label>
<input id="name" name="name" type="text" autocomplete="name" required aria-describedby="name-error">
<p id="name-error" class="error" hidden></p>
</div>
<div class="field">
<label for="email">Email</label>
<p id="email-hint" class="hint">We only use it to send the joining details.</p>
<input id="email" name="email" type="email" autocomplete="email" required aria-describedby="email-hint email-error">
<p id="email-error" class="error" hidden></p>
</div>
<fieldset class="field">
<legend>Experience with Git</legend>
<div class="choice"><input id="exp-none" name="experience" type="radio" value="none" checked><label for="exp-none">None yet</label></div>
<div class="choice"><input id="exp-some" name="experience" type="radio" value="some"><label for="exp-some">I have used it a little</label></div>
</fieldset>
<button type="submit">Reserve my seat</button>
</form>
<p id="form-status" role="status"></p>
</section>
</main>
<footer class="site-footer">
<p>Riverside Code Club · Volunteer-run since 2019</p>
</footer>
<script src="form.js"></script>
</body>
</html>
Things to notice:
- The skip link is the first focusable element and jumps to
#main, so keyboard users do not have to tab through the navigation on every page. <nav aria-label="Main">names the landmark. Sections usearia-labelledbypointing at their heading, which turns them into named regions.- The feature cards are a list (
<ul>), so screen readers announce “list, 3 items”. - “Reserve a seat” is a link because it navigates; “Reserve my seat” is a
<button>because it performs an action. Never use a<div>with a click handler for either.
Open the file in a browser now. Without any CSS it is plain but completely usable and in a sensible reading order. That is a good sign.
Step 3: Add base styles and a mobile-first layout
Why: “mobile-first” means the default CSS is for small screens and media queries only add layout for wider screens. Small screens get the simplest, single-column version, and you never fight a desktop layout to squeeze it down.
Here is the complete styles.css:
:root {
--text: #1f2328;
--muted: #57606a;
--bg: #ffffff;
--surface: #f6f8fa;
--accent: #0b5cad;
--accent-text: #ffffff;
--error: #b42318;
--focus: #c2410c;
--radius: 8px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
color: var(--text);
background: var(--bg);
}
img { max-width: 100%; height: auto; }
a { color: var(--accent); }
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
.skip-link {
position: absolute;
left: 1rem;
top: -3rem;
padding: 0.5rem 1rem;
background: var(--text);
color: var(--bg);
}
.skip-link:focus { top: 1rem; }
.site-header,
main,
.site-footer {
padding: 1rem;
}
.site-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 2rem;
border-bottom: 1px solid #d0d7de;
}
.logo { margin: 0; font-weight: 700; }
nav ul {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
nav a { display: inline-block; padding: 0.5rem 0; }
main {
display: grid;
gap: 2.5rem;
max-width: 60rem;
margin: 0 auto;
}
.button,
button {
display: inline-block;
padding: 0.75rem 1.25rem;
border: 0;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.cards {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
.card {
padding: 1rem;
border-radius: var(--radius);
background: var(--surface);
}
.card h3 { margin-top: 0; }
table { width: 100%; border-collapse: collapse; }
caption { text-align: left; color: var(--muted); padding-bottom: 0.5rem; }
th, td { padding: 0.5rem; border-bottom: 1px solid #d0d7de; text-align: left; }
form { display: grid; gap: 1.25rem; max-width: 32rem; }
.field { display: grid; gap: 0.25rem; margin: 0; }
fieldset { border: 1px solid #d0d7de; border-radius: var(--radius); padding: 1rem; }
label, legend { font-weight: 600; }
.choice { display: flex; align-items: center; gap: 0.5rem; }
.choice label { font-weight: 400; }
input[type="text"],
input[type="email"] {
padding: 0.6rem;
border: 1px solid #6e7781;
border-radius: var(--radius);
font: inherit;
}
input[aria-invalid="true"] { border: 2px solid var(--error); }
.hint { margin: 0; color: var(--muted); }
.error { margin: 0; color: var(--error); font-weight: 600; }
.error-summary {
padding: 1rem;
border: 3px solid var(--error);
border-radius: var(--radius);
}
.error-summary h3 { margin-top: 0; }
.site-footer { color: var(--muted); border-top: 1px solid #d0d7de; text-align: center; }
@media (min-width: 40em) {
.cards { grid-template-columns: repeat(3, 1fr); }
.hero { padding: 2rem 0; }
}
@media (min-width: 64em) {
.site-header { padding: 1rem 2rem; }
h1 { font-size: 3rem; }
}
@media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth; }
}
How the layout works:
- Flexbox handles one-dimensional rows: the header puts the logo and navigation side by side, and
flex-wrap: wraplets the navigation drop below the logo when there is no room. - Grid handles the cards:
grid-template-columns: 1frby default (one column), andrepeat(3, 1fr)from40emupwards.gapspaces items without margin tricks. mainis also a grid with agap, which spaces the sections evenly, andmax-width: 60remkeeps lines readable on large screens.- Breakpoints use
em, so they respect the user’s font size setting: someone who sets a larger default font gets the mobile layout sooner, which is what they need. scroll-behavior: smoothis only enabled when the user has not asked their operating system to reduce motion.
Step 4: Make focus visible and colors readable
Why: keyboard users must always see where they are, and text must have enough contrast to be readable in sunlight, on cheap screens and with low vision. WCAG 2.2 level AA asks for a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (at least 18 point, or 14 point bold). Non-text parts needed to identify controls, such as input borders and focus indicators, need at least 3:1 against what is next to them.
The stylesheet uses :focus-visible, which shows the outline for keyboard focus without adding it to every mouse click, and the skip link slides into view only when focused. Do not remove outlines with outline: none unless you replace them with something at least as visible.
To check the palette without guessing, this small Node.js script computes the WCAG contrast ratio for each color pair used on the page (save as contrast.js):
const pairs = [
['body text', '#1f2328', '#ffffff'],
['muted text', '#57606a', '#ffffff'],
['link', '#0b5cad', '#ffffff'],
['button', '#ffffff', '#0b5cad'],
['error', '#b42318', '#ffffff'],
['card text', '#1f2328', '#f6f8fa'],
['input border', '#6e7781', '#ffffff'],
['focus ring', '#c2410c', '#ffffff'],
];
function luminance(hex) {
const [r, g, b] = hex.match(/\w\w/g).map((h) => {
const c = parseInt(h, 16) / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
for (const [name, fg, bg] of pairs) {
const [hi, lo] = [luminance(fg), luminance(bg)].sort((a, b) => b - a);
const ratio = (hi + 0.05) / (lo + 0.05);
console.log(`${name.padEnd(13)} ${ratio.toFixed(2)}:1`);
}
node contrast.js
body text 15.80:1
muted text 6.39:1
link 6.67:1
button 6.67:1
error 6.57:1
card text 14.84:1
input border 4.55:1
focus ring 5.18:1
Every text pair passes 4.5:1 and the border and focus ring pass 3:1. This check caught a real problem while writing the tutorial: a bright amber focus color (#f59e0b) looked fine but measured only 2.15:1 on white, so it was replaced with a darker orange. Browser developer tools also show the contrast ratio when you inspect a text element’s color.
Step 5: Build the form so errors are easy to find
Why: forms are where accessibility problems hurt most. The rules used in the markup from step 2:
- Every input has a visible
<label for="...">that matches itsid. Clicking the label focuses the input, and screen readers read the label when the input is focused. A placeholder is not a label. - Radio buttons that belong together sit in a
<fieldset>with a<legend>, so the question is announced with each option. aria-describedbylinks an input to its hint and to its error message, so both are read when the input gets focus.autocomplete="name"andautocomplete="email"let browsers fill in known values, which helps everyone and especially people with motor or memory difficulties.novalidateturns off the browser’s built-in bubbles so the page can show its own consistent, persistent messages. Therequiredandtype="email"attributes are kept so the semantics stay correct.
Now form.js checks the fields when the form is submitted, marks invalid fields with aria-invalid="true", fills the error messages, builds an error summary with links to each problem and moves focus to the summary:
const form = document.querySelector('#signup-form');
const summary = document.querySelector('#error-summary');
const statusBox = document.querySelector('#form-status');
const rules = {
name: (v) => (v.trim() ? '' : 'Enter your full name'),
email: (v) => {
if (!v.trim()) return 'Enter your email address';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return 'Enter an email address like [email protected]';
return '';
},
};
function showError(input, message) {
const error = document.querySelector(`#${input.id}-error`);
error.textContent = message;
error.hidden = !message;
if (message) input.setAttribute('aria-invalid', 'true');
else input.removeAttribute('aria-invalid');
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const problems = [];
for (const [name, check] of Object.entries(rules)) {
const input = form.elements[name];
const message = check(input.value);
showError(input, message);
if (message) problems.push({ id: input.id, message });
}
const list = summary.querySelector('ul');
list.replaceChildren(...problems.map(({ id, message }) => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = message;
li.append(link);
return li;
}));
if (problems.length) {
summary.hidden = false;
statusBox.textContent = '';
summary.focus();
return;
}
summary.hidden = true;
statusBox.textContent = `Thanks, ${form.elements.name.value.trim()}! Your seat is reserved.`;
form.reset();
});
Moving focus to the summary (it has tabindex="-1" so it can receive focus from script) means screen reader users hear the problems immediately, and each link takes them straight to the field. The success message goes into an element with role="status", which screen readers announce without moving focus. Remember to validate again on the server; client-side checks are only a convenience.
Step 6: Validate the HTML
Why: a validator catches typos, invalid nesting, missing alt text and inputs without labels before a person has to. You can paste the page into the W3C Nu HTML Checker online, or run html-validate locally. To run it locally, install it into the project folder (Node.js required) and create a configuration that combines the recommended and accessibility presets:
npm init -y
npm install --save-dev html-validate
{
"extends": ["html-validate:recommended", "html-validate:a11y"],
"rules": {
"input-missing-label": "error"
}
}
Save that as .htmlvalidate.json and run the checker. No output and exit status 0 means no problems:
npx html-validate index.html; echo "exit=$?"
To see what an error looks like, try a broken copy with an unlabeled input and an image without alt:
/path/to/workshop/b2.html
2:59 error <input> element does not have a <label> input-missing-label
2:85 error <img> is missing required "alt" attribute wcag/h37
The rule input-missing-label is switched on explicitly above because, in the version tested, the two presets alone did not report a missing label. html-validate also prefers an uppercase <!DOCTYPE html> by default (rule doctype-style); both spellings are valid HTML.
Step 7: Test with the keyboard and a screen reader
Why: validators cannot tell whether the page makes sense. Serve the folder and test like a user:
python3 -m http.server 8000
- Open
http://localhost:8000, click the address bar, then press Tab. The first stop must be “Skip to main content”, visible with an orange outline. Press Enter and the next Tab lands in the main content. - Keep pressing Tab: navigation links, “Reserve a seat”, the form fields and the button, in reading order, each with a visible outline. Use the arrow keys to move between the radio buttons. On macOS Safari, enable “Press Tab to highlight each item on a webpage” in Safari’s advanced settings first.
- On the button, press Enter with the fields empty. Focus jumps to the error summary listing “Enter your full name” and “Enter your email address”; activating a link moves focus to that field, and its red border and message appear.
- Turn on a screen reader (VoiceOver on macOS with Cmd+F5, Narrator on Windows with Win+Ctrl+Enter, or the free NVDA). Open the landmarks or headings list and check that header, navigation, main and footer, and all headings, are there with sensible names. Focus the email field: you should hear the label, the hint and, after a failed submit, the error.
- In the browser’s developer tools, open the accessibility tree (Chrome and Edge: Elements panel, Accessibility pane; Firefox: Accessibility panel) and confirm that the textboxes are named “Full name” and “Email”.
Step 8: Test the responsive layout
Why: “responsive” means it works at every width, not just at the breakpoints you designed for.
- Open the device toolbar in the developer tools (Chrome and Edge: Ctrl+Shift+M, or Cmd+Shift+M on macOS; Firefox calls it Responsive Design Mode) and pick a 375-pixel-wide phone. The cards are in one column and nothing scrolls sideways.
- Drag the width slowly up to 1400 pixels. Around 640 pixels (40em at the default font size) the cards switch to three columns.
- Zoom the page to 200% with Ctrl/Cmd++. Text must still be readable without horizontal scrolling; because breakpoints use
em, the single-column layout returns as you zoom.
A quick check for sideways scrolling: run document.documentElement.scrollWidth <= innerWidth in the developer console at phone width. It should print true.
Check that it works
npx html-validate index.htmlprints nothing and exits with status 0.node contrast.jsshows at least 4.5:1 for every text pair and at least 3:1 for the border and focus ring.- With only the keyboard you can reach every link and control, see where focus is, submit the form, follow the error summary links and get the “Your seat is reserved” message after filling in valid values.
- At 375 pixels wide there is one column and no horizontal scrolling; on a wide screen the cards form three columns.
If something goes wrong
- The page looks zoomed out on a phone. The
viewportmeta tag is missing or misspelled. - Clicking a label does not focus its input. The label’s
forvalue and the input’siddo not match exactly (they are case-sensitive), or theidis used twice on the page. - The skip link never appears. It only shows on keyboard focus. Press Tab right after loading the page; in Safari, enable Tab navigation first.
- Error messages appear visually but the screen reader does not read them. Check that
aria-describedbylists the error element’sidand that the summary receives focus. npx html-validatereports nothing for a file that obviously has problems. It did not find your configuration. The.htmlvalidate.jsonmust be in the file’s folder or a parent folder.
Clean up
Stop the local server with Ctrl+C. If you installed html-validate, it lives only in the project’s node_modules folder. Warning: the second command deletes the whole project; check with pwd that you are in the folder that contains workshop.
rm -r workshop/node_modules workshop/package.json workshop/package-lock.json
rm -r workshop
Next steps
- Add a dark color scheme with
@media (prefers-color-scheme: dark)and re-run the contrast check for the new colors. - Add images with meaningful
alttext, and emptyalt=""for purely decorative ones. - Send the form to a real backend and show server-side errors with the same summary pattern.
- Run an automated audit such as Lighthouse or axe in the browser and fix what it reports, remembering that tools find only part of the problems.
- Read the WCAG 2.2 quick reference and check the page against the success criteria for forms and keyboard use.
Official documentation
See also
- 0090 Flexbox and CSS Grid: laying out a page Flexbox arranges items along one line, Grid arranges them in rows and columns at once. Learn the handful of properties that cover… Beginner · How-to · Browser Article Layout 15 Oct 2026
- 0092 Semantic HTML and accessibility basics, with a responsive page skeleton A minimal responsive HTML page built from semantic elements, plus the accessibility basics every page needs: headings, labels, alt text, keyboard focus,… Beginner · How-to · Browser Article Accessibility · Layout 15 Oct 2026
Repos in the wild
- Tailwind CSS (tailwindlabs/tailwindcss) tailwindlabs/tailwindcss A utility-first CSS framework: you style elements with small single-purpose classes in your HTML, and the build step generates only the CSS you actually use. GitHub
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- commitpedia-admin +25,421 chars