Skip to Content
Accessible To Do List with React.js

Accessible To Do List with React.js

Are you looking to build a React To Do List with React Hooks? Is User Experience, Inclusivity, and Accessibility important to you? Are you wanting to build something from scratch but aren’t sure where to start? If you answered yes to the above questions, you found it here. In this article I’ll show you how to build an accessible to do list with React, diving deep into how to build form components with inclusive elements.

Estimated reading time: 20 minutes

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 travel guide. In it, we’ll journey through building these React Components with Accessibility & Inclusivity in every feature.

Let’s Start

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. My article extrapolates from his ideas. In his example he built his with Vue.JS but I wanted to build mine with React.

To build an accessible to do list with react, we’ll want to break this article down into parts. We are going to cover beginning with Empty State. Then we’ll move onto the Heading which is part of the App component. Next we’ll go over the Add Item Component. And finally, the UnorderedList component. We’re going to cover some key concepts we’ll need to understand for the React To Do List to be actually Inclusive.

See the final product:

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 we can add 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 state will add the class name visible to the heading. The heading shows up to the user when we call the setDisplay function.

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>

When we submit the heading, the h1 element appears :

.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 screen reader reads the text in this label, preventing 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>

Set Up Focus Management

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. When we submit the heading the tabindex is set to -1. This allows us to manage focus with JavaScript:

    setTabIndex('-1');

Programmatically Label the Aria-Labelledby Attribute

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.

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. Color queues like Green (when we add an item), Red (when we delete an item), and Grey (to signify an inactive element can be useful). Check out how I use these color queues in this React Toggle Buttons post.

We can ignore the red & green colors here. Instead I’m using blue to signify the active state of the buttons and grey for the inactive state.

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 the user can see when the item on the list.

Visual Result

Providing Audio Feedback on the Add Input

Providing audio feedback to non-visual users is equally important, but less automatic. We add an Aria Live Region to the Add-Item component. This tells 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('');

The state needs to update when the form is submits. When we submit the add-item form, the elements need to reset to inactive state. Also, the state resets only when it’s appropriate by adding conditional statements:

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. The browser or screenreader can still read anything with this visually hidden class:

.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:

The Aria-live state shows; “Get a life 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

Import Font Awesome

First, you’ll want to install Font Awesome in your React App. Start here, it’s relatively simple.

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';

Build the Trash Button

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 new button is inheriting my previous buttons styles, but my new button doesn’t use the aria-invalid attribute. 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. If I delete an empty element, it won’t cause any harm. That’s why I’m not including the active state in my trash button.

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 . The id on the checkbox should 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>

Don’t do this:

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 do this:

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 we check the checkbox.

: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. React requires unique keys on list items. A unique identifier tells react to render a new list item. But you can’t generate unique keys 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. When we delete the last item on the list, we have to control the keyboard focus. 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>

When we click the button, the function below deletes the first list item in the list. 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.

The Aria Live region shows; “Make a friend has been removed

For the purpose of demonstration, I’ve added a “focus” style to the outline of the heading. So we can see the focus jump back to the heading once we delete the last item. This makes 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>
  );
  
}

Wrapping Up

Well you made it this far. You deserve drink 🥃 , or a cookie 🍪 , or maybe a good book 📕 and some tea 🫖 . In this article you learned how to create Audio Feedback with Empty State, and the Aria Live section. How to create Visual Feedback with color changes, and how to add Iconography.

I showed you how to manage focus with tabindex and javascript on the Heading Component. We built the Heading Component, (part input part heading), which is part of the App component.

We built the Add Item Component and styled it smartly controlling the color with the state of the aria-invalid input.

And finally, we built the UnorderedList component. You cross off or delete an element from the list, and the program will communicate your actions back to you.

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.

Featured Image by Lautaro Andreani on Unsplash