Simulating color vision deficiencies in the Blink Renderer
Interested in helping improve DevTools? Sign up to participate in Google User Research here.
This article describes why and how we implemented color vision deficiency simulation in DevTools and the Blink Renderer.
Note: If you prefer watching a presentation over reading articles, then enjoy the video below! If not, skip the video and read on.
Background: bad color contrast
Low-contrast text is the most common automatically-detectable accessibility issue on the web.
According to WebAIM’s accessibility analysis of the top 1-million websites, over 86% of home pages have low contrast. On average, each home page has 36 distinct instances of low-contrast text.
Using DevTools to find, understand, and fix contrast issues
Chrome DevTools can help developers and designers to improve contrast and to pick more accessible color schemes for web apps:
- The Inspect Mode tooltip that appears on top of the web page shows the contrast ratio for text elements.
- The DevTools color picker calls out bad contrast ratios for text elements, shows the recommended contrast line to help manually select better colors, and can even suggest accessible colors.
- Both the CSS Overview panel and the Lighthouse Accessibility audit report lists low-contrast text elements as found on your page.
We’ve recently added a new tool to this list, and it’s a bit different from the others. The above tools mainly focus on surfacing contrast ratio information and giving you options to fix it. We realized that DevTools was still missing a way for developers to get a deeper understanding of this problem space. To address this, we implemented vision deficiency simulation in the DevTools Rendering tab.
In Puppeteer, the new page.emulateVisionDeficiency(type)
API lets you programmatically enable these simulations.
Color vision deficiencies
Roughly 1 in 20 people suffer from a color vision deficiency (also known as the less accurate term "color blindness"). Such impairments make it harder to tell different colors apart, which can amplify contrast issues.
As a developer with regular vision, you might see DevTools display a bad contrast ratio for color pairs that visually look okay to you. This happens because the contrast ratio formulas take into account these color vision deficiencies! You might still be able to read low-contrast text in some cases, but people with vision impairments don’t have that privilege.
By letting designers and developers simulate the effect of these vision deficiencies on their own web apps, we aim to provide the missing piece: not only can DevTools help you find and fix contrast issues, now you can also understand them!
Simulating color vision deficiencies with HTML, CSS, SVG, and C++
Before we dive into the Blink Renderer implementation of our feature, it helps to understand how you’d implement equivalent functionality using web technology.
You can think of each of these color vision deficiency simulations as an overlay covering the entire page. The Web Platform has a way to do that: CSS filters! With the CSS filter
property, you can use some predefined filter functions, such as blur
, contrast
, grayscale
, hue-rotate
, and many more. For even more control, the filter
property also accepts a URL which can point to a custom SVG filter definition:
<style>
:root {
filter: url(#deuteranopia);
}
</style>
<svg>
<filter id="deuteranopia">
<feColorMatrix values="0.367 0.861 -0.228 0.000 0.000
0.280 0.673 0.047 0.000 0.000
-0.012 0.043 0.969 0.000 0.000
0.000 0.000 0.000 1.000 0.000">
</feColorMatrix>
</filter>
</svg>
The above example uses a custom filter definition based on a color matrix. Conceptually, every pixel’s [Red, Green, Blue, Alpha]
color value is matrix-multiplied to create a new color [R′, G′, B′, A′]
.
Each row in the matrix contains 5 values: a multiplier for (from left to right) R, G, B, and A, as well as a fifth value for a constant shift value. There are 4 rows: the first row of the matrix is used to compute the new Red value, the second row Green, the third row Blue, and the last row Alpha.
You might be wondering where the exact numbers in our example come from. What makes this color matrix a good approximation of deuteranopia? The answer is: science! The values are based on a physiologically accurate color vision deficiency simulation model by Machado, Oliveira, and Fernandes.
Anyway, we have this SVG filter, and we can now apply it to arbitrary elements on the page using CSS. We can repeat the same pattern for other vision deficiencies. Here's a demo of what that looks like:
If we wanted to, we could build our DevTools feature as follows: when the user emulates a vision deficiency in the DevTools UI, we inject the SVG filter into the inspected document, and then we apply the filter style on the root element. However, there are several problems with that approach:
- The page might already have a filter on its root element, which our code might then override.
- The page might already have an element with
id="deuteranopia"
, clashing with our filter definition. - The page might rely on a certain DOM structure, and by inserting the
<svg>
into the DOM we might violate these assumptions.
Edge cases aside, the main problem with this approach is that we’d be making programmatically observable changes to the page. If a DevTools user inspects the DOM, they might suddenly see an <svg>
element they never added, or a CSS filter
they never wrote. That would be confusing! To implement this functionality in DevTools, we need a solution that doesn’t have these drawbacks.
Let’s see how we can make this less intrusive. There’s two parts to this solution that we need to hide: 1) the CSS style with the filter
property, and 2) the SVG filter definition, which is currently part of the DOM.
<!-- Part 1: the CSS style with the filter property -->
<style>
:root {
filter: url(#deuteranopia);
}
</style>
<!-- Part 2: the SVG filter definition -->
<svg>
<filter id="deuteranopia">
<feColorMatrix values="0.367 0.861 -0.228 0.000 0.000
0.280 0.673 0.047 0.000 0.000
-0.012 0.043 0.969 0.000 0.000
0.000 0.000 0.000 1.000 0.000">
</feColorMatrix>
</filter>
</svg>
Avoiding the in-document SVG dependency
Let’s start with part 2: how can we avoid adding the SVG to the DOM? One idea is to move it to a separate SVG file. We can copy the <svg>…</svg>
from the above HTML and save it as filter.svg
—but we need to make some changes first! Inline SVG in HTML follows the HTML parsing rules. That means you can get away with things like omitting quotes around attribute values in some cases. However, SVG in separate files is supposed to be valid XML—and XML parsing is way more strict than HTML. Here’s our SVG-in-HTML snippet again:
<svg>
<filter id="deuteranopia">
<feColorMatrix values="0.367 0.861 -0.228 0.000 0.000
0.280 0.673 0.047 0.000 0.000
-0.012 0.043 0.969 0.000 0.000
0.000 0.000 0.000 1.000 0.000">
</feColorMatrix>
</filter>
</svg>
To make this valid standalone SVG (and thus XML), we need to make some changes. Can you guess which?
<svg xmlns="http://www.w3.org/2000/svg">
<filter id="deuteranopia">
<feColorMatrix values="0.367 0.861 -0.228 0.000 0.000
0.280 0.673 0.047 0.000 0.000
-0.012 0.043 0.969 0.000 0.000
0.000 0.000 0.000 1.000 0.000" />
</filter>
</svg>
The first change is the XML namespace declaration at the top. The second addition is the so-called “solidus” — the slash that indicates the <feColorMatrix>
tag both opens and closes the element. This last change is not actually necessary (we could just stick to the explicit </feColorMatrix>
closing tag instead), but since both XML and SVG-in-HTML support this />
shorthand, we might as well make use of it.
Anyway, with those changes, we can finally save this as a valid SVG file, and point to it from the CSS filter
property value in our HTML document:
<style>
:root {
filter: url(filters.svg#deuteranopia);
}
</style>
Hurrah, we no longer have to inject SVG into the document! That’s already a lot better. But… we now depend on a separate file. That’s still a dependency. Can we somehow get rid of it?
As it turns out, we don’t actually need a file. We can encode the entire file within a URL by using a data URL. To make this happen, we literally take the contents of the SVG file we had before, add the data:
prefix, configure the proper MIME type, and we’ve got ourselves a valid data URL that represents the very same SVG file:
data:image/svg+xml,
<svg xmlns="http://www.w3.org/2000/svg">
<filter id="deuteranopia">
<feColorMatrix values="0.367 0.861 -0.228 0.000 0.000
0.280 0.673 0.047 0.000 0.000
-0.012 0.043 0.969 0.000 0.000
0.000 0.000 0.000 1.000 0.000" />
</filter>
</svg>
The benefit is that now, we no longer need to store the file anywhere, or load it from disk or over the network just to use it in our HTML document. So instead of referring to the filename like we did before, we can now point to the data URL:
<style>
:root {
filter: url('data:image/svg+xml,\
<svg xmlns="http://www.w3.org/2000/svg">\
<filter id="deuteranopia">\
<feColorMatrix values="0.367 0.861 -0.228 0.000 0.000\
0.280 0.673 0.047 0.000 0.000\
-0.012 0.043 0.969 0.000 0.000\
0.000 0.000 0.000 1.000 0.000" />\
</filter>\
</svg>#deuteranopia');
}
</style>
At the end of the URL, we still specify the ID of the filter we want to use, just like before. Note that there’s no need to Base64-encode the SVG document in the URL—doing so would only hurt readability and increase file size. We added backslashes at the end of each line to ensure the newline characters in the data URL don’t terminate the CSS string literal.
So far, we’ve only talked about how to simulate vision deficiencies using web technology. Interestingly, our final implementation in the Blink Renderer is actually quite similar. Here’s a C++ helper utility we’ve added to create a data URL with a given filter definition, based on the same technique:
AtomicString CreateFilterDataUrl(const char* piece) {
AtomicString url =
"data:image/svg+xml,"
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
"<filter id=\"f\">" +
StringView(piece) +
"</filter>"
"</svg>"
"#f";
return url;
}
And here’s how we’re using it to create all the filters we need:
AtomicString CreateVisionDeficiencyFilterUrl(VisionDeficiency vision_deficiency) {
switch (vision_deficiency) {
case VisionDeficiency::kAchromatopsia:
return CreateFilterDataUrl("…");
case VisionDeficiency::kBlurredVision:
return CreateFilterDataUrl("<feGaussianBlur stdDeviation=\"2\"/>");
case VisionDeficiency::kDeuteranopia:
return CreateFilterDataUrl(
"<feColorMatrix values=\""
" 0.367 0.861 -0.228 0.000 0.000 "
" 0.280 0.673 0.047 0.000 0.000 "
"-0.012 0.043 0.969 0.000 0.000 "
" 0.000 0.000 0.000 1.000 0.000 "
"\"/>");
case VisionDeficiency::kProtanopia:
return CreateFilterDataUrl("…");
case VisionDeficiency::kTritanopia:
return CreateFilterDataUrl("…");
case VisionDeficiency::kNoVisionDeficiency:
NOTREACHED();
return "";
}
}
Note that this technique gives us access to the full power of SVG filters without having to re-implement anything or re-invent any wheels. We’re implementing a Blink Renderer feature, but we’re doing so by leveraging the Web Platform.
Okay, so we’ve figured out how to construct SVG filters and turn them into data URLs that we can use within our CSS filter
property value. Can you think of a problem with this technique? It turns out, we can’t actually rely on the data URL being loaded in all cases, since the target page might have a Content-Security-Policy
that blocks data URLs. Our final Blink-level implementation takes special care to bypass CSP for these “internal” data URLs during loading.
Edge cases aside, we’ve made some good progress. Because we no longer depend on inline <svg>
being present in the same document, we’ve effectively reduced our solution to just a single self-contained CSS filter
property definition. Great! Now let’s get rid of that too.
Avoiding the in-document CSS dependency
Just to recap, this is where we’re at so far:
<style>
:root {
filter: url('data:…');
}
</style>
We still depend on this CSS filter
property, which might override a filter
in the real document and break things. It would also show up when inspecting the computed styles in DevTools, which would be confusing. How can we avoid these issues? We need to find a way to add a filter to the document without it being programmatically observable to developers.
One idea that came up was to create a new Chrome-internal CSS property that behaves like filter
, but has a different name, like --internal-devtools-filter
. We could then add special logic to ensure this property never shows up in DevTools or in the computed styles in the DOM. We could even make sure it only works on the one element we need it for: the root element. However, this solution wouldn’t be ideal: we’d be duplicating functionality that already exists with filter
, and even if we try hard to hide this non-standard property, web developers could still find out about it and start using it, which would be bad for the Web Platform. We need some other way of applying a CSS style without it being observable in the DOM. Any ideas?
The CSS spec has a section introducing the visual formatting model it uses, and one of the key concepts there is the viewport. This is the visual view through which users consult the web page. A closely related concept is the initial containing block, which is kind of like a styleable viewport <div>
that only exists at the spec level. The spec refers to this “viewport” concept all over the place. For example, you know how the browser shows scrollbars when the content doesn’t fit? This is all defined in the CSS spec, based on this “viewport”.
This viewport
exists within the Blink Renderer as well, as an implementation detail. Here’s the code that applies the default viewport styles according to the spec:
scoped_refptr<ComputedStyle> StyleResolver::StyleForViewport() {
scoped_refptr<ComputedStyle> viewport_style =
InitialStyleForElement(GetDocument());
viewport_style->SetZIndex(0);
viewport_style->SetIsStackingContextWithoutContainment(true);
viewport_style->SetDisplay(EDisplay::kBlock);
viewport_style->SetPosition(EPosition::kAbsolute);
viewport_style->SetOverflowX(EOverflow::kAuto);
viewport_style->SetOverflowY(EOverflow::kAuto);
// …
return viewport_style;
}
You don’t need to understand C++ or the intricacies of Blink’s Style engine to see that this code handles the viewport’s (or more accurately: the initial containing block’s) z-index
, display
, position
, and overflow
. Those are all concepts you might be familiar with from CSS! There’s some other magic related to stacking contexts, which doesn’t directly translate to a CSS property, but overall you could think of this viewport
object as something that can be styled using CSS from within Blink, just like a DOM element—except it’s not part of the DOM.
This gives us exactly what we want! We can apply our filter
styles to the viewport
object, which visually affects the rendering, without interfering with the observable page styles or the DOM in any way.
Conclusion
To recap our little journey here, we started out by building a prototype using web technology instead of C++, and then started working on moving parts of it to the Blink Renderer.
- We first made our prototype more self-contained by inlining data URLs.
- We then made those internal data URLs CSP-friendly, by special-casing their loading.
- We made our implementation DOM-agnostic and programmatically unobservable by moving styles to the Blink-internal
viewport
.
What’s unique about this implementation is that our HTML/CSS/SVG prototype ended up influencing the final technical design. We found a way to use the Web Platform, even within the Blink Renderer!
For more background, check out our design proposal or the Chromium tracking bug which references all related patches.
Download the preview channels
Consider using the Chrome Canary, Dev or Beta as your default development browser. These preview channels give you access to the latest DevTools features, test cutting-edge web platform APIs, and find issues on your site before your users do!
Getting in touch with the Chrome DevTools team
Use the following options to discuss the new features and changes in the post, or anything else related to DevTools.
- Submit a suggestion or feedback to us via crbug.com.
- Report a DevTools issue using the More options > Help > Report a DevTools issues in DevTools.
- Tweet at @ChromeDevTools.
- Leave comments on our What's new in DevTools YouTube videos or DevTools Tips YouTube videos.
More from the Chrome DevTools team
Subscribe to Chrome DevTools blog to stay up to date with the DevTools news.