CSS Performance and Accessibility: Focus and Contrast
Make CSS fast and usable for everyone: visible focus styles, colour contrast, reduced motion, skip links, hidden text, stable layouts and efficient rendering.
What you will learn
A website that looks great but cannot be used by someone with low vision, a broken wrist or a slow phone is not a good website. The good news is that most of what makes CSS accessible and fast is simple, and a lot of it is writing less and being kind to the browser. In this lesson you will learn how to keep focus visible, check colour contrast, honour reduced motion and other user preferences, add a skip link, hide text properly, keep layouts stable, and write CSS that renders efficiently.
The HTML we will style
<a class="skip-link" href="#main">Skip to main content</a>
<header class="site-header">
<nav class="nav" aria-label="Main">
<a class="nav__link" href="#home">Home</a>
<a class="nav__link" href="#courses">Courses</a>
<a class="nav__link" href="#about">About</a>
</nav>
</header>
<main id="main" class="page">
<h1 class="page-title">Accessible CSS Lab</h1>
<!-- Part 1: contrast -->
<section class="contrast">
<p class="text text--bad">Low contrast text. It looks stylish and is hard to read.</p>
<p class="text text--good">Good contrast text. Comfortable for everyone.</p>
</section>
<!-- Part 2: focus styles. Press Tab to move through these -->
<div class="actions">
<button class="btn" type="button">Save</button>
<a class="btn btn--outline" href="#more">Read more</a>
</div>
<!-- Part 3: a label that is hidden visually but still read by screen readers -->
<form class="search" role="search">
<label class="visually-hidden" for="q">Search lessons</label>
<input class="search__input" id="q" type="search" placeholder="Search lessons">
<button class="btn" type="submit">Search</button>
</form>
<!-- Part 4: reserved space and reduced motion -->
<div class="media-box">
<span class="media-box__label">16:9 box reserved with aspect-ratio</span>
</div>
<p class="status"><span class="status__dot"></span> Live</p>
<!-- Part 5: content-visibility on a long section -->
<section class="long-section">
<h2 class="long-section__title">A section far down the page</h2>
<p>The browser can skip the work of drawing this section until it is close to the screen.</p>
</section>
</main>
| Class | Element | Used for |
|---|---|---|
.skip-link |
the first <a> on the page |
lets keyboard users jump past the navigation |
.site-header, .nav, .nav__link |
the header, its <nav> and three links |
something to Tab through |
#main, .page |
the <main> (the id is the skip link’s target) |
the content area |
.text, .text--bad, .text--good |
two paragraphs | contrast comparison |
.actions, .btn, .btn--outline |
the wrapper, a <button> and a link styled as a button |
focus styles |
.search, .search__input |
the form and the field | form with a hidden label |
.visually-hidden |
the <label> |
hidden on screen, available to screen readers |
.media-box, .media-box__label |
a box and its text | aspect-ratio to prevent layout shift |
.status, .status__dot |
a paragraph and a small dot | reduced motion |
.long-section, .long-section__title |
the last <section> |
content-visibility |
Part 1: Accessibility
Accessibility (often written a11y) means that people with different abilities and devices can use your site. That includes people who are blind or have low vision, colour blindness, motor difficulties, vestibular disorders (motion sickness), or who simply have a bright screen outside. Much of it is decided in CSS.
Focus styles: never hide them
When someone uses the keyboard, the focus ring is their cursor. It shows which link or button will activate when they press Enter. The browser draws one by default. The worst accessibility mistake in CSS is this:
/* NEVER do this without a replacement */
* { outline: none; }
a:focus { outline: 0; }
Instead, improve the focus style. :focus-visible shows it for keyboard users (and not on mouse clicks), which keeps designers happy:
/* the <button class="btn"> and <a class="btn btn--outline"> */
.btn {
display: inline-block;
padding: 0.6rem 1.1rem;
border: 2px solid #1d4ed8;
border-radius: 8px;
background-color: #1d4ed8;
color: white;
font: inherit;
text-decoration: none;
cursor: pointer;
}
.btn:focus-visible {
outline: 3px solid #f59e0b; /* a strong, colourful ring */
outline-offset: 3px; /* a gap, so it does not touch the button */
}
.btn--outline {
background-color: transparent;
color: #1d4ed8;
}
Good focus styles are thick (2 to 3px), high-contrast against both the element and the background (at least 3 : 1) and offset slightly. Press Tab in the preview to see each element in turn.
Do the same for links and form fields:
a:focus-visible,
.search__input:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 2px;
}
Colour contrast
Text is only readable if it stands out from its background. The measure is the contrast ratio, from 1 : 1 (identical colours) to 21 : 1 (black on white). The accessibility guidelines (WCAG) ask for:
| What | Minimum ratio |
|---|---|
| Normal text | 4.5 : 1 |
| Large text (about 24px, or 19px bold, and up) | 3 : 1 |
| Icons, borders of form fields and focus indicators | 3 : 1 |
/* the <p class="text text--bad">: roughly 2.5 : 1 on white. FAILS */
.text--bad {
color: #9ca3af;
background-color: white;
}
/* the <p class="text text--good">: roughly 10 : 1 on white. Passes easily */
.text--good {
color: #374151;
background-color: white;
}
Placeholder text, disabled-looking text, light grey captions and text over images are the usual offenders. Check your pairs with a checker such as the contrast tool in the Color Converter, or the colour picker in Chrome DevTools, which shows the ratio and a pass/fail mark.
Two more rules about colour:
- Never use colour as the only signal. “Fields in red have errors” fails for colour-blind users. Add an icon, a message, or a border style too.
- Links inside paragraphs should be underlined (or otherwise clearly different), not just a slightly different colour.
Reduced motion and other user preferences
Users can tell their device what they need. CSS can listen through media queries:
/* Part 4: the pulsing dot beside "Live" */
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.6); opacity: 0.4; }
}
.status__dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #dc2626;
animation: pulse 1.5s ease-in-out infinite;
}
/* people who asked their system for less movement */
@media (prefers-reduced-motion: reduce) {
.status__dot {
animation: none;
}
}
The same pattern covers other preferences:
| Media feature | Means | A typical response |
|---|---|---|
prefers-reduced-motion: reduce |
wants less animation | remove or shorten animations and smooth scrolling |
prefers-color-scheme: dark |
prefers a dark theme | dark colour variables (see the dark mode lesson) |
prefers-contrast: more |
wants stronger contrast | darker text, stronger borders |
forced-colors: active |
uses a forced palette, such as Windows High Contrast | do not rely on background colours; keep real borders |
hover: none |
the main input cannot hover (touch) | do not hide information behind hover |
For forced colours, remember that custom backgrounds and shadows are replaced by the user’s chosen palette. A button that is only a coloured rectangle may become invisible, so give interactive elements a real border (it can be transparent; it turns visible in forced-colour mode).
Text that scales
- Use
remfor font sizes so the text follows the visitor’s preferred size. - Never block zooming. Do not put
user-scalable=noormaximum-scale=1in the viewport tag. - Keep
line-heightat 1.5 or more for body text and the width of a line to about 65 characters (max-width: 65ch). - Test at 200% zoom. Content must remain usable with no sideways scrolling, which means flexible layouts and no fixed heights on text containers.
Target size
Fingers are larger than mouse pointers. Make buttons and links at least 24 by 24 CSS pixels (the accessibility minimum), and aim for 44 by 44 for the main touch targets. You achieve this with padding rather than by enlarging the text.
The skip link
Keyboard users have to Tab through every navigation link on every page before they reach the content. A skip link is the first focusable element and jumps straight to <main>. It stays hidden until it gets focus:
/* the <a class="skip-link" href="#main"> */
.skip-link {
position: absolute;
top: -100px; /* parked above the visible page */
left: 8px;
padding: 8px 14px;
background-color: #111827;
color: white;
border-radius: 6px;
z-index: 100;
}
.skip-link:focus {
top: 8px; /* slides into view on keyboard focus */
}
The href="#main" points to the id="main" on the <main> element. Press Tab once in the preview to see the link appear.
Hiding things correctly
There are four different ways to hide something, and they are not interchangeable:
| Technique | Visible | Read by screen readers | Takes space |
|---|---|---|---|
display: none |
no | no | no |
visibility: hidden |
no | no | yes |
opacity: 0 |
no | yes | yes |
.visually-hidden (below) |
no | yes | no |
When you want something present for screen readers only, such as the label for a search field that has a visible magnifier icon, use the standard “visually hidden” pattern:
/* the <label class="visually-hidden"> */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
Never use display: none on a label you still want announced.
Do not change the reading order with CSS
Screen readers and the Tab key follow the HTML order. Features such as order, flex-direction: row-reverse and Grid placement change only how it looks. If the visual order and the HTML order disagree, keyboard focus will jump around the page unexpectedly. Fix it in the HTML.
Content from CSS
Text added with ::before { content: "..." } may not be read, or is read unreliably. Use it for decoration only.
Part 2: Performance
CSS is render-blocking: the browser will not draw the page until it has downloaded and processed the stylesheets in the <head>. So the goals are: send less CSS, and make the rendering work cheap.
Send less CSS
- Delete unused CSS. The DevTools Coverage panel shows the percentage of each stylesheet that was never used.
- Minify for production (remove spaces and comments), and make sure the server compresses it (gzip or Brotli). Most hosts do this automatically.
- Combine files and avoid
@importchains in the CSS.@importmakes the browser discover the next file only after downloading the previous one. Link the files in the HTML, or bundle them. - Cache it. Static CSS with a hashed file name can be cached for a very long time.
- Split by purpose when it helps: load a print stylesheet only for printing, so that screens do not wait for it.
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="print.css" media="print">
- Load fonts wisely. Fewer families and weights,
font-display: swap, andpreconnectfor Google Fonts (see the text and fonts lesson).
Keep selectors simple
Browsers match selectors extremely fast. Selector speed is almost never your bottleneck, so do not distort your code to chase it. Simple class selectors are fast and readable. The only things worth avoiding are very broad :has() selectors on body, and giant universal rules that do expensive work on every element.
Make rendering cheap
Drawing a page happens in steps: layout (where things go), paint (what they look like) and composite (layering the result). Changing a property forces the browser back to the step it belongs to:
| Property changed | Forces | Cost |
|---|---|---|
width, height, margin, top, left, font-size |
layout, paint, composite | high |
color, background, box-shadow, border-radius |
paint, composite | medium |
transform, opacity |
composite only | low |
So animate transform and opacity, as you have done throughout the course.
Other tools:
/* skip drawing sections that are off-screen until needed */
.long-section {
content-visibility: auto;
contain-intrinsic-size: auto 400px; /* a placeholder height, so the scrollbar stays stable */
}
content-visibility: auto tells the browser it may skip the layout and painting of the section until it is near the screen, which makes long pages load noticeably faster. contain-intrinsic-size supplies an estimated height until then. Use it on big blocks that are well below the fold, not on content at the top.
/* a hint that this element is about to change: use sparingly */
.menu-panel {
will-change: transform;
}
will-change asks the browser to prepare an element in advance. It uses memory, so apply it only to a few elements that really animate, and remove it when finished. Heavy effects such as large blurs (filter: blur(40px), backdrop-filter) and huge shadows are expensive on phones, so use them sparingly.
Stop the layout jumping
When images, fonts or ads load and push the content around, the page shifts. This is frustrating (you tap the wrong button) and is measured by the Cumulative Layout Shift score. CSS helps you reserve space in advance:
/* the <div class="media-box"> reserves a 16:9 area before any image loads */
.media-box {
aspect-ratio: 16 / 9;
display: grid;
place-items: center;
background-color: #e2e8f0;
border-radius: 12px;
}
.media-box__label {
color: #334155;
font-size: 0.875rem;
}
- Give every
<img>itswidthandheightattributes, or anaspect-ratioin CSS. - Do not insert content above the existing content after the page has loaded.
- A font swap can also nudge text. Choose a fallback font of similar size.
Print styles (a bonus)
A short print stylesheet makes pages friendlier on paper and saves ink:
@media print {
.site-header,
.search,
.actions {
display: none;
}
body {
color: black;
background: white;
}
}
Measure before you optimise
Do not guess. Use Lighthouse (in Chrome DevTools) to audit performance and accessibility together, the Performance panel for rendering problems, and the Coverage panel for unused CSS. Fix the biggest issue first.
Putting it together
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
line-height: 1.6;
color: #1f2937;
}
/* skip link */
.skip-link { position: absolute; top: -100px; left: 8px; padding: 8px 14px; background-color: #111827; color: white; border-radius: 6px; z-index: 100; }
.skip-link:focus { top: 8px; }
.site-header { padding: 12px 20px; background-color: #0f172a; }
.nav__link { margin-right: 16px; color: #e2e8f0; }
.page { max-width: 640px; margin: 0 auto; padding: 24px; }
/* focus styles */
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
/* contrast */
.text--bad { color: #9ca3af; }
.text--good { color: #374151; }
/* buttons */
.btn {
display: inline-block;
padding: 0.6rem 1.1rem;
border: 2px solid #1d4ed8;
border-radius: 8px;
background-color: #1d4ed8;
color: white;
font: inherit;
text-decoration: none;
cursor: pointer;
}
.btn--outline { background-color: transparent; color: #1d4ed8; }
/* form */
.search { display: flex; gap: 8px; margin: 24px 0; }
.search__input { flex: 1; padding: 0.6rem 0.8rem; border: 2px solid #6b7280; border-radius: 8px; font: inherit; }
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* stable layout */
.media-box { aspect-ratio: 16 / 9; display: grid; place-items: center; background-color: #e2e8f0; border-radius: 12px; margin-bottom: 24px; }
/* motion */
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.6); opacity: 0.4; }
}
.status__dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background-color: #dc2626; animation: pulse 1.5s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.status__dot { animation: none; }
html { scroll-behavior: auto; }
}
/* off-screen work */
.long-section { content-visibility: auto; contain-intrinsic-size: auto 400px; }
Quick checklist
- Every interactive element has a visible focus style.
- Text contrast is at least 4.5 : 1 (3 : 1 for large text and UI parts).
- Colour is not the only way to show information.
- Animations respect
prefers-reduced-motion, and nothing flashes. - Text uses
rem, and the page works at 200% zoom. - Targets are at least 24px, ideally 44px.
- There is a skip link, and the visual order matches the HTML order.
- Hidden text uses the right technique.
- Images and media reserve space so the page does not jump.
- Only
transformandopacityare animated. - Unused CSS is removed, and the CSS is minified and compressed.
Common mistakes
- Removing the focus outline with nothing in its place.
- Light grey text on white for captions, placeholders and “subtle” labels.
- Using colour alone to mark errors, required fields or the current page.
- Ignoring
prefers-reduced-motionor auto-playing motion with no way to stop it. - Using
display: nonefor a label that screen readers need. - Fixed pixel font sizes, or blocking zoom in the viewport tag.
- Tiny touch targets and links packed too close together.
- Using
orderor Grid placement to rearrange content so that focus order no longer makes sense. - Animating
width,height,toporleft. - Images without dimensions, so the page jumps as they load.
@importchains and giant unminified stylesheets on a production site.- Optimising without measuring.
Practice
- Click Practice in Editor. Press Tab repeatedly. Which elements show a focus ring? Add
:focus-visiblestyles to the links and buttons. - Remove the
.skip-linkrule, then add it back. Press Tab once on page load and describe what changes. - Make
.text--badpass: find a colour that has a contrast ratio of at least 4.5 : 1 on white, using the Color Converter or Chrome DevTools. - Add the
visually-hiddenclass to the search label. Check that the label is invisible yet still clickable (click where it would be). - Add a
.status__dotpulse and then theprefers-reduced-motionblock. Test it with DevTools Rendering panel (Emulate CSS media feature prefers-reduced-motion). - Give
.media-boxaspect-ratio: 16 / 9, then change the width of the preview. Does the box keep its shape? - Zoom the page to 200%. Does anything overlap or scroll sideways?
- Run a Lighthouse audit in Chrome DevTools on one of your pages and fix the first accessibility warning it reports.
Recap
- Never remove focus outlines. Use
:focus-visiblewith a thick, high-contrast, offset ring. - Aim for contrast of 4.5 : 1 for normal text and 3 : 1 for large text and UI parts, and never use colour as the only signal.
- Listen to user preferences:
prefers-reduced-motion,prefers-color-scheme,prefers-contrast,forced-colors. - Use
rem, allow zoom, make targets large, add a skip link, hide text correctly (.visually-hiddenfor screen-reader-only text) and keep the visual order equal to the HTML order. - CSS blocks rendering: ship less of it (remove unused rules, minify, compress, avoid
@importchains). - Prefer animating
transformandopacity, usecontent-visibilityfor long off-screen sections, and reserve space withaspect-ratioand image dimensions so the page does not shift. - Measure with Lighthouse, Coverage and the Performance panel before you optimise.
- Last in the course: a one-page cheat sheet of everything you have learned.