wandres.dev
NIVEL DIOS · Síntesis de la animación web

Patrones resueltos: acordeón, modal y esqueleto

Los tres patrones que se resuelven con CSS puro, con el código completo, la justificación de cada técnica elegida, el estado accesible correcto y la versión de movimiento reducido.

⏱ 22 min

Los tres patrones más frecuentes de cualquier interfaz se resuelven sin una sola dependencia, con animaciones que corren en el compositor y con el estado representado con atributos que el navegador entiende. El código de esta lección funciona pegado tal cual. Lo que importa tanto como el código es por qué cada técnica es la elegida y qué se descartó.

🎯 Al terminar esta lección sabrás
  • Implementar un acordeón accesible con dos técnicas y saber cuándo usar cada una.
  • Implementar un modal completo con dialog, @starting-style y allow-discrete.
  • Implementar un esqueleto de carga que no repinte y que no parpadee.
  • Escribir la versión de movimiento reducido de los tres.

Acordeón

El problema clásico: height: auto no es interpolable, y sin animar la altura el contenido de debajo salta. Hay dos soluciones, y ninguna es la de medir con JavaScript y escribir píxeles, que rompe con contenido responsivo.

Técnica base: grid-template-rows de 0fr a 1fr. Funciona en todos los motores desde hace años. La rejilla de una sola fila interpola la fracción, el hijo con min-height: 0 y overflow: hidden se recorta, y el resultado es una altura animada sin conocerla.

<div class="acordeon">
  <h3 class="acordeon-titulo">
    <button class="acordeon-boton" id="b-envio" aria-expanded="false" aria-controls="p-envio">
      Envíos y devoluciones
      <svg class="acordeon-icono" viewBox="0 0 16 16" aria-hidden="true">
        <path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="2" />
      </svg>
    </button>
  </h3>
  <div class="acordeon-panel" id="p-envio" role="region" aria-labelledby="b-envio">
    <div class="acordeon-contenido">
      <p>Los pedidos salen del almacén en 24 horas laborables.</p>
    </div>
  </div>
</div>
.acordeon-panel {
  display: grid;
  grid-template-rows: 0fr;
  visibility: hidden;
  transition:
    grid-template-rows var(--mov-media) var(--mov-estandar),
    visibility var(--mov-media);
}

.acordeon-panel[data-abierto] {
  grid-template-rows: 1fr;
  visibility: visible;
}

/* El hijo es quien se recorta. min-height 0 es imprescindible:
   sin el, el contenido impone su altura minima y no hay nada que animar. */
.acordeon-contenido {
  min-height: 0;
  overflow: hidden;
}

.acordeon-icono {
  inline-size: 1em;
  rotate: 0deg;
  transition: rotate var(--mov-media) var(--mov-estandar);
}

.acordeon-boton[aria-expanded="true"] .acordeon-icono { rotate: 180deg; }

Mejora progresiva: interpolate-size. Donde esté disponible, animar height: auto directamente es más simple y no necesita el envoltorio de la rejilla.

@supports (interpolate-size: allow-keywords) {
  :root { interpolate-size: allow-keywords; }

  .acordeon-panel {
    display: block;
    block-size: 0;
    overflow: hidden;
    transition: block-size var(--mov-media) var(--mov-estandar), visibility var(--mov-media);
  }

  .acordeon-panel[data-abierto] { block-size: auto; }
}

Y el JavaScript, que solo gestiona estado:

for (const boton of document.querySelectorAll(".acordeon-boton")) {
  const panel = document.getElementById(boton.getAttribute("aria-controls"));
  panel.inert = true;

  boton.addEventListener("click", () => {
    const abierto = boton.getAttribute("aria-expanded") === "true";
    boton.setAttribute("aria-expanded", String(!abierto));
    panel.toggleAttribute("data-abierto", !abierto);
    panel.inert = abierto;
  });
}

Tres decisiones que merecen justificación. inert en lugar de hidden porque hidden impediría la transición y porque inert quita el contenido del orden de tabulación y del árbol de accesibilidad sin tocar la presentación. visibility sin allow-discrete porque visibility tiene una regla de interpolación propia que mantiene visible durante toda la transición si alguno de los dos extremos lo es, que es exactamente el comportamiento que se quiere. El icono rota con rotate y no con transform, para no competir con ninguna otra transformación que el elemento pudiera tener.

⚠️
grid-template-rows anima layout, no composición

Esta técnica cambia la geometría en cada fotograma y por tanto dispara layout. En un acordeón dentro de una página larga, ese layout recalcula todo lo que viene después en el flujo. Acótalo con contain: layout en el contenedor del acordeón y comprueba el coste en el panel de rendimiento si el documento es grande.

El elemento dialog con showModal() resuelve gratis lo más difícil: la captura del foco, el cierre con Escape, el fondo inerte y la capa superior por encima de cualquier z-index. Lo único que queda es la animación de entrada y salida, y la salida es el caso interesante porque el elemento pasa a display: none.

<button id="abrir">Ver detalles</button>

<dialog id="dlg" class="modal" aria-labelledby="dlg-titulo">
  <h2 id="dlg-titulo">Detalles del pedido</h2>
  <p>Pedido 4821, entregado el 3 de agosto.</p>
  <form method="dialog">
    <button value="cerrar" autofocus>Cerrar</button>
  </form>
</dialog>
.modal {
  border: 0;
  border-radius: 12px;
  padding: 1.5rem;
  max-inline-size: 32rem;

  opacity: 0;
  translate: 0 8px;
  scale: 0.98;

  transition:
    opacity var(--mov-corta) linear,
    translate var(--mov-media) var(--mov-entrada),
    scale var(--mov-media) var(--mov-entrada),
    display var(--mov-media) allow-discrete,
    overlay var(--mov-media) allow-discrete;
}

.modal[open] {
  opacity: 1;
  translate: 0 0;
  scale: 1;
}

/* Sin esto no hay animacion de entrada: el elemento aparece ya con [open]
   y no hay un estado anterior desde el que interpolar. */
@starting-style {
  .modal[open] {
    opacity: 0;
    translate: 0 8px;
    scale: 0.98;
  }
}

.modal::backdrop {
  background: rgb(0 0 0 / 0);
  transition:
    background var(--mov-media) linear,
    display var(--mov-media) allow-discrete,
    overlay var(--mov-media) allow-discrete;
}

.modal[open]::backdrop { background: rgb(0 0 0 / 0.5); }

@starting-style {
  .modal[open]::backdrop { background: rgb(0 0 0 / 0); }
}
const dlg = document.getElementById("dlg");
document.getElementById("abrir").addEventListener("click", () => dlg.showModal());

// Cierre al pulsar fuera del contenido: el propio dialog ocupa toda la pantalla,
// asi que un clic cuyo objetivo sea el dialog y no su contenido esta fuera.
dlg.addEventListener("click", (e) => {
  if (e.target === dlg) dlg.close();
});

Las dos piezas que hacen que esto funcione y que casi nadie tiene completas:

display en la lista de transiciones con allow-discrete. Sin ella, al cerrar el diálogo display pasa a none de forma inmediata y no ves la salida. Con ella, el valor discreto se retrasa hasta el final de la transición.

overlay con allow-discrete. overlay es la propiedad que controla la promoción del elemento a la capa superior. Sin retrasarla igual que display, el diálogo cae de la capa superior en el primer fotograma de la salida y se ve desaparecer detrás del resto de la página. Es un detalle que solo se descubre cuando ocurre.

Y la versión reducida, que sustituye en lugar de apagar:

@media (prefers-reduced-motion: reduce) {
  .modal, .modal[open], .modal::backdrop {
    translate: none;
    scale: none;
    transition-duration: 120ms;
  }
  @starting-style {
    .modal[open] { opacity: 0; translate: none; scale: none; }
  }
}

Esqueleto de carga

Dos problemas independientes: que el brillo no repinte, y que el esqueleto no parpadee.

El brillo sin repintar. La forma común de hacerlo es animar background-position de un degradado, y eso repinta el área completa en cada fotograma. La forma correcta es un pseudoelemento con el degradado que se traslada dentro de un contenedor recortado: translate va al compositor.

.esqueleto {
  position: relative;
  overflow: hidden;
  border-radius: 4px;
  background: color-mix(in oklab, currentColor 12%, transparent);
}

.esqueleto::after {
  content: "";
  position: absolute;
  inset: 0;
  translate: -100% 0;
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in oklab, currentColor 10%, transparent),
    transparent
  );
  animation: esqueleto-barrido 1400ms linear infinite;
}

@keyframes esqueleto-barrido {
  to { translate: 100% 0; }
}

.esqueleto-linea { block-size: 0.9em; margin-block: 0.35em; }
.esqueleto-linea:last-child { inline-size: 60%; }

@media (prefers-reduced-motion: reduce) {
  .esqueleto::after {
    translate: 0 0;
    background: color-mix(in oklab, currentColor 6%, transparent);
    animation: esqueleto-pulso 1600ms ease-in-out infinite;
  }
  @keyframes esqueleto-pulso {
    50% { opacity: 0.25; }
  }
}

El parpadeo. Un esqueleto que aparece durante ochenta milisegundos porque la respuesta llegó rápida es peor que no mostrar nada: se percibe como un fallo de renderizado. Y uno que aparece y desaparece a los ciento cincuenta milisegundos, igual. La solución es la pareja de umbrales: no mostrarlo si la espera va a ser corta, y si se muestra, mantenerlo un mínimo.

const ESPERA_ANTES = 200;   // no mostrar si termina antes de esto
const MINIMO_VISIBLE = 400; // si se muestra, al menos esto

export async function conEsqueleto(contenedor, promesa) {
  let mostrado = 0;
  contenedor.setAttribute("aria-busy", "true");

  const temporizador = setTimeout(() => {
    contenedor.dataset.cargando = "";
    mostrado = performance.now();
  }, ESPERA_ANTES);

  try {
    const datos = await promesa;
    clearTimeout(temporizador);

    if (mostrado) {
      const restante = MINIMO_VISIBLE - (performance.now() - mostrado);
      if (restante > 0) await new Promise((r) => setTimeout(r, restante));
    }
    return datos;
  } finally {
    clearTimeout(temporizador);
    delete contenedor.dataset.cargando;
    contenedor.removeAttribute("aria-busy");
  }
}

aria-busy es la parte de accesibilidad que se olvida: le dice al lector de pantalla que la región está en construcción y que no anuncie los cambios intermedios. Y el esqueleto en sí debe ser aria-hidden, porque no aporta nada leído: una sucesión de rectángulos grises no es información.

Los tres patrones comparten la misma estructura, y verla es lo que te permite resolver el cuarto sin buscarlo

Mirados juntos, el acordeón, el modal y el esqueleto parecen tres problemas distintos con tres soluciones distintas. Tienen la misma forma, y reconocerla es más útil que memorizar las tres soluciones. En los tres casos hay un estado lógico que vive en un atributo que el navegador entiendearia-expanded con inert, el open del dialog, aria-busy con un atributo de datos—, una expresión visual de ese estado escrita como declaraciones CSS y una transición que solo describe cómo se pasa de una expresión a otra. La animación no define nada: es la consecuencia de que un estado cambie. Y las tres dificultades técnicas que aparecen son también la misma dificultad vista tres veces: qué hacer con las propiedades que no son interpolables por naturaleza. height: auto no es un número, así que hay que traducirlo a una fracción de rejilla o pedirle al motor que lo interprete. display: none no tiene valores intermedios, así que hay que retrasar su aplicación con allow-discrete. El estado inicial de un elemento que acaba de aparecer no existe, así que hay que declararlo con @starting-style. Las tres son la misma frontera del sistema —la interpolación necesita un continuo y algunas propiedades no lo tienen— y las tres se han resuelto en los últimos años con mecanismos que añaden un continuo donde no lo había en lugar de pedirte que lo simules con JavaScript. Quien entiende que esa es la naturaleza del problema deja de buscar soluciones patrón a patrón: ante un caso nuevo, la pregunta es cuál de las tres fronteras está tocando, y la respuesta está en las tres herramientas de arriba. Ese es el salto entre saber copiar un acordeón y saber resolver el patrón que nadie ha escrito todavía.