Skip to main content
Web app skills · Lesson 9 of 11 · 14 min

Web Accessibility Basics for Beginners in a Next.js Site

Learn web accessibility basics from a real Next.js site: semantic HTML, keyboard focus, accessible names, contrast thresholds and reduced motion.

By Matthews WongReviewed on

Key takeaway

Web accessibility means building pages that people can perceive and operate with a keyboard, a screen reader or reduced motion settings. Start with native HTML, which gives every control a name, a role and keyboard support, then keep focus visible, meet the contrast thresholds, and switch movement off when the visitor asks.

You will be able to

  • Pick the native element that gives a control its name, role and keyboard behaviour for free.
  • Add a skip link and a visible focus ring, and check the contrast thresholds.
  • Honour prefers-reduced-motion in both CSS and JavaScript without hiding any information.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

One keyboard path through a page, then the motion setting

Focus moves from the Tab key to the skip link, then to the main landmark and a native quiz button, which exposes a name and role. Reduced motion is a separate preference.

Step 1 of 6. A keyboard visitor has no pointer. Focus is their cursor, and it should reach the content quickly, land on real controls, and never hide.

Keys: ← → step · Home / End · K play or pause

  1. A keyboard visitor has no pointer. Focus is their cursor, and it should reach the content quickly, land on real controls, and never hide.

  2. The first Tab stop is a link that is visible only while focused. It lets a visitor bypass the repeated header on every page.

    href="#main-content"
  3. Pressing Enter moves focus to the main element. The repeated navigation is skipped, and a screen reader can also list main as a landmark.

    main id="main-content"
  4. The quiz options are real button elements, so Tab reaches them and Enter or Space presses them with no extra script.

    type="button"Predict the next step Next, a screen reader meets this button. What must it be able to determine? (Only its colour · Its name, role and state · Where the mouse last was)
  5. A native button already exposes its role. Its visible text becomes its name, and aria-pressed reports whether it was chosen.

    aria-pressedPredict the next step A visitor has reduce motion switched on. What should the explainer do with its movement? (Hide the information · Keep the information, drop the movement · Play it faster)
  6. Motion is a separate preference. Under reduce, the explainer shortens transitions and hides decorative rings, yet every step and caption stays readable.

    @media (prefers-reduced-motion: reduce)

Why should you reach for native HTML before ARIA?

A native element carries its meaning with it. MDN puts it plainly: a button has built-in keyboard accessibility, so users move between buttons with Tab and activate one with Space, Return or Enter, and the advice is to use the right element for the right job in the first place. A div styled like a button loses all of that, and you would have to rebuild it with tabindex, a role and a key handler.

Read more (2)

The WCAG 2.2 criterion called Name, Role, Value (level A) asks that every user interface component has a name and role that software can determine, and that states a user can change are exposed too. The W3C notes that standard HTML controls already meet it when used according to specification. That is the whole case for native elements: the browser does the exposing for you.

Structure matters as much as controls. MDN explains that headings give screen reader users a table of contents they can jump through, and that sectioning elements such as nav, main and footer give extra clues about the content. Without them the page reads as one giant block. In a Next.js layout this is cheap: one h1 per page, headings in order, and a single main element wrapping the page content.

How do keyboard users move through your pages?

Three WCAG 2.2 criteria cover the basics. Keyboard (2.1.1, level A) says all functionality is operable through a keyboard interface. Focus Visible (2.4.7, level AA) says a keyboard focus indicator must be visible. Bypass Blocks (2.4.1, level A) asks for a way past blocks repeated on many pages, which is exactly what a skip link is.

Read more (1)

The common failure is removing the outline because it looks untidy. The fix is to restyle it, not delete it: one global :focus-visible rule gives every focusable element a clear ring, and individual components can refine it. Be careful with tabindex too. MDN calls positive values nearly always a bad idea, and reserves tabindex 0 and -1 for the rare cases where an element must join the tab order or receive focus from a script.

What do the contrast thresholds actually say?

WCAG 2.2 Contrast (Minimum), 1.4.3 at level AA, asks for a ratio of at least 4.5:1 for text, and at least 3:1 for large-scale text, defined as 18 point or 14 point bold. Non-text Contrast, 1.4.11 at level AA, asks for 3:1 against adjacent colours for user interface components and graphical objects needed to understand the content, such as a focus ring or an input border.

Read more (1)

Do not judge contrast by eye, especially on a dark theme with translucent layers, because the effective background is a blend. Measure the final rendered colours with a contrast checker or the browser's developer tools, and re-measure when a token changes. This lesson quotes no ratio for the site's own palette on purpose: a ratio is only a claim once somebody has measured it.

How do you respect prefers-reduced-motion?

prefers-reduced-motion is a media feature with two values, no-preference and reduce. MDN says reduce means the visitor has asked the system for an interface that removes, reduces or replaces motion-based animation, and warns that animations such as scaling or panning large objects can trigger discomfort for people with vestibular motion disorders. Honour it in CSS with a media query, and in JavaScript by asking window.matchMedia before you start any scripted animation.

Read more (2)

Reduced motion does not mean reduced content. Replace movement with an instant change or a fade, and keep every piece of information. The WCAG 2.2 Animation from Interactions criterion (2.3.3) sits at level AAA, the strictest tier, and asks that motion triggered by interaction can be disabled unless it is essential. Treat it as a good target even when you are not chasing that level.

None of this is a certificate. Automated checkers find only a part of the problems, so also tab through the page yourself, listen to it with a screen reader, and turn reduced motion on. The standards describe what to aim for; they are not a promise that any page meets a legal requirement.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

app/components/SiteChrome.tsx:40-55
function SkipLink(): JSX.Element {
  return (
    <a
      href="#main-content"
      className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:font-bold focus:border-2"
      style={{
        backgroundColor: "var(--primary)",
        color: "var(--text-dark)",
        borderColor: "var(--border-color)",
      }}
    >
      Skip to main content
    </a>
  );
}

sr-only hides the link visually but keeps it in the tab order, and focus:not-sr-only reveals it the moment it receives focus. It points at the main element that SiteChrome renders with the same id. The label is a literal English string in this component, so a page in another language would need it translated.

app/components/learn/LessonQuiz.tsx:148-157
                      <button
                        key={option}
                        type="button"
                        className="quiz-option"
                        data-state={state}
                        aria-pressed={choice === oi}
                        disabled={locked}
                        onClick={() => choose(qi, oi)}
                      >

Each answer is a real button, so keyboard support and the role come from the browser. The visible text is the accessible name, and aria-pressed adds the one thing HTML cannot say alone: which option was chosen. The letter badge is aria-hidden so it is not read twice, and each question sits in a fieldset with a legend.

app/globals.css:1255-1258
*:focus-visible {
  outline: 3px solid var(--accent-yellow);
  outline-offset: 2px;
}

One rule gives every focusable element a thick outline, offset from the edge so it does not collide with borders. The quiz option adds its own :focus-visible rule on top, with an orange outline, so a component can refine the ring without removing it.

app/components/learn/explainer.css:803-822
@media (prefers-reduced-motion: reduce) {
  .mx-node,
  .mx-edge path,
  .mx-zone rect,
  .mx-zone ellipse,
  .mx-step,
  .mx-progress-dot {
    transition-duration: 0.01ms;
  }
  .mx-ring,
  .mx-signal {
    display: none;
  }
  .mx-predict,
  .mx-predict-option,
  .mx-predict-result {
    animation: none;
  }
}

Transitions collapse to almost nothing instead of being removed, so the state change still happens and every element still ends in the right place. Only the decorative rings and signals are hidden, which are pure movement. The section's own comment sums it up: information stays, movement goes.

app/components/learn/MotionExplainer.tsx:144-148
  return (
    typeof window !== "undefined" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );
}

CSS cannot stop an animation that script drives, so the component asks the same media query from JavaScript. The typeof window check keeps the function safe during server rendering, where there is no window and no preference to read.

What breaks

Mistakes that cost real time

  • A div with an onClick instead of a button

    Mouse users can click it, but Tab skips it, Enter does nothing and a screen reader announces plain text. Replace it with a button element and keep the styling in CSS. Only when a native element truly cannot work, add the role, tabindex 0 and the key handler yourself.

  • outline: none with nothing in its place

    The page looks tidier and keyboard users lose track of where they are, which fails Focus Visible. Keep a ring, and restyle it: a thick outline with an offset reads well on both light and dark surfaces.

  • A skip link that points at nothing

    If the href is #main-content but no element has that id, the link does nothing visible. Keep the link and the target in the same component or a shared constant, and press Tab then Enter on a real page to confirm that focus moves.

  • Removing content under reduced motion

    Hiding a whole animated panel because the visitor asked for less movement takes their information away. Swap the animation for a fade or an instant state change and keep the text, the captions and the controls.

Try it yourself

Do it on your own machine

Test any page of your own with no mouse and one browser setting. It takes ten minutes and finds problems no linter reports.

  1. Click the address bar, then press Tab once. Does a skip link appear, and does Enter move focus into the main content?

  2. Keep pressing Tab through the whole page. Every control must show a visible ring, and nothing clickable may be skipped.

  3. Search your components for onClick on a div or span. Each hit is a candidate for a native button or link.

    grep -rn "<div[^>]*onClick" app
  4. Turn on the reduce motion setting of your operating system and reload. Confirm that nothing slides or pulses, and that no information vanished.

Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.A card must act like a button. What is the best first choice?
  2. 2.Which contrast ratio does WCAG 2.2 level AA ask for on normal body text?
  3. 3.A visitor has prefers-reduced-motion set to reduce. What is the right response?
Think it through

Answer in your head, then check

  1. 1.What does a skip link do, and which WCAG criterion does it address?Show the answer

    Answer:It is the first focusable element and jumps keyboard focus past the repeated header to the main content. It addresses Bypass Blocks, 2.4.1, a level A criterion.

  2. 2.Why is outline: none with no replacement a problem?Show the answer

    Answer:Keyboard users lose sight of where focus is, which fails Focus Visible (2.4.7, level AA). Restyle the ring with a rule such as the global one in globals.css instead of deleting it.

  3. 3.Why does the explainer check reduced motion in both CSS and JavaScript?Show the answer

    Answer:CSS handles transitions and keyframes, but motion driven by script is invisible to a stylesheet. The component reads the same media query with window.matchMedia so scripted movement can stop too.

Go deeper

Related deep dives on this site