CSS Focus Styles for Keyboard Users Only

Abstract image used to represent CSS Focus Styles for Keyboard Users Only
Image by Yingchih.

There is a fine line to be trodden between the aesthetic expectations of a project, and allowing suitable accessibility. I would always argue that accessibility far outweighs any importance visuals carries, but that can be an uphill struggle in teams where the distinction between technical UX and UI aesthetics has become blurred.

We are all familiar with the situation where interactive elements onpage gain an unexpected blue glow:

  • Text inputs when clicked into;
  • Links and buttons when clicked upon.

It is these things that will have the hybrid UX/UI person frenetically walking up behind you to push their laptop in your face and frantically point at it.

Screenshot of an HTML button displaying the blue focus outline when clicked upon.

This is inherited from the :focus pseudoclass, jump onto Stack Overflow and you will find dozens of answers all saying the same thing: remove the outline, maybe even drop an !important in there for good measure.

*:focus {
  outline: none !important;
}

The key thing to bear in mind is that this bright outline is very important for keyboard or visually impaired users. I won't dwell on this point for too long because I hope to make it absolutely clear in a single, short sentence:

You must never just remove CSS outlines.

If you find yourself struggling against this one in a professional development environment, give them my email address. I'll explain it to them.

To placate both visuals and accessibility, there are three options you can explore:

  1. Style the outline. It doesn't actually have to be that default blue glow (which differs from browser to browser). As long as it is visually distinctive from the surrounding site, keyboard users will still be able to use it.
  2. Give the element more specific styling. You really should have a defined visual state for :focus anyway, just make sure it is distinctive enough to show that the interaction is occurring.
  3. Take the outlines away, but only for nonkeyboard users.

It is a combination of the second and third options that I most commonly implement. As I mentioned: you should already have a distinct style for your interactables anyway, remember the minimum you should be styling is default, :visited, :hover, :active, :focus. It often makes sense to combine these so that default and visited are the same, as are the other three:

Screenshot of buttons in the five pseudo-class states: default, :visited, :hover, :active, :focus.

So, with obvious enough visuals, you could argue that an outline isn't necessary at all.


Take Away Outlines for Non‑Keyboard Users Only

This is where things get a little more interesting. Although it should be a last resort, it is also possible to remove outlines for nonkeyboard users, whilst retaining them for people traversing and interacting with the site via the keyboard.

:focus-visible uses the browser's judgement about when focus needs to be visible, rather than simply detecting a mouse or keyboard. A text input can still need an indicator after a click. Here's an example from the MDN documentation which I've modified a little and moved into Sass using the parent selector:

.button {
  display: block;

  // Provide a fallback style for browsers
  // that don't support :focus-visible
  &:focus {
    outline: none;
    background: lightgrey;

    // Remove the focus indicator on mouse-focus for browsers
    // that do support :focus-visible
    &:not(:focus-visible) {
      background: transparent;
    }
  }

  // Draw a very noticeable focus style for keyboard-focus
  // on browsers that do support :focus-visible
  &:focus-visible {
    outline: 4px dashed darkorange;
    background: transparent;
  }
}

As of September 2020, browser support is still limited, and whilst there is a polyfill available, I used the narrower workaround below for this site.

Photograph of an older wireless Apple keyboard by Clay Banks on Unsplash.

For this workaround, call useKeyboardFocus once in a component near the root of the application that stays mounted between pages. The document access and listeners belong inside the effect, which runs in the browser:

import { useEffect } from 'react';

const useKeyboardFocus = () => {
  useEffect(() => {
    const bodyEl = document.body;
    const keyboardClass = 'keyboard-user';
    const hadKeyboardClass = bodyEl.classList.contains(keyboardClass);

    const handleKeydownOnce = (event) => {
      if (event.key !== 'Tab') return;
      bodyEl.classList.add(keyboardClass);
      document.removeEventListener('keydown', handleKeydownOnce);
      document.addEventListener('mousedown', handleMousedownOnce);
    };

    const handleMousedownOnce = () => {
      bodyEl.classList.remove(keyboardClass);
      document.removeEventListener('mousedown', handleMousedownOnce);
      document.addEventListener('keydown', handleKeydownOnce);
    };

    document.addEventListener('keydown', handleKeydownOnce);
    return () => {
      document.removeEventListener('keydown', handleKeydownOnce);
      document.removeEventListener('mousedown', handleMousedownOnce);
      bodyEl.classList.toggle(keyboardClass, hadKeyboardClass);
    };
  }, []);
};

export default useKeyboardFocus;

What this does is:

  1. Use the React Effect Hook to add a keydown event listener. On unmount, remove both possible listeners and restore the previous body class. You could just as easily use vanilla JavaScript to achieve this if you aren't using React.
  2. When a key event occurs, we check event.key to determine whether it was the Tab key that was pressed or not. In the past, we could have checked to see if event.keyCode is 9 (the tab key), but keyCode has been deprecated for some time. That does not tell us when browsers might remove it. Check support for KeyboardEvent.key, which may be incomplete in older browsers so if you are supporting legacy Firefox or Internet Explorer, you may need to use a combination of key and/or code alongside keyCode.
  3. If it was a tab key, we know this is somebody attempting to traverse the page using the keyboard. Set the classname 'keyboard-user' on body, remove the keyboard event listener, and instead attach an event to listen for mouse events.
  4. If there is a mouse event, we know this user is now using the mouse instead. We remove the classname from the body, and reinstate the keyboard listener.

This tracks Tab presses and mouse presses for the workaround; it is not a complete account of how someone interacts with the page. Keep the wider limitations in mind, particularly text inputs and focus moved by scripts.

The final piece of the puzzle is a simple piece of CSS (or Sass):

body {
  &:not(.keyboard-user) {
    *:focus,
    *:active {
      outline: none;
    }
  }
}

This effectively removes the outline for nonkeyboard users and allows the browser default interaction highlight (assuming it has not been overridden elsewhere in your project) when using the keyboard. This describes the approach I used on this site in September 2020.

As a final note on the subject: it should be considered that outlines aren't only indicative of, or for, keyboard users: the highlight allows for much easier passage through your site. Unless you are able to implement really clear visual indicators for your interactables, I would always advise great caution in overriding browser defaults.

Postscript

March 2022: Native :focus-visible is now available across the major browsers. I'd prefer it to the eventlistener workaround when it covers the browsers you support. The browser uses heuristics to decide when focus needs an indicator, so this is not a strict mouseversuskeyboard switch: text inputs can match after pointer focus too. Retain a visible :focus fallback for browsers without support.

Want to find out more?

If you need senior handson support with a complex React or Next.js platform, migration, performance issue, or technical SEO problem, send me the context and I'll tell you where I can help.