Throughout this module we've been leaving IOUs: that putting onClick on an <article> works with a mouse but not with a keyboard, that color can't be the only carrier of information, that a red error message below a field isn't enough for a screen reader to connect it to that field. The time has come to settle all those debts at once. Accessibility isn't a layer you bolt on at the end, nor a list of exotic attributes: it's, 90% of the time, writing correct HTML and not breaking what the browser already gives you for free. In this lesson you'll revisit everything built in this module — the event handlers, the lists, the booking form — and leave it usable with a keyboard and with a screen reader, learning along the way which ARIA attributes exist, when to use them and, more importantly, when not to.
Contents
- Why accessibility matters
- The four ideas behind WCAG
- Semantic HTML first
- The case of the clickable card
- Labels and error messages in forms
- Keyboard navigation and focus management
- Live regions: announcing what changes
- Images, icons, and the color problem
- Common ARIA attributes
- How to check that it works
- Why accessibility matters
There are three reasons, and all three are good ones.
People. Around 15% of the world's population lives with some form of disability. We're not just talking about total blindness: there's low vision, color blindness, motor impairments that prevent precise mouse use, cognitive disabilities, deafness. And there are temporary disabilities (an arm in a cast) and situational ones (using your phone in bright sunlight, one-handed, in a hurry). In CicloUrbano, someone might be browsing the catalogue with one hand while holding onto the bike with the other.
Legal obligation. In the European Union, Directive 2016/2102 requires public-sector websites and apps to be accessible, and the European Accessibility Act extends similar requirements to numerous private services, including e-commerce. In Spain, Royal Decree 1112/2018 develops those obligations. In the United States, the ADA is now consistently applied to websites. Translation: for many projects, it isn't optional.
Overall quality. An accessible interface is better for everyone. Captions help on a noisy train; good contrast helps in the sun; keyboard shortcuts are appreciated by whoever enters a hundred bookings a day; and semantic markup improves SEO and makes automated testing easier, as you'll see in Module 9.
And there's a decisive practical argument: fixing accessibility at the end costs five times more than doing it right from the start. Swapping a <div onClick> for a <button> while you're writing the component costs nothing; doing it six months later means redoing styles, reviewing the layout, and retesting everything.
- The four ideas behind WCAG
The WCAG (Web Content Accessibility Guidelines) are the international standard. Behind their dozens of criteria there are only four principles, which form the acronym POUR:
| Principle | In one sentence | Example in CicloUrbano |
|---|---|---|
| Perceivable | Information must be perceivable through more than one sense or channel | A bike's status is stated in text, not only with color |
| Operable | Everything that can be done with a mouse must be doable with a keyboard | You can browse the catalogue and book a bike without touching the mouse |
| Understandable | The interface must be predictable and the messages, clear | "The minimum booking is 1 hour," not "Invalid value" |
| Robust | The markup must be valid and interpretable by any assistive technology | Correct semantic HTML instead of a <div> with simulated behavior |
The WCAG also define three conformance levels: A (minimum), AA (the one almost every regulation requires), and AAA (very demanding, rarely targeted). The reasonable goal for any project is level AA.
- Semantic HTML first
The first rule of accessibility — and the one that prevents the most problems — is this: use the HTML element that matches what you're doing.
A <button> isn't a <div> styled to look like one. A <button> comes with, built in:
What a <button> gives you |
What you'd have to reimplement on a <div> |
|---|---|
Is focusable with Tab |
tabIndex={0} |
Activates on Enter and Space |
An onKeyDown handler that checks both keys |
| Is announced as "button" to a screen reader | role="button" |
| Has a visible focus ring by default | Custom :focus-visible styles |
Supports disabled, with its full semantics |
aria-disabled and manually gating the handler |
Submits forms with type="submit" |
Nothing equivalent |
Five things you'd have to rebuild, and forgetting just one leaves someone out.
| Correct element | When to use it | Common mistake |
|---|---|---|
<button> |
Performs an action on the page | <div onClick> |
<a href> |
Navigates to another address | <button> with programmatic navigation |
<nav> |
Navigation block | <div class="menu"> |
<main> |
Main content, one per page | <div id="content"> |
<ul> / <ol> / <li> |
Lists of items | Repeated <div>s |
<table> with <th> |
Tabular data | A grid of <div>s |
<form> |
A set of fields that gets submitted | A <div> with a button |
<fieldset> + <legend> |
A group of related fields (radios) | Loose fields |
<h1>–<h6> in order |
Document structure | Headings chosen by size |
There's a nuance about headings that gets overlooked: their level communicates hierarchy, not size. Someone navigating with a screen reader jumps from heading to heading to build a mental map of the page; if you choose an <h4> because "it looks smaller," that map breaks. Size is a CSS decision.
CicloUrbano's structure, with the semantics in place:
// src/App.jsx (structure)
function App() {
return (
<>
<Header /> {/* <header> with a <nav> inside */}
<main> {/* the main content, only one per page */}
<FleetSummary fleet={bikes} /> {/* <section> with an <h2> */}
<TypeSelector /> {/* real buttons */}
<BikeList bikes={bikes} /> {/* <ul> of cards */}
<BookingForm bikes={bikes} /> {/* <form> */}
</main>
<Footer /> {/* <footer> */}
</>
);
}Those elements create regions that assistive technologies let you jump to directly: "go to main content," "go to navigation." With <div>, that possibility doesn't exist.
- The case of the clickable card
In lesson 03-01 we put onClick on BikeCard's <article> and noted it as a debt. Let's settle it.
The problem, specifically: with an <article onClick>, someone navigating by keyboard never reaches the card — it isn't focusable — and someone using a screen reader hears "article," with no hint that it can be clicked.
Solution 1: a button inside, and the card itself isn't clickable (the best one)
<article className={styles.card}>
<h3 className={styles.title}>
<button type="button" className={styles.titleLink} onClick={handleCardClick}>
{bike.model}
</button>{' '}
<StatusBadge status={bike.status} />
</h3>
…
</article>Only the title is clickable, and it's a genuine <button>. With styles, you can make it look like a link or a plain heading. This is the recommended option: zero ARIA attributes, zero keyboard handlers, everything just works.
Solution 2: the whole card clickable, done properly
If the design requires the whole card to be a clickable area, you have to hand-rebuild what a button gives you for free:
function BikeCard({ bike, onSelect, onBook, stationName }) {
function handleCardClick() {
if (onSelect) {
onSelect(bike);
}
}
function handleKeyDown(event) {
// We're reproducing what a <button> does out of the box
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); // otherwise Space would scroll the page
handleCardClick();
}
}
return (
<article
className={styles.card}
role="button" // announced as a button
tabIndex={0} // enters the tab order
onClick={handleCardClick}
onKeyDown={handleKeyDown} // Enter and Space
aria-label={`View details for ${bike.model}, ${bike.status}`}
>
…
</article>
);
}/* BikeCard.module.css — focus MUST be visible */
.card:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}Four additions to match a <button>, and there's still a problem left: if there's another button inside the card ("Book"), you end up with a control nested inside another control, something no assistive technology handles well. That's why solution 1 is almost always preferable.
| Approach | Extra code | Risks |
|---|---|---|
| Button inside the card | None | None |
Whole card with role="button" |
role, tabIndex, onKeyDown, aria-label, focus styles |
Nested controls, forgotten keys |
The rule: if something can be clicked, make it a <button> or an <a>. Rebuilding the behavior is only justified when there's no alternative.
- Labels and error messages in forms
Here we close out what was promised in the previous lesson. An accessible form needs three things: associated labels, linked help text, and announced errors.
<label htmlFor> associated with the id
{/* ✔ CORRECT: htmlFor points at the field's id */}
<label htmlFor="hours">Duration (hours)</label>
<input id="hours" name="hours" type="number" />
{/* ✔ ALSO CORRECT: the field lives inside the label */}
<label>
Duration (hours)
<input name="hours" type="number" />
</label>
{/* ✘ WRONG: a paragraph is not a label */}
<p>Duration (hours)</p>
<input name="hours" type="number" />Remember from lesson 01-04: in JSX you write htmlFor, not for, because for is a reserved JavaScript keyword.
What you gain from the correct association:
- Clicking the label's text focuses the field. The clickable area grows, which especially helps on mobile and for anyone with limited motor precision.
- The screen reader announces "Duration (hours), numeric edit box" on reaching the field. Without an associated label, it just says "edit box," and the person doesn't know what to type.
In lists generated with map, watch out for duplicate ids: they must be unique across the whole page. Generate them from the data: id={hours-${bike.id}}.
aria-describedby for help text
When a field needs extra explanation, it's linked with aria-describedby, which accepts one or several ids separated by spaces:
<label htmlFor="hours">Duration (hours)</label>
<input
id="hours"
name="hours"
type="number"
min={1}
max={24}
aria-describedby="hours-help"
/>
<p id="hours-help" className={styles.help}>
Between 1 and 24 hours. The price is calculated by the full hour.
</p>The screen reader reads the label first and then the description. The difference from aria-label matters: the label says what the field is; the description adds extra detail.
aria-invalid and the announced error
And here's the piece that lesson 03-05 left open: a red paragraph below the field is invisible to a screen reader if it isn't associated with the field and doesn't get announced when it appears.
<div className={styles.field}>
<label htmlFor="hours">Duration (hours)</label>
<input
id="hours"
name="hours"
type="number"
min={1}
max={24}
value={formData.hours}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={showError('hours')}
aria-describedby={
showError('hours') ? 'hours-help hours-error' : 'hours-help'
}
className={cx(styles.control, showError('hours') && styles.invalid)}
/>
<p id="hours-help" className={styles.help}>
Between 1 and 24 hours.
</p>
{showError('hours') && (
<p id="hours-error" className={styles.error} role="alert">
<span aria-hidden="true">⚠ </span>
{errors.hours}
</p>
)}
</div>The four mechanisms, one by one:
| Mechanism | What it does |
|---|---|
aria-invalid={true} |
The screen reader announces the field as "invalid" when it's reached |
aria-describedby="… hours-error" |
Associates the message with the field: it's read when the field is focused |
role="alert" |
Turns the paragraph into an alert: it's announced the moment it appears, without waiting for focus |
aria-hidden="true" on the icon |
The "⚠" symbol isn't read aloud; its information is already in the text |
Notice that aria-describedby changes depending on whether there's an error, chaining the two ids together. That way the field keeps its help text and adds the error when there is one.
And here's a detail that now clicks into place: in lesson 03-05 we recommended not disabling the submit button. One of the reasons is accessibility: elements with disabled don't receive focus, so someone navigating by keyboard could reach the end of the form, not find the button, and not understand why. An active button that explains what's missing when pressed is better.
- Keyboard navigation and focus management
Every piece of functionality must be usable with the keyboard alone. These are the keys people expect:
| Key | Expected behavior |
|---|---|
Tab |
Move to the next interactive element |
Shift + Tab |
Move to the previous one |
Enter |
Activate buttons and links; submit the form from a text field |
Space |
Activate buttons; check and uncheck checkboxes |
Arrow keys |
Move within a radio group, a select, or a menu |
Escape |
Close a dialog, close a menu, or cancel |
Focus order
The tab order is the markup's order, not the visual one. If you position an element on the left with CSS but it's last in the HTML, focus will land there last, and someone navigating by keyboard will get disoriented. The fix isn't touching tabIndex: it's writing the markup in logical order and only using flexbox or grid's order for layout when it doesn't break that sense.
tabIndex: three values and one thing you must never do
| Value | Meaning | When to use it |
|---|---|---|
tabIndex={0} |
Focusable with Tab, in natural order |
A non-interactive element you've given behavior to (with its role) |
tabIndex={-1} |
Not focusable with Tab, but reachable from code with .focus() |
Programmatic focus targets: a dialog that opens, an error message you want to jump to |
tabIndex={1} or higher |
Jumps ahead of everything else | Never. It wrecks the page's order and is impossible to maintain |
Native interactive elements — <button>, <a href>, <input>, <select>, <textarea> — are already focusable: don't add tabIndex to them.
Focus must be visible
The most widespread and most damaging mistake:
Removing the focus outline leaves someone navigating by keyboard literally in the dark: they don't know where they are. If the default outline doesn't fit your design, replace it, don't remove it:
/* ✔ A visible focus indicator that matches the brand */
.button:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
border-radius: var(--radius);
}:focus-visible is the modern pseudo-class that shows the indicator when the browser judges it's needed — keyboard navigation — and skips it after a mouse click. It's the best of both worlds.
Handling keys: Enter and Escape
In lesson 03-01 you already added Escape to TypeSelector to reset the filter to "All." The general pattern:
function handleKeyDown(event) {
if (event.key === 'Escape') {
close();
return;
}
if (event.key === 'Enter') {
confirm();
}
}Always use event.key with the key's name ('Enter', 'Escape', 'ArrowRight', ' ' for space). event.keyCode has been deprecated for years: don't use it.
- Live regions: announcing what changes
When something changes on screen without the person having moved focus — "Booking created," "3 bikes found," "Saving…" — a screen reader says nothing, because it only reads what's under focus. The solution is live regions.
import { useState } from 'react';
function App() {
const [bookings, setBookings] = useState([]);
const [announcement, setAnnouncement] = useState('');
function handleCreateBooking(booking) {
setBookings((previous) => [...previous, booking]);
setAnnouncement(`Booking ${booking.id} created for ${booking.hours} hours.`);
}
return (
<main>
<BookingForm bikes={bikes} onCreateBooking={handleCreateBooking} />
{/* The region ALWAYS exists in the markup, even when it's empty */}
<p className="live-announcement" role="status" aria-live="polite">
{announcement}
</p>
</main>
);
}The detail almost everyone skips: the region must exist in the DOM from the start, even when it's empty. If the element with aria-live appears at the same time as its content, many screen readers won't announce it, because they weren't watching that node yet. Always render the container and only change its text.
| Value | Behavior | What for |
|---|---|---|
aria-live="polite" |
Waits until the person finishes what they're doing | Confirmations, result counts, "saved" |
aria-live="assertive" |
Interrupts immediately | Serious errors, a session about to expire. Use it sparingly |
role="status" |
Equivalent to aria-live="polite" |
The usual semantic shortcut |
role="alert" |
Equivalent to aria-live="assertive" |
Error messages, like the ones in section 5 |
In CicloUrbano there are three places where a live region genuinely helps:
- Confirmation of a created booking, with
role="status". - Catalogue result count when the filter changes: "3 bikes found."
- Form error messages, with
role="alert".
- Images, icons, and the color problem
Alt text
Every <img> needs an alt attribute. The key question is: if this image failed to load, what text would convey the same information?
{/* Informative image: alt describes the relevant content */}
<img src="/img/urban-bike.webp" alt="Classic Urban bike parked at Main Square" />
{/* Purely decorative image: EMPTY alt, never missing */}
<img src="/img/ornament.svg" alt="" />
{/* ✘ WRONG: without alt, the screen reader will read the file name */}
<img src="/img/urban-bike.webp" />alt="" and the absence of alt are not the same thing: the first says "this image carries no information, ignore it"; the second leaves the screen reader guessing, and it typically ends up reading the URL out loud.
And don't start the text with "Image of…": the reader already announces that it's an image.
Icons
Icons that are purely decorative should be hidden; the ones that are the information need a text alternative:
{/* Decorative icon next to text that already says it all */}
<button type="button">
<span aria-hidden="true">🔒</span> Lock bike
</button>
{/* Icon-ONLY button: needs an accessible name */}
<button type="button" aria-label="Lock bike">
<span aria-hidden="true">🔒</span>
</button>Without the aria-label, the second button is announced as just "button." A form full of buttons like that is unusable.
Never carry information through color alone
This is WCAG criterion 1.4.1, and it affects StatusBadge directly. Roughly 8% of men have some form of color blindness; for many of them, the green of "available" and the red of "maintenance" are practically the same shade.
The good news is our component has been doing this right since lesson 03-02: it shows the status text as well as the color. Let's polish it off:
// src/components/StatusBadge.jsx
import { cx } from '../utils/classNames.js';
import styles from './StatusBadge.module.css';
const STATUSES = {
disponible: { text: 'Available', symbol: '●', key: 'disponible' },
alquilada: { text: 'Rented', symbol: '◐', key: 'alquilada' },
mantenimiento: { text: 'In maintenance', symbol: '✕', key: 'mantenimiento' }
};
const UNKNOWN_STATUS = { text: 'Unknown status', symbol: '?', key: 'unknown' };
/**
* Badge for a bike's status.
* The information is carried through THREE channels: color, symbol, and text.
* Props:
* - status (string, optional, defaults to 'disponible')
*/
function StatusBadge({ status = 'disponible' }) {
const data = STATUSES[status] ?? UNKNOWN_STATUS;
return (
<span className={cx(styles.badge, styles[data.key])}>
{/* The symbol is redundant for someone who sees the text: hidden from the reader */}
<span aria-hidden="true">{data.symbol} </span>
{data.text}
</span>
);
}
export default StatusBadge;Three independent channels: color for whoever can distinguish it, symbol for whoever can't, and text for everyone, including the screen reader.
Contrast
WCAG level AA requires a minimum contrast of 4.5:1 for normal text and 3:1 for large text (starting at 18.66px bold or 24px regular). CicloUrbano's brand green, #12805c, reaches roughly 4.8:1 against white, so it passes. Any online contrast checker or the browser's accessibility panel gives you the number in a second.
- Common ARIA attributes
ARIA (Accessible Rich Internet Applications) is a set of attributes that add semantics when HTML falls short. They're a supplement, not a substitute.
| Attribute | What it does | Example |
|---|---|---|
aria-label |
Gives an accessible name when there's no visible text | <button aria-label="Close">✕</button> |
aria-labelledby |
Takes its name from another element via its id |
<section aria-labelledby="catalogue-title"> |
aria-describedby |
Associates a description or an error message | Field + help text |
aria-live |
Announces content changes in that region | Booking confirmation |
aria-invalid |
Marks a field as invalid | Field with a validation error |
aria-hidden |
Hides an element from assistive technologies | Decorative icons |
aria-expanded |
Indicates whether a dropdown is open | A button that opens a menu |
aria-current |
Flags the current item in a set | aria-current="page" on the active menu link |
aria-disabled |
Indicates "disabled" without removing focus | A button that explains why it can't be pressed |
role |
Changes the element's semantic role | role="button", role="alert" |
The golden rule: no ARIA is better than bad ARIA
It's the first of the five official rules of ARIA use, and it means exactly what it says: a misapplied ARIA attribute is worse than no attribute at all, because it lies to the assistive technology, and the assistive technology believes the lie.
{/* ✘ WRONG: unnecessary ARIA on an element that already does the job */}
<button role="button" aria-label="Book">Book</button>
{/* ✔ RIGHT: the HTML already says everything necessary */}
<button>Book</button>
{/* ✘ WORSE: the ARIA label contradicts the visible text */}
<button aria-label="Cancel">Book</button>That last case is especially damaging: whoever sees the screen reads "Book" and whoever hears it hears "Cancel." And someone using voice control will say "click Book" and nothing will happen, because as far as the system is concerned, that button is called "Cancel."
The other four rules, summarized:
- Use the native HTML element before reaching for ARIA.
- Don't override native semantics:
<h2 role="button">makes no sense. - Every ARIA control must be usable with the keyboard.
- Don't put
aria-hidden="true"on something focusable: you'd create an element invisible to the reader but still reachable withTab.
- How to check that it works
Manual Tab test: two minutes, half the problems
Put the mouse away and go through the screen using only the keyboard. Check:
- Can I reach everything? Every button, link, and field must be reachable.
- Can I see where I am? The focus indicator must be visible at all times.
- Does the order make sense? It should follow the visual reading order.
- Can I activate everything with
EnterorSpace? - Do I get trapped? If focus enters something and can't leave with
Tab, that's a serious bug.
In CicloUrbano, the correct path looks like this:
flowchart LR
A["Skip to content<br/>(visible on focus)"] --> B["Header links<br/>Catalogue · Stations · My bookings"]
B --> C["TypeSelector buttons<br/>All · Urban · Electric · Cargo"]
C --> D["'Book' button<br/>on each card, in order"]
D --> E["Form fields<br/>bike → date → hours → terms"]
E --> F["'Create booking' button"]
F --> G["Footer links"]
eslint-plugin-jsx-a11y
It catches a good share of this lesson's mistakes as you write the code:
It warns you about, among other things, images without alt, non-interactive elements with click handlers, labels without an associated field, positive tabIndex values, and invalid ARIA attributes. Projects scaffolded from React templates usually already come with it configured.
Browser auditing
The dev tools ship with two indispensable utilities:
- Lighthouse (the tab of the same name in Chrome and Edge): runs an automatic audit and scores accessibility, with a list of the problems found and how to fix them.
- Accessibility tree (inside the element inspector): shows how an assistive technology sees the page — the name, role, and state of every element. It's the most direct way to check whether your
aria-labelis actually working.
There are also specialized extensions like axe DevTools or WAVE, which give more detailed reports.
Important warning: automated tools detect roughly 30% to 40% of real problems. They can tell you an alt is missing, but not whether the text you wrote is actually useful; they can check contrast, but not whether the focus order makes sense. Testing with Tab and, whenever possible, with a real screen reader (NVDA on Windows, VoiceOver on macOS and iOS, TalkBack on Android) remains irreplaceable.
There's also the option to automate accessibility checks inside your tests, with tools like jest-axe integrated into the project's test suite. It's an excellent practice, and it's mentioned here just so you know it exists; testing itself is Module 9's territory.
Common Mistakes and Tips
<div onClick>instead of<button>. It's not focusable, doesn't respond to the keyboard, and isn't announced as a control. It's by far the most common accessibility mistake in React.outline: nonewith no replacement. Leaves whoever navigates by keyboard not knowing where they are. Use:focus-visiblewith your own outline.- A positive
tabIndex. Breaks the tab order of the entire page. Only0and-1. - Adding
tabIndexto elements that are already focusable. A<button tabIndex={0}>is redundant and confusing. - Unassociated labels. A
<p>above the field isn't a label. Use<label htmlFor>with the matchingid, and remember that in JSX it'shtmlFor, notfor. - Duplicate
ids in lists generated withmap. They break the label-field association. Generate them from the data's id. - Error messages that are only visual. Without
role="alert"they aren't announced when they appear, and withoutaria-describedbythey aren't read when the field is focused. - A
aria-liveregion that appears together with its content. It doesn't get announced. The container must always be in the DOM. - Using
aria-live="assertive"for everything. It interrupts constantly and becomes unbearable. Reserve assertive mode for what's genuinely urgent. - Images without
alt, or with "image of…". Withoutaltthe URL gets read; the prefix is redundant because the reader already announces it's an image. - Icon-only buttons with no accessible name. They're announced as just "button."
aria-labelis mandatory. - Carrying information through color alone. Always add text or a symbol.
- Headings chosen for their size. They break the page's map. The level is hierarchy; size is a CSS decision.
- Redundant or contradictory ARIA.
<button role="button">is unnecessary; anaria-labelthat differs from the visible text is a serious bug. No ARIA is better than bad ARIA. - Tip: test with
Tabevery time you finish a component. Two minutes that save whole audits later. - Tip: install
eslint-plugin-jsx-a11yon day one of the project. Fix things as you write instead of at the end. - Tip: write the markup in logical reading order and use CSS to position it, not
tabIndexto reorder it.
Exercises
Exercise 1
This component has six accessibility problems. Identify them, explain who each one affects, and write the corrected version.
function InteractiveStationCard({ station, occupied, onSelect }) {
return (
<div className="card" onClick={() => onSelect(station)}>
<img src={`/img/stations/${station.id}.webp`} />
<p className="large-title">{station.name}</p>
<span className={occupied === station.docks ? 'red-dot' : 'green-dot'} />
<div role="button" onClick={() => onSelect(station)}>
View details
</div>
<button aria-label="Close">View on map</button>
</div>
);
}Exercise 2
Take the "Bike" field from BookingForm in lesson 03-05 and make it fully accessible. It must include:
- A correctly associated label.
- Linked help text: "Only bikes available right now are shown."
aria-invalidwhen the field has a visible error.- The error message associated and announced when it appears.
- An
aria-describedbythat combines help and error when both exist.
Also write the CSS classes needed so the error state stands out without relying on color.
Exercise 3
Design the full keyboard journey through CicloUrbano's main screen and describe it step by step. Then answer:
- Where would you place an
aria-liveregion, and what would it announce in each case? - What should happen when
Escapeis pressed in each area of the screen? - If the bike list had a hundred cards, each with a button, what navigation problem would arise, and how would you solve it without breaking anything?
Solutions
Solution 1.
The six problems:
| Problem | Who it affects |
|---|---|
<div onClick> as a clickable card |
Someone navigating by keyboard can't reach it; the screen reader doesn't announce it as interactive |
<img> without alt |
The screen reader reads out the file's URL |
<p className="large-title"> instead of a heading |
Someone navigating by jumping between headings can't find the station |
| The color dot as the only occupancy signal | Someone who can't distinguish red from green gets no information |
<div role="button"> with no tabIndex or keyboard handler |
It's announced as a button but can't be reached or activated by keyboard: worse than not adding the role at all |
aria-label="Close" that contradicts the visible text "View on map" |
Whoever hears it gets something different from what's shown; voice control doesn't work |
And in the CSS, outline: none with no replacement removes the focus indicator.
// src/components/InteractiveStationCard.jsx
import { cx } from '../utils/classNames.js';
import styles from './InteractiveStationCard.module.css';
/**
* A CicloUrbano station card with actions.
* Props:
* - station (object, required) { id, name, district, docks }
* - occupied (number, optional, defaults to 0)
* - onSelect, onViewMap (functions, optional)
*/
function InteractiveStationCard({ station, occupied = 0, onSelect, onViewMap }) {
const full = occupied === station.docks;
const free = station.docks - occupied;
return (
<article className={styles.card}>
<img
src={`/img/stations/${station.id}.webp`}
alt={`${station.name} station, in the ${station.district} district`}
/>
{/* A real heading: level 3 inside the stations section */}
<h3 className={styles.title}>{station.name}</h3>
{/* Color + symbol + TEXT: three channels */}
<p className={cx(styles.occupancy, full ? styles.full : styles.free)}>
<span aria-hidden="true">{full ? '✕' : '●'} </span>
{full ? 'Station full' : `${free} docks free`}
</p>
{/* REAL buttons: focusable, activatable with Enter and Space */}
<button type="button" onClick={() => onSelect(station)}>
View details for {station.name}
</button>
<button type="button" onClick={() => onViewMap(station)}>
View on map
</button>
</article>
);
}
export default InteractiveStationCard;/* src/components/InteractiveStationCard.module.css */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: var(--space);
}
/* Focus is replaced, never removed */
.card button:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
.free { color: var(--color-brand); }
.full { color: var(--color-maintenance); font-weight: 700; }The underlying decision: the onClick on the whole card has been removed. The clickable area is now two real buttons with descriptive text, so there's no need for role, tabIndex, onKeyDown, or aria-label.
Solution 2.
<div className={styles.field}>
<label htmlFor="bicicletaId">Bike</label>
<select
id="bicicletaId"
name="bicicletaId"
required
value={formData.bicicletaId}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={showError('bicicletaId')}
aria-describedby={
showError('bicicletaId')
? 'bike-help bike-error'
: 'bike-help'
}
className={cx(styles.control, showError('bicicletaId') && styles.invalid)}
>
<option value="">— Choose a bike —</option>
{available.map((bike) => (
<option key={bike.id} value={bike.id}>
{bike.model} · €{bike.pricePerHour.toFixed(2)}/h
</option>
))}
</select>
<p id="bike-help" className={styles.help}>
Only bikes available right now are shown.
</p>
{showError('bicicletaId') && (
<p id="bike-error" className={styles.error} role="alert">
<span aria-hidden="true">⚠ </span>
{errors.bicicletaId}
</p>
)}
</div>/* src/components/BookingForm.module.css */
.control {
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 0.5rem;
width: 100%;
}
.control:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
/* Error state: color + border THICKNESS + background, not only color */
.invalid {
border-color: var(--color-maintenance);
border-width: 2px;
background-color: #fdf2f2;
}
.help {
font-size: 0.85rem;
color: var(--color-text);
opacity: 0.8;
margin: 0.25rem 0 0;
}
/* The message carries a symbol and text: color is the third channel, not the only one */
.error {
font-size: 0.9rem;
font-weight: 700;
color: var(--color-maintenance);
margin: 0.25rem 0 0;
}The three signals of the error state, each independent of the others: the thicker border and background (perceivable without distinguishing color), the ⚠ symbol, and the message's text. And for the screen reader, aria-invalid announces it as an invalid field, aria-describedby links it to both help and error, and role="alert" makes the message get read the moment it appears.
Solution 3.
Proposed keyboard journey:
- "Skip to content" link, hidden until it receives focus, that jumps straight to
<main>. It's the first element on the page and avoids having to tab through the whole navigation on every visit. - Header: links "Catalogue," "Stations," "My bookings," with
aria-current="page"on the active one. TypeSelector: the four filter buttons, in order.BikeList: for each card, its "Book" button.BookingForm: bike → date → hours → terms checkbox → "Create booking" button.- Footer: its links.
1. aria-live regions: two, both present in the DOM from the first render.
| Region | Role | What it announces |
|---|---|---|
| Catalogue count | role="status" |
"3 bikes found" when the filter changes |
| Booking confirmation | role="status" |
"Booking created for 2 hours" after a successful submission |
Form errors don't need a shared region: each message already carries its own role="alert".
2. Escape behavior:
| Area | Escape action |
|---|---|
TypeSelector |
Reset the filter to "All" (already implemented in 03-01) |
| A form field | Restore the field's previous value, or do nothing. Never clear the entire form: that would be an irreversible, surprising loss of data |
| A dialog or dropdown menu | Close it and return focus to the element that opened it |
3. The hundred-cards problem: reaching the form would require pressing Tab a hundred times. It's a real barrier, and the solutions that don't work are positive tabIndex values (they wreck the whole order) or removing the buttons from the tab sequence with tabIndex={-1} (that would make them unreachable). What does work:
- Skip links before and after the list: "Skip the bike list" / "Back to the filter," visible only on focus.
- Well-marked headings and regions, so someone using a screen reader can jump by structure instead of by tabbing.
- Pagination or progressive loading, which also improves performance and benefits everyone.
- A
<section aria-labelledby>around the list, so it's announced as an identifiable region and can be skipped in one go.
Conclusion
With this lesson you close out Module 3 and settle every debt it left along the way. You know why accessibility matters — people, legal obligation, and overall quality — and you know WCAG's four principles: perceivable, operable, understandable, and robust. Above all, you've internalized the rule that resolves most problems before they exist: use the correct HTML element. A <button> comes with focus, keyboard support, role, and state built in; a <div> with onClick forces you to rebuild five things, and forgetting just one leaves someone out.
You've applied that to everything built in this module: the clickable card from lesson 03-01 now has genuine buttons; BookingForm associates every <label htmlFor> with its id, links help text with aria-describedby, flags fields with aria-invalid, and announces messages with role="alert" — closing out exactly what was left open in 03-05; StatusBadge carries status through three independent channels, color, symbol, and text, so as not to leave out anyone who can't distinguish colors; and a aria-live region announces what changes without anyone moving focus. You know how to manage tab order, when to use tabIndex={0} and tabIndex={-1} and why positive values are forbidden, and that the focus indicator gets replaced with :focus-visible but is never removed. You know the common ARIA attributes and the rule that governs them: no ARIA is better than bad ARIA. And you know how to check it: with Tab in two minutes, with eslint-plugin-jsx-a11y as you write, and with Lighthouse and the browser's accessibility tree, remembering that automated tools only catch about a third of the real problems.
Taking stock of the whole module: CicloUrbano has stopped being something you only look at and has started responding. Events connect the interface to the logic, with well-named handlers, synthetic events, and propagation under control. Conditional rendering makes every card show what matches its status and lets the catalogue know what to say when there's nothing there. Lists with map and stable keys have eliminated hand-written repetition and closed out the reconciliation debt. Controlled forms collect data with state as the single source of truth, validation filters it with genuine business rules, and accessibility makes sure all of that reaches everyone.
But there's a boundary we've brushed up against in every lesson without being able to cross it. TypeSelector stores the chosen type in its own state and notifies the parent, but it still doesn't filter the list, because BikeList can't see that state. BikeCard notifies through onSelect, and App just does a console.log, because there's nowhere to store the selected bike. State is private to each component, and that's exactly what keeps the pieces from working together. The solution has a name, and it opens Module 4: Advanced Component Concepts. The next lesson is Lifting State Up, and with it CicloUrbano's catalogue will genuinely filter.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
