CSSAdvanced

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.

All CSS lessons

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 rem for font sizes so the text follows the visitor’s preferred size.
  • Never block zooming. Do not put user-scalable=no or maximum-scale=1 in the viewport tag.
  • Keep line-height at 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.

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 @import chains in the CSS. @import makes 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, and preconnect for 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> its width and height attributes, or an aspect-ratio in 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.

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 transform and opacity are 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-motion or auto-playing motion with no way to stop it.
  • Using display: none for 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 order or Grid placement to rearrange content so that focus order no longer makes sense.
  • Animating width, height, top or left.
  • Images without dimensions, so the page jumps as they load.
  • @import chains and giant unminified stylesheets on a production site.
  • Optimising without measuring.

Practice

  1. Click Practice in Editor. Press Tab repeatedly. Which elements show a focus ring? Add :focus-visible styles to the links and buttons.
  2. Remove the .skip-link rule, then add it back. Press Tab once on page load and describe what changes.
  3. Make .text--bad pass: find a colour that has a contrast ratio of at least 4.5 : 1 on white, using the Color Converter or Chrome DevTools.
  4. Add the visually-hidden class to the search label. Check that the label is invisible yet still clickable (click where it would be).
  5. Add a .status__dot pulse and then the prefers-reduced-motion block. Test it with DevTools Rendering panel (Emulate CSS media feature prefers-reduced-motion).
  6. Give .media-box aspect-ratio: 16 / 9, then change the width of the preview. Does the box keep its shape?
  7. Zoom the page to 200%. Does anything overlap or scroll sideways?
  8. 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-visible with 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-hidden for 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 @import chains).
  • Prefer animating transform and opacity, use content-visibility for long off-screen sections, and reserve space with aspect-ratio and 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.