learning-front

Nivel 6 · React de cero a héroe (con TypeScript)

Accesibilidad en React: useId, foco y teclado

La accesibilidad mínima y deliberada de una app React: useId para atar etiquetas y errores a sus campos, gestión del foco al validar y navegación por teclado para que un tablist se comporte como tal. La diferencia entre poner atributos aria y que de verdad funcionen.

En el capítulo de patrones construiste un sistema de pestañas con role="tab" y aria-selected. Pruébalo con el teclado: llega a las pestañas con Tab y luego pulsa las flechas para cambiar de una a otra. No pasa nada. Las flechas no hacen nada y, encima, Tab te obliga a pasar por cada pestaña una a una. Pusimos los atributos de accesibilidad, pero no el comportamiento que prometen: y un role="tab" que no se navega como un tab es, para quien usa el teclado, una etiqueta que miente.

Este capítulo va de cerrar esa brecha. No es accesibilidad exhaustiva —eso da para un curso entero—, sino el mínimo deliberado que se da por supuesto en una empresa en 2026: atar las cosas que van juntas, llevar el foco a donde toca y hacer que el teclado funcione. Tres herramientas: useId, la gestión del foco y la navegación por teclado.

Por qué esto es parte del trabajo, no un extra#

Una parte de tus usuarios no usa el ratón: navega con teclado, con lector de pantalla, con un conmutador. No es un caso raro y lejano —es gente con una mano ocupada, con el trackpad roto, o que simplemente va más rápido con el teclado—. Y en sectores como la banca o lo público, la accesibilidad es un requisito legal, no una cortesía.

React no te ayuda aquí: es permisivo. Te deja pintar un <div> que hace de botón sin teclado, un input sin etiqueta o un tablist sin flechas, y con el ratón “funciona”. El fallo es silencioso: nadie ve un error en consola, la demo se ve bien, y el problema solo existe para quien no usa el ratón —que es justo quien no estaba en la sala cuando lo probaste—. Por eso hay que ponerlo a propósito.

useId: atar una etiqueta (y un error) a su campo#

Un <label> solo “pertenece” a un input si lo dices explícitamente: el htmlFor del label tiene que coincidir con el id del input. Así, al pulsar la etiqueta el foco salta al campo, y el lector de pantalla lee “Nombre” cuando llegas al input en vez de leer un campo anónimo.

¿Y de dónde sacas ese id? La tentación es escribirlo a mano: id="nombre". Funciona en una página simple. Pero un id debe ser único en todo el documento, y en cuanto ese componente aparece dos veces —dos formularios, o una tarjeta por héroe con un campo cada una— tienes dos id="nombre". El htmlFor apunta al primero, y la asociación de las demás copias se rompe en silencio. Para esto está useId:

tsx
import { useId } from "react";

function CampoNombre() {
  // useId genera un id único y estable para esta instancia del componente.
  // Cada copia en la página recibe uno distinto: nunca chocan.
  const id = useId();
  return (
    <div className="campo">
      {/* htmlFor = id del input: ahora el navegador sabe que van juntos. */}
      <label htmlFor={id}>Nombre</label>
      <input id={id} type="text" />
    </div>
  );
}

El mismo id (con un sufijo) sirve para atar el mensaje de error al campo. Un error que solo es texto rojo debajo del input no existe para un lector de pantalla: la proximidad visual no se “oye”. Hay que conectarlos:

tsx
// id viene de useId; derivamos el del error a partir de él.
const id = useId();

// hayError es true cuando este campo ha fallado la validación.
return (
  <div className="campo">
    <label htmlFor={id}>Nombre</label>
    <input
      id={id}
      type="text"
      // aria-invalid marca el campo como inválido para la tecnología asistiva.
      aria-invalid={hayError ? true : false}
      // aria-describedby ata el input a su mensaje de error por id.
      aria-describedby={hayError ? id + "-error" : undefined}
    />
    {hayError && (
      // role="alert" hace que el lector anuncie el error en cuanto aparece.
      <span id={id + "-error"} className="error-msg" role="alert">
        El nombre es obligatorio
      </span>
    )}
  </div>
);

Nota de SSR: useId no es capricho. Como genera el MISMO id en el servidor y en el cliente, no rompe el renderizado en servidor (donde un id “aleatorio” daría un valor distinto en cada lado y React se quejaría). Por eso no se usa Math.random() para esto.

El foco: dónde está y a dónde lo mandas#

El foco es dónde está “puesto” el teclado ahora mismo: el elemento que recibe lo que escribes y sobre el que actúa Enter. Para quien no usa el ratón, mover el foco es la forma de llevarle la atención a un sitio.

El caso clásico: validas un formulario al enviar y aparecen errores arriba, pero el foco se queda en el botón de enviar, abajo. Quien ve la pantalla nota el rojo; quien navega con teclado o lee con voz, no se entera de que algo ha fallado. La solución es llevar el foco al primer campo inválido, con una ref:

tsx
import { useRef } from "react";

// Una ref al input para poder darle el foco por código.
const refNombre = useRef<HTMLInputElement>(null);

function alEnviar() {
  if (nombre.trim() === "") {
    setError("El nombre es obligatorio");
    // Llevamos el foco al campo que falla: quien no ve el error rojo, lo nota aquí.
    if (refNombre.current) refNombre.current.focus();
    return;
  }
  // ...el envío normal si no hay errores.
}

// En el JSX, conectamos la ref al input:
// <input ref={refNombre} ... />

El “¿y qué?” si no lo haces: el formulario parece roto para un usuario de teclado. Pulsa enviar, no pasa nada visible para él, no hay pista de qué corregir, y abandona. No es un detalle estético: es la diferencia entre poder usar el formulario o no.

Aquí cerramos la brecha del principio. Un grupo de pestañas con role="tab" promete un comportamiento concreto, definido por el estándar WAI-ARIA: Tab entra al grupo una sola vez (cae en la pestaña activa) y, ya dentro, las flechas mueven entre pestañas, con Home/End para ir a los extremos. Esa es la diferencia con un montón de botones sueltos, donde Tab te hace pasar por todos.

La técnica se llama roving tabindex (“tabindex itinerante”): en cada momento, solo la pestaña activa entra en el orden de tabulación (tabIndex={0}); las demás quedan fuera (tabIndex={-1}) y se alcanzan con las flechas, no con Tab. Un onKeyDown calcula a qué pestaña ir y le mueve el foco:

tsx
// Refs a todos los botones, para poder enfocar el de destino.
const refs = useRef<Array<HTMLButtonElement | null>>([]);

function alPulsarTecla(e: React.KeyboardEvent<HTMLButtonElement>, i: number) {
  let destino = i;
  // Las flechas mueven entre pestañas (en bucle con el módulo %).
  if (e.key === "ArrowRight") destino = (i + 1) % TABS.length;
  else if (e.key === "ArrowLeft") destino = (i - 1 + TABS.length) % TABS.length;
  // Home y End van a la primera y a la última.
  else if (e.key === "Home") destino = 0;
  else if (e.key === "End") destino = TABS.length - 1;
  // Otra tecla cualquiera (incluido Tab) sigue su curso normal.
  else return;

  // Evita que la flecha haga scroll de la página.
  e.preventDefault();
  setActiva(destino);
  // Mueve el foco real al botón de destino.
  const btn = refs.current[destino];
  if (btn) btn.focus();
}

// Y cada botón:
// <button
//   role="tab"
//   aria-selected={i === activa}
//   // La activa es tabbable (0); las demás salen del Tab (-1).
//   tabIndex={i === activa ? 0 : -1}
//   ref={(el) => { refs.current[i] = el; }}
//   onKeyDown={(e) => alPulsarTecla(e, i)}
// >

El “¿y qué?” otra vez: sin esto, el role="tab" que ya tenías engaña. Un lector de pantalla anuncia “pestaña, 1 de 4”, el usuario pulsa la flecha derecha esperando ir a la siguiente… y no se mueve nada. Le has prometido un comportamiento con el atributo y no se lo has dado. Poner el role sin la navegación es peor que no ponerlo: crea una expectativa que rompes.

Pruébalo#

Haz clic en una pestaña; luego pulsa Tab para entrar al grupo y muévete con las flechas (y prueba Home/End). El foco salta de pestaña en pestaña, la selección cambia con él y el filtro activo se actualiza. Eso es un tablist que cumple lo que su role promete.

Comprueba lo que sabes#

Pregunta 1 de 3

¿Por qué usar useId en lugar de escribir id="nombre" a mano para atar un label a su input?

Tu turno#

Ejercicio · en esta página

El Team Builder, accesible

Partes de un Team Builder que funciona con el ratón pero falla con el teclado y el lector de pantalla: las etiquetas no están atadas a sus campos, los errores no se anuncian y el filtro no se navega con flechas. Hazlo accesible, capa a capa.

Paso 1: Etiquetas atadas con useId

  • Cada label se ata a su input con useId (htmlFor en el label, el mismo id en el input)
  • Pulsar la etiqueta enfoca su campo
  • El filtro por rol sigue funcionando al hacer clic
Ver soluciones
import { useId, useState } from "react";

interface Heroe {
  id: number;
  nombre: string;
  rol: "Daño" | "Apoyo" | "Tanque";
  partidas: number;
}

const ROSTER_INICIAL: Heroe[] = [
  { id: 1, nombre: "Tracer", rol: "Daño", partidas: 120 },
  { id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200 },
  { id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95 },
];

const FILTROS = ["Todos", "Daño", "Apoyo", "Tanque"] as const;
type Filtro = (typeof FILTROS)[number];

let proximoId = 4;

export default function App() {
  const [roster, setRoster] = useState<Heroe[]>(ROSTER_INICIAL);
  const [filtro, setFiltro] = useState<Filtro>("Todos");

  // useId genera un id único y estable por campo, sin hardcodear id="nombre".
  // Si el formulario se montara dos veces, cada copia tendría ids distintos y la
  // asociación label↔input no se rompería (dos id="nombre" sí la romperían).
  const idNombre = useId();
  const idPartidas = useId();

  const [nombre, setNombre] = useState("");
  const [partidas, setPartidas] = useState("");
  const [errores, setErrores] = useState<{ nombre?: string; partidas?: string }>({});

  const visibles = filtro === "Todos" ? roster : roster.filter((h) => h.rol === filtro);

  function alEnviar() {
    const nuevos: { nombre?: string; partidas?: string } = {};
    if (nombre.trim().length < 2) nuevos.nombre = "El nombre debe tener al menos 2 caracteres";
    const num = Number(partidas);
    if (!Number.isInteger(num) || num < 1) nuevos.partidas = "Escribe un número de al menos 1";
    setErrores(nuevos);
    if (nuevos.nombre || nuevos.partidas) return;

    setRoster((prev) => [...prev, { id: proximoId++, nombre: nombre.trim(), rol: "Daño", partidas: num }]);
    setNombre("");
    setPartidas("");
  }

  return (
    <section>
      <h1 className="titulo">Team Builder — Roster</h1>

      {/* role="tablist" + role="tab" + aria-selected: el filtro tiene la semántica correcta. */}
      <div className="barra-filtro" role="tablist" aria-label="Filtrar por rol">
        {FILTROS.map((f) => (
          <button
            key={f}
            type="button"
            className="filtro-tab"
            role="tab"
            aria-selected={f === filtro}
            onClick={() => setFiltro(f)}
          >
            {f}
          </button>
        ))}
      </div>

      {/* preventDefault corta la recarga de página del submit; luego validamos a mano. */}
      <form className="formulario" onSubmit={(e) => { e.preventDefault(); alEnviar(); }} noValidate>
        <div className="campo">
          {/* htmlFor del label = id del input: al pulsar el label, el foco va al input. */}
          <label htmlFor={idNombre}>Nombre</label>
          <input
            id={idNombre}
            type="text"
            value={nombre}
            onChange={(e) => setNombre(e.target.value)}
          />
          {errores.nombre && <span className="error-msg">{errores.nombre}</span>}
        </div>

        <div className="campo">
          <label htmlFor={idPartidas}>Partidas jugadas</label>
          <input
            id={idPartidas}
            type="number"
            value={partidas}
            onChange={(e) => setPartidas(e.target.value)}
          />
          {errores.partidas && <span className="error-msg">{errores.partidas}</span>}
        </div>

        <button type="submit" className="boton">
          Añadir héroe
        </button>
      </form>

      <h2 className="subtitulo">{visibles.length} héroe(s)</h2>
      <div className="lista">
        {visibles.length === 0 ? (
          <p className="vacio">No hay héroes de ese rol.</p>
        ) : (
          visibles.map((h) => (
            <article key={h.id} className="tarjeta">
              <h3 className="tarjeta__nombre">{h.nombre}</h3>
              <p className="tarjeta__rol">{h.rol} · {h.partidas} partidas</p>
            </article>
          ))
        )}
      </div>
    </section>
  );
}

Por qué este nivel

  • useId resuelve el problema de los ids: hardcodear id="nombre" funciona... hasta que ese componente se monta dos veces en la página (dos formularios, o una tarjeta con un campo por héroe). Entonces hay dos id="nombre", el htmlFor del label apunta al primero y la asociación se rompe sin avisar. useId da un id único por instancia.
  • Con esto ya tienes lo mínimo: cada label apunta a su input, así que pulsar la etiqueta enfoca el campo y un lector de pantalla lee 'Nombre, campo de texto' en vez de un input anónimo.