Styling, Accessibility and Build
Before you start
You need: components and state (Articles 01–08), plus CSS (Track 09).
Time: about 45 minutes, plus the practice.
Learning objective
Style components without global collisions, make an application usable by keyboard and screen reader, and ship a correct production build.
Topics
- Styling options
- CSS Modules
- Conditional classes
- Accessible components
- Focus management
- Live regions
- Error boundaries
- The production build
- Deployment configuration
Styling options
| Approach | Scoped | Build cost | Notes |
|---|---|---|---|
| Global CSS | No | None | Collisions at scale |
| CSS Modules | Yes | Built into Vite | The default choice |
| Tailwind | N/A | Small | Utility classes in markup |
| CSS-in-JS | Yes | Runtime cost | Falling out of favour |
Inline style | Yes | None | Cannot be themed or overridden |
React prescribes nothing. CSS Modules is the default worth adopting: real CSS, scoped automatically, no runtime.
CSS Modules
/* StudentCard.module.css */
.card {
padding: var(--space-4);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
}
.title { font-size: 1.125rem; font-weight: 600; }
.muted { color: var(--colour-muted); }
.selected { border-color: var(--colour-primary); }
import styles from './StudentCard.module.css';
export function StudentCard({ student, isSelected }: StudentCardProps) {
return (
<article className={`${styles.card} ${isSelected ? styles.selected : ''}`}>
<h3 className={styles.title}>{student.name}</h3>
<p className={styles.muted}>{student.rollNumber}</p>
</article>
);
}
The build rewrites .card to something like _card_1a2b3, so a .card in another module cannot collide. Any file named *.module.css is treated this way by Vite.
Tokens in a global stylesheet:
/* index.css */
:root {
--colour-text: #1f2937;
--colour-muted: #6b7280; /* 4.6:1 on white — passes AA */
--colour-primary: #2563eb;
--colour-danger: #b91c1c;
--colour-border: #d1d5db;
--space-2: 0.5rem;
--space-4: 1rem;
--radius: 0.5rem;
}
@media (prefers-color-scheme: dark) {
:root {
--colour-text: #f3f4f6;
--colour-muted: #9ca3af;
--colour-border: #374151;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
*, *::before, *::after { box-sizing: border-box; }
:focus-visible {
outline: 3px solid var(--colour-primary);
outline-offset: 2px;
}
Every colour as a token means dark mode is one block, and no component contains a raw hex value.
prefers-reduced-motion respects an OS setting for people who experience nausea from motion. Three lines, and one of the few justified uses of !important.
Conditional classes
// Manual — fine for one or two
className={`${styles.card} ${isSelected ? styles.selected : ''}`}
npm install clsx
import clsx from 'clsx';
<article className={clsx(styles.card, {
[styles.selected]: isSelected,
[styles.inactive]: student.status === 'Inactive'
})}>
{student.name}
</article>
clsx skips falsy values, so no stray undefined appears in the class attribute.
Never set colours with inline style. An inline style beats every stylesheet rule, so it cannot be themed, cannot be overridden, and hides contrast problems from anyone auditing the CSS:
// Wrong
<span style={{ color: '#ccc' }}>{student.status}</span>
// Right
<span className={styles.muted}>{student.status}</span>
Inline style is for genuinely dynamic values — a computed width, a position — not for anything a class could express.
Accessible components
// Wrong — not focusable, no keyboard support, announced as nothing
<div onClick={handleSelect}>Select</div>
// Right
<button type="button" onClick={handleSelect}>Select</button>
Native elements are accessible by default; every re-implementation is worse. A <div> with a click handler needs role, tabIndex and a keydown handler to match what <button> gives free.
type="button" matters: a <button> inside a form defaults to type="submit".
export function StudentTable({ students }: { students: Student[] }) {
return (
<div className={styles.scroll}>
<table>
<caption>Students in class 10, section A</caption>
<thead>
<tr>
<th scope="col">Roll number</th>
<th scope="col">Name</th>
<th scope="col">Class</th>
<th scope="col"><span className="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody>
{students.map(student => (
<tr key={student.publicId}>
<th scope="row">{student.rollNumber}</th>
<td>{student.name}</td>
<td>{student.className} - {student.section}</td>
<td>
<Link to={`/students/${student.publicId}`}>
Edit<span className="visually-hidden"> {student.name}</span>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
scope is what makes a table navigable. Without it a screen reader reads a wall of values with no indication of which column they belong to.
The hidden text in the Edit link matters: screen-reader users often pull up a list of every link on the page. Twenty links all saying "Edit" are useless; "Edit Ravi Kumar" is not.
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
display: none would hide it from screen readers too, which defeats the purpose.
A dialog
export function ConfirmDialog({ title, message, onConfirm, onCancel }: ConfirmDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
return () => dialogRef.current?.close();
}, []);
return (
<dialog
ref={dialogRef}
aria-labelledby="dialog-title"
onCancel={onCancel}
onClick={event => {
if (event.target === dialogRef.current) onCancel();
}}
>
<h2 id="dialog-title">{title}</h2>
<p>{message}</p>
<div className={styles.actions}>
<button type="button" onClick={onCancel}>Cancel</button>
<button type="button" onClick={onConfirm} autoFocus>Confirm</button>
</div>
</dialog>
);
}
Use the native <dialog> element. showModal() gives focus trapping, Escape to close, an inert background and correct screen-reader semantics — all of which a hand-built <div> modal has to reimplement, and usually gets wrong.
The onCancel prop fires on Escape. The click handler closes on a backdrop click, since a click on the dialog element itself means the backdrop.
Focus management
A single-page application changes content without a page load, so focus must be managed deliberately.
// After a validation failure, take the user to the problem
function focusFirstInvalid() {
document.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
}
// Focus the heading after navigation
export function PageHeading({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
return <h1 ref={ref} tabIndex={-1}>{children}</h1>;
}
tabIndex={-1} makes an element programmatically focusable without adding it to the tab order.
// Return focus after closing a dialog
const triggerRef = useRef<HTMLButtonElement>(null);
function handleClose() {
setDialogOpen(false);
triggerRef.current?.focus();
}
Returning focus to the trigger is what people forget. Closing a dialog without it drops focus to the document start, and a keyboard user has to tab through the whole page again.
/* Never do this */
*:focus { outline: none; }
Removing the focus ring makes the application unusable by keyboard — the user has no idea where they are. :focus-visible shows it for keyboard focus and not for mouse clicks, which is the behaviour designers actually want.
Add a skip link:
<a href="#main" className={styles.skipLink}>Skip to main content</a>
Without it, a keyboard user tabs through the whole navigation on every page before reaching the content.
Live regions
Content that changes without a page load is silent to a screen reader unless announced.
export function StudentListPage() {
const { state } = useStudents(term, page);
const announcement =
state.status === 'loading' ? 'Loading students' :
state.status === 'error' ? state.message :
state.status === 'empty' ? 'No students found' :
`${state.result.totalCount} students found`;
return (
<>
<p role="status" aria-live="polite" className="visually-hidden">
{announcement}
</p>
{/* the visible UI */}
</>
);
}
aria-live="polite" announces at the next pause. assertive interrupts and is for genuine errors only.
This is the accessibility failure most SPAs ship with. A list silently changing under a screen-reader user, with a spinner they cannot see, is a broken experience — and it is a handful of lines to fix.
Error boundaries
An uncaught render error unmounts the whole application, leaving a blank white page.
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props { children: ReactNode; fallback?: ReactNode; }
interface State { hasError: boolean; }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('Render error', error, info.componentStack);
// report to your error-tracking service here
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div role="alert">
<h2>Something went wrong</h2>
<button type="button" onClick={() => window.location.reload()}>
Reload the page
</button>
</div>
);
}
return this.props.children;
}
}
<ErrorBoundary>
<RouterProvider router={router} />
</ErrorBoundary>
Error boundaries must be class components — there is no hook equivalent. This is the one place a class is still required.
They catch errors during rendering, in lifecycle methods and in constructors. They do not catch errors in event handlers, in async code, or during server rendering. A try/catch in the handler is still needed for those.
With React Router, errorElement on a route does the same for that subtree and is usually more useful — a failing page keeps the layout and navigation.
Place boundaries around independent regions, so one failing widget does not take down the page.
The production build
npm run build # writes dist/
npm run preview # serves dist/ locally
// vite.config.ts
export default defineConfig({
plugins: [react()],
build: {
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom']
}
}
}
}
});
npm run preview before every deployment. It serves the built output rather than the dev server, and it is where SPA-fallback and environment problems appear — the ones that are invisible in npm run dev.
Bundle size
npm install --save-dev rollup-plugin-visualizer
Route-level code splitting is the main lever:
const StudentListPage = lazy(() => import('./features/students/StudentListPage'));
Check the Network tab: navigating to a lazy route should download a new chunk. If it does not, the split is not happening.
sourcemap: true lets an error-tracking service show your original source in a stack trace. Upload the maps to that service rather than serving them publicly, or your source is readable by anyone.
Environment variables
# .env.production
VITE_API_URL=https://api.nexcoding.in
Only VITE_-prefixed variables are exposed, and exposed means shipped to the browser. Run npm run build and search dist/ for the value — it is there in plain text.
URLs and feature flags belong here. Never an API key or a secret.
Deployment
/students/a1 exists only in the router. The server has no such file, so a direct request or a refresh returns 404.
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
<!-- IIS -->
<rewrite>
<rules>
<rule name="React Router">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
</conditions>
<action type="Rewrite" url="/index.html" />
</rule>
</rules>
</rewrite>
Vite fingerprints asset filenames, so /assets/ can be cached for a year. index.html must not be cached, or users keep loading an old build that references deleted chunks.
Everything works in npm run dev because Vite handles the fallback automatically — which is exactly why this is discovered after deployment.
Testing accessibility
Keyboard walkthrough. Put the mouse away and complete a full flow. Every control reachable, in a sensible order, with visible focus, no trap. This finds more real problems than any automated tool.
Screen reader. NVDA on Windows (free) or VoiceOver on Mac (built in). Confirm labels are announced, table headers read with cells, state changes announced, and errors read.
Lighthouse. DevTools → Lighthouse → Accessibility. Catches missing alt, contrast failures, missing labels, duplicate ids.
eslint-plugin-jsx-a11y catches many issues while you type:
npm install --save-dev eslint-plugin-jsx-a11y
Automated tools catch roughly a third of accessibility issues. The keyboard test catches most of the rest.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| Styles leak between components | Global CSS | CSS Modules or scoped styles |
| A class name collides | Same name in two files | Modules generate unique names |
| Screen reader announces nothing on update | No live region | aria-live="polite" |
A clickable <div> is unreachable | Not focusable | Use <button> |
| Focus lost after a modal closes | Focus not restored | Return focus to the trigger |
| Colour-only error indication | No text alternative | Add a message |
If you are adding role="button" and tabIndex to a <div>, use a <button>. It gives you keyboard, focus and semantics for nothing.
Common mistakes
- Global CSS with no scoping
- Inline
stylefor colours - A
<div>withonClickinstead of a<button> - Missing
type="button"inside a form outline: noneon focus- No skip link
- Focus not returned after closing a dialog
- A hand-built modal instead of
<dialog> - No
scopeon table headers - Twenty identical "Edit" links
- No live region, so SPA updates are silent
- No error boundary, giving a blank white page
- Expecting an error boundary to catch handler or async errors
- No
npm run previewbefore deploying - A secret in a
VITE_variable - No SPA fallback on the server
- Caching
index.html
Practice
- Convert a component to CSS Modules. Add a
.cardclass in two modules and confirm no collision. - Define colour and spacing tokens, then add a
prefers-color-scheme: darkblock. Confirm the whole app themes. - Set a status colour with inline
style, then try to override it from CSS. Switch to a class. - Replace a
<div onClick>with a<button>. Tab to it and press Enter. - Remove
type="button"from a button inside a form and click it. - Add
outline: noneglobally, tab through the app, then replace it with:focus-visible. - Add a skip link and confirm it appears on first Tab.
- Build the student table with
captionandscope. Test with a screen reader, then removescopeand compare. - Add hidden text to Edit links. Pull up the screen reader's link list before and after.
- Build a confirm dialog with
<dialog>andshowModal(). Test Escape, backdrop click and focus trapping. - Close it without returning focus. Tab and observe where you are.
- Add a live region announcing loading, empty, error and result count. Test with a screen reader.
- Add an error boundary. Throw during render and confirm the fallback. Throw in an event handler and confirm it is not caught.
- Run Lighthouse Accessibility and fix every finding. Then do the keyboard walkthrough and record what Lighthouse missed.
- Run
npm run build && npm run preview, then refresh on a deep link. Confirm the 404 and configure the fallback. - Search
dist/for yourVITE_API_URLvalue.
Exercises 12 and 15 are the two most often shipped broken.
You can now
- Style components without collisions
- Build an application usable by keyboard alone
- Announce state changes to a screen reader
- Use real buttons rather than clickable divs
- Never rely on colour alone
Review questions
- What does a CSS Module do that a global stylesheet does not?
- Why is a
<button>better than a<div>withonClick? - What do error boundaries not catch?
- Why does a deep link 404 in production but not in dev?
Next: Guided React project