CSS :playing y :paused: tu reproductor de video dejó de necesitar JavaScript de estado
webdevelopment 3 de agosto de 2026 · Mintec

CSS :playing y :paused: tu reproductor de video dejó de necesitar JavaScript de estado

Las pseudo-clases de medios (:playing, :paused, :seeking, :buffering, :muted) son compatibles en Safari y Firefox desde 2025 y Chrome/Edge las integran en 2026 como parte de Interop 2026. Te mostramos cómo eliminamos el JavaScript de estado de un reproductor de video real y qué ganamos en rendimiento.

CSS :playing y :paused: tu reproductor de video dejó de necesitar JavaScript de estado

Las pseudo-clases de medios de CSS — :playing, :paused, :seeking, :buffering, :muted y :volume-locked — ya son compatibles en Safari y Firefox desde 2025, y Chrome y Edge tienen la implementación en curso como parte de Interop 2026. Esto significa que el estado de un reproductor de video se puede estilizar 100% en CSS, sin listeners de JavaScript que alternen clases. En el portafolio multimedia que reconstruimos este trimestre eliminamos la capa completa de estado de nuestro reproductor personalizado: 250 líneas de JavaScript que ya no tienen razón de existir.

Qué son las pseudo-clases de medios

Desde hace años, si querías que el botón de play de tu reproductor personalizado cambiara de forma cuando el video se estaba reproduciendo, tenías dos opciones: usar el pseudo-elemento ::-webkit-media-controls (frágil, no estándar) o construir un reproductor con JavaScript que escuchara eventos y alternara clases.

Las pseudo-clases de medios cierran esa brecha. Son selectores nativos que reflejan el estado real del elemento multimedia, definidos en la especificación de CSS y en HTML. Las más útiles en producción:

  • :playing — el video o audio se está reproduciendo
  • :paused — está pausado
  • :seeking — el usuario arrastró la barra o el elemento está buscando posición
  • :buffering — está descargando datos para poder continuar
  • :stalled — la reproducción se detuvo por falta de datos
  • :muted — el sonido está silenciado
  • :volume-locked — el volumen fue bloqueado por el usuario (por ejemplo, por políticas de autoplay)

Lo importante no es la lista, sino el patrón: el navegador ya conoce el estado de reproducción porque es quien lo controla. Nosotros estábamos duplicando esa información en JavaScript, sincronizándola con eventos (play, pause, waiting, playing, volumechange, seeked), y pagando el costo de mantenerla consistente. Ahora el estado vive en el engine y CSS lo lee directo.

El patrón viejo: listeners + toggle de clases

El reproductor personalizado típico de una agencia en 2023 se veía así:

const video = document.querySelector("video");
const btn = document.querySelector(".player__toggle");

video.addEventListener("play", () => player.classList.add("is-playing"));
video.addEventListener("pause", () => player.classList.remove("is-playing"));
video.addEventListener("waiting", () => player.classList.add("is-buffering"));
video.addEventListener("playing", () => player.classList.remove("is-buffering"));
video.addEventListener("volumechange", () => {
  player.classList.toggle("is-muted", video.muted);
});

Siete listeners solo para pintar estado. Cada uno es una oportunidad de desincronización: un seeking que no se limpió, un pause que no se disparó porque el video terminó, un volumechange que llegó antes de que el estado de mute se propagara al DOM. Y todo ese JavaScript corre en el thread principal, en el peor momento posible: mientras el usuario interactúa con el reproductor.

El patrón nuevo: estado en CSS

El mismo reproductor, con pseudo-clases de medios:

.player__toggle { background: var(--accent); }
.player__toggle:hover { filter: brightness(1.1); }

video:playing + .player__toggle {
  background: var(--accent-paused);
}

video:buffering ~ .player__spinner { display: block; }
video:seeking ~ .player__progress .player__bar { opacity: 0.5; }
video:muted ~ .player__indicator { display: inline; }

Cero JavaScript de estado. El selector lee el estado del elemento directamente, y como CSS se procesa en el compositor, los cambios de estilo no pasan por el thread principal. Un detalle que importa en dispositivos de gama baja: el classList.toggle que antes generaba un reflow a mitad de reproducción, ahora es un cambio de estilo manejado por el navegador.

Comparación: el antes y el después en producción

AspectoPatrón viejo (JS + clases)Patrón nuevo (pseudo-clases)
Estado del reproductorDuplicado en JS, sincronizado con eventosLeído directo del engine por CSS
Código necesario~250 líneas de listeners + toggles0 líneas de JS para estado
Riesgo de desincronizaciónAlto: eventos que no se disparan, carrerasNulo: no hay estado que sincronizar
Costo en el thread principalListeners + classList en cada transiciónSolo estilos en el compositor
Estados cubiertosLos que recuerdes escucharTodos los que el engine conoce
Soporte de navegadoresUniversalSafari y Firefox desde 2025; Chrome/Edge en 2026

Soporte real y estrategia progresiva

La adopción es la típica de Interop: Safari y Firefox implementaron primero (2025) y ahora el grupo Interop 2026 empuja a Chrome y Edge para cerrar la brecha, junto con la Navigation API y otras áreas de foco. Mientras tanto, la estrategia correcta es mejora progresiva:

  1. Escribe los estilos base como si nada existiera (tu reproductor se ve bien sin estados).
  2. Envuelve los estilos de estado en @supports selector(:playing) — los navegadores que soportan la pseudo-clase la usan; los demás ignoran el bloque sin romper nada.
  3. Si necesitas paridad visual inmediata, existe un polyfill ligero (css-media-pseudo-polyfill) que reescribe los selectores a clases y actualiza el elemento con los eventos de siempre. Nosotros lo usamos en un proyecto heredado y luego lo eliminamos cuando el tráfico mayoritario pasó a navegadores compatibles.

Así se ve en la práctica:

.player__spinner { display: none; }

@supports selector(video:buffering) {
  video:buffering ~ .player__spinner { display: block; }
}

El patrón es aditivo: el sitio funciona completo en Chrome de hoy y gana los estados nativos en cuanto Chrome implemente la feature. Sin build steps, sin bibliotecas, sin polyfill permanente.

Lo que hicimos en el portafolio multimedia

En el proyecto del que hablamos en nuestra migración a APIs nativas — el portafolio de un cliente de producción de video con más de 60 reels — el reproductor personalizado era de los que duplicaban estado en JavaScript. Cada reel abría en un overlay con su propio reproductor: play/pause, spinner de buffering, indicador de mute. El JavaScript de estado pesaba unos 6 KB minificados y era, objetivamente, la parte más frágil del overlay: en conexiones lentas veíamos spinners que no aparecían y botones de play que no reflejaban el estado real.

Al migrar el sitio a View Transitions y Speculation Rules, aprovechamos para reescribir el reproductor con pseudo-clases de medios y @supports selector(:playing). El resultado:

  • 250 líneas de JavaScript de estado eliminadas — el overlay quedó con JS solo para abrir/cerrar el modal y para analítica
  • Spinners y estados sincronizados por el engine — si el navegador dice que está buffering, el spinner aparece, sin condiciones de carrera
  • INP estable durante la reproducción — al no tocar el thread principal en cada transición de estado, la interacción con los controles no compite con el reproductor

No es el cambio más sexy del proyecto, pero es el que más dudas nos quita: el estado del reproductor ya no es código nuestro que pueda fallar, es una garantía del navegador.

Cuándo seguir usando JavaScript

Las pseudo-clases de medios no eliminan el JavaScript del reproductor completo. Lo eliminan para el estado visual. Todavía necesitas JS para:

  • La barra de búsqueda — el scrubbing requiere conocer currentTime y actualizar la UI en cada frame
  • El control de volumen — la API de volumen sigue siendo JavaScript
  • Eventos de analíticaplay, pause, ended para tracking siguen viniendo de listeners
  • Autoplay con sonido — la política de autoplay del navegador se maneja con JS
  • Accesibilidad fina — si tu reproductor personalizado expone roles ARIA propios, la interacción con teclado sigue siendo JS

El framework de decisión que usamos hoy: estado → CSS; interacción → JS; medición → JS. Si un comportamiento es puramente visual y depende del estado de reproducción, es candidato a pseudo-clase. Si modifica el playback o reporta datos, se queda en JavaScript. Esa separación reduce el código de estado a casi cero sin perder ninguna capacidad del reproductor.

Opinión: el navegador cerró otra brecha

Llevamos tres años diciéndole a los clientes que los reproductores personalizados eran necesarios porque el control nativo no se podía estilizar. Con las pseudo-clases de medios, la ecuación cambió: el control nativo sigue siendo feo, pero un reproductor personalizado CSS-first ahora es tan liviano que la excusa del "reproductor bonito pesado" desapareció. En sitios de contenido y medios, el reproductor por defecto en 2026 debería ser: HTML5 <video>, pseudo-clases para el estado, y un puñado de JavaScript para interacción. Eso es compatible con las estrategias de video y Core Web Vitals que ya usamos: menos JavaScript significa mejor INP, y mejor INP significa mejores resultados en sitios con mucho video.

Para equipos que mantienen sitios con mucho medio, la recomendación concreta es la misma que dimos con Web Codecs y con el presupuesto de rendimiento multimedia: auditen el reproductor, cuenten las líneas de estado, y si pasan de 50, la pseudo-clase ya les está ahorrando dinero. Y si todavía cargan video con estrategias de lazy loading, el estado nativo hace que el reproductor aparezca en el viewport ya sincronizado con el elemento real, sin parpadeos de "cargando" falsos.

El patrón es simple: el navegador sabe si tu video está sonando. Deja de decírselo tú.

Preguntas Frecuentes

¿Qué son las pseudo-clases de medios en CSS?

Son selectores como :playing, :paused, :seeking, :buffering, :muted y :volume-locked que representan el estado real de un elemento de video o audio (<video>, <audio>). Permiten estilizar el reproductor según si está reproduciendo, pausado, cargando o silenciado, sin mantener ese estado en JavaScript.

¿Las pseudo-clases de medios funcionan en todos los navegadores?

Safari y Firefox las soportan desde 2025. Chrome y Edge tienen la implementación en curso y su compatibilidad cruzada es un foco de Interop 2026, por lo que se esperan este año. Hoy se usan con mejora progresiva: estilos base + @supports selector(:playing) para los navegadores que ya las tienen.

¿Sigo necesitando JavaScript para un reproductor personalizado?

Para el estado visual, no. Para acciones como el control de volumen, la barra de búsqueda o eventos de analítica, sí. El patrón recomendado es CSS para el estado y JavaScript solo para la interacción y el tracking.

Artículos Relacionados