Saltar al contenido
Pon el foco

Componentes accesibles, con código

Muchos elementos de HTML ya son accesibles de serie: solo hay que usarlos. Cuando el diseño pide algo que no existe en HTML, aquí tienes cómo construirlo para que cumpla.

La regla de oro

Si existe un elemento HTML que hace lo que necesitas, úsalo. Un button trae de serie el foco, el teclado, el rol y los estados. Con un div tienes que programarlo todo, y es fácil que falte algo. ARIA cambia lo que se anuncia, pero no añade comportamiento.

Botón

<button>Nativo de HTML · Acciones

Para cualquier acción dentro de la página: enviar, abrir, guardar, cerrar.

Lo que te da el navegador

  • Se anuncia como «botón» con su texto como nombre.
  • Se enfoca con Tab y se activa con Enter y con Espacio.
  • Con disabled sale del orden de foco y se anuncia como no disponible.
  • Dentro de un formulario, type="submit" lo envía sin JavaScript.
Uso con teclado
TeclaQué hace
TabLlega al botón
Enter o EspacioLo activa

Vista previaPruébala con el ratón y con el teclado

HTML
<button type="button">Guardar borrador</button>
<button type="submit">Enviar solicitud</button>

Ojo: Pon siempre type="button" si no envía un formulario: el valor por defecto es submit.

Criterios relacionados: 2.1.1 Teclado · 4.1.2 Nombre, función, valor

Interruptor

role="switch"A medida · Acciones

El clásico «encendido / apagado» de los ajustes. Una casilla (input type="checkbox") con estilos ya es accesible; si el diseño lo pide como botón, usa role="switch".

Lo que tienes que añadir

  • role="switch": se anuncia como interruptor.
  • aria-checked="true" o "false": el estado actual.
  • El texto del botón es su nombre; el dibujo del interruptor va con aria-hidden.
  • Dibuja el estado a partir de aria-checked: así lo que se ve y lo que se anuncia nunca se separan.
Uso con teclado
TeclaQué hace
TabLlega al interruptor
Espacio o EnterCambia el estado

Vista previaPruébala con el ratón y con el teclado

HTML
<button type="button" role="switch" aria-checked="false" class="switch">
  <span class="switch-track" aria-hidden="true"></span>
  Recibir avisos por correo
</button>
JavaScript
// Interruptor: un botón con role="switch". El botón ya responde a Enter y Espacio;
// solo hay que cambiar aria-checked, y el estilo visual se basa en ese atributo.
for (const sw of document.querySelectorAll('[role="switch"]')) {
  sw.addEventListener("click", () => {
    const on = sw.getAttribute("aria-checked") === "true";
    sw.setAttribute("aria-checked", String(!on));
  });
}
CSS
/* El estado visual sale del atributo ARIA */
.switch[aria-checked="true"] .switch-track { background: var(--accent); }
.switch[aria-checked="true"] .switch-track::after { transform: translateX(1.25rem); }

Criterios relacionados: 4.1.2 Nombre, función, valor · 1.4.1 Uso del color · 1.4.11 Contraste no textual · Patrón en la guía del W3C (en inglés)

Campo de texto con etiqueta

<label> + <input>Nativo de HTML · Formularios

Todo campo necesita una etiqueta visible unida al campo con for e id.

Lo que te da el navegador

  • La etiqueta es el nombre accesible del campo.
  • Pulsar la etiqueta enfoca el campo: el área para tocar es más grande.
  • type="email" o inputmode muestran el teclado adecuado en el móvil.
  • required y aria-invalid se anuncian; aria-describedby añade la ayuda.
Uso con teclado
TeclaQué hace
TabLlega al campo; el lector lee etiqueta, tipo y ayuda

Vista previaPruébala con el ratón y con el teclado

Te enviaremos la confirmación aquí.

HTML
<label for="demo-email">Correo electrónico</label>
<p id="demo-email-ayuda">Te enviaremos la confirmación aquí.</p>
<input id="demo-email" type="email" autocomplete="email"
  aria-describedby="demo-email-ayuda" required>

Criterios relacionados: 1.3.1 Información y relaciones · 1.3.5 Identificar el propósito de la entrada · 3.3.2 Etiquetas o instrucciones

Grupo de opciones

<fieldset> + <legend>Nativo de HTML · Formularios

Para agrupar opciones relacionadas, como botones de opción o casillas.

Lo que te da el navegador

  • Al entrar en el grupo, el lector anuncia la pregunta (la legend).
  • Tab entra y sale del grupo de una vez; las flechas cambian de opción.
  • Cada opción anuncia si está marcada y su posición («2 de 3»).
Uso con teclado
TeclaQué hace
TabEntra en el grupo, en la opción marcada
FlechasCambian de opción
EspacioMarca la opción enfocada

Vista previaPruébala con el ratón y con el teclado

Tipo de entrega
HTML
<fieldset>
  <legend>Tipo de entrega</legend>
  <label><input type="radio" name="demo-entrega" value="domicilio" checked> A domicilio</label>
  <label><input type="radio" name="demo-entrega" value="tienda"> Recoger en tienda</label>
  <label><input type="radio" name="demo-entrega" value="punto"> Punto de recogida</label>
</fieldset>

Criterios relacionados: 1.3.1 Información y relaciones · 3.3.2 Etiquetas o instrucciones

Lista desplegable

<select>Nativo de HTML · Formularios

Para elegir una opción de una lista. En el móvil usa el selector del sistema operativo.

Lo que te da el navegador

  • Se anuncia con su etiqueta, la opción elegida y cuántas hay.
  • Se maneja con flechas y saltando a la primera letra.
  • Funciona con lectores de pantalla, control por voz y teclados de cualquier sistema.
Uso con teclado
TeclaQué hace
FlechasCambian de opción
Una letraSalta a la opción que empieza por ella
Alt + ↓ o EspacioAbre la lista (según navegador)

Vista previaPruébala con el ratón y con el teclado

HTML
<label for="demo-provincia">Provincia</label>
<select id="demo-provincia" name="provincia" autocomplete="address-level2">
  <option value="">Elige una provincia</option>
  <option>Álava</option>
  <option>Albacete</option>
  <option>Alicante</option>
</select>

Ojo: Antes de construir un desplegable a medida, prueba a dar estilo al select. Con appearance: base-select (ya disponible en Chrome y Edge) se puede personalizar casi por completo.

Criterios relacionados: 1.3.1 Información y relaciones · 3.2.2 Al recibir entradas · 4.1.2 Nombre, función, valor

Autocompletar

role="combobox"A medida · Formularios

Un campo que sugiere opciones mientras escribes. Es de los componentes más difíciles de hacer bien: si te basta, usa datalist, que es nativo.

Lo que tienes que añadir

  • El input lleva role="combobox", aria-expanded y aria-controls con la lista.
  • La lista es role="listbox" y cada sugerencia role="option".
  • aria-activedescendant indica qué opción está resaltada sin mover el foco del campo.
  • Una región role="status" anuncia cuántas sugerencias hay.
Uso con teclado
TeclaQué hace
EscribirFiltra las sugerencias
↓ ↑Recorren las sugerencias
EnterElige la sugerencia resaltada
EscCierra la lista

Vista previaPruébala con el ratón y con el teclado

HTML
<label for="ciudad">Ciudad</label>
<input id="ciudad" type="text" role="combobox" autocomplete="off"
  aria-autocomplete="list" aria-expanded="false" aria-controls="ciudad-lista">
<ul id="ciudad-lista" role="listbox" aria-label="Sugerencias" hidden></ul>
<p role="status" id="ciudad-estado" class="sr-only"></p>
JavaScript
const CIUDADES = ["Barcelona", "Bilbao", "Madrid", "Málaga", "Sevilla", "Valencia", "Zaragoza"];
const input = document.getElementById("ciudad");
const lista = document.getElementById("ciudad-lista");
const estado = document.getElementById("ciudad-estado");
let activa = -1;

function mostrar(opciones) {
  lista.replaceChildren(...opciones.map((texto, i) => {
    const li = document.createElement("li");
    li.id = "ciudad-op-" + i;
    li.setAttribute("role", "option");
    li.textContent = texto;
    li.addEventListener("click", () => elegir(texto));
    return li;
  }));
  lista.hidden = opciones.length === 0;
  input.setAttribute("aria-expanded", String(!lista.hidden));
  resaltar(-1);
}

function resaltar(i) {
  activa = i;
  [...lista.children].forEach((op, j) => op.setAttribute("aria-selected", String(j === i)));
  if (i >= 0) input.setAttribute("aria-activedescendant", lista.children[i].id);
  else input.removeAttribute("aria-activedescendant");
}

function elegir(texto) {
  input.value = texto;
  mostrar([]);
}

input.addEventListener("input", () => {
  const q = input.value.trim().toLowerCase();
  const opciones = q ? CIUDADES.filter((c) => c.toLowerCase().startsWith(q)) : [];
  mostrar(opciones);
  estado.textContent = opciones.length === 1 ? "1 sugerencia" : opciones.length + " sugerencias";
});

input.addEventListener("keydown", (e) => {
  const n = lista.children.length;
  if (e.key === "ArrowDown" && n) {
    e.preventDefault();
    resaltar((activa + 1) % n);
  } else if (e.key === "ArrowUp" && n) {
    e.preventDefault();
    resaltar((activa - 1 + n) % n);
  } else if (e.key === "Enter" && activa >= 0) {
    e.preventDefault();
    elegir(lista.children[activa].textContent);
  } else if (e.key === "Escape") {
    mostrar([]);
  }
});
CSS
/* La opción resaltada tiene que verse, no solo anunciarse */
[role="option"][aria-selected="true"] {
  background: var(--accent);
  color: var(--accent-ink);
}

Vista previaPruébala con el ratón y con el teclado

Alternativa nativa
<label for="ciudad-nativa">Ciudad</label>
<input id="ciudad-nativa" list="ciudades">
<datalist id="ciudades">
  <option value="Barcelona"></option>
  <option value="Madrid"></option>
  <option value="Sevilla"></option>
</datalist>

Criterios relacionados: 4.1.2 Nombre, función, valor · 2.1.1 Teclado · 4.1.3 Mensajes de estado · 1.3.1 Información y relaciones · Patrón en la guía del W3C (en inglés)

Enlace

<a href>Nativo de HTML · Navegación

Para ir a otra página o a otra parte de la misma. Si no navega, es un botón.

Lo que te da el navegador

  • Se anuncia como «enlace» y aparece en la lista de enlaces del lector de pantalla.
  • Se activa con Enter.
  • Se puede abrir en otra pestaña, copiar o guardar desde el menú contextual.
  • Sin href no es un enlace: no se enfoca ni se anuncia como tal.
Uso con teclado
TeclaQué hace
TabLlega al enlace
EnterLo sigue

Vista previaPruébala con el ratón y con el teclado

HTML
<a href="/criterios/">Ver los criterios WCAG</a>

<!-- Si descarga un archivo, dilo en el texto -->
<a href="/guia.pdf">Guía de accesibilidad (PDF, 1,2 MB)</a>

Ojo: ¿Lleva a otro sitio? Enlace. ¿Hace algo aquí? Botón. Mezclarlos confunde a quien usa lector de pantalla, porque espera un comportamiento distinto.

Criterios relacionados: 2.4.4 Propósito de los enlaces (en contexto) · 4.1.2 Nombre, función, valor

Regiones de la página

<header> <nav> <main> <footer>Nativo de HTML · Navegación

Dividen la página en zonas a las que se puede saltar directamente.

Lo que te da el navegador

  • Los lectores de pantalla listan las regiones y saltan entre ellas (tecla D en NVDA, rotor en VoiceOver).
  • Si hay varias nav, aria-label las distingue: «Principal», «Pie de página».
  • search marca la zona del buscador.
  • Solo debe haber un main visible por página.

Vista previaAsí se ve

Esquema visual de las regiones; no se reproducen de verdad para no duplicar las de esta página.

HTML
<body>
  <a href="#contenido">Saltar al contenido</a>
  <header>
    <search>…buscador…</search>
  </header>
  <nav aria-label="Principal">…</nav>
  <main id="contenido">
    <h1>Título de la página</h1>
  </main>
  <footer>…</footer>
</body>

Criterios relacionados: 1.3.1 Información y relaciones · 2.4.1 Evitar bloques

Pestañas

role="tablist"A medida · Navegación

Varias vistas en el mismo espacio. No hay elemento nativo, así que ARIA y un poco de JavaScript.

Lo que tienes que añadir

  • role="tablist" agrupa las pestañas; aria-label le da nombre.
  • Cada pestaña es un button con role="tab", aria-selected y aria-controls.
  • Solo la pestaña activa recibe Tab (tabindex="-1" en las demás); las flechas mueven entre ellas.
  • Cada panel tiene role="tabpanel" y aria-labelledby con su pestaña.
Uso con teclado
TeclaQué hace
TabEntra en la pestaña activa; otro Tab va al panel
← →Pestaña anterior o siguiente
Inicio / FinPrimera o última pestaña

Vista previaPruébala con el ratón y con el teclado

Zapatillas de trail con suela de agarre y malla transpirable.
HTML
<div class="tabs">
  <div role="tablist" aria-label="Información del producto">
    <button type="button" role="tab" id="tab-desc" aria-selected="true" aria-controls="panel-desc">Descripción</button>
    <button type="button" role="tab" id="tab-tallas" aria-selected="false" aria-controls="panel-tallas" tabindex="-1">Tallas</button>
    <button type="button" role="tab" id="tab-envio" aria-selected="false" aria-controls="panel-envio" tabindex="-1">Envío</button>
  </div>
  <div role="tabpanel" id="panel-desc" aria-labelledby="tab-desc" tabindex="0">…</div>
  <div role="tabpanel" id="panel-tallas" aria-labelledby="tab-tallas" tabindex="0" hidden>…</div>
  <div role="tabpanel" id="panel-envio" aria-labelledby="tab-envio" tabindex="0" hidden>…</div>
</div>
JavaScript
// Pestañas con activación automática: las flechas cambian de pestaña y muestran su panel.
// Solo la pestaña activa está en el orden de Tab (tabindex 0); el resto tiene -1.
for (const list of document.querySelectorAll('[role="tablist"]')) {
  const tabs = [...list.querySelectorAll('[role="tab"]')];

  function select(tab) {
    for (const t of tabs) {
      const active = t === tab;
      t.setAttribute("aria-selected", String(active));
      t.tabIndex = active ? 0 : -1;
      document.getElementById(t.getAttribute("aria-controls")).hidden = !active;
    }
    tab.focus();
  }

  list.addEventListener("click", (e) => {
    const tab = e.target.closest('[role="tab"]');
    if (tab) select(tab);
  });

  list.addEventListener("keydown", (e) => {
    const i = tabs.indexOf(document.activeElement);
    const next = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key];
    if (next === undefined) return;
    e.preventDefault();
    select(tabs[(next + tabs.length) % tabs.length]);
  });
}

Criterios relacionados: 4.1.2 Nombre, función, valor · 2.1.1 Teclado · 1.3.1 Información y relaciones · Patrón en la guía del W3C (en inglés)

Desplegable o acordeón

<details> + <summary>Nativo de HTML · Contenido

Para mostrar y ocultar contenido, como preguntas frecuentes. Sin una línea de JavaScript.

Lo que te da el navegador

  • El summary se anuncia como un control con estado «contraído» o «expandido».
  • Se abre y se cierra con Enter o Espacio.
  • Con el mismo atributo name en varios details, solo uno queda abierto a la vez.
Uso con teclado
TeclaQué hace
TabLlega al summary
Enter o EspacioAbre o cierra

Vista previaPruébala con el ratón y con el teclado

¿Cuánto tarda el envío?

Entre 24 y 48 horas en la península.

¿Puedo devolver un pedido?

Sí, durante 30 días desde la entrega.

HTML
<details name="demo-faq">
  <summary>¿Cuánto tarda el envío?</summary>
  <p>Entre 24 y 48 horas en la península.</p>
</details>
<details name="demo-faq">
  <summary>¿Puedo devolver un pedido?</summary>
  <p>Sí, durante 30 días desde la entrega.</p>
</details>

Criterios relacionados: 4.1.2 Nombre, función, valor · 2.1.1 Teclado

Tabla de datos

<table> + <th>Nativo de HTML · Contenido

Para datos con filas y columnas. Nunca para maquetar.

Lo que te da el navegador

  • El lector anuncia cuántas filas y columnas tiene y lee el título (caption).
  • Al moverse por las celdas, lee el encabezado de la fila y de la columna.
  • scope indica si un th encabeza una columna o una fila.
Uso con teclado
TeclaQué hace
Atajos del lectorMueven celda a celda (por ejemplo, Ctrl + Alt + flechas en NVDA)

Vista previaAsí se ve

Multas según la gravedad
InfracciónMulta
Leve301 € a 30.000 €
Grave30.001 € a 90.000 €
Muy grave90.001 € a 1.000.000 €
HTML
<table>
  <caption>Multas según la gravedad</caption>
  <thead>
    <tr><th scope="col">Infracción</th><th scope="col">Multa</th></tr>
  </thead>
  <tbody>
    <tr><th scope="row">Leve</th><td>301 € a 30.000 €</td></tr>
    <tr><th scope="row">Grave</th><td>30.001 € a 90.000 €</td></tr>
    <tr><th scope="row">Muy grave</th><td>90.001 € a 1.000.000 €</td></tr>
  </tbody>
</table>

Criterios relacionados: 1.3.1 Información y relaciones

Imagen con alternativa

<img alt> + <figure>Nativo de HTML · Contenido

El alt es lo que se lee en lugar de la imagen. figcaption añade un pie visible para todo el mundo.

Lo que te da el navegador

  • Se anuncia como imagen y lee el alt.
  • Con alt="" el lector la ignora (decorativa).
  • Sin alt, muchos lectores leen el nombre del archivo.

Vista previaAsí se ve

Las ventas online crecieron un 30 % entre 2024 y 2026.
Evolución de las ventas online, 2024 a 2026 (datos de ejemplo).
HTML
<figure>
  <img src="ventas.png" alt="Las ventas online crecieron un 30 % entre 2024 y 2026.">
  <figcaption>Evolución de las ventas online, 2024 a 2026.</figcaption>
</figure>

<img src="separador.svg" alt="">

Criterios relacionados: 1.1.1 Contenido no textual

Carrusel

aria-roledescription="carrusel"A medida · Contenido

Diapositivas que rotan. Antes de usarlo, pregúntate si hace falta: mucha gente no pasa de la primera. Si lo usas, que se pueda pausar y manejar con botones.

Lo que tienes que añadir

  • Botón de pausa, el primero en el orden de foco.
  • Botones anterior y siguiente, además de cualquier gesto de deslizar.
  • Cada diapositiva se identifica como «1 de 3».
  • Se detiene cuando el foco entra y no arranca si la persona prefiere menos movimiento.
  • Los cambios se anuncian (aria-live="polite") solo cuando los provoca la persona.
Uso con teclado
TeclaQué hace
TabPausa, anterior, siguiente y contenido de la diapositiva
Enter o EspacioActiva el botón enfocado

Vista previaPruébala con el ratón y con el teclado

Envío gratis en pedidos desde 30 €.

HTML
<section class="carrusel" aria-roledescription="carrusel" aria-label="Ofertas destacadas">
  <button type="button" class="pausa">Pausar</button>
  <button type="button" class="anterior">Anterior</button>
  <button type="button" class="siguiente">Siguiente</button>
  <div class="diapositivas" aria-live="off">
    <div role="group" aria-roledescription="diapositiva" aria-label="1 de 3">…</div>
    <div role="group" aria-roledescription="diapositiva" aria-label="2 de 3" hidden>…</div>
    <div role="group" aria-roledescription="diapositiva" aria-label="3 de 3" hidden>…</div>
  </div>
</section>
JavaScript
const carrusel = document.querySelector(".carrusel");
const zona = carrusel.querySelector(".diapositivas");
const diapositivas = [...zona.children];
const pausa = carrusel.querySelector(".pausa");
let actual = 0;
let timer = null;

function mostrar(i) {
  actual = (i + diapositivas.length) % diapositivas.length;
  diapositivas.forEach((d, j) => (d.hidden = j !== actual));
}

function reproducir(si) {
  clearInterval(timer);
  timer = si ? setInterval(() => mostrar(actual + 1), 6000) : null;
  pausa.textContent = si ? "Pausar" : "Reproducir";
  // Mientras gira solo, no se anuncia cada cambio
  zona.setAttribute("aria-live", si ? "off" : "polite");
}

pausa.addEventListener("click", () => reproducir(!timer));
carrusel.querySelector(".anterior").addEventListener("click", () => {
  reproducir(false);
  mostrar(actual - 1);
});
carrusel.querySelector(".siguiente").addEventListener("click", () => {
  reproducir(false);
  mostrar(actual + 1);
});
carrusel.addEventListener("focusin", () => reproducir(false));

reproducir(!matchMedia("(prefers-reduced-motion: reduce)").matches);

Criterios relacionados: 2.2.2 Pausar, detener, ocultar · 2.5.1 Gestos del puntero · 4.1.2 Nombre, función, valor · Patrón en la guía del W3C (en inglés)

Ventana modal

<dialog>Nativo de HTML · Avisos y estado

Para pedir atención o confirmar algo sin salir de la página. Ábrela con showModal().

Lo que te da el navegador

  • Mueve el foco dentro de la ventana y no deja que salga mientras está abierta.
  • Esc la cierra.
  • El resto de la página queda inerte: no se puede enfocar ni leer.
  • Al cerrarse, el foco vuelve al botón que la abrió.
Uso con teclado
TeclaQué hace
TabRecorre solo los controles de la ventana
EscCierra la ventana

Vista previaPruébala con el ratón y con el teclado

HTML
<button type="button" id="demo-abrir">Ver condiciones de envío</button>

<dialog id="demo-dialogo" aria-labelledby="demo-dialogo-titulo">
  <h2 id="demo-dialogo-titulo">Condiciones de envío</h2>
  <p>Envío gratis a partir de 50 €. Devoluciones en 30 días.</p>
  <form method="dialog">
    <button>Cerrar</button>
  </form>
</dialog>

<script>
  const dialogo = document.getElementById("demo-dialogo");
  document.getElementById("demo-abrir").addEventListener("click", () => dialogo.showModal());
</script>

Criterios relacionados: 2.1.2 Sin trampas para el foco del teclado · 2.4.3 Orden del foco · 4.1.2 Nombre, función, valor

Progreso y medidores

<progress> <meter>Nativo de HTML · Avisos y estado

Para mostrar el avance de una tarea o un valor dentro de un rango.

Lo que te da el navegador

  • El lector anuncia el valor (por ejemplo, «60 %»).
  • La etiqueta les da nombre como a cualquier campo.
  • No necesitan ARIA ni JavaScript para ser accesibles.

Vista previaAsí se ve

60 % Buena
HTML
<label for="demo-subida">Subiendo documento</label>
<progress id="demo-subida" max="100" value="60">60 %</progress>

<label for="demo-fuerza">Seguridad de la contraseña</label>
<meter id="demo-fuerza" min="0" max="4" low="2" high="3" optimum="4" value="3">Buena</meter>

Criterios relacionados: 1.3.1 Información y relaciones · 4.1.2 Nombre, función, valor

Aviso emergente (toast)

role="status"A medida · Avisos y estado

Mensajes breves como «Cambios guardados». Tienen que anunciarse sin robar el foco y no desaparecer antes de poder leerlos.

Lo que tienes que añadir

  • La región con role="status" existe desde que carga la página, vacía.
  • Para errores urgentes, role="alert" (interrumpe la lectura): úsalo con moderación.
  • No lo hagas desaparecer a los pocos segundos: deja que se cierre con un botón.
  • No muevas el foco al aviso.

Vista previaPruébala con el ratón y con el teclado

HTML
<button type="button" class="guardar">Guardar cambios</button>
<div class="avisos" role="status"></div>
JavaScript
const avisos = document.querySelector(".avisos");

function avisar(texto) {
  const aviso = document.createElement("div");
  aviso.className = "aviso";
  const mensaje = document.createElement("p");
  mensaje.textContent = texto;
  const cerrar = document.createElement("button");
  cerrar.type = "button";
  cerrar.textContent = "Cerrar aviso";
  cerrar.addEventListener("click", () => aviso.remove());
  aviso.append(mensaje, cerrar);
  avisos.append(aviso);
}

document.querySelector(".guardar").addEventListener("click", () => avisar("Cambios guardados."));

Criterios relacionados: 4.1.3 Mensajes de estado · 2.2.1 Tiempo ajustable · Patrón en la guía del W3C (en inglés)

Un div que hace de botón

role="button" tabindex="0"A medida · Qué no hacer

Solo si no puedes cambiar el HTML (por ejemplo, en un componente de terceros). Mira todo lo que hay que añadir para igualar a un simple button.

Lo que tienes que añadir

  • role="button" para que se anuncie como botón.
  • tabindex="0" para que se pueda enfocar.
  • Enter y Espacio tienen que activarlo: un div no lo hace solo.
  • Aun así, no envía formularios ni tiene estado deshabilitado nativo.
Uso con teclado
TeclaQué hace
TabLlega al elemento (gracias a tabindex)
EnterLo activa al pulsar (hay que programarlo)
EspacioLo activa al soltar (hay que programarlo)

Vista previaPruébala con el ratón y con el teclado

Mejor así
<button type="button">Añadir a favoritos</button>

Vista previaPruébala con el ratón y con el teclado

Añadir a favoritos

Si no queda otro remedio
<div role="button" tabindex="0" class="favorito">Añadir a favoritos</div>

<script>
  const favorito = document.querySelector(".favorito");
  favorito.addEventListener("click", anadirFavorito);
  favorito.addEventListener("keydown", (e) => {
    if (e.key === "Enter") anadirFavorito();
    if (e.key === " ") e.preventDefault();  // evita que la página se desplace
  });
  favorito.addEventListener("keyup", (e) => {
    if (e.key === " ") anadirFavorito();
  });
</script>

Criterios relacionados: 2.1.1 Teclado · 4.1.2 Nombre, función, valor · Patrón en la guía del W3C (en inglés)