Accessibilità

Focus trap in un modale: come funziona davvero

Un focus trap tiene Tab e Shift+Tab dentro il modale aperto e riporta il focus al trigger alla chiusura. L'elemento dialog nativo lo fa con showModal().

In sintesi

  • Il criterio 2.1.2 No Keyboard Trap, livello A, richiede che il focus possa sempre uscire: Escape deve chiudere.
  • dialog con showModal() rende inerte il resto della pagina e gestisce Escape senza codice.
  • Alla chiusura il focus torna al trigger; se il trigger viene rimontato, ripristinalo a mano.

Un focus trap è la regola che tiene Tab e Shift+Tab dentro un modale aperto e impedisce al focus di raggiungere la pagina sotto. Il modo più corto per averlo è <dialog> con showModal(): il browser porta il resto del documento nell'area inerte e chiude con Escape.

Cosa deve fare un modale

Quattro comportamenti, non uno. Il focus entra nel modale all'apertura. Tab cicla solo tra i controlli interni. Escape chiude, perché il criterio 2.1.2 No Keyboard Trap, livello A, vieta di intrappolare il focus senza via d'uscita. Alla chiusura il focus torna al controllo che ha aperto il dialogo, come chiede il criterio 2.4.3 Focus Order, livello A.

Il dialogo nativo

Open settings


  Settings
  Name
  
  Close



  const dialog = document.getElementById('settings');
  const trigger = document.getElementById('open');
  trigger.addEventListener('click', () => dialog.showModal());
  dialog.addEventListener('close', () => trigger.focus());

showModal() fa tre cose che un div non fa: applica lo stile ::backdrop, rende inerte il resto della pagina e intercetta Escape. Il browser riporta il focus all'elemento attivo prima dell'apertura; il listener close copre il caso in cui quel nodo sia stato rimontato. Il contenitore ha aria-labelledby verso il titolo: senza nome accessibile il lettore annuncia «dialog» e niente altro.

Dove i focus trap artigianali si rompono

  • Iframe e shadow DOM: un listener su keydown non vede il focus dentro un iframe, e il ciclo si interrompe.
  • Contenuto rimosso: se il primo o l'ultimo controllo sparisce, il calcolo del ciclo punta a un nodo che non esiste.
  • Più modali: due trap sovrapposti si contendono il Tab; il dialogo nativo gestisce lo stack con il top layer.

Il test manuale

Apri il modale da tastiera, non con il mouse. Premi Tab fino all'ultimo controllo e ancora una volta: il focus deve tornare al primo, non finire sulla pagina. Premi Escape e verifica che il focus sia sul bottone di apertura. Poi riprova con Shift+Tab. Il criterio 2.4.11 Focus Not Obscured (Minimum), livello AA, aggiunge un controllo che gli strumenti non fanno: l'indicatore non deve restare coperto da uno sticky header o dal modale stesso.

npx --yes lighthouse https://example.com --only-categories=accessibility --chrome-flags="--headless" --output=json --output-path=./a11y.json

Lighthouse è open source con licenza Apache-2.0 e misura l'accessibilità della pagina renderizzata. Non apre il modale e non preme Tab: il report può essere pulito mentre il trap è rotto.

Il catalogo EneUI

EneUI indicizza i dialog, i modali e il record dialog di Origin UI, licenza MIT dichiarata a monte: dipende da @radix-ui/react-dialog e non reinventa il trap. Verifica del 1 novembre 2026 sul catalogo da 111.985 componenti.

La limitazione

Il dialogo nativo non sistema l'ordine di lettura: se il markup interno mette il titolo dopo il contenuto, il lettore di schermo legge nell'ordine sbagliato. E non copre i pannelli non modali, che lasciano la pagina interattiva: lì un trap è la scelta sbagliata, perché toglie all'utente controlli che dovrebbero restare raggiungibili.

Dove continuare

Il caso di shadcn è in dialog accessibile con shadcn; i portal sono in React portal per i modali. Il confronto tra blocchi già pronti è in i migliori dialog block.

Domande frequenti

Chiudere con Escape è obbligatorio?

Sì, per il livello A: il criterio 2.1.2 No Keyboard Trap richiede una via d'uscita da tastiera. Escape è la convenzione, e il dialogo nativo la implementa da solo.

Serve un listener per il ciclo del Tab?

No, se usi dialog con showModal(). Il browser rende inerte il resto della pagina, quindi Tab resta dentro senza codice. Il listener serve solo per contenitori non nativi.

Dove va il focus all'apertura?

Sul primo controllo utile, di solito il campo o il bottone principale. Il titolo va letto tramite aria-labelledby e non riceve il focus: chi usa la tastiera non deve passare da un titolo non interattivo.

Il focus torna da solo al trigger?

Sì, con showModal(): il browser ricorda l'elemento attivo prima dell'apertura. Se il trigger viene smontato e ricreato, ripristina il focus nel gestore close, altrimenti finisce sul body.

Un modale può avere più di un focus trap?

No. Due trap sovrapposti si contendono il Tab e il risultato dipende dall'ordine dei listener. Con dialog annidati il top layer gestisce lo stack; con div serve un solo gestore che conosce il livello attivo.