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