Type something to search...
CSS Accessibility Best Practices Every Developer Should Know

CSS Accessibility Best Practices Every Developer Should Know

When developers talk about web accessibility, the conversation usually turns to semantic HTML, ARIA attributes, and screen readers. Those matter enormously. But a surprising number of real accessibility failures come from CSS: text that's too faint to read, layouts that break when someone zooms in, focus indicators deleted by a reset, content hidden in a way that also hides it from assistive technology, and animations that make people feel sick.

CSS controls how everyone perceives your content, so it's where a lot of accessibility is won or lost. This guide collects the practices I check on every project, with code you can drop straight into your stylesheets.

1. Meet Color Contrast Requirements

Low contrast is the most common accessibility problem on the web. WCAG 2.2 sets these minimum contrast ratios at Level AA:

  • 4.5:1 for normal body text
  • 3:1 for large text (roughly 24px regular, or about 18.5px bold, and larger)
  • 3:1 for user interface components and meaningful graphics, like input borders, icons, and focus indicators

Light grey placeholder text, pale text on photos, and "subtle" borders on form fields are frequent offenders.

/* Too faint: roughly 2.3:1 on white */
.caption {
  color: #a0aec0;
}

/* Passes AA: roughly 7:1 on white */
.caption {
  color: #475569;
}

Check contrast with your browser's DevTools (the color picker shows the ratio and flags failures) or a dedicated contrast checker. Remember to check every state: hover, focus, disabled, visited, error, and dark mode.

Don't Use Color Alone

About 1 in 12 men and 1 in 200 women have some form of color vision deficiency. If the only difference between a valid and an invalid field is a red border, some users won't see it. Pair color with another cue:

.field--error input {
  border-color: #b91c1c;
  border-width: 2px;
}

.field--error .field__message::before {
  content: "Error: ";
  font-weight: 700;
}

Links in body text are a classic case. If they're distinguished from surrounding text only by color, keep the underline:

.prose a {
  color: #1d4ed8;
  text-decoration: underline;
  text-underline-offset: 0.15em;
  text-decoration-thickness: 1px;
}

.prose a:hover {
  text-decoration-thickness: 2px;
}

2. Never Remove Focus Indicators Without a Replacement

Keyboard users, switch users, and many people with motor impairments navigate by moving focus from element to element. The focus indicator is their cursor. This line, still found in far too many stylesheets, makes the page unusable for them:

/* Please don't */
*:focus {
  outline: none;
}

If you don't like the default outline, style it rather than remove it, and use :focus-visible so it appears for keyboard navigation without showing on every mouse click:

:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}

Make sure the indicator has at least 3:1 contrast against the surrounding colors, and that it isn't hidden by overflow: hidden on a parent or covered by a sticky header. WCAG 2.2 added criterion 2.4.11 Focus Not Obscured, which requires the focused element not to be completely hidden by author-created content like sticky footers and cookie banners. scroll-padding helps here:

html {
  scroll-padding-top: 5rem; /* height of the sticky header */
  scroll-padding-bottom: 4rem; /* height of a sticky footer bar */
}

3. Support Text Resizing and Zoom

People with low vision often increase their browser's default font size or zoom to 200% or more. WCAG requires content to work at 200% text size and to reflow at a width equivalent to 320 CSS pixels without horizontal scrolling (criterion 1.4.10).

Use Relative Units for Text

/* No font-size on html: the user's default (usually 16px) applies */

body {
  font-size: 1rem;
  line-height: 1.6;
}

h1 {
  font-size: clamp(1.75rem, 1.25rem + 2vw, 2.75rem);
}

Avoid setting font-size in px on html or body, since that overrides the user's chosen default in some situations. And when using fluid type with vw, always mix in a rem value. Pure vw font sizes don't grow when a user zooms, which fails the resize text requirement.

Don't Fix Heights on Text Containers

/* Breaks at larger text sizes: content overflows */
.banner {
  height: 80px;
}

/* Grows with its content */
.banner {
  min-height: 80px;
  padding-block: 1rem;
}

Let Layouts Reflow

Test at 320px wide with zoom. Common reflow failures are fixed-width containers, long unbroken strings like URLs, and data tables. These help:

.container {
  width: min(100% - 2rem, 70rem);
  margin-inline: auto;
}

.prose {
  overflow-wrap: break-word;
}

.table-wrapper {
  overflow-x: auto; /* tables may scroll; the page shouldn't */
}

Data tables and maps are allowed to scroll in two dimensions under WCAG. The page as a whole shouldn't.

4. Respect Text Spacing Overrides

Some users apply custom stylesheets or browser extensions to increase line height, letter spacing, word spacing, and paragraph spacing, which helps with dyslexia and low vision. WCAG 1.4.12 requires that content still works when users set:

  • line height to 1.5 times the font size
  • paragraph spacing to 2 times the font size
  • letter spacing to 0.12 times the font size
  • word spacing to 0.16 times the font size

If your buttons or cards have fixed heights, or text is clipped with overflow: hidden, it will break under those settings. Use min-height and padding rather than fixed dimensions, and avoid white-space: nowrap on anything that might contain more than a couple of words.

5. Hide Content Correctly

There are several ways to hide things in CSS, and they behave very differently for assistive technology.

TechniqueVisibleRead by screen readersFocusable
display: noneNoNoNo
visibility: hiddenNo (keeps its space)NoNo
hidden attributeNoNoNo
opacity: 0NoYesYes
Visually hidden classNoYesYes

Use display: none or the hidden attribute to hide something from everyone, like a closed menu or an inactive tab panel.

Use a visually hidden utility to hide something only from sighted users, like a descriptive label for an icon-only button or a "(opens in new tab)" note:

.visually-hidden:not(:focus):not(:active) {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
<button class="icon-button" type="button">
  <svg aria-hidden="true" focusable="false" width="20" height="20">
    <!-- icon -->
  </svg>
  <span class="visually-hidden">Close dialog</span>
</button>

The :not(:focus) part means the element becomes visible if it receives focus, which is what you want for skip links.

Beware opacity: 0: the element is invisible but still focusable and clickable. A keyboard user can tab into an invisible menu and have no idea where they are.

Skip Links

A skip link lets keyboard users jump past the navigation straight to the main content:

<a class="skip-link" href="#main">Skip to main content</a>
<header>...</header>
<main id="main" tabindex="-1">...</main>
.skip-link {
  position: absolute;
  left: 1rem;
  top: -100%;
  padding: 0.75rem 1rem;
  background: #0f172a;
  color: #ffffff;
  z-index: 100;
}

.skip-link:focus {
  top: 1rem;
}

6. Keep Visual Order and DOM Order in Sync

Flexbox and Grid make it easy to reorder content visually with order, flex-direction: row-reverse, or grid placement. But keyboard focus and screen readers follow the DOM order, not the visual order. If they don't match, focus jumps around the screen unpredictably, which fails WCAG 2.4.3 Focus Order.

/* Risky if these items contain links or buttons */
.feature-list .featured {
  order: -1;
}

Reorder freely for purely decorative arrangements, but for interactive content, change the source order instead. The reading-flow property is being developed to let the browser follow the visual order in grid and flex layouts. It has shipped in Chromium-based browsers, but it isn't cross-browser yet, so don't rely on it alone.

7. Make Touch Targets Big Enough

WCAG 2.2 added criterion 2.5.8 Target Size (Minimum) at Level AA: interactive targets should be at least 24×24 CSS pixels, or have enough spacing around them. For comfortable touch use, 44×44 is a better goal.

.icon-button {
  display: inline-grid;
  place-items: center;
  min-width: 44px;
  min-height: 44px;
  padding: 0.5rem;
}

/* Expand the hit area of a small inline link without changing layout */
.tag-link {
  position: relative;
}

.tag-link::after {
  content: "";
  position: absolute;
  inset: -8px;
}

8. Honor User Preferences

Operating systems let people ask for reduced motion, higher contrast, dark mode, and forced colors. CSS can read all of these.

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

@media (prefers-contrast: more) {
  :root {
    --border-color: #0f172a;
    --text-muted: #1e293b;
  }
}

Forced Colors Mode

In Windows Contrast Themes, the browser replaces your colors with the user's palette, and box-shadow and background images are removed. Anything you drew with only a shadow or background, such as a focus ring, a custom checkbox, or a card border, can disappear. A transparent border or outline is a simple safety net because it becomes visible in forced colors:

.card {
  border: 1px solid transparent;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.2);
}

.button:focus-visible {
  outline: 2px solid transparent;
  box-shadow: 0 0 0 3px #2563eb;
}

@media (forced-colors: active) {
  .button:focus-visible {
    outline-color: Highlight;
  }
}

9. Be Careful with Generated Content

Text inserted with ::before and ::after is read by most screen readers, and it can't be selected, copied, or translated reliably. Keep meaningful content in the HTML and use pseudo-elements for decoration.

/* Decorative: fine */
.quote::before {
  content: "\201C";
}

/* Meaningful: this belongs in the HTML */
.price::after {
  content: " per month";
}

Modern browsers support alternative text for generated content, which is useful for decorative icons you want screen readers to skip:

.new-badge::before {
  content: "\2605" / "";
}

The empty string after the slash marks the star as decorative. It's supported in current Chromium and Safari and newer Firefox versions, and in browsers that don't understand it the whole declaration is dropped, so declare a plain content value first as a fallback if the icon matters visually.

10. Don't Break Native Semantics with display

Changing display on table elements, or on lists styled with list-style: none, has historically caused some browsers to drop their semantics from the accessibility tree. Browsers have fixed most of these bugs, but a known quirk remains in Safari: lists with list-style: none may not be announced as lists. If the list semantics matter, add role="list":

<ul class="nav-list" role="list">
  <li><a href="/">Home</a></li>
  <li><a href="/pricing">Pricing</a></li>
</ul>

For responsive tables that switch to display: block on small screens, test with a screen reader and consider adding explicit ARIA table roles.

A Quick Testing Checklist

  • Tab through every page with the keyboard only. Can you see where focus is at all times?
  • Zoom to 200% and 400%. Does anything overflow, overlap, or get cut off?
  • Set your browser's default font size to 24px. Does the layout still hold?
  • Run a contrast check on text, borders, and icons, in every theme.
  • Turn on reduced motion, increased contrast, and forced colors.
  • Run an automated checker like axe or Lighthouse, then remember that automation only catches a fraction of issues.

Conclusion

Accessible CSS isn't a separate discipline. It's regular CSS written with everyone in mind. Keep contrast high, keep focus visible, let text resize and reflow, hide content with the right technique, keep visual order aligned with the DOM, and respect the preferences people have set on their devices.

Pick one item from this list and audit your current project against it this week. Small, steady fixes add up to a site that works for far more people.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading