web3icons

Making wallet connection modals accessible

How to build a connect-wallet modal with real dialog semantics, managed focus, wallet buttons named by text, and status messages for pending requests.

6 min read

The connect-wallet modal is the front door of most dApps, and often the first thing that fails for keyboard and screen reader users. Focus stays on the page behind it. The wallet buttons are logos with no names. Escape does nothing. After a click, a spinner turns silently while the wallet's own window waits somewhere else on screen.

None of this is hard to fix. A modal that follows the dialog pattern from the ARIA Authoring Practices Guide, with plain-text wallet names and a status line, covers almost everything.

Start from a real dialog

The native <dialog> element opened with showModal() gives you most of the dialog pattern for free. The rest of the page becomes inert, so neither clicks nor Tab reach it. Escape fires a cancel event and closes the dialog. Screen readers are told they are inside a dialog, and if you label it with its heading they announce that name too.

import { useEffect, useRef } from 'react'

type Wallet = { uuid: string; name: string; icon: string }

type Props = {
  open: boolean
  wallets: Wallet[]
  status: string
  onConnect: (wallet: Wallet) => void
  onClose: () => void
}

export function ConnectDialog({ open, wallets, status, onConnect, onClose }: Props) {
  const ref = useRef<HTMLDialogElement>(null)

  useEffect(() => {
    const dialog = ref.current
    if (!dialog) return
    if (open && !dialog.open) {
      dialog.showModal()
      dialog.querySelector<HTMLButtonElement>('[data-wallet]')?.focus()
    }
    if (!open && dialog.open) dialog.close()
  }, [open])

  return (
    <dialog ref={ref} aria-labelledby="connect-title" onClose={onClose}>
      <h2 id="connect-title">Connect a wallet</h2>
      <button type="button" aria-label="Close" onClick={onClose}>
        <svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20">
          <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" />
        </svg>
      </button>
      <ul>
        {wallets.map((wallet) => (
          <li key={wallet.uuid}>
            <button type="button" data-wallet onClick={() => onConnect(wallet)}>
              <img src={wallet.icon} alt="" width={32} height={32} />
              {wallet.name}
            </button>
          </li>
        ))}
      </ul>
      <p role="status">{status}</p>
    </dialog>
  )
}

The close event fires whether the dialog closed through Escape or through your own code, so routing it to onClose keeps React state in sync either way.

If you need a custom modal instead, you have to provide each of those behaviors yourself: role="dialog" with aria-modal="true", aria-labelledby pointing at the heading, the inert attribute on everything outside the modal, a Tab handler that wraps from the last control to the first, and an Escape handler. Getting all of that right by hand is fiddly, so the native element is worth trying first.

Focus in, focus around, focus back

On open, move focus into the dialog. For a wallet list, the first wallet button is a good target, since choosing a wallet is the reason the dialog exists. The sketch focuses it explicitly after showModal() rather than relying on each browser's default.

While open, focus stays inside. That isn't a violation of 2.1.2 No Keyboard Trap (A): the criterion is about places you cannot leave by keyboard, and Escape plus a reachable Close button are the way out. A modal without either of those is a trap.

On close, focus goes back to the element that opened the dialog, usually the "Connect wallet" button. Current browsers generally do this for native modal dialogs, but test it in the browsers you support. There is one case they can't handle: after a successful connection, the "Connect wallet" button is often replaced by an account button. The element focus would return no longer exists, so focus falls back to the page body. Move focus to the new account button yourself once it renders.

Wallet buttons: logo plus a visible name

Wallet logos are not a reliable way to tell options apart. Many are abstract marks, several share similar shapes, and screen reader users get nothing from an unlabeled image. Show the name as text next to every logo, and the logo can be decorative with alt="", as in the sketch. The button's accessible name is then simply its visible text, which also satisfies 2.5.3 Label in Name (A) without any extra work. Voice control users can say "click Rainbow" because "Rainbow" is what they see.

Extra information belongs in that same text. "MetaMask, Installed" or "Phantom, Last used" reads naturally. Avoid putting an aria-label on these buttons; it replaces the visible text, and labels written separately tend to drift away from what's on screen.

With EIP-6963, injected wallets announce themselves with a name, an icon as a data URI, a uuid and an rdns. Use info.name as the visible label and info.icon as the decorative image. The icon is supplied by the extension, so render it through <img src>, which doesn't run scripts inside SVG, rather than injecting it as markup. For wallets you list yourself, such as mobile wallets, you'll need your own logo files; the wallet category of the library has SVGs for many of them.

Size matters too. Rows of at least 44 px are comfortable on touch screens. The close button is the usual offender: a 16 px "×" with no padding falls short of 2.5.8 Target Size (Minimum) (AA), which asks for at least 24 by 24 CSS pixels unless an exception applies. It also needs a name, like the aria-label="Close" above; icon-only buttons need names covers the options.

Pending, declined, connected

After someone picks a wallet, the dApp sends a request and waits while the wallet shows its own window. Sighted users can see that window pop up; others need to be told what is happening. The role="status" paragraph in the sketch is a polite live region: text you place in it is announced without moving focus.

type Eip1193Provider = {
  request: (args: { method: string; params?: unknown[] }) => Promise<unknown>
}

async function connect(wallet: Wallet & { provider: Eip1193Provider }) {
  setStatus(`Waiting for ${wallet.name}. Approve the request in your wallet.`)
  try {
    await wallet.provider.request({ method: 'eth_requestAccounts' })
    setOpen(false)
  } catch (error) {
    const code = (error as { code?: number }).code
    setStatus(
      code === 4001
        ? `You declined the request in ${wallet.name}. Choose a wallet to try again.`
        : `${wallet.name} could not connect. Try again or choose another wallet.`,
    )
  }
}

setStatus and setOpen are state setters in the component that renders the dialog. Code 4001 is "User Rejected Request" in EIP-1193. Declining is a normal choice, so the message is calm and doesn't need to be an alert.

Three details save a lot of confusion:

  • Don't disable the button that was just pressed. A disabled button loses focus, and the user lands on the page body. Use aria-disabled="true" and ignore further clicks instead, or offer a Cancel button next to the status.
  • Keep the live region in the DOM before you write to it. A region that appears with its text already inside is often not announced.
  • Announce success outside the dialog. The dialog's status line disappears when the dialog closes, so put "Connected" in a page-level status region. Announcing transaction status shows a shared announcer you can reuse here.

QR codes and mobile wallets

A QR code for connecting a phone wallet is an image whose content can't be read aloud in any useful way. Give it alt text that says what it is, such as "QR code for connecting a mobile wallet", and offer alternatives next to it: a Copy link button, and on mobile, a link that opens the wallet app directly. A screen reader user browsing on a phone can't scan a code displayed on that same phone.

Before you ship

  • Tab reaches the "Connect wallet" button, Enter opens the dialog, and focus lands inside it.
  • Tab and Shift+Tab stay within the dialog; Escape closes it from anywhere inside.
  • Every wallet option has its name as visible text, and logos have empty alt text.
  • The close button has an accessible name and a target of at least 24 by 24 CSS pixels.
  • Waiting, declined and failed states are announced, and focus never drops to the page body.
  • After connecting, focus lands on the account button and "Connected" is announced.

For the wider flow around this modal, including what to show before and after it, see designing the connect wallet flow.

More in Accessibility

All guides