A wooden sign made out of scraps of wood and tree bark next to a lake in Saskatchewant. A maple leaf is carved out of the middle of the sign, allowing light to pass through. The placard beneath it says Madge Lake,

Accessible SVG Forms

Jake Robins - Dev

Recently a client asked me to build a component that was a map of Canada on which users could select different provinces as part of a form submission. It ended up being a really fun build that blended three different areas of front-end development that interest me: SVGs, Reactivity, and Accessibility. Honestly, it taught me a lot by marrying these domains into a single feature and solidified many of the concepts I had been noodling over for a while, so I thought I'd share the process of putting it together.

The code displayed here is available in a public repo so you can clone it and fire it up yourself!

Accessibility

I've been very slowly making my way through an excellent course by Sara Soueidan called Practical Accessibility. Sara is an expert in this field who I had been following through social media for a long time, and accessibility of web applications had been something I had been thinking about a lot when she announced that she would be releasing this course. So it was an easy buy for me! If you're interested in this topic, I'd recommend it. Her blog has some text-only exerpts if you want to preview it for free. She currently has a sale on (25% off) for Global Accessibility Awareness Day, but this ends on May 20th, 2024.

Broadly speaking, accessibility refers to building applications that work for users with disabilities. As a primarily visual medium, the obvious demographic to think about when it comes to accessibility on the web is users with visual impairments. But this is not the only demographic for whom accessibility concerns itself! Hearing impaired users will experience audio and video content differently, and there are many kinds of users who may need to interact with your application using only a keyboard or voice commands. And this isn't a tiny minority of users, either. Sara's course teaches that one in five users have some kind of disability. What is maybe the most striking statistic is this:

100% of the population will be faced with vision, hearing, motion, or cognitive disabilities at some point in their lives.
- Sara Soueidan, Practical Accessibility

I'm reaching an age now (pushing 40) where I'm starting to see the truth behind this. I don't consider myself disabled, but I prefer subtitles or closed captioning when watching videos, for example. They help me understand better what the characters are saying, especially if there is background noise or the characters are mumbly or have accents. It's especially useful for me when I'm watching content in Spanish, a language I am learning, to be able to get English translations alongside the speech. We all benefit from accessibility, whether now or in the future.

We're all just temporarily abled.
- Cindy Li

In my own accessibility journey, I've recently learned another critical lesson, and I feel like this is something that isn't talked about enough. Accessible websites are easier to author, easier to test, and perform better in search! I wouldn't have understood this a year ago, but I have now fully embraced this idea, having practiced it in production. It makes a lot of sense when you think about it.

...search engines are deaf, blind, use only a keyboard, and have limited technical abilities.
- Jared Smith, WebAIM: Accessibility and SEO

This applies to test suites too! Jest, Vitest, Cypress, and Playwright all rely on accessibility tools to find, test, and verify content in your application.

When my client approached me about building this component, I knew it would be a really fun challenge in accessibility, and since it would end up being a reasonably complex component, I wanted to make sure that I leveraged accessibility tools not only to provide good experiences for all users, but to simplify my authoring and testing experience. So let's get in to it!

Accessibility Basics

When building accessible components, there are four key pieces of data about different components on the site that are important to surface to assistive devices like screen readers.

  • Role - What purpose does this component serve?
  • Name - What is this component called?
  • Description - How do I use this component?
  • State - What state is the component currently in?

In addition, you'll want to think about keyboard operability as a way to interact with elements, should that be necessary. In the case of our map selector, that is obviously going to be important.

There are a few different ways to make sure this information is present in your markup. The first and often easiest way is through Semantic HTML.

Semantics

I try to place semantics really high up the decision tree when I'm building components. It's basically the first thing I think about. I teach my students to work hard to separate what something is from what is does or what it looks like. These were the requirements from my client on this map component:

  • When users click on a province, that province becomes selected.
  • Users can select 0, 1, or more province(s).
  • When they have finished their selection(s), they should click a submit button to send the results to the server.

Forgetting that this is a map for a second and trying not to get wrapped up in what it looks like, I wanted to understand what it was. Semantically, this is a form, and each province is a checkbox which can be checked or not. Finally a submit button should submit the form. You could express the simplest such component using semantic HTML like this:

<form title="Select your preferred province(s)">
  <label>
    <input value="ab" type="checkbox" checked />
    Alberta
  </label>
  <label>
    <input value="bc" type="checkbox" />
    British Columbia
  </label>
  <label>
    <input value="sk" type="checkbox" />
    Saskatchewan
  </label>
  {/* More provinces... */}
  <button type="submit">Submit</button>
</form>

If we examine this in the Chrome Dev Tools Accessibility panel, we can see a lot of the necessary accessible information is provided for free as part of the native implementation of these semantic elements. By choosing the right element and attribute, we communicate a lot of information about what something is, how it should be interacted with, and what state it has. Out of the box, we get the accessible "role", "name" and "state" of the checkbox just by using the <input> element, the type=checkbox attribute, and the associated <label> element.

A screenshot of the Chrome Dev Tools showing the accessible information of the checkbox from the above code. The "Name" is set to Alberta, "role" is set to "checkbox", and "checked" is set to true.

Our <form> element has great accessibility out of the box, too. By adding the title attribute to the <form> (which gives it its accesible name), the browser also sets it as what's called a "landmark region", helping users identify it as a key section of the site. The title also provides useful information about what the user should do with the form, which covers our "description" data.

A screenshot of the Chrome Dev Tools showing the accessible information of the form from the above code. The "Name" is set to "Select your preferred province(s), and "role" is set to "form".

The instructions should be available to sighted users too, so we can improve upon this by using a heading inside the form with a specific id and associating it via an aria-labelledby attribute, instead of the title attribute. This doesn't change the accesibility data, but it does provide sighted users the same instructions.

<form aria-labelledby="provinces-select">
  <h3 id="provinces-select">Select your preferred province(s)</h3>
  <label>
    <input value="ab" type="checkbox" checked />
    Alberta
  </label>
  <label>
    <input value="bc" type="checkbox" />
    British Columbia
  </label>
  <label>
    <input value="sk" type="checkbox" />
    Saskatchewan
  </label>
  {/* More provinces... */}
  <button type="submit">Submit</button>
</form>

Finally, our <button> deserves a look! Both "role" and "name" are handled by the appropriate semantic element and the appropriate child text.

A screenshot of the Chrome Dev Tools showing the accessible information of the button from the above code. The "Name" is set to "Submit", and "role" is set to "button".

If you'd like to learn more about which roles elements expose automatically through their semantics, you can read through this list. One interesting gotcha is that these implicit roles aren't always automatic just by using the element. Sometimes, you have to satisfy other criteria before it's exposed. In our example above, <form> only got the form role when we added an accessible name. Another example, <header> only gets the banner role if it's a child of a specific list of elements/roles, like <main>.

You'll also notice that the <input> and <button> elements each have Focusable: true in the dev tools. This is the keyboard functionality I mentioned earlier. Using the Tab key or Shift + Tab key, users can move forward or backward through all the focusable elements automatically. The checkboxes can be toggled on or off using the Space key, and the <button> can be pressed using the Space key or the Enter key. This is more automatic accessibility functionality you get out of the box, and underlines the importance of starting with semantic elements first. It's our first example of what I mean when I say "accessible websites are easier to author".

I also want to showcase the "Full Page Accessibility Tree" option Chrome has, which allows you to toggle the Elements tab to show the Accessibility Tree instead of the DOM tree (Firefox dev tools has a similar feature, but it looks like Safari lacks it natively). The Accessbility Tree is a parallel representation of your website, which omits non-useful elements or structure, like <div> elements used for layout, etc. It's a great way to see just how an assistive device will visualize your application.

Screenshot of the Accessibility Tree from the browser dev tools, showing a "main" section, with a "heading", a "form", and the remainder of the code above represented.

At this point, we have a working form and users would be able to operate the component and get the desired results whether using their eyes or a screen reader, a mouse or a keyboard. But we're a long way off from our map, though, and it sure doesn't look great.

A screenshot of the basic form outlined in the code above. A form with instructions to "Select your preferred province(s)", and three checkbox options of Alberta, British Columbia, and Saskatchewan. A submit button rests at the end. The entire section is unstyled, showing the default Chrome web styling.
It's giving 1998

Going Off-Road

Taking the simple form code above and trying to add in more advanced functionality and more appropriate styling presents our next challenge. Styling form inputs is a notoriously difficult thing to do, and has been for a long time. In some cases, you can get what you need done with CSS. Styling buttons, simple text fields, or just making minor modifications to checkboxes, radio buttons, etc., is often achievable without too much fuss. And if you can accompish it all with CSS without touching the markup, you know your accessibility is generally going to be ok.*

*Note: CSS does have the ability to alter accessibility information, so take this generalization with a grain of salt.

But let's imagine we are in a scenario where the light touch isn't sufficient. While it's possible through a bunch of CSS to completely hide the checkboxes from our form without getting rid of the elements themselves, in some cases there are things that simply cannot be styled. The guts of a <select> element are not entirely accessible via CSS, for example. Or, in our case, we know that we're going to need to use SVG elements at some point to represent our checkboxes. In these scenarios, we can "take the wheel" on accessibility and set these pieces of data by hand.

To illustrate the concept, let's build our form again, but do it entirely with non-semantic elements for our inputs, while continuing to use our semantic <form> and <button> elements. As an exercise, let's try to build this:

A screenshot reinterpretation of the above simple form, this time as a stack of three elements with Alberta, British Columbia and Saskatchewan inside. The whole stack has rounded edges. There are no checkboxes, but the selected item (Alberta) has a red background and white text, while the unselected ones have a light grey background with black text.

To start, I've removed the <label> elements and the <input> elements as per our constraints. I've replaced them with simple <div> elements for now. I also wrapped these in a <fieldset> element to provide something we can target with styling, since in our example, the checkboxes are grouped together. Fieldset is a nice semantic element to group <input> elements, especially useful in larger complex forms but also great in this scenario. I gave it a title to give it an accessible name as well.

<form class="non-semantic-form" aria-labelledby="provinces-select">
  <h3 id="provinces-select">Select your preferred province(s)</h3>

  <fieldset title="Provinces">
    <div>Alberta</div>
    <div>British Columbia</div>
    <div>Saskatchewan</div>
    {/* More provinces... */}
  </fieldset>

  <button type="submit">Submit</button>
</form>

And here is some CSS to style it.

.non-semantic-form {
  gap: 1rem;

  & fieldset {
    /* override defaults */
    padding: 0rem;
    border: 1px solid #aaa;

    background-color: #fafafa;

    /* rounded borders */
    border-radius: 1rem;
    overflow: hidden;

    /* flow */
    display: flex;
    flex-direction: column;

    /* checkbox padding */
    & * {
      padding: 0.5rem 1.5rem;
    }

    /* adds borders between elements */
    & > * + * {
      border-top: 1px solid #aaa;
    }
  }
}

This gets us looking like the screenshot, other than the checked functionality (more on that later). But let's look at our accessibility tree in the dev tools to see how assistive devices might see this. Here is our "checkbox" <div>.

Screenshot of the Chrome Dev Tools Accessibility panel showing the DIV elements. The role is "generic" and no other information is visible.

We can see an accessible role of "generic", which is wrong in this case (we need a checkbox). There is no accessible description, name, or state. You'll often hear a common refrain of "<div> elements are not accessible". While that's actually only kinda true, this lack of info in the tree is what they mean when they say that.

In truth, <div> elements are accesible - they just don't have any semantic meaning, so if you're using them to represent something else, you'll be in trouble. Many assistive technologies will skip over <div> if it thinks it isn't important. This is an especially dangerous thing when using JavaScript to manage state, which we'll need to do now. For example, it would be really easy for me to hook up React's useState with some values and event handlers to these <div> elements, providing all the state functionality we need. Using other state management schemes would be equally dangerous. I could add a class conditionally based on state and then style the selected <div> elements using that. Sighted users may not ever notice the difference. But assistive technologies would have no idea these were meant to be checkboxes.

Regardless of our state management choice, if we start first from an accessibility standpoint, we can leverage the accessible features to make authoring and testing these easier, in addition to providing assistive devices the data they need.

First, let's take care of our "role". HTML attributes can have their role manually set using the role attribute. In this case, we'll add role="checkbox" to the <div> elements. You can learn more about the different roles available here . Adding this role immediately shows the elements are "checkboxes" in the Accessibility tree, and even sets up a "checked" state (which is by default false). In addition, because the <div> has text content inside of it, Chrome automatically associates that as the accessible name, which works great for us.

Screenshot of the Chrome Accessibility Panel showing the div element with the checkbox role enabled. Name is set to Alberta, and state is checked: false.

If you didn't have contents in the <div>, or maybe the contents were more complex like a whole nested HTML structure, you could also explicitly set the label by giving an id to whatever element is your label, then associating it with the <div> using aria-labelledby, just like we did with the heading element above.

On to state. When we moved off of the native <input type="checkbox"/> element, we lost the built-in state management implemented by the browser. So, at the end of the day, we're now going to have to use JavaScript to manage it. For this demo, I've chosen React as my JavaScript library, and I've set up a simple useState with an object with each province as a boolean property. Then I passed the values in to thearia-checked attribute to connect that JavaScript state to the accessible checked state. This is a bit different than what you would normally do via a controlled input (using the value or checked attributes) but works roughly the same way. I connected a click handler function to the <fieldset> element to handle user interactions.

Finally, I used HTML's custom data attributes feature to set each checkbox option with a data-prov property to encode its value in to the element, much like a checkbox would using the value attribute. This custom data attribute can be accessed in the click handler, when a click event here bubbles up to the <fieldset>.

  const [provinces, setProvinces] = useState({
    ab: true,
    bc: false,
    sk: false,
  });

  const handleChange = (e) => {
    const prov = e.target.dataset.prov;

    setProvinces({
      ...provinces,
      [prov]: !provinces[prov],
    });
  };
<fieldset title="Provinces" onClick={handleChange}>
  <div role="checkbox" aria-checked={provinces.ab} data-prov="ab">
    Alberta
  </div>
  <div role="checkbox" aria-checked={provinces.bc} data-prov="bc">
    British Columbia
  </div>
  <div role="checkbox" aria-checked={provinces.sk} data-prov="sk">
    Saskatchewan
  </div>
  {/* More provinces... */}
</fieldset>

To style the "checked" provinces, we can actually target the accessible state via CSS. This is another way accessible components are easier to author. By selecting the checked state, we avoid having to specify an element type, making the code more resilient to change. The selector would continue to work, for example, even if we switched from a <div> back to a semantic checkbox. Selecting via the state intrinsicially connects the sighted experience with the screen reader experience, unifying your overall user experience, and it obviates the need to create a new class to "mark" selected items for styling, which is kind of a hack, honestly.

/* checkbox state */
& [aria-checked="true"] {
  background-color: hsl(0, 100%, 30%);
  color: white;
}

You can select via [aria-checked] or [aria-role="checkbox"] to broadly target all checkboxes whether they are checked or not, or you can specify the true and false value like I did above to narrow your selector to the two states.

The last step to manually setting up the accessibility is to enable keyboard navigation. There are a few steps to this.

First, let's make the elements focusable using the tabindex attribute. This tells the browser that your element should be in the tab-flow. Normally, all you have to do here is set tabindex="0" (or tabIndex={0} in JSX). A 0 value tells the browser to put it in the default flow order based on the order of the elements in the markup, generally speaking going from top to bottom. If you need to customize the order, you can specify values in there but that becomes a lot harder, since you have to consider all the tab-indexed items across the whole page (even those semantic checkboxes we added up above!).

<fieldset title="Provinces" onClick={handleChange} onKeyUp={onKeyUp}>
  <div
    tabIndex={0}
    role="checkbox"
    aria-checked={provinces.ab}
    data-prov="ab"
  >
    Alberta
  </div>
  <div
    tabIndex={0}
    role="checkbox"
    aria-checked={provinces.bc}
    data-prov="bc"
  >
    British Columbia
  </div>
  <div
    tabIndex={0}
    role="checkbox"
    aria-checked={provinces.sk}
    data-prov="sk"
  >
    Saskatchewan
  </div>
  {/* More provinces... */}
</fieldset>

Next, we need some way for the user to be able to toggle the options using the space bar. We can add a keyup event handler to our <fieldset> much the same way we add a click event handler. It just needs an extra step to screen which key is being pressed (otherwise it would fire when you tabbed off of it or hit any other key).

You may also want to handle other keystrokes, like the Enter key, or maybe left and right arrows to navigate through options, depending on your component.

const onKeyUp = (e) => {
  // If not a spacebar, return early
  if (e.key !== " ") {
    return;
  }

  const prov = e.target.dataset.prov;

  setProvinces({
    ...provinces,
    [prov]: !provinces[prov],
  });
};

Lastly, we probably want to add some nicer styling to our focused state. When you are tabbing through items, the browser adds a focused state to tell the user which item the tab is on. The default stying is usually a blue outline, and you can see in our setup this looks awful with the rounded borders of our elements.

A screenshot of the element stack with Saskatchewan in a focused state. The Blue outline is visible and is cropped awkwardly where the rounded corners come in.

Instead, let's do something a little funky. First, we can disable the default blue outline using this CSS inside our fieldset selector:

& *:focus-visible {
  outline: none;
}

Then, we can add our own styling based on the focus selector. What I've done below is create a gradient pseudo element which will appear to the left of our text, fading into it for a subtle highlight. It leverages a CSS custom property --focus-color which you can set to different things in our checked state selectors so that the colour of the gradient matches the seleted state.

& fieldset {
  --focus-color: rgba(125, 125, 125, 0.5);

  & [aria-checked="true"] {
    --focus-color: rgba(255, 255, 255, 0.5);
  }

  & *:focus {
    position: relative;

    &::before {
      content: "";
      position: absolute;
      left: 0;
      top: 0;
      width: 5rem;
      height: 100%;

      background: linear-gradient(
        90deg,
        var(--focus-color) 0%,
        rgba(9, 9, 121, 0) 30%
      );
    }
  }
}

It looks like this:

A screen shot of the options stack, now with Saskatchewan highlighted using a slight grey gradient to its left.

I would like the record to show that I am not a designer, and am fully aware that this is in no way a pretty or chique design. Please sub in whatever CSS style you want to showcase a focused state. Maybe I'm just nostalgic for the Web 1.0 days ok? What's wrong with a little serif font and grayscale gradients? The point is - we can style this and keep it accessible!

Anyway, there you have it - a completely accessible form, navigable by keyboard, using non-semantic checkboxes. Looking at our accessibility tree, we can see that it is almost indistinguishable from the semantic version, though it took significantly more work.

Screen shot of the Accessibility Tree showing form, heading, group, checkbox and focus states with accessible names.

You can apply these same principles to "take over" any other element too. In this example we still used semantic headings, forms, and a button. They can be mix and matched. Just remember, as always, the more control you take, the more work it will be, both now, and in your maintenance period, so from a productivity standpoint it's often best to try to get the "free" functionality of the semantic elements. There's also significantly more room for bugs because your implemention will by definition be more brittle than the battle tested browser specs. You the platform when you can! Still, having these skills in your toolset means you can do some really cool custom stuff, like make a map form!

SVGs as HTML Elements

If you don't know much about SVGs, get ready to be excited. These are some of my favourite things to play with in web development. SVG stands for Scalable Vector Graphic. It's an image, but not a typical image you might be used to. Whereas simple images like JPGs, WebPs or PNGs are bitmaps (grids of pixels with different colour values), SVGs are like images as code. They contain an instruction set on how to draw the image, and when your browser comes across it, it renders it live. Because this instruction set is not bound by resolution, SVGs can be scaled without quality loss, from very small to very large. It just costs the computer more calculation juice to paint it. Here's some simple SVG code, which describes a green circle.

<svg viewBox="0 0 1000 1000">
  <style type="text/css">{`.st0{fill:green;}`}</style>
  <circle class="st0" cx="500" cy="500" r="500" />
</svg>

Some basics to know about how SVGs work, in the context of our Map form:

  • SVGs are composed of elements; they are markup! They can be selected, styled and we can manuplate their accessibility (though some things work a bit differently)!
  • The parent container for an SVG is <svg>.
  • Inside the <svg> are any number of shape elements (<circle>, <rect> , <line> or <path>, generally), or groups of shape elements (enclosed in a <g> element).

Using these features of SVGs, you can already see how straightforward it will be to map the concepts we've learned about turning custom elements into checkboxes and using SVG shapes as those elements!

Finding our Map

I've spent some time in Adobe Illustrator making all kinds of things from T-shirts, to cofee mugs, to stickers, but I don't consider myself an expert. So for something like this, it's good to check if someone has already done some great work building a map of Canada. It turns out, they have! User Fonadier over on Wikipedia created what I think will work great for this purpose, and the image is available to share and remix via Creative Commons, so we can have some fun with it while respecting the wishes of the author. I like this one because it's not too high resolution, with some approachable rounded corners that's kind of fun.

A black map of Canada. Fonadier
Map of Canada

I downloaded this and opened it up in Adobe Illustrator to do a few inspections. What's great about this SVG is that the provinces are already divided for us into groups in the Adobe layers panel. Each layer in Adobe will export as a <g> element, enclosing all the shapes that make up a province in one place. This is super helpful for us as we know we're going to want to interact with these groups together. For example, the action should be the same whether a user clicks on Vancouver Island or Mainland British Columbia - they are selecting British Columbia, even though they are different "shapes" in the SVG.

Screenshot of Adobe Illustrator's Layers panel. All the provinces in the Map of Canada are grouped together and it's well-organized.

We do have some colour cleanup to do (Prince Edward Islands is coloured red in the downloaded image) but this is totally workable!

Our next step is to "export" this to code. Adobe makes this pretty easy just be saving the file and clicking SVG code option. It outputs a huge amount of boilerplate stuff, though.

<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.5.0, SVG Export Plug-In . SVG Version: 9.03 Build 54727)  -->

<svg version="1.1"
  id="svg2" 
  xmlns:cc="http://creativecommons.org/ns#" 
  xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 
  xmlns:dc="http://purl.org/dc/elements/1.1/" 
  xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 
  xmlns:svg="http://www.w3.org/2000/svg" 
  xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
  xmlns="http://www.w3.org/2000/svg" 
  xmlns:xlink="http://www.w3.org/1999/xlink" 
  x="0px" 
  y="0px" 
  viewBox="0 0 978 949.6"
  style="enable-background:new 0 0 978 949.6;" 
  xml:space="preserve"
>
  <style type="text/css">
	.st0{fill:#CD3512;}
  </style>
  <sodipodi:namedview 
    bordercolor="#666666" 
    borderopacity="1.0" 
    gridtolerance="10.0" 
    guidetolerance="10.0" 
    id="base" 
    inkscape:current-layer="svg2" 
    inkscape:cx="492.10147" 
    inkscape:cy="409.63616" 
    inkscape:pageopacity="0.0" 
    inkscape:pageshadow="2" 
    inkscape:window-height="750" 
    inkscape:window-width="1264" 
    inkscape:window-x="0" 
    inkscape:window-y="14" 
    inkscape:zoom="1.1008632" 
    objecttolerance="10.0" 
    pagecolor="#ffffff" 
    showgrid="false">
  </sodipodi:namedview>
  <defs>
    <inkscape:perspective  
      id="perspective112" 
      inkscape:persp3d-origin="611.22046 : 395.66931 : 1" 
      inkscape:vp_x="0 : 593.50397 : 1" 
      inkscape:vp_y="0 : 1000 : 0" 
      inkscape:vp_z="1222.4409 : 593.50397 : 1" 
      sodipodi:type="inkscape:persp3d">
    </inkscape:perspective>
  </defs>
  <!-- the rest of the shapes -->
</svg>

Almost all of this can be deleted. It's mostly metadata that isn't necessary if you aren't shipping the file by itself. Since we're going to inline this right in to our code, we can throw away most of it. The only thing we really need for this to be operable is the viewBox attribute, which defines a grid system for the entire image. The shapes inside the <svg> will use this grid system to anchor its rendering instructions.

I'm throwing away the <style> tag since I'll take care of that with my own CSS, too (this removes the PEI red color), and we don't need the Inkscape metadata, which is what the author used to create this file. Removing this content is also good for performance, since it's a whole whack of text that doesn't ship to the browser.

After removing all this, we are left with a simple parent <svg> container, with a principal "Canada" group. I've truncated the shapes here since it's a lot of text.

<svg viewBox="0 0 978 949.6">
  <g id="Canada">
    {/* shapes are here */}
  </g>
</svg>

Inside the master grouping there are either single <path> elements for single-shape provinces like Prince Edward Island...

<path
  id="PrinceEdwardIsland"
  d="M846.2,802.3c-4.1-0.8-8.4-1.7-9.5-2c-2.4-0.8-2.7-3.4-0.6-6.2
    c1.4-1.9,1.5-1.9,5.1,0.8l3.6,2.8l7-1.5c3.9-0.8,8.3-1.8,10-2.2c2.5-0.6,2.9-0.5,2.4,1.2c-0.3,1.1-0.9,3.4-1.2,5.3
    c-0.6,3.2-0.9,3.3-5,3.2C855.6,803.7,850.3,803.1,846.2,802.3L846.2,802.3z"
/>

...or grouped paths for provinces requiring more than one shape, enclosed in a <g> element, like Nova Scotia, which has one shape definition for the peninsula, and another for Cape Breton:

<g id="NovaScotia">
  <path
    id="CapeBreton"
    d="M880.5,801.8c-2-2.3-3.7-4.7-3.7-5.4c0-2.4,7.3-20.3,8.3-20.3c1.2,0,3,4.5,3,7.7c0,1.7,1.1,3.1,3.6,4.5
      c2,1.2,3.9,2.8,4.2,3.6c0.8,2.1-2.5,8.6-4.7,9.3c-1,0.3-2.6,1.5-3.7,2.6c-1,1.1-2.2,2-2.6,2C884.6,806,882.6,804.1,880.5,801.8
      L880.5,801.8z"
  />
  <path
    id="AcadianPeninsula"
    d="M830.9,861.2c-2.4-1.2-5.1-6-6-10.8c-1.1-5.3,9.9-19.7,20.2-26.5c5.4-3.6,3.9-4.7-2.1-1.6
      c-2.4,1.2-4.6,1.9-4.9,1.5c-1-1,3.7-9.2,7-12.4c2.5-2.4,4.1-2.9,11.6-3.6c4.8-0.4,9.6-1.3,10.8-1.9c2.7-1.5,9.7-1.4,13,0.1
      c5.1,2.3,4.5,4.6-3.2,12.3c-3.9,3.9-8.9,8.3-11.2,9.8s-5.3,4.2-6.6,6.1c-1.5,2.2-3.2,3.4-4.7,3.4c-1.8,0-2.6,0.9-3.6,3.9
      c-2.1,6.5-5,11.5-9.4,16.3C837.4,862.6,835.1,863.3,830.9,861.2L830.9,861.2z"
  />
</g>

This is more or less ready to go in terms of setting up our map now. We just need to repeat the steps from our non-semantic form!

Connecting the Pieces

As before, we'll start with our accessible "role" and place role="checkbox" on each province's parent element (whether <g> or <path>). As before, this is immediately visible in our Accessibility tree with a role and checked state now connected.

Unlike before, we don't get a free accessible "name", since the contents are a shape and not text. The simplest way is to use a title attribute, but we could also separately set a label and connect it with aria-labelledby as before. I don't want to add any additional markup, so I wanted to pursue the title option. One thing that's different about SVG though, is that title is not a valid attribute for it or any of its groups or paths. Instead, you can add a <title> element as a child element to accomplish the same thing (ok, so I guess I'm adding some additional markup after all).

I also added role="group" to the parent <g> for the elements, plus a title="Provinces" to make it match the <fieldset> element's job in the previous example. The first few lines of the SVG now give you an idea of where we're at:

<svg viewBox="0 0 978 949.6">
  <g 
    id="Canada" 
    role="group" // setting group role to mimic <fieldset> 
  >
    {/* accessible name for group */}
    <title>Provinces</title>
    <g
      role="checkbox" // setting role to mimic checkbox
      id="NewfoundlandLabrador"
    >
      {/* accessible name for checkbox */}
      <title>Newfoundland and Labrador</title>
      {/* Newfoundland and Labrador Shapes */}
    </g>
    {/* Remaining Provinces */}
  </g>
</svg>

Next, we need to handle the state. Just like our non-semantic form example above, we'll need to manage this with JavaScript. For consistency, I'll just rig up the exact same values from useState and the exact same event handlers, too. But as before, you can use any kind of state management scheme you like. Once you have the state management set up, you can use it to tell the browser's accessibility data the current state using the aria-checked property.

We also set up a data-prov property just like before, and hook up our JavaScript event handlers to change the state, just like before.

<svg viewBox="0 0 978 949.6">
  <g
    id="Canada"
    role="group"
    onClick={handleChange} // click handler
    onKeyUp={onKeyUp} // keyup handler
  >
    <title>Provinces</title>
    <g
      role="checkbox"
      aria-checked={provinces.nl} // telling accessibility tree about state
      data-prov="nl" // setting the value for event handlers
      tabIndex={0}
    >
      <title>Newfoundland and Labrador</title>
      {/* Newfoundland and Labrador Shapes */}
    </g>
    {/* Remaining Provinces */}
  </g>
</svg>

There's one change we have to make to our event handlers, however. In our non-semantic form, we had a guarantee that the element clicked (the <div> in that case) was the same element that had the data-prov property. But with the SVG, that may not be the case, since the data-prov property may be on a <g> element but the click could come from a <path> inside of it. This means that accessing data-prov via event.target.dataset.prov doesn't work for multi-shape provinces.

Instead, we can add one check to see if that's undefined, and if it is, look at the targets parent.

const handleChange = (e) => {
  // grab the data from the clicked element
  let prov = e.target.dataset.prov;

  // if it's undefined, grab it from the parent instead
  if (!prov) {
    const parent = e.target.parentElement;
    prov = parent.dataset.prov;
  }

  setProvinces({
    ...provinces,
    [prov]: !provinces[prov],
  });
};

One of the great things about how SVGs work in the browser is that they only accept click events within their paths, so even though the "element" ends up being a rectangle enclosing the entire province on the map, you must actually click inside the path to get a valid click event. This means that you don't have a confusing overlap for provinces with tangled borders, and clicking on water around the provinces does nothing. The browser handles this all for you automatically.

Lastly, we can set up our keyboard navigation by adding tabIndex={0} and an keyup event handler like before. One change in doing this is it becomes apparent that the order of the provinces is not ideal. Canada's territories and provinces kind of form two "rows" from top to bottom, and I think that matching the tab order to this (scanning left to right, top to bottom) makes a lot of sense. So I rearranged the <g> and <path> elements representing the provinces to that order inside the <svg>. It doesn't change how it was rendered, but now the tab order follows the document flow.

Styling

And now, the fun part, styling. Since our SVGs are now fully accessible in the exact same way as before, we can use many of the same CSS selectors. We do have to make changes to our CSS properties because of how you style SVG shapes, however. For example, we'll use fill instead of background-color. I'll also get rid of the default focus state as before (it looks especially egregious with an obnoxious blue square highlighted around the province) and add a kind of actual outline to it in black using SVG's stroke attributes (which works kind of like border). I added hover states to make it feel a bit more interactive. Finally, I added a CSS transition effect on the fill property to make the state transitions a little warmer. This is all the CSS we need:

.svg-form-map {
  /* Just to constraint the size of the map */
  max-width: 600px;

  & [role="checkbox"] {
    transition: fill 0.1s ease-in-out;
  }

  & [aria-checked="true"] {
    fill: hsl(0, 100%, 30%);
    
    /* selected hover */
    &:hover {
      fill: hsl(0, 100%, 40%);
      cursor: pointer;
    }
  }

  & [aria-checked="false"] {
    fill: hsl(0, 0%, 85%);

    /* unselected hover */
    &:hover {
      fill: hsl(0, 0%, 65%);
      cursor: pointer;
    }
  }

  & *:focus {
    outline: none;
    stroke: hsl(0, 0%, 0%);
    stroke-width: 0.2rem;
  }
}

And check that out! Here's a fully interactive demo of the map selector. If you're on a computer and not a mobile device, try using your keyboard to tab into the provinces and select them with Space.

Provinces Yukon Northwest Territories Nunavut British Columbia Alberta Saskatchewan Manitoba Ontario Québec New Brunswick Prince Edward Island Nova Scotia Newfoundland and Labrador

Note: This demo was reimplemented in vanilla JavaScript since I didn't want to import React into this static blog, and I removed the <form> element for demonstration purposes. So if you're inspecting this element with your DOM tools you may spot some things that are different, but it is largely ported over exactly as we have discussed.

That's it! This is now a fully functional, accessible form selector component using an SVG with sub paths as checkboxes. And it looks way nicer than my retro stack of buttons!

Final Thoughts, and what we didn't cover

This post ended up pretty long so I skipped over a few things worth talking about at least briefly.

First of all, using a map like this all by itself as a form element is probably not recommended, even if it's accessible to screen readers. Two major UX concerns stand out to me:

  1. Clicking smaller provinces like Prince Edward Island is hard, and might be difficult for some users with mouse accuracy disabilities, or for users on mobile.
  2. This map depends on you knowing which provinces are which and where they are since there are no visual labels. Easy for a patriot like me, less easy for someone who isn't Canadian.

For this reason, in my clent's delivery I also accompanied it with a more classical text-based checkbox list, and synchronized the state. This means that users can click either the map or the list and get the desired results. Another option would be to maybe have floating labels that appear via hover events or long presses. Lots of ways to solve that, I suppose.

Second, we skipped over some other form attributes worth mentioning, like how to disable a form element. This is easily achieved in semantic checkboxes by using the disabled="true" attribute, and in non-semantic elements using aria-disabled="true". Importantly, much like aria-checked, using aria-disabled only sets the accessibility exposure; we still need to manage the disabled state using JavaScript, both in telling the browser it's disabled but also preventing any action when the user interacts with it. More on this here. Styling disabled inputs is just as easy; we can tap in to the disabled state via our selectors just like we did "checked"! We can also use aria attributes for things like required fields and more.

Side note: there's a whole other discussion about disabled states and accessibility that you should about!

Third, making SVGs themselves accessible as images by their own content is also a thing you should do! Like images, they can have names and alt text, and for brevity that was omitted here.

Lastly, I mentioned testing a few times but that's probably another blog post. But most importantly, remember that libraries like React Testing Library recommend using role-based selectors first. And because we set up our roles and names correctly, we can really easily select different checkboxes as needed in our tests. No need to try and use test IDs or do any kind of DOM navigation. This selector, for example, would work exactly the same in all three implementations of our form, which is the ultimate in decoupling your test from your implementation.

screen.getByRole("checkbox", { name: /Ontario/ }).click()

I had a lot of fun with this, and I hope you enjoyed it and learned something too!