Making accessible interactive React components

Last updated on April 2023

If you are like me, you might primarily use your mouse to interact with websites.

But sometimes you will open up a modal, and instinctively hit escape to close it.

Try out this modal - it is a very simple one that is modeled on a typical (inaccessible) modal:

Some main issues with this modal:

  • No use of aria attributes to signify information about the interactive modal
  • Once triggered, you cannot close the modal by hitting the escape key
  • Once triggered if you hit 'tab', instead of focusing something inside the modal, it carries on focusing on the next element in the DOM (outside of the modal)
  • You probably would want to click 'outside' of the modal (black background) to close the modal, although this depends on how the modal is used.

To fix these issues, you would have to write code that:

  • Sets (and keeps them in sync) aria attributes
  • Remember what role attribute to set (and use it correctly)
  • Set up keyboard event listeners to trap focus within the modal
  • And set up a keyboard event listener to close the modal when escape is hit.

And this is just for a modal, which is very simple in terms of interaction. (Imagine all the things that go into a dropdown or a multi select box...)

Headless components UI's

You may have heard about headless UI's in the last few years. They are like UI libraries (such as Chakra-ui)... but without any rendering of HTML.

The normal way that they work (in React apps) is you call a hook, which returns properties such as onClick, keyboard event listeners, aria attributes.

Then you spread those onto your HTML elements.

For example, something like this (from Adobe's React Aria):

import {Overlay, useModalOverlay} from 'react-aria';

function YourAccessibleModal({ state, children, ...props }) {
  const ref = React.useRef(null);
  const { modalProps, underlayProps } =
    useModalOverlay(props, state, ref);

  return (
    <Overlay>
      <div
        className="modal-underlay"
        {...underlayProps}
      >
        <div
          {...modalProps}
          ref={ref}
        >
          {children}
        </div>
      </div>
    </Overlay>
  );
}

The modalProps and underlayProps are DOMAttributes that can be applied to your HTML elements. It will set up things like role="dialog", tabindex="-1", aria-labelledby etc.

Using headless UI's like this makes very easy to make accessible rich web applications.

The examples above are quite trivial to make fully accessible by hand. I think where headless components really shine is when creating complex widgets such as date pickers or select boxes. There are a lot of edge cases to cater for, and its much easier if you can rely on a nice library to handle the complexity for you.

Of course you can do it yourself - all the documentation and specs are easy to find. But despite being easy to find, its hard to implement correctly and error free. I would highly recommend using a library to handle all of the accessibility and 'behind the scenes' logic, so you can focus on presentation/rendering.

Here are the more common headless component libraries. I've split it into two sections - multi-component libraries (where you can use their modal, checkbox toggles, selects etc) and one-off component libraries (e.g. a couple of libraries just for dropdowns).

If you are starting a new project, I'd recommend Radix, headlessui.com, or React Aria. They cover most of the important and common interactive widgets that you might need to implement

Multi headless component libraries

Adobe's React Aria

React Aria is a really nice headless UI library, for building accessible components in your React application.

They focus on making things accessible, support internationalization, adaptive for all UI interaction types (mouse, touch, keyboard, screen readers) in all screen sizes. Its also easy to customize and render the components in a way that matches your UI/designs.

I think Adobe's React Aria is probably the most comprehensive - but also slightly more trickier to use than some others I'll list on this page.

They have nice hooks (like useFocus, useFocusVisible), nice support for internationalization, and support tons of widget ypes (like checkboxes, sliders, modals, breadcrumbs, etc)

Check them out here

Show demo code
import {Button, Dialog, DialogTrigger,
    Heading, Input, Label, Modal,
    TextField} from 'react-aria-components';

<DialogTrigger>
  <Button>Sign up…</Button>
  <Modal>
    <Dialog>
      {({ close }) => (
        <form>
            <Heading>Sign up</Heading>
            <TextField autoFocus>
                <Label>First Name:</Label>
                <Input />
            </TextField>
            <TextField>
                <Label>Last Name:</Label>
                <Input />
            </TextField>
            <Button onPress={close}>
                Submit
            </Button>
        </form>
        )}
     </Dialog>
  </Modal>
</DialogTrigger>

HeadlessUI

I'm not sure if headlessui.com came first, or the generic name of "headless UI" came first. But anyway, this is a very popular library which supports React and Vue.

Demo code:

Show demo code
import { Dialog, Transition } from '@headlessui/react'
import { Fragment, useState } from 'react'

export default function MyModal() {
  const [isOpen, setIsOpen] = useState(true)

  function closeModal() {
    setIsOpen(false)
  }

  function openModal() {
    setIsOpen(true)
  }

  return (
    <>
      <div className="fixed inset-0 flex items-center justify-center">
        <button
          type="button"
          onClick={openModal}
          className="rounded-md bg-black bg-opacity-20 px-4 py-2 text-sm font-medium text-white hover:bg-opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75"
        >
          Open dialog
        </button>
      </div>

      <Transition appear show={isOpen} as={Fragment}>
        <Dialog as="div" className="relative z-10" onClose={closeModal}>
          <Transition.Child
            as={Fragment}
            enter="ease-out duration-300"
            enterFrom="opacity-0"
            enterTo="opacity-100"
            leave="ease-in duration-200"
            leaveFrom="opacity-100"
            leaveTo="opacity-0"
          >
            <div className="fixed inset-0 bg-black bg-opacity-25" />
          </Transition.Child>

          <div className="fixed inset-0 overflow-y-auto">
            <div className="flex min-h-full items-center justify-center p-4 text-center">
              <Transition.Child
                as={Fragment}
                enter="ease-out duration-300"
                enterFrom="opacity-0 scale-95"
                enterTo="opacity-100 scale-100"
                leave="ease-in duration-200"
                leaveFrom="opacity-100 scale-100"
                leaveTo="opacity-0 scale-95"
              >
                <Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
                  <Dialog.Title
                    as="h3"
                    className="text-lg font-medium leading-6 text-gray-900"
                  >
                    Payment successful
                  </Dialog.Title>
                  <div className="mt-2">
                    <p className="text-sm text-gray-500">
                      Your payment has been successfully submitted. We’ve sent
                      you an email with all of the details of your order.
                    </p>
                  </div>

                  <div className="mt-4">
                    <button
                      type="button"
                      className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
                      onClick={closeModal}
                    >
                      Got it, thanks!
                    </button>
                  </div>
                </Dialog.Panel>
              </Transition.Child>
            </div>
          </div>
        </Dialog>
      </Transition>
    </>
  )
}

They have support for components such as:

  • dropdown menus,
  • select listboxes,
  • combobox (autocomplete),
  • toggle switch,
  • disclosures,
  • modal dialogs,
  • popover,
  • radio groups,
  • tabs,
  • and transitions

Check out some demos on their site. Each component has comprehensive documentation giving details of how to implement their

Radix

Radix is a very popular headless component UI library.

An open-source UI component library for building high-quality, accessible design systems and web apps.

Radix Primitives is a low-level UI component library with a focus on accessibility, customization and developer experience. You can use these components either as the base layer of your design system, or adopt them incrementally.

They make it easy to add a wide variety of components, and supports nice transitions too.

They focus on accessibility (which is great!). They provide uncontrolled (can be set up to be controlled) components, nice and easy to use and implement

Demo of a checkbox:

Show demo code
import React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import './styles.css';

const DialogDemo = () => (
<Dialog.Root>
    <Dialog.Trigger asChild>
        <button className="Button violet">Edit profile</button>
    </Dialog.Trigger>
    <Dialog.Portal>
        <Dialog.Overlay className="DialogOverlay" />
        <Dialog.Content className="DialogContent">
            <Dialog.Title className="DialogTitle">Edit profile</Dialog.Title>
            <Dialog.Description className="DialogDescription">
                Make changes to your profile here. Click save when you're done.
            </Dialog.Description>
            <fieldset className="Fieldset">
                <label className="Label" htmlFor="name">
                    Name
                </label>
                <input className="Input" id="name" defaultValue="Pedro Duarte" />
            </fieldset>
            <fieldset className="Fieldset">
                <label className="Label" htmlFor="username">
                    Username
                </label>
                <input className="Input" id="username" defaultValue="@peduarte" />
            </fieldset>
            <div style={{ display: 'flex', marginTop: 25, justifyContent: 'flex-end' }}>
                <Dialog.Close asChild>
                    <button className="Button green">Save changes</button>
                </Dialog.Close>
            </div>
            <Dialog.Close asChild>
                <button className="IconButton" aria-label="Close">
                    <Cross2Icon />
                </button>
            </Dialog.Close>
        </Dialog.Content>
    </Dialog.Portal>
</Dialog.Root>
);

export default DialogDemo;

Check out their docs

react-form-hook

Although I'm putting this in the section for multi-component libraries, really react-form-hook of course only includes support for form related components.

They don't focus on making it accessible - but checkout https://react-hook-form.com/advanced-usage/

One-off headless component libraries

dayzed (for date pickers)

If you have ever implemented a date picker (or calendar) you will know that while you can easily render out a calendar-like view of 31 days... there are so many edge cases and things to think about.

Dayzed is one solution. It can be used to create WAI-ARIA compliant React date pickers.

You can check out their demo at dayzed.netlify.app. Like most headless component libraries, they provide the logic behind the component but leave all rendering to you.

Downshift (For autocomplete/combobox/select dropdowns)

Kent C Dodds (famous for a lot of React & testing React content) worked on Downshift, to let you easily implement complex dropdowns/autocomplete/combobox components.

Example:

Show demo code
import Downshift from 'downshift'

const items = [
  {value: 'apple'},
  {value: 'pear'},
  {value: 'orange'},
  {value: 'grape'},
  {value: 'banana'},
]

function ADropdownDemo() {
  return <Downshift
    onChange={selection =>
      alert(selection ? `You selected ${selection.value}` : 'Selection Cleared')
    }
    itemToString={item => (item ? item.value : '')}
  >
    {({
      getInputProps,
      getItemProps,
      getLabelProps,
      getMenuProps,
      isOpen,
      inputValue,
      highlightedIndex,
      selectedItem,
      getRootProps,
    }) => (
      <div>
        <label {...getLabelProps()}>Enter a fruit</label>
        <div
          style={{display: 'inline-block'}}
          {...getRootProps({}, {suppressRefError: true})}
        >
          <input {...getInputProps()} />
        </div>
        <ul {...getMenuProps()}>
          {isOpen
            ? items
                .filter(item => !inputValue || item.value.includes(inputValue))
                .map((item, index) => (
                  <li
                    {...getItemProps({
                      key: item.value,
                      index,
                      item,
                      style: {
                        backgroundColor:
                          highlightedIndex === index ? 'lightgray' : 'white',
                        fontWeight: selectedItem === item ? 'bold' : 'normal',
                      },
                    })}
                  >
                    {item.value}
                  </li>
                ))
            : null}
        </ul>
      </div>
    )}
  </Downshift>
}

As you can see, its not as simple as something like <SomeDropdown items={items} />. But this gives you a lot of flexibility to implement the dropdown as you want, while knowing behind the scenes it is set up correctly with keyboard and mouse event listeners, correct aria attributes etc.

Tanstack headless components

Tanstack have a few headless components that let you easily add your own styling to an accessible component.

Unlike some other multi component libraries (such as Radix), these are individual libraries that you will need to install separately.

  • TanStack ranger for range/multi range inputs
  • TanStack table for powerful tables/data grids. Of course normal tables can be implemented without too many accessibility considerations. But once you start to add filtering and sorting you should consider using a headless library like this
  • TanStack virtual is a nice headless ui for virtualizing scrollable elements. (Something the TanStack Table library also does)
  • TanStack Form for creating simple or complex forms
© 2019-2023 a5h.dev.
All Rights Reserved. Use information found on my site at your own risk.