React
Come costruire una schermata di login accessibile
Una schermata di login accessibile ha una label per campo, un errore collegato all'input e un solo pulsante di invio. Ecco la base, il blocco pronto e il limite da colmare.
In sintesi
- Label visibile, non placeholder: il placeholder resta un esempio.
- Errore con aria-describedby e aria-invalid, annunciato in una live region.
- Il blocco pronto copre l'aspetto, non l'annuncio: quello lo aggiungi tu.
Una schermata di login accessibile è un form con una label visibile per ogni campo, un errore collegato al campo che lo ha causato e un solo pulsante di invio. Il resto si aggiunge dopo, senza toccare quella base.
Quasi tutti i login che falliscono un test di accessibilità sbagliano nello stesso punto: il placeholder fa da label e il messaggio d'errore compare accanto al campo, ma non viene annunciato.
Quattro elementi non negoziabili
- Label visibile con htmlFor uguale all'id dell'input; il placeholder resta un esempio.
- Errore con aria-describedby e aria-invalid, non solo testo rosso.
- Un solo pulsante type=submit; «password dimenticata» resta un link.
- Focus visibile e ordine di tab naturale: nessun tabIndex positivo.
- Annuncio dell'errore in una live region, perché chi usa uno screen reader non vede il rosso.
I gestori di password leggono autoComplete: email su un campo e current-password sull'altro, mai off. Senza quegli attributi l'utente copia la password a mano, e su mobile quello è il punto in cui chiude la pagina.
Cosa c'è nel catalogo
Il blocco login-01 copre email, password e link; login-04 aggiunge l'immagine di copertina. Sfoglia la categoria login per le varianti. Sorgente: github.com/shadcn-ui/ui, licenza MIT.
Il form minimo che passa
npx shadcn@latest add input label button
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function LoginForm({ error }: { error?: string }) {
return (
Email
Accedi
{error ? (
{error}
) : null}
);
}
L'esempio concreto: focus dopo l'errore
Il form qui sopra copre i campi e collega il messaggio. Manca il passo dopo la risposta del server: il focus resta sul pulsante e chi usa la tastiera non sa che è successo qualcosa. Un ref sul paragrafo e un focus() dentro useEffect chiudono il cerchio: la live region annuncia, il focus porta.
import { useEffect, useRef } from "react";
export function LoginError({ message }: { message: string }) {
const ref = useRef(null);
useEffect(() => {
ref.current?.focus();
}, [message]);
return (
{message}
);
}
Il tabIndex={-1} rende il paragrafo raggiungibile dal codice senza aggiungerlo all'ordine di tabulazione. Senza quell'attributo focus() non fa nulla su un elemento non interattivo: il messaggio resta muto.
Il limite da conoscere
Limite: login-01 non include una live region e non collega l'errore all'input con aria-describedby. Il blocco copre l'aspetto e la struttura; l'annuncio dell'errore lo aggiungi tu, come nello snippet qui sopra. Se usi un server che restituisce l'errore dopo il submit, metti il focus sul messaggio o sulla form, non lasciarlo sul pulsante.
Quanti blocchi ci sono davvero
Cinque dei quindici blocchi sono di shadcn/ui, e login-01 dichiara cinque registryDependencies e due file: add login-01 scrive anche card, field, input, label e button. Contare le dipendenze prima del comando evita di scoprire un componente in più nel diff.
La regola di decisione
Regola: se l'errore nasce dalla validazione prima dell'invio, bastano aria-describedby e aria-invalid. Se arriva dal server dopo l'invio, serve anche il focus sul messaggio. Se servono entrambi, un solo nodo con role="alert" fa il lavoro: due regioni sullo stesso testo lo annunciano due volte.
L'errore da evitare
Disabilitare il pulsante mentre la richiesta è in corso sembra prudente e rompe il flusso: il focus cade sul body, la pagina sembra bloccata e chi usa uno screen reader perde il punto. Lascia il pulsante attivo, mostra «invio in corso» e ignora il secondo invio con un flag.
Per confrontare le varianti leggi Migliori login blocks 2026 e la checklist in 12 punti per componenti React accessibili. Se devi decidere fra blocco pronto e primitiva headless, parti da Copy-paste o headless.
Domande frequenti
Il placeholder può sostituire la label?
No: il placeholder sparisce appena l'utente scrive e non ha un nome stabile per lo screen reader. Tieni una label visibile e usa il placeholder solo come esempio.
Come collego l'errore al campo giusto?
Con aria-describedby che punta all'id del messaggio e aria-invalid sul campo. Il testo rosso da solo non dice nulla a chi non lo vede.
Serve una live region per un solo errore?
Sì, se l'errore appare dopo il submit senza spostare il focus. role=alert o aria-live=assertive annunciano il messaggio appena compare.
Meglio email e password in due step o in un form solo?
Un form solo, finché non hai un motivo misurato per separarli. Due step aggiungono un caricamento e un punto in cui l'utente può abbandonare.
I blocchi di login del catalogo sono utilizzabili in produzione?
Sì, login-01 e login-04 sono MIT. Sono codice shadcn/ui da copiare, quindi la manutenzione e i test restano tuoi.