Pestañas y acordeones sin bibliotecas: details/summary y mejora progresiva

¿Te fue útil?

En algún lugar de tu producción hay una página cuyo acordeón de preguntas frecuentes “simple” arrastra 180KB de JavaScript, bloquea el renderizado y todavía falla en la navegación por teclado. Se nota: la paciencia del usuario se va, tu puntuación en Lighthouse baja y el futuro de on-call recibe un nuevo ticket titulado “El acordeón no abre en iPad”.

Esta es la mejor manera: envía HTML semántico primero y luego mejora. Usa <details>/<summary> cuando encaje, y solo escribe JavaScript donde realmente aporte valor.


El enfoque: mejora progresiva, no arrepentimiento progresivo

Si construyes componentes de UI para la web, no solo envías píxeles. Envías modos de fallo: cómo se comporta la página cuando el JavaScript carga tarde, cuando el CSS no carga, cuando el usuario navega con teclado, cuando el navegador es viejo, cuando el proxy corporativo inyecta basura o cuando tu propio empaquetador dobla silenciosamente la carga porque alguien importó una utilidad del “paquete de UI compartido”.

La mejora progresiva es la disciplina aburrida de hacer que la experiencia base funcione con las mínimas suposiciones. Luego la mejoras por capacidad, no por moda. La línea base debería ser legible, navegable y operable con la pila más primaria que toleres: HTML renderizado en servidor, CSS mínimo y cero dependencias de JavaScript para la interacción esencial cuando sea posible.

Opinión: Si tu acordeón requiere un framework JavaScript para abrirse, no es un acordeón. Es un rótulo luminoso que dice “Soy una máquina de estados sin plan de respaldo”.

El nativo <details>/<summary> es la historia de mejora progresiva más limpia para disclosures y acordeones. Las pestañas son más complicadas: no existe un elemento nativo “tab”, e intentar forzar <details> en la semántica de pestañas es como tener una UI que parece pestañas pero se lee como un montón de toggles.

Dos objetivos guían todo aquí:

  • Resiliencia: si los scripts fallan, el contenido sigue siendo accesible y la interacción sigue teniendo sentido.
  • Observabilidad: si se rompe, puedes diagnosticarlo rápido con herramientas normales, no con un sacerdote y un bundle minificado.

Y sí, aún puedes tener animaciones agradables, enlaces profundos por URL y comportamiento de “solo uno abierto”. Simplemente gánatelo de forma incremental.

Una cita para el camino, porque sigue siendo verdad en el mundo UI: “La esperanza no es una estrategia.” — Vince Lombardi

Chiste #1: Una librería de UI es como una planta de interior: parece inocua hasta que te das cuenta de que necesita atención constante y de algún modo atrae bugs.


Hechos y contexto histórico para usar en argumentos

Esto no es trivia por gusto. Son los tipos de hechos que te ayudan a ganar una revisión de diseño y evitan que los equipos cargo-culten una librería pesada “porque todo el mundo lo hace”.

  1. <details> es un elemento HTML real. No es un div con actitud; es un widget de disclosure estandarizado con alternancia incorporada y un mapeo de accesibilidad definido en navegadores modernos.
  2. El comportamiento inicial entre navegadores fue inconsistente. Durante años, algunos motores trataban el enfoque/teclado de <summary> de manera distinta; muchos equipos escribieron polyfills. Hoy es mayormente estable, pero las suposiciones heredadas persisten en código antiguo.
  3. El patrón “acordeón” es anterior a las apps web. Las interfaces de escritorio tenían triángulos de disclosure y paneles expansibles mucho antes de los SPA; la web se pone al día reutilizando ese mismo modelo mental, ahora con semántica.
  4. Pestañas y acordeones sirven para trabajos distintos. Las pestañas implican una vista “actual” entre pares; los acordeones implican paneles expandibles múltiples. Los usuarios los interpretan distinto y los lectores de pantalla los anuncian distinto.
  5. La mejora progresiva precede a los frameworks modernos. Surgió de la realidad de que la web es heterogénea: redes lentas, soporte parcial y fallos son normales, no casos extremos.
  6. ARIA no reemplaza la semántica. ARIA puede describir comportamiento, pero si puedes usar un elemento nativo, normalmente deberías. ARIA es afilada; corta en ambos sentidos.
  7. Las pestañas de frameworks a menudo fallan cuando se hidratan tarde. El servidor renderiza “Pestaña A”, el cliente hidrata “Pestaña B” y obtienes un parpadeo más desajuste de estado. El disclosure nativo evita mucho de esto porque el navegador posee el estado base.
  8. El deep-linking es un requisito recurrente. Los equipos de producto adoran “compartir un enlace a la tercera panel”. Si lo ignoras, alguien añadirá lógica de hash después y creará una nueva categoría de bugs.

Acordeones con details/summary: la elección por defecto

Usa <details> cuando tengas un encabezado que alterna la visibilidad de contenido. Eso es todo. Si la interacción es “clic en el título para mostrar/ocultar el cuerpo”, no te negocies: empieza con <details>.

HTML base que funciona dondequiera que funcione el contenido

Esta base no depende de CSS ni de JavaScript. Si los estilos fallan, el contenido sigue siendo lineal y legible. Si los scripts fallan, el disclosure aún se abre y cierra.

cr0x@server:~$ cat accordion.html
<section aria-label="Shipping FAQs">
  <h2>Shipping</h2>

  <details>
    <summary>When do you ship?</summary>
    <p>Orders placed before 2pm ship the same business day.</p>
  </details>

  <details>
    <summary>Do you ship internationally?</summary>
    <p>Yes. Duties and taxes are calculated at checkout when available.</p>
  </details>

  <details open>
    <summary>How do returns work?</summary>
    <p>Start a return within 30 days. We email a label if eligible.</p>
  </details>
</section>

Fíjate en el atributo open. No está ahí solo por decoración: es tu estado por defecto renderizado en servidor, y es una salida útil para “primer elemento abierto por defecto” sin scripting.

Estilizar details/summary sin romperlo

La forma rápida de arruinar <details> es quitarle el marcador a <summary>, eliminar su outline de enfoque y luego reemplazarlo por un div porque “diseño”. No lo hagas. Puedes estilizarlo y mantenerlo operativo.

  • Mantén un indicador de foco visible en summary.
  • Si personalizas el marcador, hazlo con CSS, no quitando la semántica.
  • No pongas controles interactivos dentro de summary a menos que hayas probado el comportamiento por teclado a fondo (spoiler: se pone raro).

Comportamiento “solo uno abierto” en el acordeón (opcional)

El <details> nativo no impone la regla de “solo uno abierto”. Eso es bueno: no asume tu UX. Si tus diseñadores insisten en el comportamiento clásico (abrir uno cierra los demás), mejóralo con un script pequeño que escuche toggle.

Principio clave: si el script falla, los usuarios aún pueden abrir múltiples paneles. Ese es un fallback sensato.

cr0x@server:~$ cat accordion-one-open.js
document.addEventListener("DOMContentLoaded", () => {
  document.querySelectorAll("[data-accordion]").forEach((root) => {
    const items = Array.from(root.querySelectorAll("details"));
    root.addEventListener("toggle", (e) => {
      const target = e.target;
      if (!(target instanceof HTMLDetailsElement)) return;
      if (!target.open) return;

      for (const item of items) {
        if (item !== target) item.open = false;
      }
    });
  });
});

Adjúntalo así:

cr0x@server:~$ cat accordion-enhanced.html
<section data-accordion aria-label="Billing FAQs">
  <h2>Billing</h2>
  <details>
    <summary>Can I get an invoice?</summary>
    <p>Invoices are available in your account within 24 hours.</p>
  </details>
  <details>
    <summary>Do you support ACH?</summary>
    <p>Yes for annual plans; contact support to enable it.</p>
  </details>
</section>

Esto es mejora progresiva bien hecha: el comportamiento base es nativo; el comportamiento mejorado es aditivo; el modo de fallo es aceptable.


Mejoras progresivas que no sabotean la resiliencia

Las mejoras son donde los equipos accidentalmente reconstruyen una librería UI y luego se preguntan por qué se comporta como una. Si estás mejorando <details>, conserva la forma del DOM y la interacción nativa. No luches contra el elemento.

Mejora 1: animar apertura/cierre sin jank

Animar la altura es la trampa clásica. Dispara layout, puede dar jank bajo carga y suele romper cuando el contenido es dinámico. Si debes animar, prefiere animar la opacidad y una pequeña transformada, o usa el enfoque CSS más nuevo con content-visibility para paneles grandes. Manténlo sutil.

También: no animes de forma que retrases el contenido para tecnologías asistivas. La animación es adorno; la accesibilidad es la comida.

Mejora 2: deep-link a un panel específico

Cuando alguien comparte “ver la tercera pregunta”, se refiere a una URL estable que abra el panel correcto. Hazlo con IDs y lógica de fragmentos.

  • Da a cada <details> un id estable.
  • Al cargar, si location.hash coincide con un ID de details, ábrelo y haz scroll hasta él.
cr0x@server:~$ cat accordion-deeplink.js
document.addEventListener("DOMContentLoaded", () => {
  const id = decodeURIComponent(location.hash.replace(/^#/, ""));
  if (!id) return;

  const el = document.getElementById(id);
  if (el && el.tagName === "DETAILS") {
    el.open = true;
    el.scrollIntoView({ block: "start" });
    el.querySelector("summary")?.focus();
  }
});

Modo de fallo si este script no carga: la página sigue funcionando; el hash simplemente no autoabre. Eso es tolerable.

Mejora 3: analítica sin convertir la UI en un motor de telemetría

Te pedirán registrar qué paneles abren los usuarios. Bien. Usa el evento nativo toggle; no adjuntes manejadores de click a summary que anulen el comportamiento por defecto. Tu trabajo es observar, no tomar el volante.

cr0x@server:~$ cat accordion-analytics.js
document.addEventListener("DOMContentLoaded", () => {
  document.body.addEventListener("toggle", (e) => {
    const d = e.target;
    if (!(d instanceof HTMLDetailsElement)) return;
    if (!d.id) return;

    const payload = { id: d.id, open: d.open, ts: Date.now() };
    navigator.sendBeacon?.("/ui/toggle", JSON.stringify(payload));
  }, true);
});

Si sendBeacon no está disponible, nada se rompe. Pierdes analítica, no UX.

Mejora 4: impresión y sanity check sin CSS

Las reglas de impresión importan más de lo que crees, porque “imprimir” a menudo significa “guardar como PDF”, y “guardar como PDF” a menudo significa “adjuntar a un ticket de cumplimiento”. Asegura que los paneles abiertos impriman expandidos, o elige la regla “imprimir todo expandido”.

Un patrón pragmático: en el CSS de impresión, fuerza todos los details abiertos.


Pestañas: cuando details no sirve, y cómo hacerlo bien

Las pestañas no son acordeones. Las pestañas son un widget de selección única: una pestaña está activa y su panel es la vista actual. Eso importa para tecnologías asistivas, convenciones de teclado y la expectativa general del usuario. Intentar falsificar pestañas con múltiples <details> te da un widget multi-abierto que parece pestañas pero se comporta como una pila de toggles.

¿Cuál es la base para pestañas si no usamos una librería?

La base: una lista simple de enlaces

La base más resiliente para pestañas es… no pestañas. Es un conjunto de enlaces a secciones en la página (o páginas separadas). Carga rápido, funciona con todo y es trivialmente deep-linkable.

cr0x@server:~$ cat tabs-baseline.html
<nav aria-label="Account sections">
  <ul>
    <li><a href="#profile">Profile</a></li>
    <li><a href="#security">Security</a></li>
    <li><a href="#billing">Billing</a></li>
  </ul>
</nav>

<section id="profile">
  <h2>Profile</h2>
  <p>Update your name and contact details.</p>
</section>

<section id="security">
  <h2>Security</h2>
  <p>Manage sessions and multi-factor authentication.</p>
</section>

<section id="billing">
  <h2>Billing</h2>
  <p>Invoices, payment methods, and plan changes.</p>
</section>

Esta es la opción “funciona en un navegador de texto”. Y sí, en la realidad corporativa a veces eso es lo que te salva: un bundle roto no debería bloquear a un cliente de encontrar “Restablecer MFA”.

Mejorar progresivamente hacia pestañas reales (ARIA + JS mínimo)

Cuando realmente necesitas pestañas —porque el contenido son paneles pares y la UI es densa— mejora desde la base. Mantén las mismas secciones con IDs. Añade una tablist construida a partir de esos enlaces. Luego oculta/muestra paneles con JavaScript. Si JS falla, el usuario aún tiene los enlaces y las secciones.



Profile content. In your real app, this would be real forms, not vibes.

Y el script:

cr0x@server:~$ cat tabs.js
function activateTab(tab, tabs, panels, { focus = true } = {}) {
  for (const t of tabs) t.setAttribute("aria-selected", String(t === tab));
  for (const p of panels) p.hidden = (p.id !== tab.getAttribute("aria-controls"));

  if (focus) tab.focus();
}

document.addEventListener("DOMContentLoaded", () => {
  document.querySelectorAll('[role="tablist"]').forEach((tablist) => {
    const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
    const panels = tabs
      .map(t => document.getElementById(t.getAttribute("aria-controls")))
      .filter(Boolean);

    tablist.addEventListener("click", (e) => {
      const tab = e.target.closest('[role="tab"]');
      if (!tab) return;
      activateTab(tab, tabs, panels);
      history.replaceState(null, "", "#" + tab.id);
    });

    tablist.addEventListener("keydown", (e) => {
      const current = document.activeElement.closest?.('[role="tab"]');
      if (!current) return;

      const i = tabs.indexOf(current);
      if (e.key === "ArrowRight" || e.key === "ArrowDown") {
        e.preventDefault();
        activateTab(tabs[(i + 1) % tabs.length], tabs, panels);
      } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
        e.preventDefault();
        activateTab(tabs[(i - 1 + tabs.length) % tabs.length], tabs, panels);
      } else if (e.key === "Home") {
        e.preventDefault();
        activateTab(tabs[0], tabs, panels);
      } else if (e.key === "End") {
        e.preventDefault();
        activateTab(tabs[tabs.length - 1], tabs, panels);
      }
    });

    // Deep-link: open tab by #tab-id
    const hash = decodeURIComponent(location.hash.replace(/^#/, ""));
    if (hash) {
      const t = document.getElementById(hash);
      if (t && tabs.includes(t)) activateTab(t, tabs, panels, { focus: false });
    }
  });
});

Ese es el núcleo: estado seleccionado, panels ocultos, navegación por teclado y deep-linking. Sin framework. Sin sistema de reactividad. Sin “store” de pestañas.

Chiste #2: Si construyes pestañas con un bus de eventos global, felicitaciones—has inventado una tostadora que necesita Kubernetes.

¿Por qué no pestañas solo con CSS?

Las pestañas solo con CSS usando radios pueden funcionar, pero suelen ser frágiles y torpes para deep-linking, integración con el historial y consistencia con tecnologías asistivas. Úsalas para widgets de marketing pequeños si hace falta. Para UI de producto, un script de 40 líneas suele ser más robusto y mucho más fácil de depurar.


Accesibilidad: qué garantizar, qué probar

La accesibilidad no es “añadir ARIA y listo”. Es asegurar que los usuarios puedan operar la UI con teclado y tecnologías asistivas, y que los anuncios coincidan con lo que sucede.

Para details/summary

  • Soporte de teclado: summary debe ser enfocable y alternable vía teclado. Los navegadores modernos gestionan esto, a menos que lo rompas con estilos o manejadores de eventos.
  • Foco visible: nunca elimines los outlines de foco sin proporcionar un reemplazo claro.
  • Área clicable: mantén el summary como el principal objetivo de toque.
  • Controles interactivos anidados: evita poner botones/enlaces dentro de summary; si es inevitable, prueba exhaustivamente porque los clics y toggles pueden entrar en conflicto.

Para pestañas

  • Roles y relaciones: tablist contiene elementos tab; cada tab aria-controls un tabpanel; cada panel aria-labelledby el tab.
  • Estado seleccionado: solo una pestaña debe tener aria-selected="true".
  • Convenciones de teclado: las flechas mueven entre pestañas; Home/End saltan; Enter/Espacio activan (manual) o no (automático) según tu elección. Elige una y sé consistente.
  • Paneles ocultos: usa el atributo hidden para que los paneles ocultos no estén en el árbol de accesibilidad.

Opinión: No pongas role="tab" a un enlace a menos que estés anulando intencionalmente el comportamiento de navegación. Una pestaña no es un enlace de navegación; trátala como un control.

Qué probar en la práctica

Probar accesibilidad es menos místico de lo que la gente pretende. Estás verificando mecánicas previsibles:

  • La tecla Tab mueve el foco a los controles summary/tab en un orden sensato.
  • Enter/Espacio alternan el <details> abierto/cerrado.
  • Las flechas mueven entre pestañas; el foco no se pierde en contenido oculto.
  • El lector de pantalla anuncia el tipo de control y el estado (expandido/colapsado; pestaña seleccionada).

Tareas prácticas: comandos, salidas y decisiones

Estas son las tareas que ejecutas cuando un “acordeón simple” se convierte en un problema en producción. Cada una incluye un comando realista, qué significa la salida y qué decisión tomar a continuación. Aquí es donde el frontend se encuentra con SRE: no adivinas, mides.

Tarea 1: Verificar que el HTML contenga realmente details/summary (sin renderizado solo en cliente)

cr0x@server:~$ curl -sS https://app.example.internal/faq | grep -n "<details" | head
142:<details id="returns">
163:<details id="shipping">

Significado: La respuesta del servidor ya incluye los widgets de disclosure. Buena base. Decisión: Puedes confiar en la mejora progresiva; una caída de JS no dejará en blanco las FAQ.

Tarea 2: Detectar si se está cargando una librería UI solo por acordeón/pestañas

cr0x@server:~$ curl -sS https://app.example.internal/faq | grep -Eo 'src="[^"]+\.js"' | head
src="/assets/runtime-8a1c.js"
src="/assets/vendor-2b19.js"
src="/assets/faq-41d0.js"

Significado: Hay un bundle vendor en la página de FAQ. Decisión: Audita su contenido; si es mayormente runtime del framework UI, considera servir HTML plano para esta página.

Tarea 3: Cuantificar payload JS y cabeceras de cache

cr0x@server:~$ curl -sSI https://app.example.internal/assets/vendor-2b19.js | sed -n '1,12p'
HTTP/2 200
content-type: application/javascript
cache-control: public, max-age=31536000, immutable
content-length: 286401
etag: "vendor-2b19"

Significado: ~280KB vendor JS, cacheable a largo plazo. No es catastrófico, pero mucho para “FAQ accordion”. Decisión: Si esta página es de alto tráfico o se usa durante respuesta a incidentes, elimina JS innecesario y mantenla estática.

Tarea 4: Identificar si el comportamiento del acordeón es JS personalizado que sobreescribe lo nativo

cr0x@server:~$ rg -n "preventDefault\\(\\)" public/assets/faq-41d0.js | head
1187:e.preventDefault();

Significado: El script previene el comportamiento por defecto en algo—probablemente click en summary. Decisión: Inspecciona el manejador; elimina la prevención por defecto salvo que sea absolutamente necesaria.

Tarea 5: Confirmar que la página funciona con JavaScript deshabilitado (chequeo headless)

cr0x@server:~$ chromium --headless --disable-gpu --dump-dom https://app.example.internal/faq | grep -n "<summary" | head
143:<summary>How do returns work?</summary>
164:<summary>Do you ship internationally?</summary>

Significado: Los summary están presentes en el volcado del DOM. Decisión: Si la interacción falla solo con JS activado, el bug está en el código de mejora, no en la base.

Tarea 6: Comprobar IDs duplicados que rompen el deep-linking

cr0x@server:~$ chromium --headless --disable-gpu --dump-dom https://app.example.internal/faq | grep -o 'id="[^"]*"' | sort | uniq -d | head
id="shipping"

Significado: Existe un id="shipping" duplicado. Decisión: Arregla las plantillas para garantizar IDs únicos; el deep-linking y las relaciones de etiquetado dependen de ello.

Tarea 7: Detectar thrash de layout en el script de mejora (pista de profile)

cr0x@server:~$ rg -n "getBoundingClientRect\\(|offsetHeight|scrollHeight" tabs.js accordion-one-open.js

Significado: No hay lecturas de layout directas en estos scripts pequeños. Decisión: Si ves estas llamadas dentro de bucles o manejadores toggle, espera jank; reescribe la lógica de animación.

Tarea 8: Confirmar que la CSP no bloquea tus scripts de mejora pequeños

cr0x@server:~$ curl -sSI https://app.example.internal/faq | grep -i content-security-policy
content-security-policy: default-src 'self'; script-src 'self'; object-src 'none'

Significado: Es probable que los scripts inline estén bloqueados; los scripts externos desde self están permitidos. Decisión: Entrega las mejoras como archivos estáticos, no como blobs inline <script>.

Tarea 9: Verificar que los paneles de pestañas estén ocultos del árbol de accesibilidad

cr0x@server:~$ chromium --headless --disable-gpu --dump-dom https://app.example.internal/account | grep -n 'role="tabpanel"' | head
88:<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
92:<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>

Significado: Los paneles no activos tienen hidden. Decisión: Sigue usando hidden en lugar de ocultar solo con CSS para paneles de pestañas.

Tarea 10: Encontrar listeners que puedan multiplicarse (double-binding en rerender)

cr0x@server:~$ rg -n "addEventListener\\(\"click\"|addEventListener\\(\"toggle\"" public/assets/*.js | head
public/assets/faq-41d0.js:221:addEventListener("click", function(e){
public/assets/faq-41d0.js:489:addEventListener("click", function(e){

Significado: Múltiples listeners de click en el mismo bundle. No es automáticamente malo, pero sospechoso en una página simple. Decisión: Asegura que los listeners estén delegados una vez por contenedor, no re-adjuntados por elemento en cada render.

Tarea 11: Medir TTFB y tiempo de descarga de contenido para separar red vs JS

cr0x@server:~$ curl -o /dev/null -sS -w 'ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n' https://app.example.internal/faq
ttfb=0.084531 total=0.129774 size=40218

Significado: El servidor es rápido; la red no es el problema. Decisión: Si la UX es lenta, céntrate en assets que bloquean el render y en JS del hilo principal.

Tarea 12: Comprobar si el script de mejora bloquea el render

cr0x@server:~$ curl -sS https://app.example.internal/faq | grep -n "<script" | head
35:<script src="/assets/vendor-2b19.js"></script>
36:<script src="/assets/faq-41d0.js"></script>

Significado: Los scripts se cargan sin defer ni type="module". Bloquean el parseo. Decisión: Añade defer a scripts no críticos, especialmente a código de mejora.

Tarea 13: Validar compresión gzip/brotli para payload JS

cr0x@server:~$ curl -sSI -H 'Accept-Encoding: br' https://app.example.internal/assets/vendor-2b19.js | grep -iE 'content-encoding|content-length'
content-encoding: br
content-length: 68421

Significado: Brotli reduce significativamente el tamaño transferido. Decisión: Si falta compresión, arregla la configuración del CDN/servidor antes de discutir micro-optimizaciónes.

Tarea 14: Confirmar que el comportamiento “uno abierto” no se impone con hacks CSS

cr0x@server:~$ rg -n "details\\[open\\].*~.*details\\[open\\]" public/assets/*.css | head

Significado: No se encontró hack de hermanos en CSS. Decisión: Usa el pequeño enfoque en JS; el CSS no puede imponer de forma fiable “solo uno abierto” en layouts arbitrarios.


Guion de diagnóstico rápido

Cuando las pestañas o acordeones “a veces no funcionan”, la peor jugada es mirar el código primero. Diagnostica como un operador: reduce la clase de fallo en minutos.

Primero: ¿el HTML base es correcto?

  • Comprobar: Ver fuente (no el inspector) y confirmar que existen <details>/<summary>, o que el contenido de la pestaña existe como secciones normales.
  • Si falta: Estás haciendo renderizado solo en cliente. Decide si eso es aceptable para este componente. Para FAQ y ayuda, normalmente no lo es.

Segundo: ¿JavaScript está bloqueando la interacción por accidente?

  • Comprobar: Busca preventDefault() en clicks de summary, handlers globales de click o overlays que intercepten clicks.
  • Si está presente: Remueve/limita el handler. El <details> nativo no debería requerir cableado de click.

Tercero: ¿es el problema de estado, hidratación o duplicación?

  • Comprobar: IDs duplicados, bindings múltiples de eventos y desajuste servidor/cliente en estado inicial abierto/seleccionado.
  • Si hay desajuste: Haz que el servidor emita el estado inicial canónico (p. ej., añadir atributo open; seleccionar la primera pestaña) y deja que el cliente mejore sin reescribir el historial.

Cuarto: ¿el cuello de botella es rendimiento o corrección?

  • Comprobar: ¿Las interacciones se retrasan (hilo principal ocupado) o están rotas (no hay toggle)?
  • Si hay retraso: Profilea tareas largas; elimina librería pesada de páginas de disclosure; difiere scripts.
  • Si está roto: Céntrate primero en el manejo de eventos y la estructura del DOM; las mejoras de rendimiento no repararán semántica incorrecta.

Errores comunes: síntomas → causa raíz → solución

Esta sección existe porque estos bugs vuelven a aparecer en equipos como una gripe estacional, excepto que la cura es disciplina.

1) “El acordeón no abre en algunos dispositivos”

Síntoma: Al hacer clic en el summary no pasa nada, o se abre y luego se cierra.

Causa raíz: Un handler de click en summary llama a preventDefault() o alterna open dos veces (una por el navegador, otra por script). A menudo introducido por analítica o “animación personalizada”.

Solución: Elimina la prevención por defecto; escucha toggle en details en su lugar. Si necesitas control manual, deja de depender de toggle nativo y acepta que estás construyendo un componente personalizado (y entonces prueba como tal).

2) “Usuarios por teclado no pueden operar el acordeón”

Síntoma: La tecla Tab salta los summaries, o el foco es invisible.

Causa raíz: CSS eliminó outlines; la visualización de summary cambió de forma que rompe el foco; summary fue reemplazado por un div.

Solución: Mantén el verdadero <summary>. Restaura estilos de foco. Prueba con teclado antes de mergear.

3) “Los deep links abren el panel equivocado”

Síntoma: El fragmento de URL apunta a un panel, pero se abre otro, o el scroll salta raro.

Causa raíz: IDs duplicados o scripts que reescriben location.hash al cargar sin verificar el destino.

Solución: Garantiza IDs únicos; solo llama a history.replaceState en acción explícita del usuario; valida los targets del hash.

4) “Las pestañas parpadean al cargar”

Síntoma: Todos los paneles se muestran brevemente y luego uno se oculta; o la pestaña activa cambia después de un momento.

Causa raíz: La renderización base muestra todas las secciones; la mejora con JS aplica el ocultamiento después del layout; desajuste de hidratación o script que carga tarde.

Solución: Empieza con el estado “solo activo” renderizado en servidor si es posible (p. ej., añadir atributos hidden) o aplica una pequeña clase inline antes del paint—si la CSP lo permite—o usa defer más CSS que oculte paneles solo cuando exista la clase “js-enabled”.

5) “El comportamiento ‘solo uno abierto’ se cierra inesperadamente”

Síntoma: Al abrir un panel se cierra inmediatamente, o al alternar uno se cierra otro acordeón en otra parte.

Causa raíz: La delegación de eventos está atada a document y no está limitada; el handler recoge details de toda la página.

Solución: Limítalo a un contenedor con data-accordion; cierra solo siblings dentro de ese contenedor.

6) “El lector de pantalla anuncia roles extraños o repite encabezados”

Síntoma: Los paneles de pestañas se anuncian incluso cuando están ocultos; las pestañas se anuncian como enlaces; etiquetas repetidas.

Causa raíz: Ocultamiento solo con CSS; relaciones ARIA incorrectas; uso inconsistente de display:none; reutilizar IDs en múltiples tablists.

Solución: Usa hidden para paneles inactivos; exige IDs únicos por instancia de pestañas; valida pares aria-controls y aria-labelledby.


Tres mini-historias corporativas (anonimizadas)

Incidente: una suposición errónea sobre “JS siempre carga”

Una SaaS B2B mediana tenía una página de “Recuperación de cuenta” con pestañas: “Email”, “Authenticator”, “SSO fallback”. El equipo de producto quería algo elegante, así que usaron un componente del bundle principal. Renderizaba bien en staging, en la Wi‑Fi de la oficina y en dev local donde todo es instantáneo y nadie tiene cinco VPNs.

Entonces una red de cliente empezó a bloquear un dominio de tercero usado por un script de analítica no relacionado. El navegador esperó, reintentó y retrasó la ejecución del bundle principal de forma que variaba por dispositivo. Para un conjunto de usuarios, la UI de “pestañas” cargó tan tarde que el HTML base—básicamente placeholders vacíos—fue todo lo que tuvieron. Las opciones de recuperación eran invisibles. La gente quedó bloqueada y furiosa, lo cual es un tipo especial de ticket.

La suposición equivocada no fue “la analítica puede ser bloqueada”. Todo el mundo lo sabe. La suposición equivocada fue que la interacción crítica puede ser solo cliente. La solución no fue heroica: renderizar en servidor el contenido de recuperación como secciones normales con navegación por anclas, y luego mejorar a pestañas solo si JS carga. La próxima vez que un proxy corporativo se enfureció, la recuperación siguió funcionando. Nadie llamó al on-call.

Optimización que salió mal: pestañas solo con CSS usando radios

Un equipo de comercio decidió eliminar JavaScript de un widget de pestañas de la página de producto (“Descripción”, “Especificaciones”, “Garantía”). Buena intuición. Desgraciadamente, eligieron el truco de radio-button con CSS y lo pusieron en un partial usado en todo el sitio—incluido en una vista de comparación donde se renderizaban 20 productos en la misma página.

Los inputs radio requieren grupos de name únicos e IDs únicos para mantener las labels atadas al input correcto. El partial usaba IDs estáticos porque “es solo un componente”. En páginas de producto individual se veía bien. En la vista de comparación, al pulsar “Especificaciones” del producto A se activaba el panel “Garantía” del producto B. Los usuarios creyeron que la página estaba poseída, lo cual es técnicamente exacto.

La solución fue dejar de fingir que “solo CSS” significa libre de complejidad. Pasaron a una base con anclas (cada sección tenía un ID) y una pequeña mejora con scope que actualizaba un solo contenedor a pestañas. Los IDs se namespaced por identificador del producto. La “optimización” se volvió una mejora real: menos JS que el widget del framework, y más corrección que el hack de CSS.

Práctica aburrida pero correcta que salvó el día: scoping y valores por defecto

En una firma de servicios financieros, una revisión de seguridad exigió una CSP más estricta. Los scripts inline no estaban permitidos. Algunos equipos entraron en pánico porque sus componentes UI dependían de inicializadores inline por página. Un equipo no entró en pánico porque sus patrones de interacción estaban construidos sobre mejora progresiva con scripts externos y scoping estricto.

Sus páginas con mucho disclosure usaban <details> nativos. Las mejoras (comportamiento uno-abierto, apertura por deep-link, analítica) se entregaron como archivos estáticos versionados con defer. La inicialización era por contenedor: cada acordeón tenía data-accordion, cada tablist era local y los listeners se delegaban dentro del componente.

Cuando llegó el cambio de CSP, esas páginas siguieron funcionando. Sin solicitudes de excepción de emergencia. Sin relajaciones temporales del header que se vuelven permanentes. Solo un despliegue aburrido y verde. En entornos corporativos, lo aburrido es una característica por la que debes luchar.


Listas de verificación / plan paso a paso

Paso a paso: enviar un acordeón de forma resiliente

  1. Empieza con HTML: Usa <details>/<summary>. Pon contenido real dentro. No placeholders como única fuente de verdad.
  2. Decide el estado por defecto: Añade open en el panel que quieras expandido por defecto (o ninguno).
  3. Estiliza con cuidado: Mantén el foco visible; no elimines semántica; evita controles interactivos dentro de summary.
  4. Añade mejoras solo cuando se pidan: comportamiento uno-abierto, apertura por deep-link, analítica. Mantén cada mejora independiente.
  5. Difiere scripts: Añade defer y mantiene los scripts de mejora pequeños y locales.
  6. Prueba modos de fallo: JS deshabilitado, CSS deshabilitado (o bloqueado), red lenta. Confirma que el contenido sigue siendo accesible.
  7. Fija los IDs: Asegura IDs únicos y estables si necesitas deep links o analítica.

Paso a paso: convertir navegación por anclas en pestañas

  1. Secciones base: Usa <section id="..."><h2>...</h2> para cada panel.
  2. Navegación base: Proporciona un <nav> con una lista de enlaces a esos IDs.
  3. Capa de mejora: Reemplaza/añade nav con un role="tablist" de botones cuando JS esté disponible.
  4. Oculta paneles correctamente: Usa hidden en paneles inactivos, no solo CSS.
  5. Soporte de teclado: Flechas, Home/End. No lo pases por alto; es parte del widget.
  6. Deep-linking: Usa el hash para seleccionar una pestaña. No hagas push al historial en carga; reemplaza estado en acción de usuario.
  7. Scopea todo: Un widget de pestañas no debe afectar a otro. Evita selectores globales que capturen cada panel en la página.

Checklist de release para sistemas en producción

  • ¿Un usuario puede acceder al contenido con JS bloqueado?
  • ¿Un usuario puede operar el control solo con teclado?
  • ¿Los enlaces hash abren el panel/pestaña correcta?
  • ¿Los IDs son únicos en la página?
  • ¿Los scripts están deferidos y cacheables?
  • ¿El componente sigue funcionando si la analítica falla?
  • ¿Has probado al menos un perfil “3G lento / alta latencia”?
  • ¿El código de mejora está scopeado al contenedor (sin acoplamientos accidentales a nivel documento)?

Preguntas frecuentes

1) ¿Debo usar siempre details/summary para acordeones?

Sí para contenido típico de disclosure. Si necesitas coordinación de estado compleja, encabezados interactivos anidados o semánticas personalizadas, puede que construyas un componente personalizado—pero estarás eligiendo más pruebas y más riesgo.

2) ¿Puedo estilizar el marcador de summary?

Puedes, pero con precaución. Si quitas el marcador por defecto, debes reemplazar la affordance (un indicador claro) y preservar la visibilidad del foco por teclado. No escondas la única pista de que algo es expandible.

3) ¿Por qué no usar el componente de acordeón de una librería UI?

A veces debes—si ya dependes de la librería y el componente está muy personalizado. Pero si incorporas la librería principalmente por disclosures, estás pagando un impuesto recurrente: tamaño de bundle, bugs de hidratación y churn de dependencias.

4) ¿Son posibles las pestañas sin JavaScript?

No como “pestañas reales” con semántica de tab y convenciones de teclado. La base debería ser navegación por anclas o páginas separadas. Luego mejora con JS hacia pestañas si es necesario.

5) ¿Los botones de pestañas deben ser enlaces?

Usualmente no. Las pestañas son controles; usa <button> con role="tab". Si quieres comportamiento de navegación, usa enlaces y no lo llames pestañas. Mezclar ambos crea comportamiento confuso para usuarios y tecnologías asistivas.

6) ¿Cómo manejo deep links para pestañas?

Usa el hash para representar la selección de pestaña (p. ej., #tab-security). Al cargar, lee el hash y activa la pestaña correspondiente. Al hacer clic, actualiza el hash con history.replaceState para no llenar el historial del navegador.

7) ¿Cuál es la forma más segura de imponer “solo uno abierto” en un acordeón?

Escucha el evento toggle en un contenedor y cierra los elementos details hermanos cuando uno se abre. Limítalo al contenedor del acordeón para no cerrar disclosures no relacionados en otra parte.

8) ¿Puedo anidar elementos details?

Puedes, pero es fácil crear interacción confusa y rutas de foco complicadas. Si anidas, mantén los summaries simples, evita controles anidados en encabezados y prueba navegación por teclado exhaustivamente.

9) ¿Cómo evito el parpadeo al mejorar a pestañas?

O bien renderiza el estado inicial “solo activo” del lado servidor usando hidden, o aplica una clase “js-enabled” temprano y solo oculta paneles cuando esa clase exista. No dependas de un JS que cargue tarde para ocultar contenido después del layout.

10) ¿Cuál es el plan mínimo de pruebas antes de lanzar?

Operación solo con teclado, accesibilidad con JS deshabilitado, validación de IDs únicos y al menos un perfil de red lenta. Si no puedes hacer eso, estás enviando deseos en lugar de software.


Conclusión: próximos pasos que no te perseguirán

Si recuerdas una cosa: parte de un HTML que ya funcione. <details>/<summary> es tu amigo para disclosures. Las pestañas requieren JavaScript, pero no requieren un framework—y absolutamente no requieren contenido solo en cliente.

Pasos prácticos:

  • Elige una página de acordeón de alto tráfico (FAQ, precios, ayuda de ajustes). Reemplaza el acordeón de librería por <details> nativo y mide la reducción de bundle.
  • Para widgets de pestañas existentes, asegura que haya una navegación base por anclas y secciones. Luego vuelve a añadir pestañas como mejora con JS scopeado.
  • Ejecuta las tareas de diagnóstico anteriores en CI: comprueba IDs duplicados y scripts que bloquean el render en páginas con mucho contenido.
  • Escribe el “contrato de componente” de tu equipo: comportamiento base sin JS, comportamiento mejorado con JS y modos de fallo aceptables.

Haz eso, y tu UI no solo se verá bien. Seguirá funcionando cuando el mundo real aparezca, que es lo que hacen los sistemas en producción.

← Anterior
Buzón catch‑all de correo: por qué arruina la entregabilidad (y alternativas más seguras)
Siguiente →
Correo: Registros MX múltiples — cómo funciona realmente la prioridad (y errores comunes)

Deja un comentario