Accessibility in CSS

Understand how CSS decisions affect keyboard users, screen reader users, and people with visual or motion sensitivities — and how to style accessibly by default.

Accessibility is often treated as an HTML concern — using the right semantic elements, adding alt text, labeling forms. But CSS has just as much power to help or hurt accessibility. A stylesheet can hide focus outlines, create text nobody can read, mismatch the visual order and the keyboard order, or ignore a user's request for reduced motion. None of these show up as bugs in a typical visual review — you only notice them if you test the way an actual assistive-technology user would. This lesson covers the CSS-specific accessibility issues that matter most.

Never remove focus outlines without a replacement

When you press Tab, the browser draws a focus ring around the currently focused element — usually a blue outline. Keyboard users rely on this entirely to know where they are on the page. Mouse users never see it, which is exactly why it's tempting to remove: outline: none; makes buttons and links look 'cleaner' to someone clicking around with a mouse.

But removing outline without providing something else in its place is one of the most common and most serious accessibility mistakes in CSS. A keyboard-only user — including many people with motor disabilities, and anyone simply tabbing through a form — loses all visual indication of where focus is. They can still interact with the page; they just can't see what they're interacting with.

The fix is not 'never touch outline' — it's 'never remove it without replacing it.' If the default outline clashes with your design, style a custom focus state instead.

Color contrast

Text needs enough contrast against its background to be readable, especially for users with low vision or color blindness. The Web Content Accessibility Guidelines (WCAG) define minimum contrast ratios: at level AA, normal text needs at least 4.5:1 contrast against its background, and large text (roughly 18pt+, or 14pt+ bold) needs at least 3:1.

Light gray text on a white background is a frequent offender — it often looks stylish in a mockup but fails contrast checks and is genuinely hard to read for a large share of users. Browser DevTools and many online tools can measure the exact ratio between two colors, so you don't have to guess.

Respect prefers-reduced-motion

Some users experience real discomfort — dizziness, nausea, or triggered vestibular disorders — from animations, parallax scrolling, and auto-playing motion. Operating systems let users set a 'reduce motion' preference, and CSS can read it with the prefers-reduced-motion media query.

A well-behaved site should wrap non-essential animation in this media query and either shorten it drastically or remove it entirely when the user has asked for reduced motion, rather than forcing animation on everyone regardless of their settings.

Readable text and comfortable touch targets

Text that's technically visible isn't always readable. Body text set too small, or with cramped line-height, is fatiguing to read and especially hard for users with low vision. A reasonable baseline is a body font size of at least 16px and a line-height around 1.5 for paragraph text — tighter line-heights are fine for large headings, but not for blocks of reading text.

Interactive elements — buttons, links, form controls — also need to be physically big enough to tap accurately, especially on touch devices. A commonly cited minimum touch target size is about 44×44 CSS pixels (WCAG's guidance uses a similar 24-44px range depending on the criterion). A tiny icon button with no padding is easy to miss or mis-tap, which is a bigger problem for users with limited fine motor control.

display: none vs. visibility: hidden vs. visually-hidden

These three approaches to 'hiding' content behave very differently for assistive technology, and confusing them is a common mistake:

• display: none — removes the element from the layout entirely and from the accessibility tree. Screen readers skip it completely, as if it doesn't exist. • visibility: hidden — also removes the element from the accessibility tree (and it stays invisible), but it still takes up its layout space. • A 'visually hidden' utility class — hides the element visually (off-screen or clipped to zero size) but keeps it in the accessibility tree, so screen readers still announce it. This is the right tool when you want text that's meant only for screen reader users, like extra context on an icon-only button.

Focus order is DOM order, not CSS order

This is a genuine accessibility trap: keyboard Tab order and screen reader reading order follow the HTML source order, not how things are visually arranged on screen. CSS properties like order (in Flexbox/Grid) or position can move something visually without moving it in the tab sequence at all.

If you use order to visually place a 'Submit' button before a 'Cancel' button, but they appear in the opposite order in the HTML, a sighted mouse user sees Submit first — but a keyboard user tabbing through the form reaches Cancel first. The visual order and the interaction order no longer match, which is confusing and can lead to real mistakes (like accidentally cancelling instead of submitting).

Whenever you use CSS to reorder or reposition interactive content, double-check the tab order still makes sense by actually tabbing through the page — don't assume that visual layout and interaction order automatically stay in sync.

A visually-hidden utility class

This shrinks the element to a 1px box and clips it, rather than using display:none, so screen readers still see and announce the content. It's the standard pattern used by most accessible component libraries — don't reinvent it with just 'font-size: 0' or similar, which can behave inconsistently across screen readers.

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Code Examples

The first block deletes focus indication entirely — a serious regression for keyboard users. The second replaces it with an equally visible custom style. The third uses :focus-visible, which lets browsers show the outline only when it's likely needed (keyboard navigation) while staying clean for mouse clicks.

Common mistake: outline: none; with nothing else — this is the single most common CSS accessibility bug.

The base rule animates the banner in for users who are fine with motion. The media query query checks the operating-system-level 'reduce motion' setting and turns the animation off entirely for users who've opted out, instead of forcing it on everyone.

@media (prefers-reduced-motion: reduce) { .hero-banner { animation: none; } }

The icon button has no visible text label, which would leave a screen reader user hearing nothing useful. The .visually-hidden span supplies a text label that's only ever heard by assistive technology, not seen — exactly the case display:none or visibility:hidden can't handle, since both would hide it from screen readers too.

<span class="visually-hidden">Add to favorites</span> Visually, Submit now appears on the left and Cancel on the right. But pressing Tab still reaches Cancel first, because order only changes visual/painting order in Flexbox — it never changes DOM order or tab order. A keyboard user's mental model (left-to-right, matching what they'd expect from the visual layout) no longer matches what actually happens.

Common mistake: Using order (or absolute positioning) to fix a visual layout without checking whether it now conflicts with the keyboard tab order.

/* Avoid: removes focus indication for everyone */
button:focus {
  outline: none;
}

/* Better: replace it with a custom, visible focus style */
button:focus {
  outline: none;
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.5);
}

/* Even better: only override the default for mouse users,
   keep the native outline for keyboard users */
button:focus:not(:focus-visible) {
  outline: none;
}
button:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}
.hero-banner {
  animation: slide-in 0.8s ease-out;
}

@keyframes slide-in {
  from { transform: translateX(-100%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .hero-banner {
    animation: none;
  }
}
<!-- Removed from layout AND accessibility tree -->
<p style="display: none;">Not visible, not announced, no space taken.</p>

<!-- Invisible but still takes up space; NOT announced -->
<p style="visibility: hidden;">Not visible, not announced, space reserved.</p>

<!-- Visually hidden but STILL announced by screen readers -->
<button>
  <svg aria-hidden="true"><!-- heart icon --></svg>
  <span class="visually-hidden">Add to favorites</span>
</button>
<!-- HTML (DOM/tab order): Cancel, then Submit -->
<div class="form-actions">
  <button class="btn-cancel">Cancel</button>
  <button class="btn-submit">Submit</button>
</div>

<style>
  .form-actions { display: flex; }
  /* Visually moves Submit before Cancel, but tab order is unchanged */
  .btn-submit { order: -1; }
</style>

Hiding methods and what they do to the accessibility tree

Only the visually-hidden pattern keeps content available to screen readers while hiding it from sighted users.

Visible?    Takes space?   Screen reader announces it?
display: none         no             no                   no
visibility: hidden     no            yes                   no
.visually-hidden        no          no*                   yes
(*clipped to ~0px, out of normal flow — visually gone,
 but still present for assistive tech to read)

Important Rules

Never write outline: none without providing an equally visible replacement focus style.

Check text-against-background contrast against WCAG AA (4.5:1 normal text, 3:1 large text).

Wrap non-essential animation in @media (prefers-reduced-motion: reduce) and tone it down or remove it.

Keep body text at 16px+ with roughly 1.5 line-height, and interactive targets around 44×44px.

Use a visually-hidden utility class (not display:none or visibility:hidden) for content meant only for screen readers.

Remember that order and positioning change visual order only — always verify tab order still matches user expectations.

Why this matters

Accessibility failures rooted in CSS are easy to ship because they're invisible in a normal visual QA pass — the page looks perfect to someone clicking around with a mouse. They only surface when someone navigates by keyboard, uses a screen reader, or has a vestibular disorder triggered by unexpected motion — by which point the site may already be in front of real users, or failing a legal accessibility audit. Testing with a keyboard (unplug your mouse for five minutes) and a screen reader is the fastest way to catch these before they ship.

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

Deleting the outline with nothing in its place makes the page unusable for sighted keyboard users, who lose all visual feedback about their current position..sr-only-label { display: none; } <!-- meant to be screen-reader-only text -->.sr-only-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); } display:none hides content from screen readers too, defeating the purpose of a screen-reader-only label. A proper visually-hidden pattern keeps the text in the accessibility tree while hiding it visually.

[Predict the Output] A form has these two buttons in this exact HTML order, styled with Flexbox. What happens when a screen reader user tabs through the form, and how does it compare to what they see visually? <div style="display:flex;"> <button style="order: 2;">Cancel</button> <button style="order: 1;">Submit</button> </div> A. Visually 'Submit' appears first (order:1) and Tab also reaches Submit first, since order affects both. B. Visually 'Submit' appears first (order:1), but Tab reaches 'Cancel' first because tab order follows DOM order, not the CSS order property. C. The order property is invalid outside of CSS Grid, so both buttons appear in their default DOM order for everything. D. Screen readers automatically re-sort focus order to match visual order, so this code causes no mismatch at all.

B. Visually 'Submit' appears first (order:1), but Tab reaches 'Cancel' first because tab order follows DOM order, not the CSS order property. — Flexbox's order property only changes the visual/paint order of flex items — it has no effect on DOM order, and tab order (and screen reader reading order) always follows DOM order. Since 'Cancel' comes first in the HTML, it's reached first by Tab, even though 'Submit' is displayed first visually. This mismatch is a classic CSS accessibility trap.

A card component has an icon-only 'close' button (an X icon, no visible text) styled with outline: none on focus, and the card itself uses visibility: hidden to hide it when dismissed. Identify the two accessibility problems and describe the fix for each.

1) outline: none with no replacement removes focus indication for keyboard users — add a visible :focus-visible style. 2) The icon-only close button has no accessible name — add a visually-hidden span reading 'Close' (or an aria-label) inside the button so screen readers announce what it does.

One problem is about keyboard users losing focus indication; the other is about screen reader users needing a text label for an icon-only control.