Progressively Enhancing :focus-visible
The :focus-visible CSS pseudo-class is a standard way of only showing focus styles for users of input modalities that rely on it. This post gives some background on what it is, and how you might use it without causing harm to users on non-supporting browsers.
“Focus indicator” broadly refers to focus styling around an interactive element, which is the current element that the user can interact with (Whether using a keyboard, mouse, switch control and so on). This is critical for users with modalities that serially go through each interactive element, such as by using the Tab key on a keyboard, to orient themselves on the page, and predict the outcome of actions. You can read more on focus from Eric Bailey on CSS Tricks, “Focusing on focus styles”.
This indicator is most often styled with the :focus
pseudo-class selector. When using custom :focus
styling, browsers typically expose those indicators regardless of how the user is interacting with the page. Sometimes, when using the “default” focus styles, browsers will try to make guesses, and avoid showing the focus indicator. The :focus-visible
pseudo-class is a way of standardising that latter behaviour.
I would love to write this post in a reasonable amount of time, so I will be brief about why :focus-visible
is around. When an element is styled using :focus
, many stakeholders, whether developers, designers, content editors, middle managers, do not like the focus styles appearing for what they perceive to be unnecessary. In my experience, this is more common when people interact with a touch pointer, such as a smartphone, and see the outline persist on a button. One compelling argument is to avoid the button looking “stuck”, as described in the intro to “:focus-visible and backwards compatibility”.
This distaste for focus, and its deeper roots — whether ableist thinking, or a reliance on arbitrary user metrics — has lead to a situation where many CSS stylesheets outright disable focus styles. It is very common, when using or auditing a site, to run into declarations such as *:focus { outline: none !important; }
. Over the years, there have been multiple tricks or workarounds to only selectively show focus styles (I won’t link to any, because in my experience they have pitfalls).
:focus-visible
gives us a way to keep the focus styling when the browser deems it important. I must stress that this relies on heuristics, and I am still unclear on what multi-modal use of a page looks like with it. There are also open questions about whether vanishing focus indicators can raise barriers for people with cognitive disabilities, and whether there should be a global setting to always match :focus-visible
. These issues are available on the WICG focus-visible GitHub repository.
:focus-visible
is supported in most modern browsers, in recent versions. When using it, however, you must take care to not remove :focus
styling altogether, where :focus-visible
is not supported. People matter, whether on older browsers, older versions, specialised browsers, or future browsers!
With CSS, we can achieve progressive enhancement of :focus
to :focus-visible
, by relying on the :not(:focus-visible)
selector to match and reset styles where :focus-visible
is supported. I originally learned of this technique from Patrick H. Lauke, “:focus-visible and backwards compatibility”, and then from Lea Verou’s tweet on focus-visible.
<!-- Sample matching markup -->
<a href="#">Link to test focus</a>
<button>Button to test focus</button>
/* Styles where only focus is supported */
button:focus,
a[href]:focus {
outline: 2px solid blue;
}
/* Reset styles where focus-visible
is supported (we'll add them back - in the next block) */
button:focus:not(:focus-visible),
a[href]:focus:not(:focus-visible) {
outline: none;
}
/* The final, focus-visible styles. You could elect to make this even
more - obvious, if stakeholders or whomever is pushing against focus styles this
- week are now off your back. */
button:focus-visible,
a[href]:focus-visible {
outline: 2px solid blue;
}
If :focus-visible
is not supported, nothing bad happens; the styling is still there as :focus
. As more browsers gain support, the :focus-visible
statements will be in use. Read more about :focus-visible on MDN.
There is the WICG polyfill for focus-visible. I generally recommend not using it. Not that it is bad or anything, but I do not see the benefit compared to relying on :focus
and progressive enhancement.
The polyfill adds extra script weight to the page (script which must traverse the DOM somewhat, so may be heavy), it brings an additional layer of support or quirks, and still requires extra styling classes on top, so it does not change the authoring experience in my view. It can also be painful when shared components or Design System libraries start relying on the polyfill classes, when progressive enhancement would have been a safer choice. This is not a fault of the polyfill, more an issue of process and mindset :)
I wrote some Sass mixins, to help achieve progressive enhancement consistently.
These Sass mixins help achieve that aforementioned declaration, for multiple selectors. There are three separate mixins, two focusing on common use cases, and a fully custom one:
- focusVisible: a generic mixin, giving you “slots” to decide what to render for
:focus
,:focus
reset, and:focus-visible
- focusVisibleOutline: a focusVisible, which only resets the outline. If you don’t need more custom resets, use this one!
- focusVisibleBoxShadow: a focusVisible, which only resets the box-shadow. Might be easier if you only set/reset box-shadow. For anything more custom, I recommend you write out the CSS long-hand, or create another mixin on top. Super-customisable, very generic mixins are a pain to maintain :D
Use focusVisibleOutline
@include focusVisibleOutline('button', 'a[href]') {
outline: 2px solid blue;
}
…which outputs:
button:focus,
a[href]:focus {
outline: 2px solid blue;
}
button:focus:not(:focus-visible),
a[href]:focus:not(:focus-visible) {
outline: none;
}
button:focus-visible,
a[href]:focus-visible {
outline: 2px solid blue;
}
Use focusVisibleBoxShadow:
@include focusVisibleBoxShadow('button', 'a[href]') {
box-shadow: 0 0 0 4px blue;
}
Use focusVisible:
This is the most customisable version of the mixin, where the mixin defers to the caller, with an argument representing the slot of content being rendered. The key thing here is then checking which slot is being rendered. For example, you can reset box-shadow, border, or anything else. This is how focusVisibleOutline and focusVisibleBoxShadow are implemented!
Note: this mixin uses a feature of Sass for passing arguments to content blocks. This feature is, at the time of writing, only supported in Dart Sass (sass on npm). A version of the mixin that does not have the generic version (which requires that feature) is available in the focus_legacy.scss file.
@include focusVisible('button', 'a[href]') using ($slot) {
@if $slot == focus {
outline: 2px solid transparent;
box-shadow: 0 0 0 4px blue;
}
@if $slot == focusReset {
box-shadow: none;
}
@if $slot == focusVisible {
box-shadow: 0 0 0 4px blue;
}
}
I hope this look into :focus-visible
has been helpful!
If you have thoughts on this post, you can reach out on Twitter. I realise that Twitter is not the best place for nuance, so you can use my other contact info, if those offer a better medium.