# Multi-selects with silent ticks and mouse-only chips

Source: https://easeweb.dev/learn/multi-select
Topics: Custom inputs > Multi-selects and tag inputs say what is chosen, and chips can be removed by keyboard (WCAG 2.1.1, 4.1.2)
The fix: Make each chip's × a button named after its chip, such as "Remove Design", and move focus to the next chip when one is removed. Then either build the options as checkboxes in a group named by the field's label, or keep the listbox, give it aria-multiselectable="true" and every option aria-selected, and draw the ticks from that attribute, or use a native select with multiple, knowing that adding to a choice takes keys the list never shows.
Test with: keyboard, screen reader, forced colors
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F15 https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ https://www.w3.org/TR/wai-aria-1.2/#aria-multiselectable https://www.w3.org/TR/wai-aria-1.2/#aria-selected https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/select

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A job board's profile form has a Skills field, drawn as the multi-select dropdown many component libraries ship. One rounded box holds the skills chosen so far, Design and Research, as chips, each with a small × to remove it, and a chevron at the end of the box opens a list of skills under it. With a mouse it all works: click the box to open the list, click a skill to choose it, click an × to drop a chip. Many libraries also let people type into the box to filter the list; that is left out here, and covered in [autocomplete fields with silent, mouse-only suggestions](/learn/combobox). The field is commonly built in one of two ways.

In the first, a chosen skill leaves the list and shows only as a chip, as many multi-select components do by default. Tab goes past both chips straight to the chevron button, and Enter opens the list. The arrow keys move a highlight and Space chooses the highlighted skill, so skills can be added. But neither × ever takes focus, and the chosen skills are no longer in the list, so a keyboard user who chose Writing by mistake has no way to take it off the profile.

In the second, every skill stays in the list, and the chosen ones get a tick. A keyboard user can now open the list and untick Research there. A screen reader user hears "Skills, list box" and then each skill as the highlight moves, but nothing says that Design and Research are the ticked ones. A browser may even report whichever skill is highlighted as selected, as Chromium does, because the list doesn't say it takes several choices and no option says it is chosen. And pressing Space on Writing adds a tick and a chip on screen and says nothing at all. Tab still skips both ×.

## Why it fails

Success Criterion 2.1.1 asks that everything a mouse can do can be done with a keyboard. Each × is an `svg` with a click handler. An svg is not a control: it can't take focus, so Tab never stops on it and no key presses it. In the first build, the × is the only way to remove a chosen skill, so a keyboard can add skills but never remove one. The second build gives the keyboard another way, unticking in the list, which is why it shows the other failure plainly.

Success Criterion 4.1.2 asks that a control's state, when the user can set it, is available to assistive technology. Whether a skill is chosen is such a state. In the second build, the tick is drawn by CSS from a class, and the options carry no `aria-selected`, while the list carries no `aria-multiselectable`. The browser builds what a screen reader reads from the markup, and a class means nothing to it, so it has no chosen state to pass on and no sign that the list takes several choices. W3C documents this as failure F15: a custom control that uses the accessibility API incompletely, here with roles but no state.

## Who is affected

Multi-selects often hold what a profile, a filter or a post is about: skills, interests, tags, recipients. A wrong choice that can't be taken back, or choices that can't be checked, go out with the form.

## What automation and AI miss

To a generic scan, both builds look sound. The chevron is a named button, the listbox and its options have valid roles, and an `svg` with no role is a picture that needs no name, so nothing a rule knows of is wrong. A scan doesn't see that the picture takes clicks, or that a class draws a tick. EaseWeb's audit reports a remove cross inside a chip that Tab never reaches (`ew:chip-remove-unreachable`), and options in a listbox drawn as chosen by a class with no `aria-selected` (`ew:option-selection-not-exposed`); a listbox with two or more options marked `aria-selected="true"` but no `aria-multiselectable` is a lead for review. AI coding assistants asked for "a multi-select with removable chips" can write the × as an svg or span with an onClick, or add `role="button"` to it without `tabindex` or keys, and draw the ticks from a `selected` class. Only tabbing, pressing and listening shows whether it works.

## Before: chosen skills that only a mouse can remove

```html
<span id="skills-label">Skills</span>
<div class="skills-field">
  <span class="chips">
    <span class="chip">
      Design
      <svg class="chip-remove" data-for="skill-design" viewBox="0 0 16 16">…</svg>
    </span>
    <span class="chip">
      Research
      <svg class="chip-remove" data-for="skill-research" viewBox="0 0 16 16">…</svg>
    </span>
  </span>
  <button type="button" class="skills-toggle" aria-labelledby="skills-label"
    aria-haspopup="listbox" aria-expanded="false" aria-controls="skills-list">
    <span class="placeholder">Add a skill</span>
    <svg class="chevron" viewBox="0 0 16 16" aria-hidden="true">…</svg>
  </button>
</div>
<ul id="skills-list" class="skills" role="listbox" tabindex="-1" aria-labelledby="skills-label" hidden>
  <li id="skill-design" role="option" hidden>Design</li>
  <li id="skill-writing" role="option">Writing</li>
  <li id="skill-research" role="option" hidden>Research</li>
  <li id="skill-coding" role="option">Coding</li>
  <li id="skill-video" role="option">Video editing</li>
</ul>
```

```js
const CROSS = '<path d="M4 4l8 8m0-8l-8 8"/>';
const options = [...list.querySelectorAll('[role="option"]')];
let active = null;
function move(step) {
  const shown = options.filter((option) => !option.hidden);
  if (!shown.length) return;
  const index = Math.max(0, Math.min(shown.indexOf(active) + step, shown.length - 1));
  active?.classList.remove('active');
  active = shown[index];
  active.classList.add('active');
  list.setAttribute('aria-activedescendant', active.id);
}
function setOpen(open) {
  list.hidden = !open;
  toggle.setAttribute('aria-expanded', String(open));
  if (open) {
    list.focus();
    move(0);
  }
}
// A chosen skill leaves the list and shows as a chip.
function choose(option) {
  option.hidden = !option.hidden;
  showChips();
  move(0);
}
function showChips() {
  chips.innerHTML = options
    .filter((option) => option.hidden)
    .map((option) => `
      <span class="chip">
        ${option.textContent}
        <svg class="chip-remove" data-for="${option.id}" viewBox="0 0 16 16">${CROSS}</svg>
      </span>`)
    .join('');
  placeholder.textContent = chips.children.length ? 'Add a skill' : 'Choose skills';
}
toggle.addEventListener('click', () => setOpen(list.hidden));
field.addEventListener('click', (event) => {
  if (!event.target.closest('.chip-remove, .skills-toggle')) setOpen(true);
});
document.addEventListener('click', (event) => {
  if (!field.contains(event.target) && !list.contains(event.target)) setOpen(false);
});
list.addEventListener('focusout', (event) => {
  if (!field.contains(event.relatedTarget) && !list.contains(event.relatedTarget)) setOpen(false);
});
list.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowDown') move(1);
  else if (event.key === 'ArrowUp') move(-1);
  else if (event.key === ' ' && active) choose(active);
  else if (event.key === 'Escape') {
    setOpen(false);
    toggle.focus();
  } else return;
  event.preventDefault();
});
list.addEventListener('click', (event) => {
  const option = event.target.closest('[role="option"]');
  if (option) choose(option);
});
chips.addEventListener('click', (event) => {
  const cross = event.target.closest('.chip-remove');
  if (cross) choose(document.getElementById(cross.dataset.for));
});
```

```css
.chip-remove {
  cursor: pointer;
}
.skills [role="option"] {
  display: flex;
  justify-content: space-between;
}
.skills .active {
  background: var(--highlight);
}
```

The chevron is a button, and the list works from the keyboard, so skills can be added. A chosen skill leaves the list, and the only way back is its ×, an `svg` that can't take focus: Tab skips it and no key removes the chip.

## Before: ticks that only the eye can see

```html
<span id="skills-label">Skills</span>
<div class="skills-field">
  <span class="chips">
    <span class="chip">
      Design
      <svg class="chip-remove" data-for="skill-design" viewBox="0 0 16 16">…</svg>
    </span>
    <span class="chip">
      Research
      <svg class="chip-remove" data-for="skill-research" viewBox="0 0 16 16">…</svg>
    </span>
  </span>
  <button type="button" class="skills-toggle" aria-labelledby="skills-label"
    aria-haspopup="listbox" aria-expanded="false" aria-controls="skills-list">
    <span class="placeholder">Add a skill</span>
    <svg class="chevron" viewBox="0 0 16 16" aria-hidden="true">…</svg>
  </button>
</div>
<ul id="skills-list" class="skills" role="listbox" tabindex="-1" aria-labelledby="skills-label" hidden>
  <li id="skill-design" role="option" class="ticked">Design</li>
  <li id="skill-writing" role="option">Writing</li>
  <li id="skill-research" role="option" class="ticked">Research</li>
  <li id="skill-coding" role="option">Coding</li>
  <li id="skill-video" role="option">Video editing</li>
</ul>
```

```js
const CROSS = '<path d="M4 4l8 8m0-8l-8 8"/>';
const options = [...list.querySelectorAll('[role="option"]')];
let active = null;
function move(step) {
  const shown = options.filter((option) => !option.hidden);
  if (!shown.length) return;
  const index = Math.max(0, Math.min(shown.indexOf(active) + step, shown.length - 1));
  active?.classList.remove('active');
  active = shown[index];
  active.classList.add('active');
  list.setAttribute('aria-activedescendant', active.id);
}
function setOpen(open) {
  list.hidden = !open;
  toggle.setAttribute('aria-expanded', String(open));
  if (open) {
    list.focus();
    move(0);
  }
}
// A chosen skill gets a tick and shows as a chip.
function choose(option) {
  option.classList.toggle('ticked');
  showChips();
  move(0);
}
function showChips() {
  chips.innerHTML = options
    .filter((option) => option.classList.contains('ticked'))
    .map((option) => `
      <span class="chip">
        ${option.textContent}
        <svg class="chip-remove" data-for="${option.id}" viewBox="0 0 16 16">${CROSS}</svg>
      </span>`)
    .join('');
  placeholder.textContent = chips.children.length ? 'Add a skill' : 'Choose skills';
}
toggle.addEventListener('click', () => setOpen(list.hidden));
field.addEventListener('click', (event) => {
  if (!event.target.closest('.chip-remove, .skills-toggle')) setOpen(true);
});
document.addEventListener('click', (event) => {
  if (!field.contains(event.target) && !list.contains(event.target)) setOpen(false);
});
list.addEventListener('focusout', (event) => {
  if (!field.contains(event.relatedTarget) && !list.contains(event.relatedTarget)) setOpen(false);
});
list.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowDown') move(1);
  else if (event.key === 'ArrowUp') move(-1);
  else if (event.key === ' ' && active) choose(active);
  else if (event.key === 'Escape') {
    setOpen(false);
    toggle.focus();
  } else return;
  event.preventDefault();
});
list.addEventListener('click', (event) => {
  const option = event.target.closest('[role="option"]');
  if (option) choose(option);
});
chips.addEventListener('click', (event) => {
  const cross = event.target.closest('.chip-remove');
  if (cross) choose(document.getElementById(cross.dataset.for));
});
```

```css
.chip-remove {
  cursor: pointer;
}
.skills [role="option"] {
  display: flex;
  justify-content: space-between;
}
.skills .active {
  background: var(--highlight);
}
/* The tick: two borders of a turned box, drawn from a class */
.skills .ticked::after {
  content: "";
  inline-size: 0.375rem;
  block-size: 0.75rem;
  border: solid currentColor;
  border-width: 0 2px 2px 0;
  rotate: 45deg;
}
```

This is the same field with one change: chosen skills stay in the list with a tick. The keyboard can now untick a skill there, but the tick is drawn from the `ticked` class, and nothing in the markup says which options are chosen or that the list takes several, so a screen reader reads every skill alike. The × is still an svg Tab never reaches.

## After: checkboxes in the dropdown, with a button on each chip

```html
<span id="skills-label">Skills</span>
<div class="skills-field">
  <span class="chips">
    <span class="chip">
      Design
      <button type="button" class="chip-remove" data-for="skill-design" aria-label="Remove Design">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
    <span class="chip">
      Research
      <button type="button" class="chip-remove" data-for="skill-research" aria-label="Remove Research">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
  </span>
  <button type="button" class="skills-toggle" aria-labelledby="skills-label"
    aria-expanded="false" aria-controls="skills-list">
    <span class="placeholder">Add a skill</span>
    <svg class="chevron" viewBox="0 0 16 16" aria-hidden="true">…</svg>
  </button>
</div>
<div id="skills-list" class="skills" role="group" aria-labelledby="skills-label" hidden>
  <label class="skill">
    Design
    <input type="checkbox" id="skill-design" name="skills" value="design" checked>
  </label>
  <label class="skill">
    Writing
    <input type="checkbox" id="skill-writing" name="skills" value="writing">
  </label>
  <label class="skill">
    Research
    <input type="checkbox" id="skill-research" name="skills" value="research" checked>
  </label>
  <label class="skill">
    Coding
    <input type="checkbox" id="skill-coding" name="skills" value="coding">
  </label>
  <label class="skill">
    Video editing
    <input type="checkbox" id="skill-video" name="skills" value="video">
  </label>
</div>
```

```js
const CROSS = '<path d="M4 4l8 8m0-8l-8 8"/>';
const boxes = [...list.querySelectorAll('input[type="checkbox"]')];
const nameOf = (box) => box.labels[0].textContent.trim();
function setOpen(open) {
  list.hidden = !open;
  toggle.setAttribute('aria-expanded', String(open));
  if (open) boxes[0].focus();
}
function showChips() {
  chips.innerHTML = boxes
    .filter((box) => box.checked)
    .map((box) => `
      <span class="chip">
        ${nameOf(box)}
        <button type="button" class="chip-remove" data-for="${box.id}" aria-label="Remove ${nameOf(box)}">
          <svg viewBox="0 0 16 16" aria-hidden="true">${CROSS}</svg>
        </button>
      </span>`)
    .join('');
  placeholder.textContent = chips.children.length ? 'Add a skill' : 'Choose skills';
}
toggle.addEventListener('click', () => setOpen(list.hidden));
field.addEventListener('click', (event) => {
  if (!event.target.closest('.chip-remove, .skills-toggle')) setOpen(true);
});
document.addEventListener('click', (event) => {
  if (!field.contains(event.target) && !list.contains(event.target)) setOpen(false);
});
list.addEventListener('focusout', (event) => {
  if (!field.contains(event.relatedTarget) && !list.contains(event.relatedTarget)) setOpen(false);
});
list.addEventListener('keydown', (event) => {
  if (event.key !== 'Escape') return;
  setOpen(false);
  toggle.focus();
});
list.addEventListener('change', showChips);
chips.addEventListener('click', (event) => {
  const button = event.target.closest('.chip-remove');
  if (!button) return;
  const index = [...chips.querySelectorAll('.chip-remove')].indexOf(button);
  document.getElementById(button.dataset.for).checked = false;
  showChips();
  // The pressed button is gone: focus the next one, the one before, or the field's button.
  const left = chips.querySelectorAll('.chip-remove');
  (left[index] ?? left[index - 1] ?? toggle).focus();
});
```

```css
.chip-remove {
  cursor: pointer;
}
.skill {
  display: flex;
  justify-content: space-between;
}
/* The box, drawn on the checkbox itself */
.skill input {
  appearance: none;
  display: grid;
  place-content: center;
  inline-size: 1.25rem;
  block-size: 1.25rem;
  margin: 0;
  border: 2px solid var(--edge);
  border-radius: 0.25rem;
}
/* The tick, drawn from the checkbox's own state */
.skill input:checked::before {
  content: "";
  inline-size: 0.375rem;
  block-size: 0.75rem;
  border: solid currentColor;
  border-width: 0 2px 2px 0;
  rotate: 45deg;
}
```

The dropdown keeps its look, and the list in it is now real checkboxes, so the browser makes each one a Tab stop, ticks it with Space, and tells a screen reader its name and whether it is checked: "Writing, checkbox, not checked", then "checked" after Space. The chevron button opens the list and moves focus to the first box; Escape closes it and returns focus to the button; moving focus or clicking away closes it too. The group takes its name from the "Skills" label. Each × is now a `button` holding the svg, so Tab reaches it and Enter or Space presses it, and its `aria-label` says which chip it removes: the button holds only a picture, so without it the button would have no name. When a chip goes, its button goes with it, so the script moves focus to the next chip's button, the one before when there is no next, or the chevron when none is left; without that, the focused button is gone and focus is lost to the page itself. Chosen skills stay in the list, ticked, so there is a second way to remove each one. The look changes a little: each row in the list shows an empty box until it is ticked.

## After: a multi-select listbox in the dropdown, with a button on each chip

```html
<span id="skills-label">Skills</span>
<div class="skills-field">
  <span class="chips">
    <span class="chip">
      Design
      <button type="button" class="chip-remove" data-for="skill-design" aria-label="Remove Design">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
    <span class="chip">
      Research
      <button type="button" class="chip-remove" data-for="skill-research" aria-label="Remove Research">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
  </span>
  <button type="button" class="skills-toggle" aria-labelledby="skills-label"
    aria-haspopup="listbox" aria-expanded="false" aria-controls="skills-list">
    <span class="placeholder">Add a skill</span>
    <svg class="chevron" viewBox="0 0 16 16" aria-hidden="true">…</svg>
  </button>
</div>
<ul id="skills-list" class="skills" role="listbox" tabindex="-1" aria-labelledby="skills-label"
  aria-multiselectable="true" hidden>
  <li id="skill-design" role="option" aria-selected="true">Design</li>
  <li id="skill-writing" role="option" aria-selected="false">Writing</li>
  <li id="skill-research" role="option" aria-selected="true">Research</li>
  <li id="skill-coding" role="option" aria-selected="false">Coding</li>
  <li id="skill-video" role="option" aria-selected="false">Video editing</li>
</ul>
```

```js
const CROSS = '<path d="M4 4l8 8m0-8l-8 8"/>';
const options = [...list.querySelectorAll('[role="option"]')];
let active = null;
function move(step) {
  const shown = options.filter((option) => !option.hidden);
  if (!shown.length) return;
  const index = Math.max(0, Math.min(shown.indexOf(active) + step, shown.length - 1));
  active?.classList.remove('active');
  active = shown[index];
  active.classList.add('active');
  list.setAttribute('aria-activedescendant', active.id);
}
function setOpen(open) {
  list.hidden = !open;
  toggle.setAttribute('aria-expanded', String(open));
  if (open) {
    list.focus();
    move(0);
  }
}
const isChosen = (option) => option.getAttribute('aria-selected') === 'true';
// A chosen skill stays in the list, selected, and shows as a chip.
function choose(option) {
  option.setAttribute('aria-selected', String(!isChosen(option)));
  showChips();
}
function showChips() {
  chips.innerHTML = options
    .filter(isChosen)
    .map((option) => `
      <span class="chip">
        ${option.textContent}
        <button type="button" class="chip-remove" data-for="${option.id}" aria-label="Remove ${option.textContent}">
          <svg viewBox="0 0 16 16" aria-hidden="true">${CROSS}</svg>
        </button>
      </span>`)
    .join('');
  placeholder.textContent = chips.children.length ? 'Add a skill' : 'Choose skills';
}
toggle.addEventListener('click', () => setOpen(list.hidden));
field.addEventListener('click', (event) => {
  if (!event.target.closest('.chip-remove, .skills-toggle')) setOpen(true);
});
document.addEventListener('click', (event) => {
  if (!field.contains(event.target) && !list.contains(event.target)) setOpen(false);
});
list.addEventListener('focusout', (event) => {
  if (!field.contains(event.relatedTarget) && !list.contains(event.relatedTarget)) setOpen(false);
});
list.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowDown') move(1);
  else if (event.key === 'ArrowUp') move(-1);
  else if (event.key === ' ' && active) choose(active);
  else if (event.key === 'Escape') {
    setOpen(false);
    toggle.focus();
  } else return;
  event.preventDefault();
});
list.addEventListener('click', (event) => {
  const option = event.target.closest('[role="option"]');
  if (option) choose(option);
});
chips.addEventListener('click', (event) => {
  const button = event.target.closest('.chip-remove');
  if (!button) return;
  const index = [...chips.querySelectorAll('.chip-remove')].indexOf(button);
  choose(document.getElementById(button.dataset.for));
  // The pressed button is gone: focus the next one, the one before, or the field's button.
  const left = chips.querySelectorAll('.chip-remove');
  (left[index] ?? left[index - 1] ?? toggle).focus();
});
```

```css
.chip-remove {
  cursor: pointer;
}
.skills [role="option"] {
  display: flex;
  justify-content: space-between;
}
.skills .active {
  background: var(--highlight);
}
/* The tick comes from the state a screen reader reads, never from a class of its own. */
.skills [aria-selected="true"]::after {
  content: "";
  inline-size: 0.375rem;
  block-size: 0.75rem;
  border: solid currentColor;
  border-width: 0 2px 2px 0;
  rotate: 45deg;
}
```

This is the smallest change to the Befores. `aria-multiselectable="true"` tells a screen reader the list takes several choices, and every option carries `aria-selected`, `"true"` or `"false"`, so each skill is read with its state as the highlight reaches it, and Space is heard changing it. The tick is drawn from `aria-selected`, so it can't show a choice the code doesn't hold. Chosen skills stay in the list, and the chips get the same buttons and focus handling as the checkbox fix.

The list is yours to keep working: its keys, the highlight's `aria-activedescendant`, and the `aria-selected` on every option, `"false"` included, so that "not selected" is read too. The listbox pattern also lists optional keys: Home and End, which it strongly recommends for lists of more than five options, and Shift with the arrows to choose a run of options. Add them if your list is long. Test it in every browser and screen reader you support.

## After: a native select with multiple

```html
<span id="skills-label">Skills</span>
<div class="skills-field">
  <span class="chips">
    <span class="chip">
      Design
      <button type="button" class="chip-remove" data-for="skill-design" aria-label="Remove Design">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
    <span class="chip">
      Research
      <button type="button" class="chip-remove" data-for="skill-research" aria-label="Remove Research">
        <svg viewBox="0 0 16 16" aria-hidden="true">…</svg>
      </button>
    </span>
  </span>
</div>
<select id="skills-list" name="skills" multiple size="5" aria-labelledby="skills-label">
  <option id="skill-design" value="design" selected>Design</option>
  <option id="skill-writing" value="writing">Writing</option>
  <option id="skill-research" value="research" selected>Research</option>
  <option id="skill-coding" value="coding">Coding</option>
  <option id="skill-video" value="video">Video editing</option>
</select>
```

```js
const CROSS = '<path d="M4 4l8 8m0-8l-8 8"/>';
const options = [...list.options];
function showChips() {
  chips.innerHTML = options
    .filter((option) => option.selected)
    .map((option) => `
      <span class="chip">
        ${option.textContent}
        <button type="button" class="chip-remove" data-for="${option.id}" aria-label="Remove ${option.textContent}">
          <svg viewBox="0 0 16 16" aria-hidden="true">${CROSS}</svg>
        </button>
      </span>`)
    .join('');
}
list.addEventListener('change', showChips);
chips.addEventListener('click', (event) => {
  const button = event.target.closest('.chip-remove');
  if (!button) return;
  const index = [...chips.querySelectorAll('.chip-remove')].indexOf(button);
  document.getElementById(button.dataset.for).selected = false;
  showChips();
  // The pressed button is gone: focus the next one, the one before, or the list.
  const left = chips.querySelectorAll('.chip-remove');
  (left[index] ?? left[index - 1] ?? list).focus();
});
```

```css
.chip-remove {
  cursor: pointer;
}
```

Browsers draw a select with `multiple` as a list that is always open; only some drop it down from a box, and only with `size="1"`. So here the box holds only the chips, and the list sits under it. The browser makes the list a Tab stop, tells a screen reader that it takes several choices and whether each option is selected, and sends every chosen skill with the form. The chips get the same buttons as in the other fixes.

It passes both criteria, but it is not enough on its own. The dropdown and the ticks are gone: the browser marks chosen options with its own highlight, which can be styled only a little. And the list doesn't say how to choose more than one. In Chrome, the arrow keys alone replace the choice with the highlighted skill; adding to it takes Shift with the arrows, or Ctrl (Cmd on a Mac) with the arrows and then Space, and with a mouse, Ctrl or Cmd with a click. Someone who doesn't know that loses Design and Research the moment they click Writing.

## Which option to use

All three pass 2.1.1 and 4.1.2, and all three need the same chip buttons. Use checkboxes: every browser and screen reader knows them, the browser gives each one its state, its keys and its touch behaviour with no script, and they keep the tick-list look. That is the reason to recommend them for nearly every multi-select. Keep the ARIA listbox when the list is long enough that one Tab stop for it matters, or a component already builds it, and accept that its keys and states are yours to test and maintain. Use the native select with multiple only for a short list, for people who already know its keys, such as on an admin screen, where an open list instead of a dropdown is fine, and write how to choose several beside it.

## Implementation decisions

Every chip's button needs a focus ring of its own, and a target big enough to hit: the × drawn at 12 pixels can sit in a 24 by 24 pixel button. Name it after its chip, "Remove Design", not "Remove" or "Close", so a list of buttons read out of context still says which skill each one takes away.

When a chip is removed, focus has to go somewhere that still exists. The fixes move it to the next chip's button, then the one before, then the dropdown's chevron button, or the list of the native select. Moving focus doesn't say that Design was removed; a screen reader then reads the next button. If people need to hear that, it is a confirmation, covered in [add to cart buttons that change a badge and say nothing](/learn/action-confirmations).

Tag inputs, where people type a new tag and press Enter, often let Backspace in an empty field remove the last chip. That is a shortcut, not a replacement for the buttons: it can't remove a chip from the middle of the row. When the input suggests tags as people type, the suggestions follow [autocomplete fields with silent, mouse-only suggestions](/learn/combobox). A row of chips cut short with a "+3" count needs that count to be a button that shows the hidden chips, or the hidden ones can't be reached at all.

Draw the ticks with borders or an SVG in `currentColor`, not with a background fill, so they stay in a Windows contrast theme, and give the highlighted option an outline as well as its background. Don't draw the tick as CSS text such as `content: "✓"` either: generated text is read as part of the option's name. Component libraries vary: some leave out `aria-selected` on unchosen options, some draw the × as a span, and some remove chosen options from the list. Check what they render, not their documentation. A dropdown that holds only one value is covered in [custom dropdowns that keyboards and screen readers can't use](/learn/custom-select), and a select-all checkbox for some of the options is the `mixed-checkbox` topic.

## Verify the fix

1. Inspect the markup: each chip's × is a `button` named after its chip, and each option is a checkbox, an `option` of a select with `multiple`, or a `role="option"` with `aria-selected` in a listbox with `aria-multiselectable="true"`.
2. Put the mouse aside and Tab through the field: every × takes focus with a visible ring, Enter or Space removes its chip, and focus moves to the next chip or the field, not lost to the page.
3. With the keyboard only, open the dropdown, choose and unchoose skills, close it with Escape, and remove the last chip as well as one in the middle.
4. With a screen reader, move through the options and the chips: each option is read with whether it is chosen, each change is read as you make it, and each button says which skill it removes.
5. With a Windows contrast theme on, or forced colors emulated, check the ticks, the highlighted option, each × and the focus ring still show.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the shared multi-select component changes.

## Limits

This is a starting pattern for a short list of fixed options. It doesn't cover grouped options, options loaded as the person scrolls, or reordering the chips. Test the complete journey, with your real options, in the browsers and assistive technology you support.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
