Keyboard Accessible Radios and Checkboxes in Safari

Recently, while working on a radio pane design for a product, I discovered MacOS Safari does not follow the same keyboard tab behaviors that keyboard users expect. It is normal to use the tab key to progress from field to field as you complete a form. This behavior has been ingrained in human kind. It’s not even exclusive to web applications. So why did Apple decide they needed to go against convention. The short version, I have not been able to find a reason. MacOS has OS level preferences that can remove this feature, but would it not be in the best interest of the web site to ensure that behavior is consistent from browser to browser and platform to platform.

My goal is to find a solution that still allows for semantic and accessible radio groups and checkboxes, while providing the keyboard controls expected by the end users. Here is the acceptance criteria.

Checkbox Keyboard Navigation

  • Click/Tap Events toggle the checked state
  • Shift + Tabbing from an immediate previous element
  • Tabbing to an immediate next element
  • Space bar will toggle the checked state

Radio Keyboard Navigations

  • Click/Tab Events select the radio group option
  • Shift + Tabbing from an immediate previous element
  • Tabbing to an immediate next element
  • Arrow keys will trigger radio option checked state

We will use the above as the basis of passing or failing this experiment.

First Attempt

See the Pen Basic Checkbox and Radio Group by Andrew (@avigil06) on CodePen.

I decided to start off with stylized radio and checkboxes since most design systems will require you to hide the inputs and use pseudo elements, or other means, to create a custom look. I also changed the color of the label when the sibling input has focus, just to give us an indicator of where we are on the page.

Those of you on a MacOS device, you can open chrome and test tabbing through the test form in the above pen. You will notice the text color change, using the space bar on the checkbox should toggle it and tabbing into the radio group should allow you to modify your selection with the arrow keys. Ready for it? Try it in Safari…If your keyboard tab was not inserting you into the form and allowing any modifications of the checkbox or radio fields, you’re not alone.

Second Attempt

In this attempt, I am going to remove input fields entirely. I have decided to use a combination of tab index, aria attributes, and roles. I did the checkbox myself, but I copied the radio group code from the MDN Web Docs, just to make sure I had something semantically correct to build off of. Here is the result.

See the Pen Role/Aria Checkbox and Radio Group by Andrew (@avigil06) on CodePen.

The first thing you should probably notice is we now have a JavaScript tab. That’s right, this solution only works with JavaScript as we need to listen for events to provide both mouse and keyboard control. The checkbox is pretty straight forward. We have a span with a tab index, aria-checked, and of course the role. These will be the most important pieces. The radio group is similar except we have individual radio role elements which are wrapped by an element with a role of radio group.

Here is where the magic happens. All elements with the role of radio or checkbox have a tab index. On checkboxes, this value must be 0. For individual radios in a radio group, this will need to be 0 if the radio is selected, and -1 if the radio is not. It is this tab index which allows Safari to delegate events to the keyboard. Go ahead, try to tab through this “form”. Those of you with Safari will see the cursor does not skip these elements anymore.

The last thing is simply to provide the native functionality to meet the remaining of our acceptance criteria. Lets talk about the JavaScript for our checkbox. We are listening for click events and toggling the aria-checked attribute of the span[role="checkbox"]. This allows for our CSS to show a checked or unchecked state. We also have a key up listener which allows us to listen to execute the same toggle if the space bar is pressed while the span has focus. Not to bad.

The radio group is a bit more complex. Just like the checkbox, we listen for mouse clicks on any individual click. We reset the radio group to an unselected state and then set the appropriate radio to checked. Our key up event gets a bit more complex since we must detect directional key presses. Up and Left should demote the selection to the previous option, unless no more exist. Just the opposite for Down and Right.

Conclusions

All in all, I am pretty happy with how this experiment turned out. I’d like to provide a word of caution though. Using this method with vanilla JavaScript still leaves a lot of holes in having a functional form. This form is no longer serialize-able without intercepting the submit event and writing custom logic. This would be very messy if you needed to destroy or add new checkbox or radio groups to your form programmatically. The listeners are not removed or added in any intelligent way. This was really just for proof of concept. If I had an actual requirement to implement this in a project, I would probably opt for something that can track the value of the form in memory instead of holding state in the DOM. In this way, I would already be relying on intercepting the submit event of the form and serializing the form into a request payload.

Anyway, I had fun with this and I hope you enjoyed reading about it.