Accessible and modern React icon component

A versatile React component that handles icons and icon links while meeting accessibility requirements.

Different ways icons are used

Icons can be divided roughly into two buckets:

  • Purely decorative eye candy icons, usually coupled up with text.
  • Icons that have a meaning, usually replacing text.

For example, the info icon in the "info box" below is purely decorative. The text inside tells the user everything they need to know:

But the icons in this example table have a meaning, and there’s no text coupled up with them:

Mp4 and WebM compat table
IESafariChromeFirefoxOpera
mp4SupportedSupportedSupportedSupportedSupported
webmNot supportedNot supportedSupportedSupportedSupported

Now that we know what we need to do, let’s try to make it happen.

Writing the icon component

The example code expects the SVG icons to be a React component, like so:

const ArrowRight = props => (
  <svg viewBox='0 0 16 16' {...props}>
    <path d='M15.5 8L8 .5V5H0v6h8v4.5z' />
  </svg>
)

A basic example with the bare minimum amount of code (scroll down for a more embodied example):

import { Wrap, VisuallyHidden } from './styles'
 
const Icon = ({ Icon, label }) => (
  <Wrap>
    <Icon aria-hidden focusable='false' />
    {label && <VisuallyHidden>{label}</VisuallyHidden>}
  </Wrap>
)

The relevant bits:

aria-hidden

This hides it from screen readers, they don’t really need the icon for anything, since the label is outside the icon (the hidden element).

focusable="false"

Because the icon is hidden, there’s no need for anyone to tab into it. This is a diminishing issue. It’s only a problem in Internet Explorer, but we might as well fix it.

VisuallyHidden

If the label is provided, have it available for screen readers, but hidden from eyes.

Icon component usage

When using the Icon component, add the label if the icon is not coupled with text:

import Icon from '../components/Icon'
import MyCompanyLogo from '../components/MyCompanyLogo'
 
const App = () => (
  <Logo>
    <Icon icon={MyCompanyLogo} label='Logo' />
  </Logo>
)

Skip it if you already have text in the element:

const App = () => (
  <NoteBox>
    <Icon icon={Info} />
    Did you know that SVG stands for Scalable Vector Graphics?
  </NoteBox>
)

Without visually hidden element: use aria-label

There’s a built-in way to label elements: aria-label. But keep in mind that aria-label is a less robust way to do this than putting the label inside the <VisuallyHidden> element. Because aria-label has some internationalization issues, and it’s not as widely supported (thanks Hugo for the valuable research).

Aria-label in use:

import { Wrap } from './styles'
 
const Icon = ({ Icon, label }) => (
  <Icon aria-label={label} focusable='false' role='img' />
)

About that role attribute:

If you are using embedded SVG images in your page, it is a good idea to set role="img" on the outer <svg> element and give it a label. This will cause screen readers to just consider it as a single entity and describe it using the label, rather than trying to read out all the child nodes. MDN

Icon buttons and icon links are common UI patterns. In that case, the same rules apply, but the wrapping component will be button or an a.

Twitter is a good example of a UI with many buttons that are only icons:

Tweet UI
Screenshot of Twitter’s Tweet UI, featuring loads of icon buttons (from 2020)

Another classic example is the search button next to a search bar:

Screenshot of the Duck Duck Go search bar
Icon buttons in the Duck Duck Go search UI

Below is some basic React code for an icon button component. It is not much different from the earlier icon component, but it handles button clicks or pass in an href attribute if it’s an anchor, or to if it’s a router link:

import { Button, VisuallyHidden } from './styles'
 
const ComposeButton = ({ Icon, label }) => {
  return (
    <Button
      onClick={() => {
        /* Do stuff */
      }}
    >
      <Icon aria-hidden focusable='false' />
      {label && <VisuallyHidden>{label}</VisuallyHidden>}
    </Button>
  )
}

Button with a loading spinner

A useful UI pattern is to have a spinner animation appear in the button after it has been clicked. The spinner is just an icon that is spun with a keyframe animation (click it):

The spinner component

If a component will change, that is, if it introduces new elements or changes its value, then aria-live attribute has to be used so that the screen reader will announce the changes (see more on that below):

const LoadingButton = () => {
  const [isLoading, setIsLoading] = React.useState(false)
 
  const handleClick = () => {
    setIsLoading(true)
  }
 
  return (
    <Button
      aria-disabled={isLoading}
      isLoading={isLoading}
      onClick={handleClick}
    >
      {isLoading && (
        <SpinnerIcon aria-live='assertive' icon='spinner' isBlock={false} />
      )}
      Submit
    </Button>
  )
}

Here are the highlighted bits explained:

aria-disabled

The button should be disabled when loading.

Note that the disabled attribute should not be used

because that will hide the button from assistive technologies. It should instead be deactivated with CSS. That’s why the isLoading prop is passed to the styled component.

aria-live="assertive"

JavaScript can update components on the page without reloading it. To a sighted user, the changes are obvious, but not to someone using a screen reader. aria-live makes the component to be a "live region", and tells the screen reader that the icon button has just changed. arial-live can have three values: 1) off the changes are not announced. 2) polite the change is announced the next suitable moment. 3) assertive the change is announced immediately. This should only be used for important announcements, such as errors. In this case, we’re telling the user that a form is being sent.

Confusion with aria-role and aria-live

I think there’s a bit of confusion about aria-role="alert" used in conjunction with aria-live="assertive" (or it’s just me), I’ve seen these two being used together, but as I was researching this post, the general consensus is that aria-live="assertive" has an implied value of aria-role="alert", so it’s not needed to be defined.

Btw, this is a good video explaining it quite well (taken from cccaccessibility.org article called "Using Aria-live"):

The bit about aria-role='live' starts at 3:32

Also an example in a w3.org article implies the similarity of these two attributes ...role=alert which is equivalent to using aria-live=assertive.

The spinner component styling

When isLoading is true, the button should look disabled (in this case I just played with the opacity), and all the pointer-events should be turned off. You could set the cursor to wait or not-allowed, but since we’re setting pointer-events to none, that won’t have any effect.

const Button = styled.button(props => ({
  marginBottom: '16px',
  opacity: props.isLoading ? 0.5 : undefined,
  padding: '5px 10px',
  pointerEvents: props.isLoading ? 'none' : undefined,
}))

Soup it up

The above code works but is super basic and doesn’t reflect the real-world usage.

Here are a few things you could consider adding to make it more usable:

  • Pass in the className so it can be styled with styled-component or Fela etc.
  • Make map of the icons, so that only an icon name can be passed in, rather than the actual icon component. This makes it more design system-esque.
  • Predefined set of sizes.
  • Give the icon a fill of currentcolor, so it will inherit the color from its wrapping element.
  • Define a subset of colors from your palette to be passed into the icon.
  • Possibility to display the icon as a block or inline-block.
import React from 'react'
import PropTypes from 'prop-types'
import ArrowRight from '../icons/ArrowRight'
import Book from '../icons/Book'
import CheckMark from '../icons/CheckMark'
import Cross from '../icons/Cross'
import Info from '../icons/Info'
import { Wrap, Label } from './styles'
 
export const icons = {
  arrowRight: ArrowRight,
  book: Book,
  checkMark: CheckMark,
  cross: Cross,
  info: Info,
}
 
const sizes = {
  small: '16px',
  medium: '24px',
  large: '32px',
  extraLarge: '40px',
}
 
const colors = {
  red: '--red',
  green: '--green',
  gray: '--gray-dark',
  black: '--black',
}
 
const Icon = props => {
  const { className, icon, isBlock, label, role, size } = props
  const IconComponent = icons[icon]
 
  if (!IconComponent) return null
 
  return (
    <Wrap
      className={className}
      color={colors[color]}
      isBlock={isBlock}
      size={sizes[size]}
    >
      <IconComponent
        aria-hidden
        fill='currentcolor'
        focusable='false'
        role={role}
        title={label}
      />
      {label && <Label>{label}</Label>}
    </Wrap>
  )
}
 
Icon.defaultProps = {
  color: '--black',
  isBlock: true,
  size: 'small',
}
 
Icon.propTypes = {
  className: PropTypes.string,
  color: PropTypes.oneOf(Object.keys(colors)),
  icon: PropTypes.oneOf(Object.keys(icons)).isRequired,
  isBlock: PropTypes.bool,
  label: PropTypes.string,
  role: PropTypes.string,
  size: PropTypes.oneOf(Object.keys(sizes)),
}
 
export default Icon

SVG icon acid test

I mentioned in my other icon post earlier, that icon fonts are finicky to size correctly. SVG icons aren’t nearly as bad, but one annoying thing I often have to deal with is alignment; sloppily exported icons align in a different way. Because the icon is bigger, or it has a height and the other one doesn’t, or the viewbox is off or missing etc. Then you have to tweak styles per icon, terrible.

To get rid of this, make an alignment acid test for the icons by placing some text next to the icon in your design system. Don’t force the alignment with flex-box or transforms etc. This way, at a glance you can see if some of the icons aren’t aligning properly, and you can go into the SVG and fix it. That you never have to worry about misaligned icons again.

Screenshot of icons in a design system
SVG icons aligned with text

Dynamic icon sizing

In my example I’ve defined the icon size in pixels, because I just like the exactness of that. But another way to handle that is to use 1em and the height and width of the icon, this way the icon will inherit the text size from its parent component. For example, an icon inside h2 title would be proportional to the text size.

const ArrowRight = props => (
  <svg viewBox='0 0 16 16' height='1em' width='1em' {...props}>
    <path d='M15.5 8L8 .5V5H0v6h8v4.5z' />
  </svg>
)

React icon component demo

Here’s a demo of the above mentioned icon component.

Conclusions

In the end, the rules related to icons are pretty simple: most of the time they’re hidden from screen readers, and if label is needed, put it inside a visually hidden element.

The better icons are prepared, the easier they’re to use, because you can trust them to be the same size and align the same way. Put in that effort in the beginning and you’ll thank yourself later.

Also, check my post about the technological singularity of icons in web.