CSS Accessibility: WCAG Compliance for Web Designers
Accessibility is not optional for professional web design service work. It's a legal requirement in many jurisdictions and increasingly demanded by clients. The good news: most CSS accessibility work is a small set of well-understood patterns applied consistently.
1. The Accessibility CSS Checklist
□ Focus rings: visible on all interactive elements
□ Colour contrast: 4.5:1 for text, 3:1 for large text and UI
□ Motion: respect prefers-reduced-motion
□ High contrast: work in forced-colors (Windows)
□ Sr-only: screen-reader-only content for context
□ Skip links: bypass navigation for keyboard users
□ Print styles: page prints cleanly without nav/ads
□ Hover + focus: never style one without the other
□ Placeholder text: never as a substitute for labels
□ Touch targets: minimum 44×44px
2. Focus and Focus-Visible
The single most important CSS accessibility rule: never remove focus outlines without replacing them.
/* ❌ The most common accessibility mistake */
* { outline: none; } /* Removes ALL focus indicators */
button:focus { outline: none; } /* Keyboard users can't see where they are */
/* ✅ The correct approach: style focus-visible, not focus */
/* focus-visible: only shows on keyboard navigation, not mouse click */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
border-radius: 2px;
}
/* Per-component focus rings */
.c-btn:focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
}
.c-nav__link:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.c-form__input:focus-visible {
outline: none; /* Remove default */
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.25);
}
/* Card focus (entire card is a link) */
.c-card:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 4px;
}
Focus-Within: Parent Highlights When Child is Focused
/* Highlight the entire form group when input is focused */
.c-form__group:focus-within {
background: rgba(108, 99, 255, 0.03);
border-radius: var(--radius-md);
}
/* Search bar: highlight wrapper when input focused */
.c-search:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.2);
}
/* Nav item: show dropdown when item or dropdown itself is focused */
.c-nav__item:focus-within .c-nav__dropdown {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
3. Colour Contrast
WCAG AA requires minimum contrast ratios:
| Text Type | Minimum Ratio |
|---|---|
| Normal text (< 18px) | 4.5:1 |
| Large text (≥ 18px normal, ≥ 14px bold) | 3:1 |
| UI components, icons, focus rings | 3:1 |
| Decorative elements | None |
/* Test your colour combinations — these are WCAG AA compliant */
/* Dark background + light text */
:root { --color-bg: #0f0f0f; --color-text: #f0f0f0; }
/* #f0f0f0 on #0f0f0f = 17.7:1 ✅ Passes AAA */
/* Muted text on dark bg */
:root { --color-text-muted: #888888; }
/* #888888 on #0f0f0f = 4.6:1 ✅ Passes AA */
/* #666666 on #0f0f0f = 2.7:1 ❌ Fails AA */
/* Primary on dark */
:root { --color-primary: hsl(245, 80%, 68%); }
/* Light purple on dark bg — check with WebAIM contrast checker */
/* Avoid these common contrast failures */
.text-danger {
/* ❌ color: hsl(0, 80%, 55%); — on white bg: 3.8:1 (fails for small text) */
color: hsl(0, 80%, 42%); /* ✅ Darker red: 5.9:1 on white */
}
.text-success {
/* ❌ color: hsl(145, 65%, 55%); — on white: 2.4:1 */
color: hsl(145, 65%, 32%); /* ✅ Darker green: 5.1:1 on white */
}
Tools for Contrast Checking
DevTools: Inspect element → click color swatch → contrast ratio shown
WebAIM: https://webaim.org/resources/contrastchecker/
OKLCH: https://oklch.com/ — pick colors with built-in WCAG indicators
Polypane: Browser with built-in contrast checking
4. prefers-reduced-motion
Some users experience nausea, vertigo, or seizures from motion. Always respect this:
/* ── Global reduced-motion reset ── */
@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;
}
}
/* ── Component-level motion handling ── */
/* Hero animation: fade-up on load */
.c-hero__title {
animation: fadeUp 0.8s ease both;
}
@media (prefers-reduced-motion: reduce) {
.c-hero__title {
animation: none; /* Remove entirely — element just appears */
opacity: 1;
transform: none;
}
}
/* Carousel: disable snap + smooth scroll */
@media (prefers-reduced-motion: reduce) {
.c-carousel__track { scroll-snap-type: none; scroll-behavior: auto; }
.c-fullpage { scroll-snap-type: none; scroll-behavior: auto; }
}
/* Parallax: stop completely — can cause severe motion sickness */
@media (prefers-reduced-motion: reduce) {
.c-parallax { transform: none !important; }
}
/* Transition-safe pattern: use @media to ENABLE motion, not disable it */
/* This approach: motion off by default, enabled for users who can handle it */
.c-btn {
/* No transition by default */
background: var(--color-primary);
}
@media (prefers-reduced-motion: no-preference) {
.c-btn {
transition: background 0.2s ease; /* Only add if motion is OK */
}
}
5. prefers-contrast
Some users need much stronger contrast than standard WCAG AA:
/* High contrast mode: increase distinction between elements */
@media (prefers-contrast: more) {
:root {
--color-border: rgba(0, 0, 0, 0.6); /* Stronger borders */
--color-text-muted: #444444; /* Darker muted text */
}
/* Stronger focus rings */
:focus-visible {
outline-width: 3px;
outline-color: #000000;
}
/* Remove subtle backgrounds that may reduce contrast */
.c-card {
border: 2px solid var(--color-text);
background: var(--color-bg);
}
}
/* Less contrast: some users find standard contrast too harsh */
@media (prefers-contrast: less) {
:root {
--color-text: #333333; /* Slightly lighter text */
--color-border: rgba(0,0,0,0.05);
}
}
6. forced-colors (Windows High Contrast Mode)
Windows High Contrast Mode overrides all your colours with system colours. Your layout must still work:
/* forced-colors: active = Windows HCM is on */
@media (forced-colors: active) {
/* System colour keywords available in forced-colors mode */
/* ButtonText, ButtonFace, Canvas, CanvasText, LinkText, etc. */
/* Ensure focus rings use system colour */
:focus-visible {
outline: 2px solid ButtonText;
}
/* Preserve icon button visibility */
.c-btn--icon {
forced-color-adjust: none; /* Opt-out of forced color for this element */
border: 1px solid ButtonText;
}
/* Ensure SVG icons are visible */
.c-icon {
fill: ButtonText;
stroke: ButtonText;
}
/* Custom focus styles that survive HCM */
.c-nav__link:focus-visible {
outline: 3px solid Highlight;
outline-offset: 2px;
}
}
/* Ensure important info isn't color-only */
/* ❌ Bad: status indicated only by color */
.status-dot { background: green; } /* Invisible in HCM */
/* ✅ Good: color + icon/text/border */
.c-status { color: var(--color-success); }
.c-status::before { content: '✓ '; } /* Always visible */
7. The sr-only (Screen Reader Only) Pattern
The most useful accessibility CSS utility — visually hidden but read by screen readers:
/* The standard sr-only pattern */
.u-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Focusable sr-only: shows when focused (for skip links) */
.u-sr-only-focusable:not(:focus):not(:focus-within) {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
When to Use sr-only
<!-- Icon buttons — add screen reader label -->
<button class="c-btn c-btn--icon" aria-label="Close">
<svg>...</svg>
<!-- Or: -->
<span class="u-sr-only">Close menu</span>
</button>
<!-- Decorative dashes / separators that should be skipped -->
<span aria-hidden="true">•</span>
<!-- Adding context to links that say "Read more" -->
<a href="/post/1">
Read more
<span class="u-sr-only">about Getting Started with BEM</span>
</a>
<!-- Table column headers in mobile view -->
<td data-label="Price">
<span class="u-sr-only">Price: </span>$29
</td>
<!-- Count notification badges -->
<button class="c-btn">
Messages
<span class="c-badge" aria-label="3 unread messages">3</span>
</button>
8. Skip Links
Skip links let keyboard users bypass navigation and jump straight to content:
<!-- First element in <body> — before nav -->
<a class="c-skip-link" href="#main-content">Skip to main content</a>
<nav>...</nav>
<main id="main-content" tabindex="-1">
<!-- tabindex="-1" allows programmatic focus -->
...
</main>
/* Skip link: hidden by default, visible on focus */
.c-skip-link {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%) translateY(-100%);
z-index: var(--z-max, 9999);
background: var(--color-primary);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0 0 var(--radius-md) var(--radius-md);
font-weight: 600;
text-decoration: none;
transition: transform 0.2s ease;
white-space: nowrap;
}
/* Show on focus (keyboard user presses Tab as first action) */
.c-skip-link:focus {
transform: translateX(-50%) translateY(0);
outline: 3px solid white;
outline-offset: -3px;
}
9. Touch Target Sizes
/* WCAG 2.5.5: Touch targets minimum 44×44px */
/* WCAG 2.5.8 (2.2): Minimum 24×24px, with spacing */
/* Small icon buttons — use padding to expand the tap area */
.c-btn--icon {
width: 2rem; /* Visual size: 32px */
height: 2rem;
padding: 0.375rem; /* Expands hit area */
/* Or: minimum 44px tap area without changing visual size */
position: relative;
}
.c-btn--icon::after {
content: '';
position: absolute;
inset: -6px; /* Expand tappable area -6px all sides */
}
/* Navigation links on mobile: ensure adequate height */
.c-nav__link {
padding-block: 0.75rem; /* At least 44px total height */
min-height: 44px;
display: flex;
align-items: center;
}
/* Checkboxes and radio buttons — custom styled */
.c-checkbox { min-width: 44px; min-height: 44px; }
/* Form inputs */
.c-form__input {
min-height: 44px; /* WCAG touch target */
padding: 0.625rem 1rem;
}
10. Print Styles
/* Print-specific CSS — always include in production sites */
@media print {
/* ── Hide non-essential elements ── */
.c-nav,
.c-sidebar,
.c-footer,
.c-cookie-banner,
.c-chat-widget,
.c-newsletter,
.c-btn,
[aria-hidden="true"] {
display: none !important;
}
/* ── Page setup ── */
@page {
margin: 20mm;
size: A4;
}
@page :first { margin-top: 30mm; }
/* ── Typography for print ── */
body {
font-family: Georgia, 'Times New Roman', serif;
font-size: 12pt;
line-height: 1.5;
color: #000000;
background: #ffffff;
}
h1, h2, h3 { page-break-after: avoid; }
p, blockquote { orphans: 3; widows: 3; }
/* ── Show URLs after links ── */
a[href^="http"]::after {
content: ' (' attr(href) ')';
font-size: 0.85em;
color: #555;
}
a[href^="#"]::after { content: ''; } /* Skip anchor links */
/* ── Expand collapsed elements ── */
.c-accordion__content {
display: block !important;
max-height: none !important;
}
/* ── Images ── */
img { max-width: 100% !important; page-break-inside: avoid; }
/* ── Code blocks ── */
pre, code {
border: 1px solid #ccc;
white-space: pre-wrap;
word-break: break-word;
}
}
11. WCAG CSS Quick Reference
| WCAG Criterion | CSS Solution | Priority |
|---|---|---|
| 1.4.3 Contrast (text) | ≥ 4.5:1 ratio for normal text | AA Required |
| 1.4.11 Contrast (UI) | ≥ 3:1 for borders, icons, focus | AA Required |
| 2.4.7 Focus Visible | :focus-visible with visible ring | AA Required |
| 2.5.3 Label in Name | aria-label matching visible text | AA Required |
| 2.5.5 Target Size | 44×44px minimum touch area | AAA |
| 2.5.8 Target Size (2.2) | 24×24px min with spacing | AA |
| 1.4.10 Reflow | No horizontal scroll at 320px | AA Required |
| 1.4.12 Text Spacing | Works when user overrides spacing | AA Required |
| 2.3.3 Animation | Respect prefers-reduced-motion | AAA |
| 1.3.4 Orientation | No CSS orientation: landscape lock | AA Required |