♿ 3.7 · Accesibilidad y SEO: apps inclusivas que Google encuentre

⏱ 5h 00min ⚡ 100 XP 🏅 Mid Junior 📖 Frontend Moderno y Proyecto Final
"Si tu app no es accesible, el 15% de la población mundial no puede usarla. Si no tiene SEO, nadie te encuentra."

🎯 Objetivo del tema

Al terminar este tema serás capaz de: Al terminar este tema serás capaz de hacer tu app accesible para usuarios con discapacidad (WCAG AA), encontrable por Google (SEO técnico), y con buenas prácticas de performance. Aprenderás: atributos ARIA, navegación con teclado, foco visible, meta tags, Open Graph, sitemap, robots.txt, Core Web Vitals, y Lighthouse. Tu app será inclusiva, rápida, y visible en internet.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

🗺️ Mapa del tema

En el Tema 1.4 aprendiste accesibilidad web básica. Ahora vas al siguiente nivel: ARIA, navegación con teclado, manejadores de foco en SPAs (que es más complejo que en HTML estático), y SEO técnico para SPAs (que es un reto porque Googlebot no ejecuta JS tan bien como un navegador). Vamos a aplicar Lighthouse como auditor, y dejar tu app con 95+ en accesibilidad, performance, y SEO. Es lo que separa una app profesional de una que solo 'funciona'.

1. Accesibilidad en SPAs: el reto del foco y ARIA

Por qué las SPAs necesitan manejo explícito del foco y regiones ARIA live.

2. Navegación con teclado: el patrón profesional

Skip links, focus traps en modales, atajos, y orden lógico.

3. SEO técnico para SPAs: meta tags, sitemap, robots

Cómo hacer que Google entienda tu SPA de React.

4. Open Graph y meta tags para redes sociales

Cómo se ve tu link cuando lo compartes en Twitter, LinkedIn, WhatsApp.

5. Core Web Vitals: las 3 métricas que Google usa para ranking

LCP, FID, CLS: cómo medirlos y optimizarlos.

6. Lighthouse: auditoría completa + mejoras

Puntaje 95+ en Accessibility, Performance, SEO, Best Practices.

3.7.1 Accesibilidad en SPAs: el reto del foco y ARIA

En HTML estático, el navegador maneja el foco y la navegación por teclado automáticamente. En SPAs de React, tú CONTROLAS la navegación: cambias el contenido sin recargar, pero el foco se queda en el botón que disparó el cambio. Si no lo manejas, el usuario con lector de pantalla no sabe que el contenido cambió. Es el reto #1 de accesibilidad en SPAs. La solución: manejo explícito del foco + ARIA live regions.

El patrón: cuando el contenido cambia, mover el foco

import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';

// Patrón: cuando navegas a una nueva página, mueve el foco al H1
function DetallePais() {
  const navigate = useNavigate();
  const tituloRef = useRef(null);

  // Cuando el componente se monta, mueve el foco al H1 (después de un tick)
  useEffect(() => {
    // Pequeño delay para asegurar que el DOM esté listo
    setTimeout(() => tituloRef.current?.focus(), 100);
  }, []);

  return (
    <article>
      <h1 ref={tituloRef} tabIndex={-1}>{pais.name.common}</h1>
      {/* ... */}
    </article>
  );
}

// ARIA live regions: anuncia cambios dinámicos al lector de pantalla
function Buscador() {
  const [resultados, setResultados] = useState([]);
  const [mensaje, setMensaje] = useState('');

  useEffect(() => {
    if (resultados.length === 0) {
      setMensaje('No se encontraron resultados');
    } else {
      setMensaje(`${resultados.length} resultados encontrados`);
    }
  }, [resultados]);

  return (
    <div>
      <input type="search" aria-label="Buscar país" />
      {/* aria-live='polite' anuncia el mensaje cuando cambia */}
      <div role="status" aria-live="polite" className="sr-only">
        {mensaje}
      </div>
      {resultados.map(r => <PaisCard key={r.id} pais={r} />)}
    </div>
  );
}

// sr-only: clase que esconde visualmente pero es accesible para lectores de pantalla
// .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }

Manejo del foco y ARIA live regions en React

⭐ Mover el foco al H1 después de navegar: El patrón 'mover el foco al H1' después de una navegación es el ESTÁNDAR de accesibilidad en SPAs. Sin esto, un usuario con lector de pantalla hace clic en 'Ver detalle' y el lector sigue anunciando el botón anterior; no sabe que el contenido cambió. Es uno de los errores de accesibilidad más comunes en React. La solución: useRef + useEffect que mueve el foco al heading principal de la nueva página. El tabIndex={-1} permite que el H1 sea focuseable por JS pero NO por tab (para no romper el orden natural). Es el patrón que usan Linear, Notion, y la mayoría de SPAs profesionales.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

3.7.2 Navegación con teclado: skip links y focus traps

Un usuario con discapacidad visual o motora navega con teclado (Tab, Enter, Escape, flechas). Tu app debe soportarlo completamente. Las dos claves: skip links para saltar el menú, y focus traps en modales para que el usuario no se 'escape' a otro lado. Son detalles que la mayoría olvida, pero que hacen la diferencia entre 'usable con teclado' y 'frustrante con teclado'.

Skip Link: saltar al contenido principal

// components/SkipLink.jsx
import { Link } from 'react-router-dom';

export function SkipLink() {
  return (
    <a href="#main-content" className="skip-link">
      Saltar al contenido principal
    </a>
  );
}

// En tu layout principal
<body>
  <SkipLink />
  <Header />
  <main id="main-content" tabIndex={-1}>
    <Outlet />  {/* React Router inyecta la página aquí */}
  </main>
  <Footer />
</body>

/* CSS: el skip link es INVISIBLE hasta que recibe foco */
.skip-link {
  position: absolute;
  top: -100px;  /* fuera de la pantalla */
  left: 0;
  background: black;
  color: white;
  padding: 12px 24px;
  z-index: 1000;
}
.skip-link:focus {
  top: 0;  /* aparece cuando recibe foco con Tab */
}

Skip link: el primer elemento focuseable, oculto hasta que se usa

Focus trap en modales: que Tab se quede dentro del modal

// hooks/useFocusTrap.js
import { useEffect, useRef } from 'react';

export function useFocusTrap(isOpen) {
  const ref = useRef(null);

  useEffect(() => {
    if (!isOpen) return;

    const modal = ref.current;
    const focusable = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    // Guardar el elemento que tenía foco antes de abrir el modal
    const previouslyFocused = document.activeElement;

    // Mover el foco al primer elemento del modal
    first?.focus();

    function handleTab(e) {
      if (e.key !== 'Tab') return;
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();  // shift+tab desde el primero: salta al último
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();  // tab desde el último: vuelve al primero
      }
    }

    function handleEscape(e) {
      if (e.key === 'Escape') onClose();
    }

    modal.addEventListener('keydown', handleTab);
    document.addEventListener('keydown', handleEscape);

    return () => {
      modal.removeEventListener('keydown', handleTab);
      document.removeEventListener('keydown', handleEscape);
      previouslyFocused?.focus();  // restaurar el foco al cerrar
    };
  }, [isOpen]);

  return ref;
}

// Uso en un modal:
function Modal({ isOpen, onClose, children }) {
  const ref = useFocusTrap(isOpen);
  if (!isOpen) return null;
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div ref={ref} className="modal-content" role="dialog" aria-modal="true" onClick={e => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
}

useFocusTrap: que Tab se quede dentro del modal

⭐ Focus trap = UX con teclado en modales: El focus trap es CRÍTICO en modales: sin él, un usuario con teclado hace Tab dentro del modal, se 'escapa' al header o footer detrás del modal, y queda perdido. Es uno de los bugs de accesibilidad más reportados. La solución: capturar la tecla Tab y hacer que cuando llegue al último elemento del modal, vuelva al primero (y al revés con Shift+Tab). Es 15 líneas de código, pero mejora la experiencia del 100% de usuarios con teclado.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

3.7.3 SEO técnico para SPAs: meta tags, sitemap, robots

Google puede EJECUTAR JavaScript (con Googlebot), pero es LENTO y a veces no ejecuta todo. Para SEO profesional, una SPA debe tener: meta tags en cada página (con React Helmet), un sitemap.xml que liste todas las URLs, y un robots.txt que le diga a Google qué indexar. Es el SEO técnico mínimo para que tu app sea encontrable.

Meta tags dinámicos con react-helmet-async

// Instalar: npm install react-helmet-async
import { Helmet } from 'react-helmet-async';

function DetallePais() {
  const { data: pais } = useFetch(...);
  if (!pais) return null;
  const p = pais[0];

  return (
    <>
      <Helmet>
        {/* SEO básico */}
        <title>{p.name.common} - Atlas Mundial</title>
        <meta name="description" content={`Información sobre ${p.name.common}: capital ${p.capital[0]}, población ${p.population.toLocaleString()}.`} />
        <link rel="canonical" href={`https://atlas-mundial.com/pais/${p.cca3}`} />

        {/* Open Graph para redes sociales */}
        <meta property="og:title" content={`${p.name.common} - Atlas Mundial`} />
        <meta property="og:description" content={`Capital: ${p.capital[0]}, Población: ${p.population.toLocaleString()}`} />
        <meta property="og:image" content={p.flags.png} />
        <meta property="og:url" content={`https://atlas-mundial.com/pais/${p.cca3}`} />
        <meta property="og:type" content="article"} />

        {/* Twitter Card */}
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:title" content={p.name.common} />
        <meta name="twitter:image" content={p.flags.png} />
      </Helmet>

      <article>
        <h1>{p.name.common}</h1>
        {/* ... */}
      </article>
    </>
  );
}

// En main.jsx, envuelve tu app con HelmetProvider
import { HelmetProvider } from 'react-helmet-async';
ReactDOM.createRoot(document.getElementById('root')).render(
  <HelmetProvider>
    <BrowserRouter><App /></BrowserRouter>
  </HelmetProvider>
);

Meta tags dinámicos con react-helmet-async

⭐ react-helmet-async: SEO dinámico por página: Sin react-helmet-async, TODAS tus páginas tienen el mismo <title> y <meta description>: una catastrophe de SEO. Google ve un sitio con 50 páginas y todas con el mismo título, y lo rankea mal. react-helmet-async te permite tener meta tags DINÁMICOS por ruta: /pais/arg tiene un título sobre Argentina, /pais/mex sobre México. Es la diferencia entre un sitio encontrable y uno invisible en Google. Instálalo desde el INICIO de tu proyecto, no después.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

3.7.4 Open Graph y meta tags para redes sociales

Cuando compartes un link en WhatsApp, Twitter, LinkedIn o Facebook, ves una tarjeta con imagen, título y descripción. Eso lo controlas TÚ con los meta tags Open Graph. Sin ellos, WhatsApp muestra solo el URL pelao. Con ellos, tienes una 'mini-publicidad' de tu app cada vez que alguien comparte. Es una de las inversiones con mayor retorno: 10 minutos de configuración, años de mejor exposición.

Las 5 Open Graph tags que DEBES tener

  • og:title: el título que se muestra en la tarjeta (60 caracteres máx).
  • og:description: la descripción (155 caracteres máx).
  • og:image: la imagen (1200x630px para Facebook/LinkedIn, 800x418 para Twitter).
  • og:url: la URL canónica de la página.
  • og:type: 'website' para home, 'article' para posts, 'product' para productos.
⭐ Imagen de Open Graph: el banner gratis de tu app: La imagen de Open Graph es CRÍTICA: WhatsApp y Twitter la muestran a tamaño grande, y es lo primero que ve la persona. Usa SIEMPRE 1200x630px, JPEG optimizado, <200KB. Herramientas gratuitas para crear las imágenes: Canva, Figma. Un sitio con buena imagen de OG tiene 3x más clicks que uno sin ella. Es el 'mini-banner' de tu app, sin gastar dinero en publicidad.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

3.7.5 Core Web Vitals: las 3 métricas de Google

Google usa 3 métricas para rankear tu sitio: Largest Contentful Paint (LCP, qué tan rápido carga el contenido principal), First Input Delay (FID, qué tan rápido responde a interacciones), y Cumulative Layout Shift (CLS, qué tanto 'salta' el contenido). Son el triángulo de la performance web moderna. Si las 3 están en verde, tu sitio rankea mejor.

MétricaQué mideBuenoCómo optimizar
LCP (Largest Contentful Paint)Cuánto tarda en cargar el contenido principal visible.< 2.5 segundos.Optimiza imágenes, usa lazy loading, preconnect a CDNs.
FID (First Input Delay)Cuánto tarda en responder al primer click/tecla.< 100 ms.Reduce JS bloqueante, usa code splitting, evita tareas pesadas en main thread.
CLS (Cumulative Layout Shift)Cuánto 'salta' el contenido mientras carga.< 0.1.Define width/height en imágenes, no insertes contenido arriba, usa font-display: swap.
⭐ Core Web Vitals = SEO + UX al mismo tiempo: El 70% de los sitios fallan al menos 1 Core Web Vital, según el Chrome User Experience Report. Si tu sitio pasa las 3 (verde en Lighthouse), no solo rankeas mejor en Google: la experiencia del usuario es dramáticamente mejor. Usuarios abandonan sitios que tardan más de 3 segundos en mostrar contenido. Optimizar Core Web Vitals es literalmente ARREGLAR tu SEO Y tu UX al mismo tiempo. La regla: mide primero con Lighthouse, optimiza lo que esté en rojo, repite.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

3.7.6 Lighthouse: auditoría completa

Lighthouse es la herramienta de auditoría de Google: mide accesibilidad, performance, SEO, best practices, y PWA en tu sitio. Se ejecuta desde Chrome DevTools > Lighthouse. El estándar profesional: 95+ en las 4 categorías. Si tu sitio no pasa, NO lo publiques. Es la línea base de 'production-ready' en el frontend moderno.

Cómo correr Lighthouse + interpretar resultados

// 1. Abrir Chrome DevTools (F12)
// 2. Pestaña 'Lighthouse'
// 3. Marcar: Accessibility, Best Practices, SEO, Performance
// 4. Seleccionar 'Mobile' o 'Desktop'
// 5. Click 'Analyze page load'
// 6. Espera 30-60 segundos
// 7. Revisa el puntaje (0-100) en cada categoría

// O desde la línea de comandos (más detallado):
// npm install -g lighthouse
// lighthouse https://tu-app.com --view

// El estándar profesional:
// Accessibility >= 95 (ideal 100)
// Best Practices >= 95
// SEO >= 95
// Performance >= 90 (depende de la app)
//
// Si alguna está en rojo (< 50), hay problemas GRAVES.
// Si está en naranja (50-89), hay mejoras importantes.
// Si está en verde (90-100), tu sitio está production-ready.

Cómo correr Lighthouse y leer el reporte

⭐ Lighthouse >= 95 en las 4 categorías = production-ready: Lighthouse NO es opcional en una app profesional. Es el 'checklist' automático de Google: verifica accesibilidad WCAG, performance, SEO, y buenas prácticas en 30 segundos. Si tu sitio tiene < 90 en Accessibility, NO lo publiques. La regla del Capítulo 1: si tu portafolio no pasa Accessibility 95+, no es un portafolio profesional. La regla se aplica a TODA tu app. Lighthouse es tu salvavidas: ejecuta antes de cada deploy significativo. Lo que mide lo mejora.
🎬
Video del instructor

El administrador aún no ha insertado un video para esta sección.

📚 Contenido ampliado

Material adicional, referencias externas verificadas y ejemplos extendidos.

🤖 AI Mission

Lighthouse es tu auditor automático. Si no pasa 95+, no publiques.

Misión: Toma tu Atlas Mundial y mejórala hasta pasar 95+ en Lighthouse en las 4 categorías. Instala react-helmet-async para SEO, agrega skip link y focus management, optimiza las Core Web Vitals, y agrega los meta tags de Open Graph. Pídele a la IA que audite tu sitio. Anota: ¿cuál métrica fue la más difícil de optimizar?

Pasos sugeridos

  1. Parte del proyecto atlas-mundial.
  2. Instala: npm install react-helmet-async. Envuelve tu app con HelmetProvider en main.jsx.
  3. Agrega Helmet a DetallePais con title, meta description, og:title, og:description, og:image, og:url.
  4. Agrega Helmet a Home con title genérico y meta description.
  5. Crea un componente SkipLink y agrégalo al inicio del MainLayout. Acepta el id en <main>.
  6. Implementa useFocusTrap en tu Modal (si tienes) y mueve el foco al H1 después de cada navegación.
  7. Agrega aria-live='polite' a tu buscador para anunciar resultados al lector de pantalla.
  8. Crea una imagen Open Graph: 1200x630px con el logo de Atlas Mundial.
  9. Ejecuta Lighthouse en Chrome DevTools. Mide: Accessibility, Best Practices, SEO, Performance.
  10. Si Lighthouse < 95 en alguna categoría, identifica y corrige los problemas.
  11. Optimiza Core Web Vitals: lazy loading en imágenes, preconnect a APIs externas, evita JS bloqueante.
  12. Pídele a la IA: 'Tengo Lighthouse en mi app. Sugiere 2 mejoras de accesibilidad y 2 de performance. NO me des código, dime QUÉ cambiar y por qué.'
  13. Aplica las 4 mejoras.
  14. Anota en tu cuaderno: puntaje inicial vs final, cuál métrica fue la más difícil, qué aprendiste sobre accesibilidad que no sabías.

📓 Entregable: URL pública, capturas de Lighthouse con 95+ en las 4 categorías, código de Helmet y SkipLink, y 1 página de cuaderno con la comparativa de puntajes.

🚫 Errores típicos de razonamiento

Error 1: Tener el mismo <title> y meta description en TODAS las páginas.
Por qué: Sin react-helmet-async, TODAS las páginas tienen el mismo <title>. Google ve 50 páginas con el mismo título y las rankea mal. Un usuario que busca 'información de Argentina' nunca encuentra tu página. Es el error #1 de SEO en SPAs. Solución: Helmet con title y meta description DINÁMICOS por ruta.
Error 2: No tener meta tags de Open Graph (WhatsApp muestra el URL pelao).
Por qué: Sin og:title, og:description, og:image, cuando compartes tu link en WhatsApp/Twitter/LinkedIn se ve solo el URL. Es un 'mini-anuncio' perdido. Con Open Graph bien configurado, ves una tarjeta con imagen, título, y descripción. 3x más clicks. Es 10 minutos de configuración para años de mejor exposición.
Error 3: Insertar contenido arriba dinámicamente (causa layout shift).
Por qué: Si tu página carga el header, luego un banner arriba, luego el contenido, hay un 'salto' visible (CLS alto). Google penaliza esto: tu sitio rankea peor. Solución: define width/height en imágenes y contenedores, usa font-display: swap, y reserva espacio para contenido que se carga tarde. La regla: el layout no debe 'saltar' mientras carga.
Error 4: No manejar el foco en modales (usuarios con teclado quedan atrapados).
Por qué: Si abres un modal y el usuario hace Tab, el foco se 'escapa' al header detrás del modal. El usuario queda perdido. Solución: useFocusTrap que captura Tab y hace que se mantenga dentro del modal hasta cerrarlo. Es 15 líneas, mejora la experiencia del 100% de usuarios con teclado.
Error 5: No probar con teclado o lector de pantalla antes de hacer deploy.
Por qué: Si solo pruebas con mouse, te perderás el 30% de usuarios que usan teclado (discapacidad motora, power users). Si no pruebas con lector de pantalla (NVDA, VoiceOver), te perderás el 15% con discapacidad visual. Es como probar una app móvil solo en iPhone: te pierdes el 70% de usuarios Android. Herramientas: Lighthouse Accessibility, axe DevTools, y un test manual con Tab + lector de pantalla. Es 30 minutos que te ahorran horas de soporte.

🧪 Laboratorio práctico

Una app sin accesibilidad es una app que excluye al 15% de la humanidad.

Laboratorio: Atlas Mundial con accesibilidad WCAG AA + SEO técnico + Lighthouse 95+

Objetivo: Llevar Atlas Mundial al nivel production-ready en accesibilidad y SEO: skip link, focus management, ARIA, meta tags dinámicos con Helmet, Open Graph, sitemap.xml, y robots.txt. El resultado: 95+ en Lighthouse en las 4 categorías, accesible con teclado, encontrable en Google.

Pasos

  1. Instala: npm install react-helmet-async.
  2. Envuelve App con HelmetProvider en main.jsx.
  3. Agrega Helmet a cada página con title, meta description, og:title, og:description, og:image, og:url, og:type.
  4. Crea public/og-image.png: 1200x630px con logo + texto descriptivo.
  5. Crea un componente SkipLink. Agrégalo al inicio del MainLayout. El <main> tiene id='main-content' y tabIndex={-1}.
  6. Implementa useFocusTrap en tu Modal. Mueve el foco al H1 después de cada navegación de React Router.
  7. Agrega aria-live='polite' a tu buscador. Agrega aria-label a inputs sin label visible. Agrega role='status' a mensajes de cargando.
  8. Crea public/sitemap.xml con todas tus rutas: <url><loc>https://tu-dominio.com/</loc></url>.
  9. Crea public/robots.txt: User-agent: * / Allow: / / Sitemap: https://tu-dominio.com/sitemap.xml.
  10. Ejecuta Lighthouse en Chrome DevTools. Mide las 4 categorías. Anota los puntajes.
  11. Si alguna < 95, identifica los problemas y corrige uno por uno (Lighthouse te dice exactamente qué).
  12. Prueba con teclado: Tab debe llevarte a través de toda la página, Enter debe activar, Escape debe cerrar modales.
  13. Sube a Vercel. Comparte URL.
  14. Haz commit: 'feat: accesibilidad WCAG AA + SEO técnico + Lighthouse 95+'.

📓 Entregable: URL pública con Lighthouse 95+, capturas de las 4 categorías, código de los meta tags, screenshot del sitemap.xml y robots.txt, y commit en Git.

📓 Tu cuaderno: Anota pseudocódigo, diagramas, errores que encontraste y respuestas a "explica sin código". La escritura manual refuerza tu razonamiento. Tu profesor puede pedirte que subas fotos de páginas específicas.