Skip to Content

Accessible To Do List with React.js

In this article I’ll show you how to build an accessible to do list with React, diving deep into building forms with inclusive elements. If you’re here for a quick start guide, this isn’t really it. Buckle up because this to do list react tutorial is a bit more like a book or guide. In it, I’ll walk you through building these React Components with Accessibility & Inclusivity in every feature.

Estimated reading time: 20 minutes

Why a To-Do List?

If you’re anything like me, you probably love checking things off of lists ✅ . You probably even struggle focussing on things without an outline, like a to-do list, to get through your day.

You probably also add things after you’ve completed them just so you can check it off for that sweet, sweet dopamine rush. If you’re not like me, that’s okay but people like me will want to use your to-do list so think of my above ramblings as user input.

In Heydon Pickering’s book Inclusive Components (which you should absolutely buy if you’re serious about accessibility development), he dives deep into the how’s and why’s of building accessible form components.

The above example is built with Vue.JS but I wanted to try building this with React.

To build an accessible to do list with react, we’ll want to break this article down into parts (like components, get it?). We have the Heading which is part of the App component, the Add Item Component and UnorderedList component. We’re going to cover some key concepts needed for the React To Do List to be actually Inclusive.

Featured Image by Lautaro Andreani on Unsplash

Check out the build on GitHub here. You can get the source code here, which I recommend having open and reviewing while you read this article for the most immersive experience.

The Empty State

When the page first renders, our visual users will see an empty list and placeholder text explaining what to do. But how do we tell our audio users that the react to-do list is ready to use? We need to build inclusive elements.

We’ll add a hidden div.

<div className="empty-state">
   <p>Either you've done everything already or there are still things to add to your list</p>
</div>

We’ll hide the above div element with the following CSS:

.empty-state, ul:empty {
    display: none;
}

ul:empty + .empty-state {
    display:block;
}

The Heading

The heading needs a unique name. Unlike in the Vue.js example, the concept for this component is one that can be added multiple times within the same web application. The heading will start as an input and then be nothing more than a heading when the user hits enter.

For this we need to use styling, State, and some built in JS functionality.

Styling the heading

We want the input and the heading to look nearly identical for sighted users. We also do not want the removal of the input to cause Layout Shift. Cumulative Layout Shift (CLS) can cause frustration when elements on the page move while the user is interacting with the page. That’s bad for users and bad for your product, so here’s how we avoid that.

The image below shows our heading input and the box properties of this element.

Notice how after we’ve input our heading and hit enter, the box properties of the Heading match the input above. This prevents layout shift:

the react to do list title text properties

The following styles control and match the input to the styles of the heading so that the heading appears to seamlessly replace the input without moving on the page.

#heading-input {
    border: transparent;
    font-size: 1.5em;
    min-width: 400px;
    border-bottom: 2px solid black;
}
h1 {
    padding: 0 2px;
    color: #444;
    font-weight: 400;
}
form, h1{
    min-height: 34px;
    margin: 20px 0;
}

Providing Visual Feedback on the Heading

Add state for displaying elements. This will add a class name “visible” which will make the heading text visible once the heading input has been submitted:

const [display, setDisplay] = useState('');

Inside the onHeadingSubmit function we update the display state:

    setDisplay('visible');

Then we add the state declaration to the H1 element inside the class:

<h1 className={`${display} to-do-title`} id={heading.replaceAll(' ', '-').toLowerCase()}> {heading}</h1>

The heading is only displayed after it’s been submitted:

.to-do-title {
    display: none;
}
.visible {
    display: block !important;
}

Visual Result:

Providing Audio Feedback on the Heading

First let’s add a hidden label to instruct the non-visual user how to use this input. For visual users, the placeholder explains the purpose of this input. For audio users, the text in this label will be read by the screen reader without creating redundancy.

<label htmlFor="add-list-heading" className="visually-hidden">Add a title for your to do list and hit enter</label>

Now we want to tell the audio user what heading is saved in case they need to edit it.

We create a state for the heading feedback:

    const [headingFeedback, setHeadingFeedback] = useState('');

Inside the onHeadingSubmit function, we update the state with this line of code:

setHeadingFeedback(`New heading set to: ${headingRef.current.value}`); //non visual feedback when list title submitted

We set the aria live region. (I go into more about this region in the Add Item section of this post).

<div role="status" aria-live="polite" className="visually-hidden">{headingFeedback}</div>

We also need to adjust the Tab Index of the heading on state for managing focus. This is because we’re going to use the heading later when we start deleting items from the list.

We’ll add a state for the tabindex attribute, (notice that this is a string and not a number):

    const [tabindex, setTabIndex] = useState('0');

Inside our onHeadingSubmit function, we’ll add this setState function. The heading input needs to be a focusable item with a tabindex of 0. We are changing the tabindex to -1 when the heading input is submitted. This allows us to manage focus with JavaScript:

    setTabIndex('-1');

Last but not least, we utilize some Vanilla JS to add the aria-labelledby functionality to the to do list. According to MDN Web Docs, “Assistive technology, such as screen readers, use this attribute to catalog the objects in a document so that users can navigate between them. Without an element ID, the assistive technology cannot catalog the object.”

The idea for this element is that you can use the to-do-list-app more than once within a given application. Being able to identify the to-do-list’s uniquely is important.

In the section element is an aria-labelledby attribute that’s taking the heading state and setting it to lowercase while replacing spaces with dashes. This allows your component to be accessible and reusable.

    <section aria-labelledby={heading.replaceAll(' ', '-').toLowerCase()}>
    <h1 className={`${display} to-do-title`} id={heading.replaceAll(' ', '-').toLowerCase()}> {heading}</h1>

Audio Feedback in the code:

The aria-live section below is reading “New heading set to: My To Do List ” to the user.

The Heading and App Component

const App = () => {
//set states
    const [heading, setHeading] = useState('');
    const [headingFeedback, setHeadingFeedback] = useState('');
    const [display, setDisplay] = useState('');
    const [tabindex, setTabIndex] = useState('0');
//set refs
    const headingRef = useRef();
    const titleFormRef = useRef();

//events

 const onHeadingChange = () => {
        setHeading(headingRef.current.value);
//turned the list heading string into a syntactically friendly ID and aria-lableled-by in App element
    }

const onHeadingSubmit = (e) => {
    e.preventDefault();
    titleFormRef.current.remove();//remove input from dom    
setHeadingFeedback(`New heading set to: ${headingRef.current.value}`); //non visual feedback when list item added 
    setDisplay('visible'); //show heading text
    setTabIndex('-1');
}
    
return (
 <section aria-labelledby={heading.replaceAll(' ', '-').toLowerCase()}>

 <h1 tabIndex={tabindex} className={`${display} to-do-title`} id={heading.replaceAll(' ', '-').toLowerCase()}> 
{heading}
</h1>

 <form ref={titleFormRef} onSubmit={onHeadingSubmit}>
    <label htmlFor="add-list-heading" className="visually-hidden">
Add a title for your to do list and hit enter
</label>
    <input id="heading-input" type="text" placeholder="E.g. Title my to do list and hit enter" ref={headingRef} value={heading} onChange={onHeadingChange} />
 </form>

<div role="status" aria-live="polite" className="visually-hidden">
{headingFeedback}
</div>
    
    
<div className="empty-state">
   <p>Either you've done everything already or there are still things to add to your list</p>
</div>

    //To Do Add Item component

</section>
);
}

The Add Item Input

Styling the add input

Not all users can see colors the same way, offering color in properly tested contrasts will be sure to provide the most usability.

Using color queues like Green to signify the item was added, Red to signify something was deleted, and Grey to signify an inactive element can be useful. Check out how I use these color queues in this React Toggle Buttons post.

While not all users can see these colors, these color choices can help ease cognitive load. For the Add Input Item specifically, we won’t be deleting anything and I go over the way Visual Feedback works for this component. We can ignore the red & green colors here. Instead I’m using blue to signify the active state of the buttons.

Inactive State

We used state here to update the color on the button. Luckily, the following build allows the in-active state to make styling pretty easy.

Here we have defined the state for the disabled attribute on the button as well as the aria-invalid attribute on the input:

const [disabled, setDisabled] = useState('disabled');
const [valid, setValid]       = useState(true);

Next we needed to add an event handler to update the state for both of these elements. Now, once the user starts typing into the input, they’ll be able to submit their to-do item to the list.

const onInputChange = (e) => {
        setValid(false); //set aria to valid for so users can submit a list item
        setDisabled(''); // enable the button
        setItem(e.target.value); //store value of input
}

On the input and button elements, we need to have the state declared, as well as call our event function:

<input type="text" ref={inputRef} value={item} onChange={onInputChange} id="add" placeholder="E.g. My first to do item" aria-invalid={valid} />
<button type="submit"  disabled={disabled}>Add</button>

Active State

The button styles will ensure the button is grey in the background with white text (Contrast Ratio 7.83:1) when the aria is invalid (in other words when the text input is empty). The blue button (Contrast Ratio 9.01:1) which signifies active state will only be blue when the input aria-invalid is set to false.

For styling the inactive and active states, we’ll use the aria and disabled attributes as our CSS Selectors to simplify the process.

button {
    color: #fff;
    background: #515252;
    border: 1px solid #515252;
    border-radius: 2px;
    padding: 4px 7px;
    font-size: 18px;
    margin-left: 7px;
}
input[aria-invalid=false] + button {
    color: #fff;
    background: #004794;
    border: 1px solid #004794;
}
button:disabled {
    color: #fff;
    background: #515252;
    border: 1px solid #515252;  
}

Providing Visual Feedback on the Add Input

The great thing about adding the value of this input to the react to do list is that this alone creates visual feedback. Drawing the users eye isn’t necessary here since they’ll see the item on the list and confirm it’s been added.

Visual Result

Providing Audio Feedback on the Add Input

Providing audio feedback to non-visual users is equally important, but less automatic. An Aria Live Region can be added to the Add-Item component to tell the screen-reader the value of the input has been added. The status role content is advisory information for the user, or in other words isn’t important enough to justify an alert.

For this feedback we need to use state within our Add-Item component:

const [feedback, setFeedback] = useState('');

We need to update the state when the form is submitted. We also need to reset the form elements to inactive state whenever the add item form is submitted. Here we’ve added conditional statements to ensure the state is only being reset when appropriate:

const onFormSubmit = (e) => {
    e.preventDefault();
    setResult(inputRef.current.value); //add the value of the input to the list
    setFeedback(`${inputRef.current.value} added`); //non visual feedback when list item added 
    if(valid !== true) {
        setValid(true);
    }
    if(disabled !== 'disabled') {
        setDisabled('disabled');
    }
//reset input after submit
    if(item !== '') {
        setItem('');
    }
}

We need to add the live region to the Add Item component. I added mine under the form:

<div role="status" aria-live="polite" className="visually-hidden">{feedback}</div>

Finally, we need to add the utility styling of the visually-hidden class. This will ensure that our labels with this class are still going to be read by the browser/screen-reader:

.visually-hidden {
    position: absolute !important;
    clip: rect(1px, 1px, 1px, 1px) !important;
    padding: 0 !important;
    border: 0 !important;
    height: 1px !important;
    width: 1px !important;
    overflow: hidden !important;
}

Audio feedback in the HTML:

Here we see the aria-live state reads that Get a life was added:

The Add Item Inclusive Element:

const AddItem = () => {
//set form states
const [disabled, setDisabled] = useState('disabled');
const [valid, setValid]       = useState(true);
const [item, setItem]         = useState('');
const [result, setResult]     = useState('');
const [feedback, setFeedback] = useState('');


//set references
const inputRef = useRef();
const formRef = useRef();



//an event handler to update the state so the user knows whether they can add the item to the list or not
const onInputChange = (e) => {
        setValid(false); //set aria to valid for submittal
        setDisabled(''); // enable button
        setItem(e.target.value); //store value of input
}

//setting the results & feedback and toggling the active state
const onFormSubmit = (e) => {
    e.preventDefault();
    setResult(inputRef.current.value); //add the value of the input to the list
    setFeedback(`${inputRef.current.value} added`); //non visual feedback when list item added 
    if(valid !== true) {
        setValid(true);
    }
    if(disabled !== 'disabled') {
        setDisabled('disabled');
    }
//reset input after submit
    if(item !== '') {
        setItem('');
    }

}

    return (
<div>
        //TODO add Unordered List Component.
 <form className="add-item-form" ref={formRef} onSubmit={onFormSubmit}>
   <label htmlFor="add-to-do" className="visually-hidden">
   Add List Item
   </label>
   <input type="text" ref={inputRef} value={item} onChange={onInputChange} id="add" placeholder="E.g.             My first to do item" aria-invalid={valid} />
   <button type="submit"  disabled={disabled}>Add</button>
</form>
   <div role="status" aria-live="polite" className="visually-hidden">
  {feedback}
  </div>
</div>
    );
};

The React To Do List

First, you’ll want to install Font Awesome in your React App. It’s relatively simple to get started here.

I’ve chosen to add a Trash Can in a button which will allow me to delete items from my To Do List.

I don’t personally want to remove something when I’ve checked it off because I might click the checkbox my accident! That would be a very frustrating user experience. So, having manual control over deleting items is better in my opinion.

After using NPM to install the Font Awesome dependencies. I import font awesome and the icon into my UnorderedList.js component.

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons';

Next, I want to add this icon to a button for usability and accessibility. The button element will tell the browser that its role is a button, and needs no additional labeling. However, in our button, there is only a visible trashcan and no text. So to label the button we can do one of the following.

Under my results label element I’m going to add this button with the trash icon as the text and an aria-label of “delete” to tell the assistive technology what the button is for:

     <button aria-label="delete" className="trash">
       <FontAwesomeIcon icon={faTrashAlt} />
     </button>

Or let’s say I want my delete icon to be accessible to audiences that don’t speak English. I may instead implement the design like this below.

       <button className="trash">
       <FontAwesomeIcon icon={faTrashAlt} />
       <span className="visually-hidden">delete</span>
       </button>

Here, I’ve removed the aria-label to prevent redundant information to the user, but have added a visually-hidden class to a span which reads “delete” as the text of the button element. Translation services can now access this text making it accessible to a wider audience:

Styling the React list – Adding Icons with Font Awesome

Now I have a problem with my CSS styles. My trash button doesn’t use the aria-invalid attribute and the styling of the previous button is being inherited. For a refresher on avoiding some common mistakes with CSS check out this article.

You may want to make your trash button with inactive and active states in your application. For me, the button can stay in an active mode since no harm will come to an empty element being deleted.

Below I’ve altered my button code so that the trash button can appear blue while the add button maintains its original styles:


button {
    border-radius: 2px;
    padding: 4px 7px;
    font-size: 18px;
    margin-left: 7px;
}
button:not(.trash) {
    color: #fff;
    background: #515252;
    border: 1px solid #515252;
}
button:not(.trash):disabled, input[aria-invalid] + button:not(.trash){
    color: #fff;
    background: #515252;
    border: 1px solid #515252;  
}
input[aria-invalid=false] + button:not(.trash) {
    color: #fff;
    background: #004794;
    border: 1px solid #004794;
}
button.trash {
    color: #fff;
    background: #004794;
    border: 1px solid #004794;
}

Providing Visual Feedback on the React To Do list

Starting with a cross through design on the list item is easy. To build out the checkbox input and label you simply need the following in your component.

You need to ensure your checkboxes are built properly with an id on the checkbox to correspond with the for attribute on the label that goes with it.

The idea is that you need to connect the checkbox to the label somehow so the browser knows they belong together.

I have the results object being transformed into lowercase strings with dashes to replace the spaces for syntactically correct mark up in my id and htmlFor attributes. The htmlFor element replaces the for HTML element in React.:

       <ul className="list-items">
       <li>
<input type="checkbox" id={`to-do-${results.replaceAll(' ', '-').toLowerCase()}`} />
       <label htmlFor={`to-do-${results.replaceAll(' ', '-').toLowerCase()}`}>
       {results}
       </label>
       </ul>

You might want to try adding a className attribute with state like this below to change the design of the element when the checkbox is clicked, but it’s actually not necessary.

const [unchecked, setChecked]  = useState('unchecked'); //add a state for the class name
const onCheckboxChange = () => { //create an onchange function for when the checkbox is clicked
  if(unchecked === 'unchecked') {
    setChecked('checked');
  } 
}
//the input with the onChange and className attributes corresponding to functions above.
<input onChange={onCheckboxChange} className={unchecked} type="checkbox" id={`todo-${results}`} />
       <label htmlFor={`todo-${results}`}>
       {results}
       </label>

Instead, this simple CSS style will give you the same result. With this simple code, the line through the label of the checkbox will be dependent on whether the checkbox is checked or not.

:checked + label {
    text-decoration: line-through;
}

Visual Result:

Building React To Do List Items

At this point you might be asking yourself, “Ryan why didn’t you make a completely functional list?” Well, you got me there, see this article is already maybe too long. I am unable to build this list without a DataBase or a much bigger brain. This is because list items need unique keys to render additional list items, and unique keys can’t be generated on render. Since the purpose of the article is to focus on front end technologies for accessibility in the browser, I decided to focus on that.

You can learn more about building Lists with React here.

Providing Audio Feedback on the list items

I mentioned in the heading section of this post how we added a tabindex -1 to the Heading component. We need to manage the focus of the keyboard when the last item on the list is deleted. That way our users don’t end up having to start outside of the component to traverse the document.

First I’m adding an onClick function to my delete button:

       <button onClick={onDeleteClick} className="trash">
       <FontAwesomeIcon icon={faTrashAlt} />
       <span className="visually-hidden">delete</span>
       </button>

The function below is deleting the first list item in the list when the button is clicked. Then it returns focus to the Heading element.

In an actual to-do-list the list item will need to be replaced by a function to delete the specific node from the DOM. For the purposes of this example though, I’ve gone with a simple function targeting my only list item :

  const onDeleteClick = () => {
    document.querySelector(`li`).remove();
    document.querySelector(`[tabindex="-1"]`).focus();
  }

To make this element inclusive, we also need to add an aria-live function to tell the user their list item has been removed. I’ve added this below my Unordered List Element but still inside my UndorderedList.js component:

       <div role="status" aria-live="polite" className="visually-hidden">{deletedFeedback}</div>
       </div>

I add state for this feedback in my component:

  const [deletedFeedback, setDeletedFeedback] = useState('');

And finally, I include the setDeletedFeedback function in my onDeleteClick function:

  const onDeleteClick = () => {
    document.querySelector(`li`).remove();
    document.querySelector(`[tabindex="-1"]`).focus();
    setDeletedFeedback(`${results} has been removed`);
  }

Audio Feedback in the Code:

The image below shows the label for Make a friend has been added to the id and corresponding for attributes and the delete hidden text is inside the delete button.

In the image below, we can see that Make a friend has been removed, will be announced to the user.

For the purpose of demonstration, I’ve added a “focus” style to show that once the element is removed from the list, the user’s focus is sent back to the heading making navigation much easier.

The Unordered List Compontent:

const UnorderdList = ({results}) => {
  const [deletedFeedback, setDeletedFeedback] = useState('');

  const onDeleteClick = () => {
    document.querySelector(`li`).remove();
    document.querySelector(`[tabindex="-1"]`).focus();
    setDeletedFeedback(`${results} has been removed`);
  }
  return  (
       <div>
       <ul className="list-items">

       <li>
<input type="checkbox" id={`to-do-${results.replaceAll(' ', '-').toLowerCase()}`} />
       <label htmlFor={`to-do-${results.replaceAll(' ', '-').toLowerCase()}`}>
       {results}
       </label>

       <button onClick={onDeleteClick} className="trash">
       <FontAwesomeIcon icon={faTrashAlt} />
       <span className="visually-hidden">delete</span>
       </button>

       </li>
       </ul>

       <div role="status" aria-live="polite" className="visually-hidden">{deletedFeedback}</div>
       </div>
  );
  
}

Things to consider

After you build an accessible to do list, be sure to check out testing tips from my help article here to test your project to see if it’s passing the basic accessibility metrics.

Sharing is caring!