Writing even more CSS with Accessibility in Mind, Part 1: Progressive Enhancement

posted on

About 4 years ago, I began to focus on web accessibility professionally. I read many articles and books, watched talks, followed experts, and I also shared my knowledge at meet-ups and online. The first 3 articles I wrote were Writing HTML with Accessibility in Mind, Writing JavaScript with Accessibility in Mind, and Writing CSS with Accessibility in Mind. I've shared the most exciting new things I've learned about creating inclusive experiences in each language.

I wrote Writing CSS with Accessibility in Mind in 2017 and I’ve covered topics like font size, line height, print style sheets, hiding content, contrast, DOM order vs. visual order and focus styles. 3 years have passed, CSS has evolved, and I’ve learned new things. Therefore, I’ve decided to write about even more CSS with accessibility in mind.

In this series

This series of articles covers 4 major topics:

  1. Progressive enhancement (this article)
  2. User preferences (coming soon)
  3. CSS and semantics (coming soon)
  4. Improving accessibility with CSS (coming soon)

Progressive enhancement basics

I strongly believe in progressive enhancement because it focuses on content and enhances experiences layer by layer. We start with a basic but resilient foundation that works in most browsers: a well structured and semantic HTML document. We enhance it with design and visual improvements by adding CSS. If the browser doesn’t support CSS at all or just some properties, the site is still accessible, thanks to our strong foundation. Finally, we may add JavaScript to enhance the experience even more. We should be careful with JavaScript because it can affect performance negatively, especially on mobile devices.

  1. HTML - Semantic markup
  2. CSS - Design and visual improvements
  3. JS - Enhanceed experience

Example

Let’s take a simple form that allows users to enable tracking.

HTML

We want to make sure that the only real dependency is core HTML.
<form>
<input type="checkbox" id="tracking" class="visually-hidden">
<label for="tracking">Turn on tracking.</label>

<button type="submit">Save settings</button>
</form>

The button does nothing here. Let’s just assume that there’s a server-side script that saves the settings.

CSS

Our checkbox looks a bit boring, let’s add another layer. We can improve the design with CSS. We add a class (.visually-hidden) to hide the checkbox visually and use pseudo elements to create something that looks like a switch toggle.

.label {
padding-left: 7.5rem;
position: relative;
}

.label::before,
.label::after {

position: absolute;
left: 0;
top: 0;
content: "";
display: inline-block;
transition: transform 0.3s;
height: 2.6rem;
}

.label::before {
width: 6rem;
border: 2px solid #aaa;
border-radius: 4px;
}

.label::after {
width: 3rem;
background: #aaa;
left: 0.2rem;
top: 0.2rem;
}

[type="checkbox"]:focus + .label::before {
border-color: #1c4e6c;
outline-offset: 2px;
outline: 2px solid #f23c50;
}

[type="checkbox"]:checked + .label::before {
border-color: #1c4e6c;
}

[type="checkbox"]:checked + label::after {
border-color: #1c4e6c;
background-color: #1c4e6c;
transform: translateX(3rem);
}

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

JS

Now we can enhance the form some more and save settings as the user clicks the checkbox so that their settings persist even when the page crashes or they close the window accidentally.

  document.querySelector('#tracking').addEventListener('change', function(e) {
// Save in database or in a local storage, etc..
alert('Saved: ' + e.target.checked)
})

Please note that this switch toggle is not accessible because it doesn’t communicate state well enough. I just built it that way to illustrate progressive enhancement. If you want to learn how to create accessible toggle buttons, read Toggle Buttons by Heydon Pickering.

We've started with a basic but resilient foundation that works in most browsers and we've enhanced it feature by feature. Instead of loading multiple megabytes of polyfills, compiled JavaScript and CSS workarounds onto users, we only give browsers code they can handle without additional help. This usually results in less JavaScript and CSS, better performance and happier users. Progressive enhancement is the key to giving more people access to our content by serving code according to the capabilities of the end user’s browser and device.

I’ve been following the basics of this principle for many years, but only recently I discovered how I can bring my practices up to date and use progressive enhancement together with modern CSS and JavaScript.

Rediscovering Progressive Enhancement

Nokia released an updated version of its iconic Nokia 3310 a few years ago. I bought it because it was affordable and I wanted to see how the surfing experience was in Opera Mini. You can download Opera Mini to your Android or iOS phone, too, but by default there isn’t much of a difference between Opera Mini and the default browser on these devices. Opera Mini on the Nokia 3310 runs on an operating system called Nokia Series 30+ and its function range is quite limited. If you search for almost any feature on caniuse.com and you see a red rectangle, that’s the Opera Mini we’re talking about. I’ve tested a website I’ve recently built using modern CSS and JS on the Nokia 3310 and after some minor tweaks it worked. Just like that. Guess why! Exactly, progressive enhancement.
The fact that JavaScript is just another layer and not a dependency allows users with low-end devices to access the website.

  1. HTML - Semantic markup
  2. CSS - Design and visual improvements
  3. JS - Enhanceed experience

You can read more about the process in The beauty of progressive enhancement.

HTML, CSS and JS are large layers in our progressively enhanced website, but each layer may comprise even more layers.

Progressively enhancing CSS

There are different ways in CSS to define layers and help browsers serve them accordingly.

Let CSS do its thing

CSS has progressive enhancement at its core. This is best illustrated by its error handling: When errors occur in CSS, the parser doesn’t stop, but it attempts to only skip content it can’t interpret before returning to parsing as normal.

The following code won’t throw any errors, the CSS parser will just skip the line it doesn’t understand and apply a #153a51 color and #36b1bf background color to all div elements.

div {
color: #153a51;
css-is: amazing;
background: #36b1bf;
}
CSS <3

The parser skips the second line in the declaration block because the property css-is doesn’t exist, but errors aren’t always mistakes. A browser like Firefox might interpret a new property without issues, while the same property looks like an error in Internet Explorer. For example: If you add the following rule to a style sheet, modern browsers apply a grid with 2 150px columns to each unordered list, while browsers that don’t support CSS Grid Layout just skip the Grid declarations.

ul {
display: flex; /* Fallback for older browsers */
flex-wrap: wrap; /* Allow items to wrap */
display: grid; /* Most modern browsers */
grid-template-columns: repeat(auto-fill, 150px) /* Add as many 150px columns per line as possible */
}

ul > * {
border: 1px solid #36b1bf;
margin: 0 0.5rem;
}

A modern browser first sets the display property to flex and allows wrapping of flex items, then it overwrites the first declaration and sets the display to grid and forgets about wrapping again because flex-wrap doesn’t work with grid items. Last, it adds grid columns with its own wrapping mechanism.

  • Element 1
  • Element 2

An older browser sets display to flex and allows wrapping of flex items and skips the rest. The list looks a little different, but most users probably won’t even notice.

  • Element 1
  • Element 2

Here’s another example:
We can use shape-outside to make a paragraph that wraps an image look more interesting.

A photo of a dog from the side looking up wearing a red party hat with white dots and text that wraps around the shape of the dogs head and body.

<img src="dog.jpg" width="400" alt="A dog from the side looking up wearing a red party hat with white dots" />
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Deserunt…
</p>
img {
shape-outside: polygon(0.23% 2px, 17.11% 0.84%, 61.14% 21.01%, 69.91% 20.17%, 86.88% 27.73%, 90.64% 36.09%, 86.53% 50.56%, 80.07% 79.29%, 86.55% 99.48%, 0px 100%);
shape-margin: 20px;
float: left;
display: inline-block;
}

Most browsers support the shape-outside and shape-margin properties. Edge < 18, Internet Explorer, and Opera Mini are an exception. This is what users of these browsers will get.

A photo of a dog from the side looking up wearing a red party hat with white dots and text that wraps around the photo.

It still looks nice and it’s accessible, just not as fancy. Check out CSS Shapes Demo / shape-outside on CodePen.

Cutting the mustard

If people visit our websites in browsers like IE 11 or Opera Mini, it’s fair to assume that they’re not using a high-end device. I like to make sure that they only get as much code as they need to access the content and still get a decent experience. Anything extra will only be served to more capable browsers and potentially faster devices. This means I have to decide at which point I want to draw the line between full and limited functionality for certain features. There are different ways of doing that.

Let CSS do its thing 2

Again, we can let CSS do its thing. For example, if we don’t want to bother users of legacy browsers with having to download hundreds of kilobytes in font files, we can serve our fonts only in the woff2 format (see .woff2 support on caniuse.com).

@font-face {
font-family: 'Lobster';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Lobster'), url('fonts/Lobster-Regular.woff2') format('woff2');
}

body {
font-family: Lobster, sans-serif;
}

Since a browser like Internet Explorer doesn’t support woff2 it won’t try to download the file and it will use the sans-serif fallback font. (Instead of just relying on the generic font family as a fallback, you could also use a system font similar in style and shape).

Feature detection in CSS

Another approach, often referred to as “cutting the mustard”, is to check whether a browser supports a certain feature and only then, if it cuts the mustard, serve additional code.

We could make a basic vertical layout and enhance it only if the browser supports custom properties. Feature detection is built into CSS in the form of the @supports at-rule aka feature queries.

:root {
--display: flex;
--gap: 1rem;
}

/* Applied in all browsers */
ul {
margin: 0;
padding: 0;
list-style: none;
}

/* Only browsers that support custom properties */
@supports (display: var(--supports)) {
ul {
display: var(--display);
gap: var(--gap);
flex-wrap: wrap;
}
}

Most browsers support feature queries.

Note: It’s not possible to check support for a property only, you have to provide a value for the property. Something like @supports (display) {} won’t work.

Feature detection in JS

I've built a large website recently with many components, some of them enhanced with JavaScript. Each component works with and without JavaScript. This is important because we only serve a critical amount of JavaScript to users of legacy browsers, which means that they will see most components in their no-JS state.

We’re cutting the mustard by adding this block of Javascript to the <head> of our site.

<head>
<script type="module">
// Add the `.js` class to the <html> element
document.documentElement.classList.add('js')
</script>

</head>

The type="module" attribute and value ensures that the scripts block will be only executed, if the browsers supports JavaScript modules. What’s great about this is that even with the attribute in place we don’t actually have to use JavaScript modules, we can write our JavaScript as usual.

If the <html> element contains the class js, we know that it’s a modern browser because it supports JavaScript modules. This allows us to style components accordingly.

In an accordion component, for example, the content is visible by default.

<body>
<div class="accordion">
<h3 class="accordion__heading">Accordion Heading</h3>
<div class="accordion__panel">
<p>Accordion panel content visibile by default and only hidden, if the `.js` class is present.
</div>
</div>
</body>

Accordion Heading

Accordion panel content visibile by default and only hidden, if the `.js` class is present.

We hide the content (.accordion__panel) and add attributes, events, etc. only if the browsers cuts the mustard.

.js .accordion__panel {
display: none;
}

.js .accordion__panel--visible {
display: block;
}

.accordion__heading button {
appearance: none;
border: none;
padding: none;
font-size: inherit;
font-family: inherit;
padding: 0;
color: inherit;
font-weight: inherit;
}

We replace the text content within the heading with a button and add a click event to the button that toggles the visibility of the panel.

  // Select elements
const accordion = document.querySelector('.js-accordion')
const heading = accordion.querySelector('h3')
const panel = accordion.querySelector('div')
const btn = document.createElement('button');

// The panel is hidden by default, so set aria-expanded to false
btn.setAttribute('aria-expanded', false)
// Associate the button with the panel (works only with some screen readers)
btn.setAttribute('aria-controls', 'panel_1')
panel.id = 'panel_1'

// Event that toggles aria-expanded and the panel visibility
btn.addEventListener('click', (e) => {
const state = btn.getAttribute('aria-expanded') === 'true' ? false : true;
btn.setAttribute('aria-expanded', state)
panel.classList.toggle('accordion__panel--visible')
})

// Replace text in heading with the button
btn.textContent = heading.textContent
heading.textContent = ''
heading.appendChild(btn)

Accordion Heading

Accordion panel content visibile by default and only hidden, if the `.js` class is present.

What’s great about this approach is that we can use ES6 without having to compile it with Babel because a browser that supports JS Modules also support ES6 syntax. No JavaScript for users of legacy browsers and less JS for everyone else 🎉.

<script src="accordion.js" type="module"></script>

Note: This accordion is not complete. Check-out Accordion Example | WAI-ARIA Authoring Practices 1.1 for a fully accessible and functional example.

Conclusion

Progressive enhancement is amazing. Building websites layer by layer allows for a cleaner separation of concerns, which makes the website more accessible. If one layer doesn't work in a specific browser, it doesn't matter because the layers below will make sure that users can still access our content.

Improve the experience of your users by making Progressive Enhancement to one of your core principles. You can learn more about it in the following articles:

Resources

Talks

If you want to learn more about CSS and accessibility and you don’t want to wait for me to publish the other articles in this series, you can watch me talk about writing CSS with accessibility in mind at these events for free: