Taking a shot at the double focus ring problem using modern CSS
The double focus ring problem is something you might run into when you are trying to make a large, complicated website or web app accessible at scale.
It is also a bit of a problem buried inside a solution. Confused? Intrigued? Let’s break it down.
First you have a focus ringpermalink
Let’s say you’re being a good steward of the web and are using custom focus styles for your website or web app.
Let’s also say you use a ring-style effect. Of course the ring’s color will have an accessible contrast ratio when compared to the background color:
No question about it, I’ve placed focus on the Courses link.
Then things get complicatedpermalink
You then need factor in things like dark and light color modes, and making sure the focus ring’s color adapts:
Don’t forget that dark and light modes can also have child themes. For example, GitHub offers nine themes:
That’s a lot of themes!
Then things get weirdpermalink
Consider that a light mode theme can have an area where a dark background is used, with an accompanying light text treatment. The opposite also applies, with a dark mode theme using a light background area, with a dark text treatment.
All of this combined and you have a situation that can get incredibly complicated incredibly quickly.
Then, consider a hard truth of design systems work: You can’t be present for every edit to every existing screen, as well as staying on top of all newly-added features and functionality.
Enter the double focus ringpermalink
To address this issue, some people have turned to using two focus rings:
- One that is high enough contrast to be visible against the current mode and theme’s main background color, and
- One that is high enough contrast to be visible against the opposite of the mode and theme’s background color.
Some people go to the extreme with this, using a layered black and white focus ring treatment:
This approach is defensive design. It provides a little more certainty that the focus effect will work in known unknown situations.
From a human-centric perspective, this is an important feature. It provides a more reliable way for someone to know what interactive element will activate when they take action on it.
The double focus ring, but make it prettier-lookingpermalink
For the most part, a black and white double focus ring works just fine! It is a hedged bet that the absolute black and white color values have the highest contrast ratio against the content they are placed over.
Our example is when you want your experience to be more branded, but also distributed across a complicated frontend experience.
CSS to the rescuepermalink
CSS is in its Renaissance era and has received some really cool updates as of late. The features I’d specifically like to call attention to for this post are:
:where()
,:focus-visible
,- Custom Properties,
- The
currentColor
keyword, box-shadow
’s ability to support multiple shadows,@supports
,- HSL colorspace values,
calc()
, and- Relative color syntax.
We can combine these things to create a double focus ring effect that:
- Colors one ring with the color used for the interactive element’s text, whatever that color may be.
- Colors the other ring to use the exact opposite color value used for the text.
Both color values are dynamic, in that they work regardless of whatever color value is used for the text color, provided its color is a CSS Custom Property.
Here’s a simplified example you can play with to get an idea of how this works:
See the Pen Double focus ring color adjust demo by piccalilli (@piccalilli) on CodePen.
And here’s a more complicated version that shows how it works in conjunction with themes and light/dark modes. Use Tab to move focus:
See the Pen Double focus ring more complicated demo by piccalilli (@piccalilli) on CodePen.
Why would you use this approach?permalink
Two diametrically opposed colors significantly increases the chance that at least one of the focus rings will be perceivable across all themes, modes, vision conditions, and other relevant factors.
Don’t get me wrong: Using black and white as focus ring colors is a good option! Our approach is for organizations who want the focus ring to look more branded and cohesive with the rest of the designed experience.
Making the ring colors dynamic means that there is far less work to accomplish this end effect. This is important for larger sites where the surface area is too vast to be able to know:
- Where each and every interactive element is,
- If it’s styled the way it should be, and
- If it honors themes and color display modes or not.
Two caveats before we get into itpermalink
1. A hedged bet
This approach is not a one hundred percent guarantee that it will produce a WCAG-conformant result.
Since our approach is dynamic, it depends on the underlying color system being used. This, in turn, presumes you’ve done some work to ensure contrast ratios between backgrounds and foregrounds are generally in good shape.
If you haven’t done that work: it is really something you should invest in.
That said: If you’re feeling ambitious, you could also extend the stacked box shadows to “bake in” additional hardcoded black and white rings to the effect.
2. Lack of color
Black, white, gray, and near-gray desaturated color values won’t generate an opposite color value. This is because there really isn’t any color to “flip.” Instead, the second ring will use the same color value as the text color.
I’m personally okay with this, in that there’s still a visually prominent double ring effect. I’m doubly okay with this if you’ve also put in the work outlined in the previous caveat.
How this all workspermalink
This approach requires investment in a modern approach to frontend.
You’ll need to be able to use Custom Properties in your CSS. Fortunately, they have good support now. We’ll be using them to creating some pseudo private properties. You will also need HSL color values, which also enjoy excellent support.
This focus style treatment can be for:
- A dedicated element or component,
- A predefined suite of elements and components, or
- All interactive elements and components on your site.
In this code example, I’ve targeted every HTML element and attribute that can receive focus. I’m doing this via :where()
, to keep the specificity nice and low:
- Code language
- css
:where( a, button, input, textarea, select, details, audio, video, object, [contenteditable], [tabindex] ) { --_focus-ring-distance: 0.2rem; --_focus-ring-color-background: var(--_color-background); &:focus-visible { --_focus-ring-color-inner: hsl(0 0% 100%); --_focus-ring-color-outer: hsl(0 0% 0%); box-shadow: 0 0 0 var(--_focus-ring-distance) var(--_focus-ring-color-background), 0 0 0 calc(var(--_focus-ring-distance) * 2) var(--_focus-ring-color-inner), 0 0 0 calc(var(--_focus-ring-distance) * 3) var(--_focus-ring-color-background), 0 0 0 calc(var(--_focus-ring-distance) * 4) var(--_focus-ring-color-outer); outline: none; } @supports (color: hsl(from hsl(0 0% 100%) h s l)) { &:focus-visible { --_focus-ring-color-inner: currentColor; --_focus-ring-color-outer: hsl(from var(--_focus-ring-color-inner) calc(h + 180) s l); box-shadow: 0 0 0 var(--_focus-ring-distance) var(--_focus-ring-color-background), 0 0 0 calc(var(--_focus-ring-distance) * 2) var(--_focus-ring-color-inner), 0 0 0 calc(var(--_focus-ring-distance) * 3) var(--_focus-ring-color-background), 0 0 0 calc(var(--_focus-ring-distance) * 4) var(--_focus-ring-color-outer); outline: none; } } }
Let’s unpack these Custom Property declarations:
--_focus-ring-distance
will be used to create the double ring effect via CSS’sbox-shadow
property. Adjusting it moves both rings’ distance from the element it is applied to.- I’m using a
rem
unit value so that the rings’ size scales proportionately to the interactive element’s computed font size. - You can also adjust the numbers that
--_focus-ring-distance
is multiplied by to tweak the ring’s thickness and distance. I’m increasing each by a factor of 1 for demonstration purposes. - You could also get clever and set up another Custom Property to set this as a ratio, but I prefer to keep it as discrete numbers to allow for more customization of ring thickness and distance.
- I’m using a
--_focus-ring-color-background
will create the effect of two focus rings by matching the background color of the element the effect is applied to.- I’m using
--_color-background
as the Custom Property that captures the background color. You’ll have to tweak this based off your website or web app’s Custom Property naming conventions.
- I’m using
--_focus-ring-color-inner
usescurrentColor
to set the color of the inner focus ring. It will use the element’s computed text color, regardless of what color is used.--_focus-ring-color-outer
then ingests--_focus-ring-color-inner
’s color value and uses relative color syntax to adjust its hue.
This is bleeding edge stuff. Because of this, let’s break down what’s going on in --_focus-ring-color-outer
’s value calculation in more detail:
- Code language
- css
hsl(from var(--_focus-ring-color-inner) calc(h + 180) s l);
hsl()
sets the HSL color space for the relative color function.from
is a relative color keyword, and sets the source color value to the--_focus-ring-color-inner
Custom Property.calc()
performs an equation within the relative color operation, adding 180 degrees to the color’s hue value. Browsers will “rubber band” relative color calculation of hue if it exceeds 360, and restart the count from 1. This allows us to dynamically create a complementary color.
These Custom Properties are all used within a box-shadow
declaration, stacked over each other to create a layered effect that looks like two rings.
The rings are then conditionally displayed via the focus-visible
pseudo-class. This means it only triggers when someone places focus on the interactive control via non-cursor based input—typically done via a Tab keypress.
Combined, this sets the outer focus ring color to the opposite hue value of whatever the text color is.
A graceful fallback
Browsers that do support relative color syntax will use dynamically-generated colors. Browsers that don’t support relative color syntax will show black and white focus rings instead.
This is accomplished by first defining our fallback experience, using black and white color values for our box-shadow
declaration:
- Code language
- css
&:focus-visible { /* Set the focus rings to use white and black */ --_focus-ring-color-inner: hsl(0 0% 100%); --_focus-ring-color-outer: hsl(0 0% 0%); box-shadow: 0 0 0 var(--_focus-ring-distance) var(--_focus-ring-color-background), 0 0 0 calc(var(--_focus-ring-distance) * 2) var(--_focus-ring-color-inner), 0 0 0 calc(var(--_focus-ring-distance) * 3) var(--_focus-ring-color-background), 0 0 0 calc(var(--_focus-ring-distance) * 4) var(--_focus-ring-color-outer); outline: none; }
We then redefine our Custom Properties within the @supports
at-rule, using currentColor
and our fancy hue rotation formula instead:
- Code language
- css
@supports (color: hsl(from hsl(0 0% 100%) h s l)) { &:focus-visible { /* Redefine the focus rings to use computed text color and its complementary color value */ --_focus-ring-color-inner: currentColor; --_focus-ring-color-outer: hsl(from var(--_focus-ring-color-inner) calc(h + 180) s l); box-shadow: 0 0 0 var(--_focus-ring-distance) var(--_focus-ring-color-background), 0 0 0 calc(var(--_focus-ring-distance) * 2) var(--_focus-ring-color-inner), 0 0 0 calc(var(--_focus-ring-distance) * 3) var(--_focus-ring-color-background), 0 0 0 calc(var(--_focus-ring-distance) * 4) var(--_focus-ring-color-outer); outline: none; } }
This ensures everyone still gets a focus style, regardless of how old their browser may be.
But what about that outline:
none
declaration?
Removing the browser-based focus outline is something you want to be both intentional and careful about doing.
We’re opting to remove the outline with the understanding that our text colors have a sufficient color contrast compared to the background we placed it over.
And what about forced colors mode?
Because we’re responsible web workers, we also provide a treatment for forced colors mode:
- Code language
- css
:where( /* List of selectors */ ) { /* Previous focus ring declarations */ @media (forced-colors: active) { &:focus-visible { box-shadow: none; outline: var(--_focus-ring-distance) solid LinkText; outline-offset: var(--_focus-ring-distance); } } }
The reason for this is forced colors mode will discard box-shadow
declarations in an attempt to preserve legibility. Here, we take advantage of using the Custom Properties’ inheritance to incorporate forced color keywords.
Forced color mode also uses a restricted palette, so we’re discarding aesthetics entirely. This is by design, in that forced colors mode prioritizes legibility above all else.
Including this media feature ensures our focus effect is preserved, regardless of what mode is being used:
A note about contrast-color()
permalink
CSS’ upcoming contrast-color()
function has a lot of potential. It serves as a guardrail for color declarations, to ensure that the value does not fall out of an conformant color range.
contrast-color()
accomplishes this by returning either a black or white color value, depending on the supplied value’s comparative ratio.
This function provides a handy way to have more assurance that color use is conformant, but still isn’t a guarantee. Consider this quote from the MDN:
Warning: There is no guarantee that the values returned using the
contrast-color()
function will produce an accessible result. Mid-tone background colors generally don’t provide enough contrast. It is recommended therefore to use light or dark colors with thecontrast-color()
function.
As of the time of this article’s publication, the color function is still highly experimental and too new to be used in production. This means it is still being worked on an could be liable to change.
Double the pleasure, double the funpermalink
Modern CSS is extremely powerful. The ability to dynamically manipulate color opens up a huge wealth of potential for crafting beautiful and accessible web experiences.
Double focus rings are a marriage of three considerations. They’re:
- A way of ensuring all aspects of your web experience are branded so they feel like a cohesive whole.
- An attempt to ensure that the branded focus state for this experience is perceivable, and therefore capable of being used.
- An acknowledgment that the surface area of a web experience, and all its constituent themes, modes, and states can be too large and complicated to reliably track everything all the time.
While not a guarantee for conformance, use of a double focus rings is a two-tiered, hedged bet in favor of legibility. This is done by dynamic color generation, built atop an already-mature set of CSS Custom Property color values.
All of this is done in the service of ensuring people who rely on focus styles get what they need online.
Enjoyed this article? You can support us by leaving a tip via Open Collective
