Accessibility in WordPress
A theme that doesn’t work for keyboard users, screen reader users, or anyone with low vision is not finished. It seems finished. It passes a glance in a browser. But a real chunk of the people who will visit the site can’t use it, and that’s a defect, the same as a a ladder with missing rungs. This chapter is a pass through the theme you built in Chapters 12 and 13 to find and fix those defects.
Why this isn’t optional
There is an ethical case for accessibility, and it’s a good one. It is also not the case that tends to move a client or a deadline. So start with the legal reality.
In the United States, the Americans with Disabilities Act (ADA) has been applied to websites by the courts, and web accessibility lawsuits are routine. Section 508 requires accessibility for federal agencies and the organizations that contract with them. Both bodies of law point at the same technical standard: the Web Content Accessibility Guidelines (WCAG) published by the W3C. A business with an inaccessible website is exposed, and “we didn’t know” is not a defense that has worked yet.
There’s a quieter cost too. An inaccessible website loses customers who can’t complete their purchase, can’t signup for the newsletter, can’t read the content. That loss doesn’t show up in a bug tracker. It shows up as traffic that arrives and leaves.
A professional website is accessible at launch. Not as an upgrade, not as a phase two. It’s the baseline, and the rest of this chapter is how you hit it.
WCAG 2.1 AA
WCAG is organized into a long list of success criteria across three levels: A, AA, and AAA. AA is the level the law and the industry treat as the standard. AAA is stricter and not always achievable for every type of content; A alone is not enough.
You don’t need to memorize the criteria. You do need to handle the things that often break in a WordPress theme, and there are four of them:
- keyboard navigation,
- color contrast,
- semantic HTML, and
- screen reader support.
Get those right and you’ve covered the large majority of what AA asks for. The rest of this chapter works through each one against the theme you’ve built.
Semantic HTML in templates
The templates you wrote in Chapters 12 and 13 already lean on semantic elements: <header>, <main>, <footer>, <nav>, <article>. That wasn’t decoration. Those elements are landmarks, and landmarks are how a screen reader user moves through a page.
A sighted visitor scans. They see the header at the top, the nav, the main column, the footer, and they jump their eyes to whatever they want. A screen reader user can’t scan. What they can do is pull up a list of landmarks and jump straight to one: skip to the main content, skip to the navigation, skip to the footer. That list is built from your semantic elements.
A page built entirely from <div> elements gives a screen reader nothing. There are no landmarks, so there is no list, so there is no jumping. The user has to listen to the page read from the top, every time, on every page. Semantic HTML is the difference between a navigable page and a wall of text read aloud.
Confirm the structure across your templates. header.php should open with <header class="site-header"> and contain a <nav>. The content templates should wrap each post in <article>. footer.php should use <footer>. One detail to add: a label on the primary navigation.
<nav class="site-nav" aria-label="<?php esc_attr_e( 'Primary', 'tutorials-theme' ); ?>">The aria-label matters when a page has more than one <nav>, which most sites do once a footer menu exists. Without labels, a screen reader announces both as “navigation” and the user can’t tell them apart. With labels, one is “Primary navigation” and the other is “Footer navigation.”
Skip links
Every page needs a skip link, and yours has one already. Chapter 12 put it in header.php as the first focusable element in the body.
<a class="skip-link screen-reader-text" href="#main-content">Skip to content</a>Here is the problem it solves. A keyboard user moves through a page with the Tab key, one focusable element at a time. The header and primary nav come first in the DOM, so on every single page, before reaching the content, that user tabs through every nav link. On a site with eight nav items, that’s eight presses of Tab to get past navigation they’ve already seen, repeated on every page they visit.
The skip link is the escape hatch. It’s the first thing Tab lands on. Activate it and focus jumps straight to the main content, past the entire header. A sighted mouse user never sees it. A keyboard user gets it on the first Tab press.
For it to work, the link has to be hidden visually but stay in the DOM and the accessibility tree, and it has to become visible when focused. That’s what the .screen-reader-text and .skip-link classes handle:
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 0;
top: 0;
z-index: 999;
padding: 8px 16px;
background: #000;
color: #fff;
}Pushing the element to left: -9999px keeps it off-screen but in the document, so a keyboard can still reach it and a screen reader still announces it. display: none would do neither: it removes the element from the accessibility tree entirely. When the link receives focus, the :focus rule pulls it back on-screen at the top left. The z-index keeps it above the header so it isn’t hidden behind it.
The skip link’s target also has to be focusable. The href="#main-content" points at the <main id="main-content"> in your templates. Add tabindex="-1" to that element so focus actually moves there when the link is activated:
<main id="main-content" class="site-main" tabindex="-1">tabindex="-1" means the element can receive focus programmatically (which is what the skip link does) without being added to the normal Tab order.
Focus management
(notes: this section needs some work… i understand the difference you’re making betwen focus and focus-visible but the wording is clunky esp in the graf starting “The reason people write it…”
a visual/interctive example of what the heck a focus ring is i think would also be useful)
When an element has keyboard focus, the browser draws a focus ring around it. That ring is the only way a keyboard user knows where they are on the page. Take it away and the user is navigating blind.
The single most common accessibility mistake in CSS is this:
/* Never do this without a replacement. */
:focus {
outline: none;
}That rule removes the focus ring from everything. The page still works for a mouse user, who has a cursor. For a keyboard user, focus becomes invisible, and the site is now genuinely hard to use.
The reason people write it is that the default focus ring is sometimes considered ugly, and it shows up for mouse users too when they click a button. The fix is not to remove the ring. It’s :focus-visible, a selector that applies only when the browser determines a visible focus indicator is actually useful, which means keyboard focus, not mouse clicks. Your stylesheet should have a single :focus-visible rule that styles the ring deliberately:
:focus-visible {
outline: 2px solid #1a1f18;
outline-offset: 2px;
}That gives keyboard users a clear, on-brand focus indicator and doesn’t show a ring to mouse users clicking buttons. It’s the whole solution. Never pair outline: none with nothing.
If a client asks you to remove focus rings because they look bad, that’s the conversation to have. You’re not being asked for a style tweak. You’re being asked to make the site unusable for keyboard-only visitors, and the answer is to style the ring to fit the design, not delete it.
(NOTEs: the framing in the above graf should NOT be that the client is asking you to break something, it’s the client doesn’t know the knock down effects of what they’re asking for, to them it’s a 100% visual thing, this is a teaching moment)
ARIA: when and when not
ARIA, Accessible Rich Internet Applications, is a set of HTML attributes that describe roles, states, and properties to assistive technology. It’s useful. It’s also misused constantly, and the misuse usually makes things worse.
The first rule of ARIA is the one that prevents most of the damage: don’t use ARIA if a native HTML element already does the job. A native <button> is keyboard operable, announces itself as a button, fires on both click and Enter and Space, and works in every browser without a line of JavaScript. A <div role="button"> announces itself as a button and does nothing else. You’d have to add tabindex, a keydown handler for Enter and Space, and the focus styling, all to reimplement what <button> gives you free, and you’d probably miss an edge case. Use the <button>.
(NOTE: i think teh above graf would benefit from a visual: 2 visually identical buttons, both styled via css to match, one is semantic, the other not. below each button the user can view the undrelying HTML structure to really drive home what the difference is here)
ARIA supplements semantic HTML. It doesn’t replace it. The right time to reach for ARIA is when you’re building something HTML has no native element for, and your theme has at least one candidate: a mobile navigation toggle.
A toggle button that shows and hides the menu needs to tell screen reader users whether the menu is currently open. HTML has no element that conveys that, so ARIA does:
<button
class="nav-toggle"
aria-expanded="false"
aria-controls="primary-menu"
>
<span class="screen-reader-text">Menu</span>
</button>aria-expanded is a state. It starts false and your JavaScript flips it to true when the menu opens. A screen reader announces “Menu, collapsed” or “Menu, expanded” so the user knows what activating the button will do. aria-controls points at the id of the menu the button controls. The button itself is a real <button>, so it’s keyboard operable for free. ARIA is adding only the one thing HTML couldn’t: the open or closed state.
That’s the pattern. Native element for the thing itself, ARIA for the state or relationship the native element can’t express. ARIA scattered onto elements that didn’t need it is noise, and noise in the accessibility tree is worse than nothing.
Color contrast
Text has to have enough contrast against its background to be readable by people with low vision or color vision deficiency. WCAG AA sets the minimums: 4.5:1 for normal text, 3:1 for large text (roughly 24px, or 19px bold and up).
(NOTES: in the above X:Y is the contrast ratio?, again i think a visual here will be helpful: a small color pallet of 3-5 swatches, each with a text sample of all same size, color, placement. different shades of bg color. left to right the samples should go from poor contrast better than needed)
Doing this with your eyes alone will only get you so far. Of course all the colors look smooth and crisp on your carefully calibrated monitor and why would you spend making a website “look good” to you? You wouldn’t.
Eye sight is subjective so you’ll need something to be as objective as possible.
The WebAIM Contrast Checker is the standard. (NOTES: i couldnt find this tool) Paste in your text color and your background color and it returns the ratio and a pass or fail for each level. Browser DevTools also do it: in the element inspector, the color swatch next to a color value expands into a contrast readout, and Chrome’s DevTools shows a contrast line directly in the Accessibility pane.
Check the real pairings in your theme. Body text on the page background is the one that matters most, since it’s the bulk of what anyone reads. Then check muted or secondary text, which is where contrast usually fails, because the whole point of muting text is to lower its contrast. Check link colors against the background. Check any text that sits on a colored surface, like a button label or a card.
When something fails, the fix is to darken the text or lighten the background until the ratio clears 4.5:1. Record the result. A comment in your tokens file noting that a color pair was checked and passed saves the next developer from re-checking and from assuming a pairing is safe when it was never tested:
:root {
--color-text: #1a1f18; /* 13.8:1 on --color-bg, passes AA */
--color-text-muted: #545c4a; /* 6.2:1 on --color-bg, passes AA */
--color-bg: #f8f6f0;
}Image alt text in WordPress
Every informational image needs alternative text. A screen reader reads the alt attribute aloud in place of the image; a user who can’t see the image gets the alt text instead.
In WordPress, you don’t usually write alt text in the template. You write it in the Media Library. Each image in the library has an Alt Text field, and the_post_thumbnail() and the block editor’s image block both read from it automatically. Set the alt text once, when the image is uploaded, and it follows the image everywhere it’s used.
This means the accessibility of the site’s images depends on whoever uploads them filling in that field. Part of handing off a theme is telling the client that. The Alt Text field is not optional metadata; it’s the image’s accessible name.
Two cases to get right. For an informational image, the alt text should convey what the image communicates, not literally describe the pixels. If a chart shows that sales doubled, the alt text is “Sales doubled between 2024 and 2025,” not “bar chart with two bars.” The reader needs the information, not a description of the container.
For a decorative image, an image that adds visual interest but carries no information, the correct alt text is empty: alt="". An empty alt tells the screen reader to skip the image entirely, which is right, because there’s nothing to announce. Leaving alt off completely is different and wrong: a screen reader with no alt attribute often falls back to reading the filename, so the user hears “IMG underscore 4 8 2 1 dot jpg.” An explicit empty alt is a decision; a missing alt is a gap.
When a template outputs a purely decorative image, set the empty alt in the template:
<?php the_post_thumbnail( 'tutorials-card', array( 'alt' => '' ) ); ?>Screen reader testing
(NOTE: the series assumes uesr os on MacOS or Ubuntu Linux, Windows is OUT OF SCOPE)
Everything above is review you can do by reading code and running a contrast checker. None of it tells you what the theme actually sounds like. For that, you have to test with a screen reader at least once, and “at least once” is the real minimum: most themes never get tested this way, and it shows.
You don’t need to buy anything. VoiceOver ships with macOS and turns on with Command-F5.
Turn it on and use the finished theme without looking at the screen, or with your eyes closed. Tab through the homepage. Open a post. Use the navigation. Trigger the mobile menu toggle. Listen for the things that don’t make sense when heard instead of seen: a link that announces only “read more” with no context, an image that reads out a filename, a heading order that jumps from <h1> to <h4>, a form field with no label, a button that doesn’t say what it does.
Then fix what you heard. Screen reader testing is uncomfortable the first few times because it’s slow and unfamiliar, and that discomfort is exactly the point: it’s a thin slice of what the experience is like for someone who relies on it. The fixes are usually small. Finding them is the part you can only do by listening.
Commit your work
You’ve audited the theme for keyboard navigation, focus styling, semantic structure, ARIA, contrast, and alt text, and tested it with a screen reader. Commit the fixes.