Chapters

Hide chapters

React Apprentice

First Edition · web · React 8.0.0 · Visual Studio Code

Section I: Rendering Right

Section 1: 7 chapters
Show chapters Hide chapters

6. Styling & Accessible Markup
Written by Eli Ganim

In the previous chapter, the catalog became data-driven: a typed array in, a filtered, keyed list of cards out, with a featured pick on top and a graceful message when nothing matches. Functionally, it’s the real thing. Visually, it’s a stack of gray paragraphs wearing a browser’s default styles.

This chapter gives the Learning Tracker its face. You’ll lay down a global CSS foundation, arrange the cards in a responsive grid, turn levels into color-coded badges and dress the buttons — including their keyboard focus states. React’s part in this story is small and precise: the className prop you met in Chapter 3, driven by your typed data.

Just as important is what the styling sits on. Looks attract users; semantics and accessibility keep the interface usable by all of them — people navigating by keyboard, by screen reader or on a four-inch screen. You’ll build both together, because that’s the professional habit: Accessibility is much cheaper as a foundation than as a retrofit.

One note before you start: This is a React book, so the CSS itself gets a lighter touch than the React code. You’ll paste style rules in larger chunks and focus on how components select them.

Laying the Global Foundation

Every page inherits from a few global rules: fonts, colors, spacing defaults. Those live in src/index.css — the file main.tsx imports for the whole app. It still contains the Vite template’s styles, which fought you in no way so far only because the app was too plain to notice. Replace the entire contents of src/index.css with:

:root {
  color-scheme: light;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
  background: #f4f6f8;
  color: #22272e;
  line-height: 1.5;
}

Four small decisions with app-wide reach. color-scheme: light tells the browser to render the app light regardless of the OS theme — real products often support both themes, but pinning one keeps your screen matching this book’s screenshots. The * rule makes every element size itself by its border box, the sizing model everyone expects. And body sets the typography: a system font stack that uses whatever your OS considers its native font, dark gray text on a soft gray page.

Save and check the browser:

New fonts, new colors — the template's defaults are gone.
New fonts, new colors — the template's defaults are gone.

Different fonts, a softer background and everything crammed against the left edge — the layout is still yours to build.

Building the Page Scaffold

Before decorating, get the bones right. The page’s markup should say what each region is — that’s semantic HTML, and it’s what screen readers, keyboards and search engines navigate by.

Start at the top — with one preparation step: Download logo.svg from this chapter’s materials and copy it into src/assets, so the import you’re about to write has something to find. Then replace the entire contents of src/PageHeader.tsx with:

import logo from './assets/logo.svg'

function PageHeader() {
  return (
    <header className="page-header">
      <img src={logo} alt="" className="logo" />
      <div>
        <h1>Learning Tracker</h1>
        <p>Your course catalog starts here.</p>
      </div>
    </header>
  )
}

export default PageHeader

The fragment from Chapter 3 retires: This content genuinely is a page header, so it deserves the header element. There’s also an image now: Importing an asset file gives you a URL string, and Vite bundles the file — which is why you copied the logo in first.

Look closely at alt="". Alternative text is how images speak to people who can’t see them — an informative image must describe itself, like alt="Six-hour course". But this logo sits right next to the app’s name; announcing “logo” would add noise, not information. The empty string marks it decorative: Screen readers skip it entirely. What you must never do is omit the attribute — that leaves screen readers guessing, often reading the file name aloud.

A header earns its status as a landmark — a region assistive technology can list and jump to — only when it sits at the top level of the page. Tucked inside main, it’s just a grouping. So the page’s real structure should be: header first, then main beside it, not around it. While you’re in there, the card list deserves a landmark of its own. In src/App.tsx, replace the whole return with:

return (
  <>
    <PageHeader />
    <main>
      {featuredCourse !== undefined && (
        <p className="featured-banner">
          Featured: {featuredCourse.title}
        </p>
      )}
      <section className="catalog" aria-label="Course catalog">
        {visibleCourses.map((course) => (
          <CourseCard key={course.id} course={course} />
        ))}
      </section>
      {visibleCourses.length === 0 && (
        <p className="empty-note">
          No courses match this level yet.
        </p>
      )}
    </main>
  </>
)

Three structural moves in one edit. PageHeader steps outside main, so its header becomes the page’s banner landmark — the Chapter 3 fragment groups the two siblings. The map block gains a section wrapper with an aria-label: A section only becomes a navigable region when it has an accessible name, and “Course catalog” is exactly what a screen reader should announce for it. And the two conditional paragraphs pick up classes for the styling ahead. Structure done — time for layout. The template’s src/App.css is dead weight; replace its entire contents with:

main {
  max-width: 960px;
  margin: 0 auto;
  padding: 0 20px 48px;
}

.page-header {
  max-width: 960px;
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 24px 20px;
}

.page-header .logo {
  width: 48px;
  height: 48px;
}

.page-header h1 {
  margin: 0;
  font-size: 1.75rem;
}

.page-header p {
  margin: 4px 0 0;
  color: #57606a;
}

.featured-banner {
  background: #fff8e6;
  border: 1px solid #e6d9a8;
  border-radius: 8px;
  padding: 10px 14px;
  font-weight: 600;
}

.catalog {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 20px;
}

The headline act is .catalog — a CSS grid whose column definition reads “fit as many 280-pixel-minimum columns as the width allows, and stretch them evenly.” That one line is the whole responsive layout — wide screens get three columns, tablets two, phones one, no media queries required. The rest centers the content — note that main and .page-header share the same width and side padding, so the standalone header lines up perfectly with everything below it — and gives the featured banner a gentle highlight.

Save and check the browser:

A real page: branded header, featured banner and a responsive card grid.
A real page: branded header, featured banner and a responsive card grid.

Styling the Cards

The grid cells need to look like cards. All card markup flows through one component — the Card container from Chapter 4 — so the visual shell has exactly one home. In src/Card.tsx, add the class to the article:

return <article className="card">{children}</article>

One line, every card in the app upgraded — composition paying rent. The card’s inner text needs classes too. In src/CourseCard.tsx, update the description and duration paragraphs and the favorite line:

<p className="course-description">{course.description}</p>

That’s the paragraph right under the title. Next, the opening tag of the duration paragraph:

<p className="duration">

Only the opening tag changes — the ternary inside stays as it is. Finally, the favorite line gets a class and, since the attribute makes it longer, a little breathing room:

{course.isFavorite && (
  <p className="favorite-note">One of your favorites</p>
)}

Same conditional as before, now with a styling hook. Then add the matching rules at the bottom of src/App.css:

.card {
  background: #ffffff;
  border: 1px solid #d8dee6;
  border-radius: 12px;
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 10px;
}

.card h2 {
  margin: 0;
  font-size: 1.2rem;
}

.course-description {
  margin: 0;
  color: #57606a;
}

.duration {
  margin: 0;
  font-size: 0.9rem;
  color: #57606a;
}

.favorite-note {
  margin: 0;
  font-size: 0.9rem;
  font-weight: 600;
}

.empty-note {
  color: #57606a;
}

Each card becomes a white, rounded panel — and inside, a vertical flex column with a consistent gap, which is why every margin got zeroed: The card’s layout now owns the spacing, so the pieces don’t fight it. Save and check the browser:

White cards on a soft grid — the catalog starts looking intentional.
White cards on a soft grid — the catalog starts looking intentional.

Turning Levels Into Badges

Level: Beginner as plain text works; a color-coded badge communicates faster. This is where your typed data starts driving CSS. Replace the contents of src/LevelBadge.tsx with:

import type { CourseLevel } from './courses.ts'

type LevelBadgeProps = {
  level: CourseLevel
}

function LevelBadge({ level }: LevelBadgeProps) {
  const badgeClass = `level-badge level-${level.toLowerCase()}`
  return <span className={badgeClass}>{level}</span>
}

export default LevelBadge

The paragraph became a span — a badge is an inline label, not a block of text — and the class name is now computed from the data: 'Beginner' produces level-badge level-beginner.

Note: The backticks are a template literal — a string that evaluates any ${...} inside itself and splices in the result. `level-${level.toLowerCase()}` builds the same string as 'level-' + level.toLowerCase(), but stays readable as the pieces multiply. They’re the standard tool for building class names from data.

Here’s the payoff of typing level as CourseLevel instead of string: The union has exactly three members, so this component can produce exactly three class names — level-beginner, level-intermediate, level-advanced — and you can write CSS for each, certain no surprise value will arrive from the data. One honest caveat: TypeScript checks your types, not your stylesheet. If someone extends the union with 'Expert', the code compiles and quietly renders an unstyled level-expert badge — the type system’s contribution is that the set of class names is finite and findable, so a search for CourseLevel leads anyone straight to this component and its CSS. Predictable, yes; self-enforcing, no.

Add the three rules — plus the shared badge shape — to the bottom of src/App.css:

.level-badge {
  display: inline-block;
  border-radius: 999px;
  padding: 2px 12px;
  font-size: 0.85rem;
  font-weight: 600;
  border: 1px solid transparent;
}

.level-beginner {
  background: #dcf5e3;
  border-color: #9fd8ae;
  color: #0f5426;
}

.level-intermediate {
  background: #fff1d6;
  border-color: #e6c37a;
  color: #6a4a00;
}

.level-advanced {
  background: #ece4fb;
  border-color: #c3aef0;
  color: #45278c;
}

Note that every badge still contains the level word. Color is a highlight here, never the message — a colorblind reader, a grayscale printout and a screen reader all get “Beginner” just the same. That principle — never communicate by color alone — costs nothing when you design it in.

Save and check the browser:

Green, amber and violet badges — with the level spelled out in every one.
Green, amber and violet badges — with the level spelled out in every one.

Visualizing Duration With an Inline Style

Everything so far used classes, and that should be your default. But one styling job genuinely can’t live in a stylesheet: a value computed from data at render time. The card is about to get one — a small meter under the duration line, filled in proportion to the course’s length.

In src/CourseCard.tsx, first compute the fill percentage. Add this at the top of the component body, above noteFavoriteClick:

let meterWidth = 0
if (course.durationHours !== undefined) {
  meterWidth = Math.min(course.durationHours * 8, 100)
}

Eight percent per hour, with Math.min capping the value at 100 — without it, a marathon 15-hour course would burst out of its track. Now render the meter. Add this below the duration paragraph:

{course.durationHours !== undefined && (
  <div className="duration-meter" aria-hidden="true">
    <div
      className="duration-fill"
      style={{ width: meterWidth + '%' }}
    />
  </div>
)}

The style prop takes an object — the outer braces say “expression”, the inner ones are an object literal — with camelCased CSS properties. A six-hour course computes width: '48%'; no stylesheet can know that number, which is exactly when inline styles earn their keep. Everything static about the meter — height, colors, rounding — stays in CSS where it belongs:

.duration-meter {
  width: 100%;
  height: 6px;
  border-radius: 3px;
  background: #e6eaef;
}

.duration-fill {
  height: 100%;
  border-radius: 3px;
  background: #4a5568;
}

Add those to the bottom of src/App.css. Two details worth noticing in the JSX: The whole meter renders inside the !== undefined guard, so self-paced courses skip it. And aria-hidden="true" hides the meter from screen readers: It repeats what the “6 hours” text already says, so announcing it would be noise. Same principle as the logo’s empty alt, different attribute.

Save and check the browser:

Data-driven meters: the only inline style in the app, and rightly so.
Data-driven meters: the only inline style in the app, and rightly so.

Dressing the Buttons

The buttons still wear their browser defaults. Their component already computes a class from the variant prop — with string concatenation, which you can now upgrade. In src/Button.tsx, change the className line to a template literal:

className={`button ${variant}`}

Same class names as the concatenation produced, easier on the eyes. Then add the button rules to the bottom of src/App.css:

.button {
  margin-top: auto;
  border-radius: 8px;
  padding: 8px 16px;
  font-size: 0.95rem;
  font-weight: 600;
  cursor: pointer;
}

.button.primary {
  background: #24467c;
  border: 1px solid #24467c;
  color: #ffffff;
}

.button.ghost {
  background: transparent;
  border: 1px solid #24467c;
  color: #24467c;
}

.button:focus-visible,
a:focus-visible {
  outline: 3px solid #2f6fde;
  outline-offset: 2px;
}

Both variants from Chapter 4’s union get a look: solid primary for a card’s main action, outlined ghost waiting for secondary actions in later chapters. The margin-top: auto line does quiet magic — inside the card’s flex column it absorbs the leftover space, pinning every button to its card’s bottom edge so uneven cards still line up.

The last rule is the accessibility heart of this section. :focus-visible styles an element when it’s focused via keyboard — and a bold, high-contrast outline is how keyboard users see where they are. Browsers provide a default, but apps that override component styles often obliterate it; defining your own makes it deliberate and consistent. While you’re thinking about interactive elements, file away the rule that governs choosing them: buttons act, links go. An element that does something — saving, toggling, submitting — is a button; an element that takes you somewhere is a link. What it’s never allowed to be is a click handler bolted onto a div: The text is still there for a screen reader, but nothing announces it as an actionable control, and the keyboard can’t reach it at all. Your Button component renders a real button, so everything built on it inherits good behavior for free.

Save and check the browser:

Primary buttons, pinned to the card bottoms by a flexbox trick.
Primary buttons, pinned to the card bottoms by a flexbox trick.

Now test what you can’t see with a mouse: Put your cursor in the address bar, then press Tab repeatedly. Focus hops from button to button, each one wearing the outline:

The focus ring: a keyboard user's you-are-here marker.
The focus ring: a keyboard user's you-are-here marker.

If you can Tab to every interactive element, see where you are at all times and activate things with Enter or Space, your page passes the most basic keyboard test — one that an alarming share of production apps fails.

Auditing the Page

Styling sprint done. Before calling the catalog polished, walk through a quick audit — the same checks worth running on any page you ship:

  • Landmarks: The page has a top-level header (the banner), a main and a named catalog region. Assistive tech can navigate by all three. ✓
  • Heading order: One h1 (the app title), then h2 per card — no skipped levels, forming a scannable outline. ✓
  • Real controls: Every action is a button; nothing clickable is a bare div or span. ✓
  • Images: The one image is decorative and declares it with alt="". Informative images would describe themselves. ✓
  • Keyboard: Everything reachable by Tab, with a visible focus indicator. ✓
  • Color: Text colors clear the standard 4.5:1 contrast ratio against their backgrounds, and no information travels by color alone — the badges spell out their level. To spot-check any pair yourself, the browser’s DevTools show a contrast ratio in the color picker. ✓

One last look — this time at width. Grab the browser window’s edge and squeeze it to phone width, or press F12 and use DevTools’ device toolbar:

One-column catalog at phone width — auto-fill doing its quiet work.
One-column catalog at phone width — auto-fill doing its quiet work.

The grid collapses to a single column on its own; that’s the auto-fill column definition adapting. The layout survives, though a phone deserves finer treatment — which happens to be your challenge.

Challenge: Polish the Phone Layout

The narrow view works, but it spends space like a desktop: roomy paddings, a wide header, buttons sized for cursors. Add a small-screen refinement pass to the bottom of src/App.css using a media query — CSS that applies only below a width you choose:

@media (max-width: 480px) {
}

Inside it, make at least three adjustments: Tighten main’s padding, stack the page header vertically (logo above title) and stretch .button to full width — a friendlier tap target. Verify with the device toolbar at a phone preset, and make sure the desktop view is untouched.

Two hints:

  • Rules inside the media query win at small widths simply by coming later in the file with equal specificity — restating the property is enough.
  • The header is a flex container; one property changes its direction.

You’ll find a complete solution in the challenge folder of this chapter’s materials.

Key Points

  • Global rules — fonts, colors, box-sizing — live in index.css; component and layout styles live in App.css, selected via className.
  • Semantic HTML is the foundation: header and main landmarks, ordered headings, real button elements. Buttons act, links go — and neither is ever a clickable div.
  • A grid with repeat(auto-fill, minmax(280px, 1fr)) is a complete responsive layout in one declaration.
  • Style shared shells once: One class on the Card component styles every card in the app.
  • Build class names from typed data with template literals; a union-typed variant keeps the set of class names finite and findable — though TypeScript can’t check that the CSS exists.
  • Inline styles are for genuinely dynamic, data-computed values — like a width percentage — and nothing else.
  • Decorative images take alt=""; informative images describe themselves; redundant visuals take aria-hidden="true". Never omit alt.
  • Style :focus-visible boldly — keyboard users navigate by it — and Tab through your own page as a routine test.
  • Meet 4.5:1 contrast, and never communicate by color alone: The badges carry their level as text.

Where to Go From Here?

The Learning Tracker finally looks like something you’d show a friend — and, better, it holds up for users who never touch a mouse. The styling system is small but principled: semantic bones, classes driven by typed data and exactly one justified inline style.

What’s left for Part I is housekeeping with a purpose. The src folder has grown organically — components, data and types all in one pile, plus leftover template files nobody imports. The next chapter completes the static catalog: a proper folder structure, the last missing components, course categories and a cleanup pass — turning six chapters of learning into one coherent, maintainable feature.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.