Web

Building a More Accessible Web

Accessibility is how we make the web feel like it belongs to everyone.

15 min read

Different people, same web: a short film that is the fast version of the post below, which tells the same story in full. One small page, used six ways: with only a keyboard, with a screen reader, at 200% zoom, with low vision, with unsteady hands, and needing a minute. For each, the page first fails, then three small changes make it work. It ends with the page as a home.

My experience with WCAG compliance

I spend hours picking colours, tweaking spacing and polishing animations until everything feels just right! Just like the dynamic line at the bottom left that changes to whatever you're doing right now. It took me 6 hours for the first version and close to 4 versions to get it perfect... for 75~85% of us. That's right, nearly 15%~25% of people using the web have some form of permanent or temporary disability.

A small aria-label. A visible focus ring. A meaningful alt on an image. These tiny things go a long way when you're the person on the other side of the screen.

Over the last year, I've been working on an app designed mainly for nursing students at UK universities. I had no experience with WCAG when I heard this sentence from my clients: 'The software should meet the criteria of WCAG 2.2 AA'. Universities in the UK use it, and for them that level isn't optional. I thought the code was in decent shape, but the first audit found multiple blocking failures and a long tail of smaller ones.

The film above shows six ways to use one page, and I'm still not sure if I did a good job making it as I'm new to animation. But I've made sure to explain anything and everything from my 1 year of making an app WCAG compliant. Let's get started!

What WCAG actually is

WCAG stands for Web Content Accessibility Guidelines. It's a document from the W3C (the same people who look after HTML and CSS btw) describing a bare minimum rule book to make sure people with temporary & permanent disabilities can use the web with as little friction as possible. The current version is 2.2, from October 2023.

It isn't a law though. It's the thing laws point at. Specifically, in the UK, public sector websites have to meet WCAG 2.2 AA. The European Accessibility Act points at a standard called EN 301 549, which is kinda the scenic route back to WCAG. With paperwork :). The US rule for state and local government points at WCAG 2.1 AA. So when someone says 'we need to be compliant', what they mean is 'we need to pass a certain level of WCAG'. And that's as far as I'll go on the legal side 0_0. I'm not a lawyer, and the dates and versions keep moving, so double check the rules for whatever you're working on.

After going through the official document, I realised it's basically shaped like a tree. Four principles at the top, 13 guidelines under those, and 86 testable rules called success criteria under the guidelines. That's pretty much the whole thing.

  • Perceivable. People need to be able to take in what's on the page. Can't see an image? There's text for it. Can't hear a video? There are captions.
  • Operable. People need to be able to actually use your website. Keyboard, mouse, finger, switch, whatever works for them. And they need enough time to do it.
  • Understandable. The content should make sense, and the page should behave the way people expect it to. Nobody likes a website that surprises them for all the wrong reasons.
  • Robust. Your website should work with the tools people use, like screen readers. That means writing code that properly tells those tools what everything is and does.

Together, they spell POUR. Pretty easy to remember, right? Just POUR a little accessibility into what you're building. Your users will thank you for it :)

Every rule has its own number, and once you get the hang of reading it, finding a specific rule in the spec takes like ten seconds. You don't have to memorise all 86 of them. Thank god.

How to read 1.4.3
  1. 1: Perceivable

    The first number. One of the four principles.

  2. 4: Distinguishable

    The second number. The guideline under that principle.

  3. 3: Contrast (Minimum)

    The last number. The actual rule you're looking for.

For example, 1.4.3 is about contrast, 2.4.7 is about focus visibility, and 4.1.2 is about screen readers understanding your custom components. You'll see these numbers pop up in audits and bug reports, so it's worth knowing how to read them. That's pretty much it.

The three levels | those fancy A ratings

Every rule falls under one of three levels: A, AA or AAA. So when someone says their website is AA compliant, they're basically saying it meets all the A and AA requirements. But what's the actual difference?

  1. AThe bare minimum

    31 rules

    The basics. Things like keyboard access, alt text and labels on form fields. Without these, parts of your website might be completely unusable for some people.

  2. AAThe usual target

    24 more, 55 in all

    This is what most websites aim for. Better colour contrast, resizable text, visible focus and error messages that actually tell you what's wrong.

  3. AAAGoing the extra mile

    31 more, 86 in all

    The highest level, with stricter requirements. Great to aim for where possible, but not every rule makes sense for every page. Even the W3C doesn't recommend making AAA the target for an entire website.

The levels stack on top of each other. So for AA, you need to pass all the A and AA rules, not just the AA ones. That's 55 rules in total. And if even one fails on a page, you can't claim that page fully meets AA. This is why you need to be careful with claims like 'our website is WCAG compliant'. I'll explain this part a bit more later.

AAA is still worth reading. Picking a few AAA rules you can meet, like 7:1 contrast or 44px targets, and saying so is a good thing to do.

Why it exists | the need for WCAG

Because the web you're building is going to be used by the 15%~25% of people we talked about earlier in this blog. And not everyone experiences the internet the same way you do.

Some people navigate everything with a keyboard. Some use screen readers to listen to a page. Some need bigger text, while others struggle to read faint grey text on a white background. A button that's easy for you to click might be impossible for someone else to hit. And sometimes, a page that's too busy can make it harder for someone to focus at all. These aren't edge cases or problems that affect a handful of people. They're real people trying to do the same things you do online, just in a different way. And they deserve to have that experience too.

Here's the part that honestly surprised me. WebAIM checks the home pages of the top million websites every year, and in 2026, 95.9% of them had at least one accessibility issue that automated tools could catch. That's almost every website. And the wild part? Just six common mistakes made up 96% of all the failures they found. Six mistakes, across a million websites. Makes you wonder how many of these we keep shipping without even realising it.

MistakeHome pagesThe fix
Low contrast text83.9%Make the text darker. Don't just trust your eyes, use a contrast checker.
Images with no alt53.1%Add alt text describing the image. If it's just decoration, alt="" is fine.
Form fields with no label51%Give the field a proper <label for>. A placeholder isn't a replacement.
Empty links46.3%Make it clear where the link goes. If it's just an icon, give it an accessible name.
Empty buttons30.6%Same idea. A button should tell people what it does, even if it's just an icon.
No page language13.5%Add lang="en" to your <html> tag. That's literally it.

And look at these fixes. Most of them aren't some complicated engineering problem that needs a week of work. Sometimes it's just adding an attribute, fixing a colour, or giving something a proper label. Of course, fixing these six things won't magically make a website fully accessible, but it's a pretty good place to start.

The six people, one by one

Remember the film I made above & the 6 people in it? It shows those people using the web in different ways. Let's go through them one by one: what can go wrong, and how we can fix it. I've added the WCAG rule numbers along the way too. The letter next to each rule is its level (A, AA or AAA), and 'new' means it was added in WCAG 2.2.

01 of 06

Using a keyboard, no mouse

Some people don't use a mouse at all. They navigate the whole page using a keyboard, mostly by pressing Tab to move between things.

What they get

You press Tab, but there's no visible focus ring. A dropdown only opens when you click it. Or a modal opens, but you can accidentally tab into the page behind it. Fun, right?

What helps

  • Make the focus ring visible
  • Keep the tab order sensible
  • Make sure everything works with a keyboard
  • 2.1.1KeyboardA
  • 2.4.7Focus VisibleAA
  • 2.4.3Focus OrderA
  • 2.4.1Bypass BlocksA
  • 2.4.11Focus Not ObscuredAAnew

Here's a quick test. Put your mouse aside and press Tab. Keep going until you reach the bottom of this page. Can you always tell which link or button is selected? If you lose track of where you are, that's a problem with 2.4.7, Focus Visible.

And sometimes, the reason is literally one line of CSS. Someone didn't like how the focus ring looked, so they removed it:

the bug
button:focus {
  outline: none;
}

Please don't do this unless you're replacing it with something better. You can use :focus-visible to show a focus ring when it's needed, like when someone is navigating with a keyboard.

the fix
:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

One more thing: the ring needs enough contrast to stand out from the background. That's where 1.4.11 comes in, and it's easy to miss if you've picked a pale blue ring on a white page.

I also got a modal issue wrong once. Tab was escaping the modal and moving into the page behind it. I initially logged it as 2.1.2, No Keyboard Trap, because it involved keyboard focus. But that rule is about focus getting trapped somewhere you can't leave. Here, focus was doing the opposite: it was leaving when it shouldn't. That's a Focus Order issue (2.4.3).

The fix is to move focus into the modal when it opens, keep keyboard navigation inside while it's open, close it with Escape, and return focus to the button that opened it. Or, honestly, use the native <dialog> element when it fits your needs. The browser already handles a lot of this for you.

the easy way
<dialog id="invite">
  <h2>Invite a provider</h2>
  <form method="dialog">…</form>
</dialog>

<script>
  // showModal() handles focus and makes the
  // background inert while the dialog is open.
  document.getElementById("invite").showModal();
</script>

Another easy mistake: using aria-hidden to hide the page behind an open menu. It hides that content from screen readers, but it doesn't stop keyboard users from tabbing into it. Those are two different things. Use inert when you need the background to be unavailable for interaction too.

And then there's a new one in WCAG 2.2: focus shouldn't disappear behind a sticky header (2.4.11). I have a sticky header myself, so this one hits close to home. Tab to a link near the top of a long page and the focus ring can end up hidden underneath it. One way to help is html { scroll-padding-top: 64px; }, where 64px is roughly your header height.

02 of 06

When someone uses a screen reader

Not everyone looks at a page to understand it. Some people use a screen reader, which reads out the content and tells them what they can interact with. But it can only do that if we've given it the right information.

What they get

An image with no alt text. A button that's just an icon with no name. A clickable div that doesn't behave like a button. Or ten browser tabs that all have the exact same title.

What helps

  • Describe images when they matter
  • Use proper headings and HTML elements
  • Give buttons and links names that actually mean something
  • 1.1.1Non-text ContentA
  • 1.3.1Info and RelationshipsA
  • 2.4.2Page TitledA
  • 2.4.4Link PurposeA
  • 4.1.2Name, Role, ValueA
  • 4.1.3Status MessagesAA

But here's something worth noting: a screen reader doesn't magically understand your design. It works with the information your HTML gives it. If a button has no name, it can't guess what that button does. If an image has no description, it doesn't know what you wanted people to take away from it. Let's look at a few examples, because these are easier to understand when you hear the difference.

  • <img src="team-photo.jpg">

    reads “team-photo dot jpg, image”

    <img src="team-photo.jpg" alt="The four of us at the launch">

    reads “The four of us at the launch, image”

  • <div onclick="subscribe()">Subscribe</div>

    reads “Subscribe (just the word; nothing says you can press it)”

    <button onclick="subscribe()">Subscribe</button>

    reads “Subscribe, button”

  • <button><svg>…</svg></button>

    reads “button”

    <button aria-label="Delete row"><svg aria-hidden="true">…</svg></button>

    reads “Delete row, button”

  • <a href="/post/42">Read more</a>

    reads “Read more, link (but read more about what?)”

    <a href="/post/42">Read more about caching</a>

    reads “Read more about caching, link”

  • <input placeholder="Email">

    reads “edit text”

    <label for="email">Email</label> <input id="email">

    reads “Email, edit text”

Notice how often the fix is just using the right HTML element. A real <button> already knows how to receive keyboard focus, respond to keyboard input, and tell assistive technology it's a button. You don't have to build all of that yourself. So if you're writing 40 lines of JavaScript to make a <div> behave like a button, stop for a second. You probably just need a button.

I actually ran into a version of this problem myself. Our icon component only accepted className. So when someone wrote <TrashIcon aria-hidden />, that prop never made it to the SVG. The result? Screen readers could still pick up icons that were supposed to be decorative. We had this problem with 32 of our 44 icons, and I hadn't caught it by looking at the code alone.

Icon.tsx
// Before: there is nowhere for aria-hidden to go.
export function TrashIcon({ className }: { className?: string }) {
  return <svg className={className}>…</svg>;
}

// After: decorative by default, with other props passed through.
export function TrashIcon({ className, ...rest }: SVGProps<SVGSVGElement>) {
  return (
    <svg aria-hidden="true" focusable="false" className={className} {...rest}>
      …
    </svg>
  );
}

The lesson here is to check what actually ends up in the rendered DOM, not just what you wrote in JSX. A component can look perfectly fine in your editor while quietly dropping the exact props you need for accessibility. Also, if an icon is meaningful on its own, make sure it has an accessible name instead of hiding it.

Page titles are another easy one to overlook. We had the same <title> across every route in our app. Imagine opening a bunch of tabs and having no idea which page is which because they all have the same name. That's what it can feel like for someone relying on those titles to navigate. If you're using Next.js, you can set the right title for each page through its metadata export. It's a small change, but it makes a real difference.

One last thing: what happens when the page changes without actually loading a new page? Maybe a form saves, a search returns three results, or something is still loading. If nothing tells the screen reader about that change, someone might be sitting there wondering whether their click even worked. You can use a live region, for example aria-live="polite", to announce updates when they're ready. And for decorative loading skeletons, use aria-hidden="true" so people don't have to hear a bunch of meaningless placeholders. Little details like these are easy to miss, but they can be the difference between a page that just looks like it works and one that actually does.

03 of 06

When people need bigger text

Some people need to make text bigger to read it comfortably. That's completely normal. The problem starts when your layout falls apart the moment they do.

What they get

You zoom in and a heading gets cut off. A sidebar refuses to shrink below 300px. A card has a fixed height, so half the text disappears.

What helps

  • Let text grow without breaking things
  • Allow content to wrap naturally
  • Make sure nothing gets cut off
  • 1.4.4Resize TextAA
  • 1.4.10ReflowAA
  • 1.4.12Text SpacingAA

You can test this yourself in about a minute. Press Cmd and + until the browser zoom reaches 200%. Can you still read everything? Is anything getting cut off? Now try 400% zoom in a 1280px-wide window. The content should reflow into something roughly 320px wide, without making you scroll sideways just to read a sentence. Data tables are one of the exceptions here.

Most of the time, this comes down to not fighting the browser. Use rem for text sizes instead of locking everything to px. Prefer max-width over a fixed width, and min-height over height when content might grow. Flexbox and grid can handle a lot of resizing for you, as long as you let them wrap instead of forcing everything to stay on one line.

There's another rule here that's easy to miss: 1.4.12, Text Spacing. People should be able to increase line height to 1.5, paragraph spacing to 2em, letter spacing to 0.12em, and word spacing to 0.16em without losing any content. Try doing that to text inside a fixed-height card with overflow: hidden. You can probably guess what happens. The text starts disappearing. Don't make people choose between readable text and seeing the whole page.

04 of 06

When contrast makes things hard to see

Some people need stronger contrast to read text or make out the different parts of a page. And honestly, even if you don't have trouble seeing, you've probably squinted at some light-grey text on a white background before.

What they get

Grey text that barely stands out from the background. Input borders you can hardly see. A required field marked red, with no other indication that anything is wrong.

What helps

  • Text that stands out from its background
  • Visual cues that are easy to recognise
  • Errors explained in words, not just colours
  • 1.4.3Contrast (Minimum)AA
  • 1.4.11Non-text ContrastAA
  • 1.4.1Use of ColorA
  • 3.3.1Error IdentificationA
  • 1.4.6Contrast (Enhanced)AAA

Let's get the numbers out of the way. Normal text needs a contrast ratio of at least 4.5:1. Large text needs 3:1, where large means 24px or around 18.66px if it's bold. Things like input borders and icons that communicate something also need 3:1 against their surroundings. If you're aiming for AAA, normal text needs 7:1.

You don't have to guess whether your colours pass. Play around with the slider below and watch the contrast ratio change. I've started it with #767676 on white, which is a grey you'll often see used for borders and other UI elements.

Some people need things to stand out.

Faint text. A button you can barely see. An error shown only in red.

email

4.54:1

  • passText, AA needs 4.5
  • passLarge text, borders, icons, AA needs 3
  • failText, AAA needs 7

4.54 to 1. Passes AA for text, AA for large text, borders, icons.

Try

A few things worth knowing: 4.49:1 still fails, even though it's ridiculously close. Disabled controls are exempt from the usual contrast requirements, but placeholder text isn't. And if your site has a dark mode, passing in light mode doesn't mean you're done. Check both. Text over images needs attention too, because the background behind it can change from one part of the image to another. Sometimes adding a dark overlay behind the text is the simplest fix.

Also, colour alone isn't enough to communicate something. Take a form where the only sign of an error is a red border around the input. If someone can't distinguish that red from the surrounding colours, how are they supposed to know what's wrong? Tell them in plain words, put the message next to the field, and connect the two so assistive technology can pick it up too.

one field
<label for="email">Email</label>
<input id="email" type="email"
       aria-invalid="true" aria-describedby="email-error">
<p id="email-error">Enter an email address with an @ in it.</p>

There's quite a bit happening in those few lines. The field has a label, the error is explained in words, and aria-describedby connects the message to the input. So instead of just encountering a field that seems broken, the user gets some idea of what went wrong and how to fix it.

05 of 06

When clicking things isn't so easy

Not everyone can make precise movements with a mouse or tap a tiny target on a screen. Something as simple as clicking a button can take a lot more effort than you'd expect.

What they get

A tiny delete icon in a table row. A session that expires without warning. A list you can only reorder by dragging items around.

What helps

  • Give buttons and icons enough space
  • Allow people enough time to finish
  • Offer alternatives to dragging
  • 2.5.8Target Size (Minimum)AAnew
  • 2.2.1Timing AdjustableA
  • 2.5.7Dragging MovementsAAnew
  • 2.5.2Pointer CancellationA
  • 2.5.5Target Size (Enhanced)AAA

WCAG 2.2 introduced a rule that makes you look at the size of your click targets a little more carefully. Under 2.5.8, they generally need to be at least 24 by 24 CSS pixels, unless they meet one of the exceptions. Spacing between targets can count too, so a smaller target may still pass if there's enough room around it. The AAA version, 2.5.5, asks for 44 by 44 pixels, with its own exceptions.

We had a delete icon in one of our table rows that was 14px, with just 4px of padding. That gave us a target of 22px across. It looked fine, and clicking it with a mouse wasn't a problem. But try hitting that tiny target when precise movements are difficult. Adding some padding brought it up to 32px. A small CSS change, and a much easier target to hit.

Timers are another thing to watch. If a session is about to expire, people should have a way to extend it when the rule applies (2.2.1). If you let users reorder a list by dragging, give them another way to do it too, like move-up and move-down buttons (2.5.7). And for pointer interactions, be careful about triggering an action the instant someone presses down. They should have a chance to change their mind before the action happens. A normal click handler generally gives you that flexibility, while a pointerdown handler fires much earlier.

06 of 06

When a page gets confusing

Sometimes the problem isn't whether someone can see or click something. It's whether they can understand what's happening without having to remember a dozen things at once.

What they get

A menu that changes order from page to page. A form that asks for the same information twice. A password field that won't let you paste. Or a disabled button that gives you absolutely no clue why you can't use it.

What helps

  • Use clear, straightforward language
  • Keep navigation consistent
  • Explain what people need to do and why
  • 3.2.3Consistent NavigationAA
  • 3.2.6Consistent HelpAnew
  • 3.3.7Redundant EntryAnew
  • 3.3.8Accessible AuthenticationAAnew
  • 1.3.5Identify Input PurposeAA
  • 3.3.2Labels or InstructionsA

Cognitive accessibility can sound a bit vague at first, but a lot of the rules are actually pretty concrete. You don't have to reinvent the way people use your website. In fact, keeping things familiar is often the whole point.

Keep your navigation in the same order across pages (3.2.3). If you have a help option, keep it in a consistent place too (3.2.6). Don't ask people to enter the same information again when you've already collected it (3.3.7). Let them paste passwords and use password managers instead of making login harder than it needs to be (3.3.8). And add things like autocomplete to name, email, phone number and postcode fields so the browser can help fill them in (1.3.5).

One thing that caught me out was a disabled button with the reason hidden in a hover tooltip. The problem? A disabled button can't receive keyboard focus, so keyboard users couldn't get to that tooltip. Touch users couldn't hover either, and screen readers weren't necessarily getting the explanation. All they knew was that the button wasn't working. The fix was simple: put the reason in visible text next to the button and connect it using aria-describedby. Don't make people guess why something isn't working, especially when a tiny bit of text can explain it.

What tools catch, and what they miss

Start with axe. It's a browser extension, and running it takes a few seconds. It'll catch things like missing alt text, missing labels, empty buttons, contrast issues and a missing lang attribute. Basically, a lot of the common mistakes we talked about earlier.

But passing axe doesn't mean your website is accessible. Deque, the team behind axe, estimates that automated tools can catch around 57% of accessibility issues. The rest needs a human to check. Does the tab order make sense? Is the alt text actually useful? Does the error message explain how to fix the problem? If there are five links saying 'Read more', can you tell which one goes where?

So after running axe, do two more things. First, put your mouse aside and go through the whole page using Tab. Then try a screen reader. If you're on a Mac, press Cmd + F5 to turn on VoiceOver. On Windows, you can install NVDA for free. Just listen to how your page is read out. It might feel a little strange the first time, but you'll notice things you'd probably miss by just looking at the screen.

One more interesting thing from the WebAIM report: websites using ARIA tend to have more detected accessibility errors on average. That doesn't mean ARIA is bad. It just means it's easy to get wrong. Before adding another ARIA attribute, check whether a normal HTML element already does what you need. Quite often, it does.

What can you actually claim?

This matters more than you might think, especially if you're building something for a client or a public sector organisation. At some point, someone might ask whether the website meets WCAG. It's worth knowing exactly what you're claiming when you answer.

Saying a website meets WCAG 2.2 AA means all the applicable A and AA success criteria have to pass across the pages you're claiming conformance for. It's not enough to run axe once, fix a few issues and call it done. You need to test the site properly and know what's still broken.

If you've still got known issues, be honest about them. Say you're working towards WCAG 2.2 AA, or explain which parts conform and which issues remain. Avoid throwing around phrases like '100% accessible' as if one test can prove that. Accessibility isn't something you can guarantee with a single scan.

It's also worth documenting what you tested, when you tested it, which WCAG version you used, and which browsers and assistive technologies you checked. And if you've met some AAA criteria too, mention the specific ones. Be clear about what you've actually tested instead of making a bigger claim than you can back up.

My checklist before calling it done

Here's what I'd check before shipping a page. For the first few, forget the mouse exists. Seriously, put it aside.

That's a decent place to start. You don't need to memorise every WCAG rule before you can build accessible interfaces. Learn the basics, test what you build, and look up the relevant rule whenever something comes up. The documentation is a lot easier to work through once you have an actual problem in front of you.

I've also put together a more detailed version of this checklist with the rule numbers, contrast checks and questions to ask while reviewing a page. It's included in the agent skill linked near the top of this post, if you want something more structured to work through.