Progressively enhancing :focus-visible

Progressively enhancing :focus-visible

ยท

4 min read

The :focus-visible pseudo-class has been one of the biggest accessibility wins in the recent history of CSS. It took some time but it's finally happening: Safari has recently unflagged :focus-visible and we'll finally be able to use it in 2022! ๐ŸŽ‰

Background

If you're unfamiliar, :focus-visible allows you to get rid of the annoying focus outline on mouse clicks, while letting you specify the focus styling only for keyboard users (as well as other "tabbing" devices like joysticks)

2021 was a good year for this pseudo-class:

  • Firefox shipped focus-visible.
  • Chrome and Firefox both started using it for default focus styles in their User Agent stylesheets.
  • And now Safari is on the cusp of shipping it.

What's wrong with the popularly suggested fallback?

Maybe you're using the polyfill (hopefully not) or maybe you're using Patrick H. Lauke's CSS-only fallback (or even the two in combination).

  :focus {
    /* your custom focus outline */
  }
  :focus:not(:focus-visible) {
    /* undo the focus styling for mouse users */
  }

There are two main problems with this "undo" approach. First, it increases the specificity, which can have unexpected consequences if you rely on the cascade, for say, defining hover styles later. Fortunately you can fix the specificity problem using the :where pseudo-class, which has similar browser support.

Here's a codepen to demonstrate. Try tabbing through the 3 buttons. Then try clicking and hovering on them. You'll notice that the first button does not respect its :hover and :active rules after it's clicked.

The second problem is with code complexity. Undoing focus styling is not trivial, especially if you've taken the extra care to make your component look beautiful in its various states. This problem gets compounded with every new state that you add to your component. Even a seemingly innocuous component like a button can have tons of extra states โ€” active, active + focused, disabled, disabled + focused, and so on.

Using :focus-visible directly

Since this is still a fairly new feature, you might feel like it's not ready to start using just yet. However, there is good news if you know these two bits of trivia:

  1. Safari already uses :focus-visible-ish logic for buttons and anchor links. That's right, buttons and anchors are not click-focusable in Safari, so regular :focus styles will only be visible to keyboard users.

  2. The @supports rule can test for a selector. It is fairly new, but has decent browser support (compare stats with your user base as always).

This means you can write code like this:

button:focus-visible {
  outline: 2px solid rebeccapurple;
}
@supports not selector(:focus-visible) {
  button:focus {
    outline: 2px solid rebeccapurple;
  }
}

Buttons and anchors make up most of the interactable web, so using this trick will get you most of the way there. For text inputs, there is no difference between :focus and :focus-visible behavior, so that's a non-issue. What remains is the custom interactable elements defined using tabindex=0. Those will still continue to behave as they have, until Safari users can get their hands on :focus-visible.

In older browser versions where neither :focus-visible or @supports selector is supported, the default focus ring should show instead. Now that's progressive enhancement without any compromises.

Sass mixin

Since we are duplicating the focus outline styles, there is an opportunity for creating a "helper" of some sort. We can define a handy Sass mixin, utilizing a @content block to defer the actual styles to the user of this mixin.

@mixin focus-visible {
  &:focus-visible {
    @content;
  }

  @supports not selector(:focus-visible) {
    &:focus {
      @content;
    }
  }
}

Now we can use it throughout our codebase without duplicating any focus styles.

button {
  @include focus-visible {
    outline: 2px solid rebeccapurple;
  }
}

Sweet!

What about :focus-within?

The last piece of the puzzle is :focus-within. While a little niche, you may have used it before. It's especially handy for giving forms an extra bit of polish. Unfortunately, there is no :focus-visible-within. However, with the :has() pseudo-class (which is already available in Safari now!), this will be a trivial matter as you'll be able to just use :has(:focus-visible).

Update: Safari 15.4 is now shipping :focus-visible! ๐Ÿฅณ