Keyboard Navigation for Custom Share Menus

This guide is part of Building a Custom Share Sheet, which itself is part of Permission Flows and Progressive Enhancement.

Keyboard support for a share menu is mostly a question of not breaking what the browser already does. A list of <button> and <a> elements inside a <dialog> is already navigable with Tab, activatable with Enter and Space, and dismissible with Escape — no script required. Problems appear when a menu is built from <div> elements, when the focus ring is styled away, or when arrow-key handling is bolted onto a widget that never needed it.

Feature Detection Gate

Nothing here depends on an API, so the only real gate is a structural one: are the controls actually focusable?

// audit-focusability.js
const FOCUSABLE = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';

export function auditShareMenu(container) {
  const items = [...container.querySelectorAll('li > *')];
  const unreachable = items.filter((el) => !el.matches(FOCUSABLE));
  const unnamed = items.filter((el) => !(el.textContent.trim() || el.getAttribute('aria-label')));

  return { total: items.length, unreachable, unnamed };
}

Running this in a test is worth more than it looks. The two defects it catches — an item the keyboard cannot reach, and an icon-only control with no accessible name — account for most share-widget accessibility failures found in audits.

List Model Versus Menu Model On the left, a list of native links and buttons where every item is in the Tab order and no script is needed. On the right, a menu widget where only one item is tabbable at a time and arrow keys move a roving tabindex between items, requiring key handling, Home and End, and type-ahead. List of controls — default Copy link — tabindex 0 (implicit) Email — tabindex 0 (implicit) WhatsApp — tabindex 0 (implicit) Tab moves · Enter/Space activates zero lines of JavaScript Menu widget — role="menu" Copy link — tabindex 0 (the one) Email — tabindex −1 WhatsApp — tabindex −1 Arrows move · Home/End jump · type-ahead only worth it for long menus

Step 1 — Prefer the List Model

For three to six destinations, the default Tab order is what users expect, and it needs no code.

<dialog id="share-sheet" aria-labelledby="share-heading">
  <h2 id="share-heading">Share this article</h2>
  <ul class="share-targets">
    <li><button type="button" data-action="copy">Copy link</button></li>
    <li><a href="mailto:?subject=…">Email</a></li>
    <li><a href="https://wa.me/?text=…" target="_blank" rel="noopener">WhatsApp</a></li>
  </ul>
  <p role="status" aria-live="polite" class="share-status"></p>
</dialog>

Every item is a native control, so Tab reaches it, Enter activates it, and a screen reader announces its role and name. Combined with showModal() — which contains Tab within the dialog, as building an accessible share modal with a focus trap covers — that is complete keyboard support with no keydown handler anywhere.

Step 2 — Add a Roving Tabindex Only for a Real Menu

If the sheet genuinely is a menu — a long list, grouped, opened from a menu button — then role="menu" brings expectations that must all be met: one tab stop for the whole widget, arrow keys between items, Home and End to jump.

// roving-tabindex.js
export function createRovingMenu(menu) {
  const items = () => [...menu.querySelectorAll('[role="menuitem"]')];

  function focusAt(index) {
    const list = items();
    const wrapped = (index + list.length) % list.length;      // wrap around both ends
    list.forEach((el, i) => el.setAttribute('tabindex', i === wrapped ? '0' : '-1'));
    list[wrapped].focus();
  }

  menu.addEventListener('keydown', (event) => {
    const list = items();
    const current = list.indexOf(document.activeElement);
    if (current === -1) return;

    switch (event.key) {
      case 'ArrowDown': event.preventDefault(); focusAt(current + 1); break;
      case 'ArrowUp':   event.preventDefault(); focusAt(current - 1); break;
      case 'Home':      event.preventDefault(); focusAt(0); break;
      case 'End':       event.preventDefault(); focusAt(list.length - 1); break;
      default:
        if (event.key.length === 1) typeAhead(event.key, list, current, focusAt);
    }
  });

  focusAt(0);
}

function typeAhead(char, list, current, focusAt) {
  const lower = char.toLowerCase();
  const order = [...list.slice(current + 1), ...list.slice(0, current + 1)];
  const match = order.find((el) => el.textContent.trim().toLowerCase().startsWith(lower));
  if (match) focusAt(list.indexOf(match));
}

preventDefault() on the arrow keys is essential: without it the page scrolls at the same time as the selection moves, which is precisely the disorientation keyboard users are trying to avoid. Wrapping at both ends and starting the type-ahead search after the current item are the two details that make the widget feel native rather than approximate.

Step 3 — Keep the Focus Indicator Visible

Removing the outline is the single most damaging line in a share-widget stylesheet, because it makes it impossible to tell which item Enter will activate.

.share-targets :is(a, button):focus-visible {
  outline: 3px solid var(--focus-color, #1a5fb4);
  outline-offset: 2px;
  border-radius: 4px;
}

/* Never do this without a visible replacement:
   .share-targets :is(a, button):focus { outline: none; } */

@media (forced-colors: active) {
  .share-targets :is(a, button):focus-visible {
    outline: 3px solid Highlight;      /* respect the user's system colours */
  }
}

:focus-visible is the right selector: it shows the ring for keyboard interaction and suppresses it for mouse clicks, which is usually the real motivation for removing it in the first place. The forced-colors block matters for Windows high-contrast users, where a hard-coded outline colour can vanish entirely.

Key Map for the Share Sheet Six keys mapped to their behaviour. Tab and Shift Tab move between controls in the list model. Enter and Space activate. Escape closes the dialog. Arrow keys, Home and End apply only in the menu model, and printable characters trigger type-ahead there. Key Behaviour Provided by Tab / Shift+Tab move between controls, contained in the dialog platform Enter / Space activate the focused button or link platform Escape close the dialog, focus returns to the trigger platform + your close handler Arrow Up / Down move the roving tabindex, preventDefault required you (menu model only) Home / End jump to the first or last item you (menu model only) printable characters — type-ahead to the next matching label (menu model only) Tab Stops in Each Model A list of four controls contributes four tab stops, which is what users expect from a short list. A menu widget must contribute exactly one, with arrow keys moving inside it; four tab stops inside a role=menu is the defect the roving tabindex prevents. list model — 4 tab stops Tab 1 Tab 2 Tab 3 Tab 4 expected for a short list of links no keydown handler at all menu model — 1 tab stop Tab arrows move a roving tabindex four tab stops here is the bug

Failure Modes and Recovery

Symptom Cause Minimal fix
Item cannot be reached by Tab Built from a div or span Use a button or a
Page scrolls while arrowing the menu preventDefault missing Call it for every key you consume
Cannot tell which item is focused Outline removed Restore with :focus-visible
Menu has many tab stops role="menu" without roving tabindex One item at tabindex="0", the rest -1
Screen reader reads “link” with no name Icon-only anchor Add text or a visually-hidden label
Focus ring invisible in high contrast Hard-coded outline colour Use Highlight under forced-colors
Escape does nothing Modal is not a real <dialog> Open with showModal()

Browser and Platform Caveat

Tab order, Enter and Space activation, and Escape-to-close inside a modal <dialog> behave consistently across Chrome, Safari, Firefox and Edge, so the list model is genuinely portable with no key handling of your own. Two platform notes are worth carrying. On macOS, Tab does not reach links in Safari unless full keyboard access is enabled in system settings, so a share list built entirely from anchors can appear unreachable there — mixing in real button elements for the primary actions sidesteps it. And :focus-visible heuristics differ slightly between engines for programmatically focused elements, which is one more reason to focus a real button when the dialog opens rather than a container. If you adopt the menu model, be aware that role="menu" sets an expectation of application-style behaviour in screen readers; for a short share list the plain list is not merely simpler, it is the better-supported choice.

FAQ

Do share items need arrow-key navigation?

Not if they are a short list of links or buttons — Tab already moves between them and users expect that. Arrow keys belong to a true menu widget with role="menu", which also commits you to roving tabindex, type-ahead and a stricter set of expectations.

Why does the page scroll when I press the down arrow in my menu?

Arrow keys scroll by default, so the handler must call preventDefault for the keys it consumes. Without it the menu selection moves and the page jumps at the same time, which is disorienting for exactly the users relying on the keyboard.

Is it acceptable to remove the focus outline if hover styling is good?

No — hover is a pointer state and says nothing to a keyboard user. Removing the outline makes it impossible to tell which item Enter will activate. Restyle it to fit the design with :focus-visible, but never set outline: none without a visible replacement.