1. Tailwind CSS
Tailwind is a utility-first CSS framework: instead of writing custom class names and separate CSS files, you compose a component's styling directly in its markup from a large set of small, single-purpose classes.
npm install -D tailwindcss @tailwindcss/postcss postcss
function ProductCard({ product }: { product: Product }) {
return (
<div className="rounded-lg border border-gray-200 p-4 shadow-sm hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold text-gray-900">{product.name}</h3>
<p className="mt-1 text-sm text-gray-500">${product.price}</p>
<button className="mt-3 w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
Add to cart
</button>
</div>
);
}
The appeal: no context-switching between a component file and a separate CSS file, no naming struggles ("what do I call this wrapper div?"), and styles that can never leak — a utility class does exactly one thing, everywhere it's used. The tradeoff: markup gets visually dense, and consistency across a codebase depends on discipline (or a shared config) rather than being structurally enforced.
2. Component-Scoped Alternatives
Tailwind isn't the only reasonable choice — two other approaches solve the same underlying problem (styles that don't leak or collide) differently, and you'll encounter both in real codebases.
.card {
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
padding: 1rem;
}
.title {
font-size: 1.125rem;
font-weight: 600;
}
import styles from './ProductCard.module.css';
function ProductCard({ product }: { product: Product }) {
return (
<div className={styles.card}>
<h3 className={styles.title}>{product.name}</h3>
</div>
);
}
CSS Modules give you real, separate CSS files (familiar if you know plain CSS) while the build tool automatically generates unique class names per component, preventing the global-namespace collisions that plain CSS files are prone to. Styled-components and similar CSS-in-JS libraries take a third approach: writing actual CSS inside a JavaScript template literal, scoped to a generated component. All three are valid; this course uses Tailwind for its examples going forward because it needs no separate files to follow along with, but the underlying component and accessibility principles in this lesson apply identically regardless of which you pick.
3. Semantic HTML First
The single highest-leverage accessibility decision is also the cheapest: use the
HTML element that actually matches what something is, before reaching for
ARIA to patch a generic <div> into behaving like it.
<div onClick={handleClick} className="button-looking-thing">
Submit
</div>
{/* Gets you: no keyboard focus, no Enter/Space activation, no screen reader
announcement as a button -- all of which you'd now need to hand-build. */}
<button onClick={handleClick} className="button-looking-thing">
Submit
</button>
{/* Keyboard-focusable, Enter/Space activates it, announced correctly by
every screen reader -- all built into the browser, for free. */}
This applies broadly: <nav> for navigation, <main>
for a page's primary content, <label> for form labels (Week 5),
a real <button> for anything clickable that performs an action, a
real <a href> for anything that navigates. Every one of these comes
with keyboard behavior and screen-reader semantics built in — ARIA (Section 4) exists
to fill genuine gaps for custom widgets, not to replace what semantic HTML already
provides for free.
4. ARIA & Keyboard Navigation for Custom Widgets
Some UI genuinely has no native HTML equivalent — Week 11's Tabs compound
component is exactly this case. For those, ARIA roles/states plus real keyboard
handling are required, not optional polish.
function Tab({ id, children }: { id: string; children: ReactNode }) {
const { activeTab, setActiveTab } = useTabsContext();
return (
<button
role="tab"
aria-selected={activeTab === id}
tabIndex={activeTab === id ? 0 : -1} // only the active tab is in the normal tab order
onClick={() => setActiveTab(id)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') focusNextTab(); // arrow keys move focus between tabs
if (e.key === 'ArrowLeft') focusPreviousTab();
}}
>
{children}
</button>
);
}
This follows the WAI-ARIA Authoring Practices' documented pattern for tabs: arrow
keys move focus between tab buttons, only the active tab sits in the normal Tab-key
order (tabIndex={'{0}'} vs. {'{-1}'}), and
aria-selected announces which tab is active to a screen reader. Building
a correct custom widget means implementing the same interaction contract a user would
already expect from a native equivalent, where one exists — ARIA Authoring Practices
is the reference for exactly what that contract should be for a given pattern.
Adding an ARIA role to an element doesn't grant it any of that role's expected behavior automatically — role="tab" on a <div> announces it as a tab but does nothing about keyboard focus or activation on its own; you still have to implement that. This is why Section 3's "use the right native element first" holds: a real <button> gets its behavior for free, an ARIA role never does.
5. Auditing with axe & Lighthouse
Automated tools can't catch everything (they can't judge whether alt text is genuinely descriptive, or whether a keyboard flow feels sensible), but they reliably catch a large class of concrete, fixable issues: missing labels, insufficient color contrast, missing alt text, invalid ARIA usage.
npm install -D @axe-core/react
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { expect } from 'vitest';
import ProductCard from './ProductCard';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<ProductCard product={mockProduct} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Wiring this into your Week 13 test suite means an accessibility regression — a missing label, a contrast issue introduced by a design change — fails CI the same way a broken feature test would, instead of only being caught by someone manually running a browser extension. Lighthouse (built into Chrome DevTools) covers similar ground at the whole-page level, plus performance and SEO, and is worth a manual pass on every major page even without wiring it into automated tests.
6. Hands-on Exercise
Style and audit Week 11's Accordion for real accessibility
Apply Tailwind and this week's accessibility checklist to a component you've already built.
Requirements:
- Restyle Week 11's
Accordioncompound component using Tailwind utility classes, including a visible focus ring on the header buttons (focus-visible:ring-2or similar) — never remove the default focus outline without replacing it with something equally visible. - Add correct keyboard support to the accordion headers: Enter/Space toggles the panel (a real
<button>gets this for free per Section 3), and confirm you can operate the entire component using only the Tab and Enter keys, mouse unplugged. - Confirm each header has
aria-expandedreflecting its panel's open state, and that each panel is properly associated with its header viaaria-controls/id. - Install
jest-axe, write an accessibility test for the Accordion following Section 5, and fix any violations it reports. - Run a Lighthouse accessibility audit on the page in Chrome DevTools and address anything it flags.
Try the whole exercise once with your mouse physically unplugged (or DevTools' focus emulation) — many keyboard gaps are invisible until you actually try to navigate without a mouse yourself, rather than just reading the ARIA attributes and assuming they're sufficient.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does using a real <button> instead of a <div onClick> save you work, not just markup purity?
Why does using a real <button> instead of a <div onClick> save you work, not just markup purity?
A native <button> comes with keyboard focusability, Enter/Space activation, and correct screen-reader announcement built into the browser for free. A <div onClick> has none of that — achieving equivalent behavior requires manually adding tabIndex, a role, and keyboard event handlers, reimplementing what semantic HTML already provides.
Q2
Does adding role="tab" to an element automatically give it tab-like keyboard behavior?
Does adding role="tab" to an element automatically give it tab-like keyboard behavior?
No. An ARIA role changes how the element is announced to assistive technology, but grants none of the associated interaction behavior automatically — arrow-key navigation, focus management, and activation all still need to be implemented by hand, which is exactly why Section 4's Tab component has explicit onKeyDown logic alongside its role.
Q3
What kind of accessibility issue can an automated tool like axe or Lighthouse reliably catch, and what can't it judge?
What kind of accessibility issue can an automated tool like axe or Lighthouse reliably catch, and what can't it judge?
It reliably catches concrete, structural issues: missing labels, insufficient color contrast, missing alt text, invalid ARIA attribute usage. It can't judge subjective or contextual quality — whether alt text is genuinely descriptive of an image's content, or whether a keyboard flow through a page feels sensible to actually use — those still require a human accessibility review.
Q4
In the keyboard-accessible Tab component, why is tabIndex set to 0 for the active tab and -1 for the others?
In the keyboard-accessible Tab component, why is tabIndex set to 0 for the active tab and -1 for the others?
This follows the documented ARIA tabs pattern: only the active tab should sit in the page's normal Tab-key order, so pressing Tab moves focus past the whole tab group in one step, while the arrow keys handle moving focus between the individual tabs once you're inside the group. Giving every tab tabIndex={'{0}'} would force a keyboard user to Tab through every single tab individually just to get past the group.