As a sighted user, it's probably clear what these icons would do, if you clicked on them:

A screenshot of two icons. The first is a question mark in a circle, the second is a machine gear.

For folks using screen readers, though, buttons without text can be absolutely mysterious. They might be announced simply as "button".

The good news is: we can solve this problem without compromising on our design! A VisuallyHidden component will allow us to place text inside this button that will only be made available to people using screen readers.


It's used like this:


This snippet might seem overly complex, but bear with me! It has a neat trick up its sleeve.

If you hold down the Alt key (⌥), the typically-hidden text springs into life, letting you verify that you haven't missed anything:

A screen recording of a Quiz UI. Several icon buttons are present, including the two shown earlier. After a moment, the text is added underneath each of them.

This functionality only exists in your development environment. It's stripped out of production builds automatically, so you don't have to worry about users stumbling upon this hidden debug feature.

I personally tap this character right before committing, but you can find whatever workflow works best for you.

Let's talk about some of this code.

This component uses the following CSS to effectively hide elements from sighted users:


Why not just use display: none or visibility: hidden? In fact, these properties are too effective; they hide the contents from screen-reader users as well!

This snippet is battle-tested. It works across all major browsers, and has zero impact on layout.

It's presented as an inline style here for maximum compatibility, but feel free to use styled-components, CSS modules, or whatever you use for styling!

Link to this heading
The hiding behaviour

Most of the rest of the code exists to allow the text to be toggled on and off with a keyboard shortcut.

Critically, the bulk of the logic is wrapped in the following conditional:


When we build for production, Webpack (or whichever bundler you use) will replace process.env.NODE_ENV with the string "production". That means that this code will actually look like this:


Modern JS bundlers use a strategy called Dead Code Elimination. This means that if the compiler can tell that some code will never be run, it will be stripped from the build. Since it is impossible for "production" to ever not equal "production", all of this code will be stripped out. This little boon to developer experience doesn't come at the cost of user experience, since it doesn't actually add any weight to the build ⚡️

The logic itself is pretty typical React code:

  • Initialize a state variable, forceShow, to false
  • Upon mount, register key-down and key-up handlers
  • When the user presses a key, check and see if it's equal to the "Alt" key. If so, flip the state variable to true
  • When the user releases a key, check if it's "Alt". If so, flip the state back to false
  • If the state variable is true, render the children without any CSS applied (so it'll be visible)
  • Otherwise, wrap the children in our CSS snippet, to hide it from sighted users.

This component does produce a bit of extra HTML. It needs to, since we need an element to apply our CSS to!

In this case, it's a span:


delegated is my name for any additional props that should be passed along to this wrapping element. For example, you may wish to pass a data-attribute for your end-to-end tests:


A span is used instead of a div because many elements (including buttons) consider it invalid to nest block-level elements inside of them.

It's important to never forget that not all of our users use our products the same way we do. Components like this don't take long to implement, and they make a huge difference to our users.

Last Updated:
March 1st, 2024