Eventos de Medios y Animaciones
Maneja cambios de estado de reproducción de video/audio, carga de imágenes, transiciones CSS y eventos del ciclo de vida de animaciones.
Referencia de Eventos
Eventos de Medios (Video y Audio)
| Evento | Se dispara cuando | Objeto de Evento |
|---|---|---|
onPlay | La reproducción comienza o se reanuda | React.SyntheticEvent |
onPause | La reproducción se pausa | React.SyntheticEvent |
onEnded | La reproducción llega al final | React.SyntheticEvent |
onTimeUpdate | El tiempo de reproducción actual cambia (se dispara frecuentemente) | React.SyntheticEvent |
onVolumeChange | El volumen o el estado de silencio cambia | React.SyntheticEvent |
onLoadedData | Se ha cargado el primer fotograma del contenido | React.SyntheticEvent |
onError | Falla la carga del contenido | React.SyntheticEvent |
onWaiting | La reproducción se detiene debido al almacenamiento en búfer | React.SyntheticEvent |
onSeeking | Comienza una operación de búsqueda | React.SyntheticEvent |
onSeeked | Se completa una operación de búsqueda | React.SyntheticEvent |
Eventos de Imagen
| Evento | Se dispara cuando | Objeto de Evento |
|---|---|---|
onLoad | La imagen termina de cargarse | React.SyntheticEvent |
onError | Falla la carga de la imagen | React.SyntheticEvent |
Eventos de Animación CSS
| Evento | Se dispara cuando | Objeto de Evento |
|---|---|---|
onAnimationStart | Comienza una animación CSS | React.AnimationEvent |
onAnimationEnd | Se completa una animación CSS | React.AnimationEvent |
onAnimationIteration | Se reinicia un bucle de animación CSS | React.AnimationEvent |
Eventos de Transición CSS
| Evento | Se dispara cuando | Objeto de Evento |
|---|---|---|
onTransitionEnd | Se completa una transición CSS | React.TransitionEvent |
Receta
Tarjeta de referencia rápida -- lista para copiar y pegar.
// Reproducción de video play/pause
"use client";
import { useRef, useState } from "react";
export function VideoToggle({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const toggle = () => {
if (videoRef.current?.paused) {
videoRef.current.play();
} else {
videoRef.current?.pause();
}
};
return (
<div>
<video
ref={videoRef}
src={src}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
/>
<button onClick={toggle}>{playing ? "Pausar" : "Reproducir"}</button>
</div>
);
}// onLoad de imagen / onError
"use client";
import { useState } from "react";
export function SafeImage({ src, alt }: { src: string; alt: string }) {
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
if (error) return <div className="bg-gray-200 p-4">Error al cargar la imagen</div>;
return (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
className={loaded ? "opacity-100" : "opacity-0"}
style={{ transition: "opacity 0.3s" }}
/>
);
}// Callback de fin de animación
"use client";
import { useState } from "react";
export function FadeOutAndRemove() {
const [visible, setVisible] = useState(true);
const [animating, setAnimating] = useState(false);
const handleDismiss = () => setAnimating(true);
if (!visible) return null;
return (
<div
className={animating ? "animate-fadeOut" : ""}
onAnimationEnd={() => {
if (animating) setVisible(false);
}}
>
<p>Me desvanecerá</p>
<button onClick={handleDismiss}>Descartar</button>
</div>
);
}Cuándo usarlo: Necesitas sincronizar el estado de la UI con la reproducción de medios, el ciclo de vida de carga de imágenes, o la finalización de animaciones/transiciones CSS.
Ejemplo Funcional
// CustomVideoPlayer.tsx
"use client";
import { useRef, useState, useCallback } from "react";
export default function CustomVideoPlayer({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [buffering, setBuffering] = useState(false);
const togglePlay = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
} else {
video.pause();
}
}, []);
const handleTimeUpdate = useCallback(
(e: React.SyntheticEvent<HTMLVideoElement>) => {
setCurrentTime(e.currentTarget.currentTime);
},
[]
);
const handleLoadedData = useCallback(
(e: React.SyntheticEvent<HTMLVideoElement>) => {
setDuration(e.currentTarget.duration);
},
[]
);
const handleVolumeChange = useCallback(
(e: React.SyntheticEvent<HTMLVideoElement>) => {
setVolume(e.currentTarget.volume);
setMuted(e.currentTarget.muted);
},
[]
);
const handleSeek = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = Number(e.target.value);
},
[]
);
const handleVolumeSlider = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const video = videoRef.current;
if (!video) return;
video.volume = Number(e.target.value);
},
[]
);
const toggleMute = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
}, []);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className="max-w-2xl mx-auto bg-black rounded-xl overflow-hidden">
<div className="relative">
<video
ref={videoRef}
src={src}
className="w-full"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={handleTimeUpdate}
onLoadedData={handleLoadedData}
onVolumeChange={handleVolumeChange}
onWaiting={() => setBuffering(true)}
onSeeked={() => setBuffering(false)}
onEnded={() => setPlaying(false)}
onClick={togglePlay}
/>
{buffering && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-10 h-10 border-4 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
{/* Barra de progreso */}
<div className="px-4 pt-2">
<div className="relative h-1 bg-gray-700 rounded-full">
<div
className="absolute h-full bg-blue-500 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={currentTime}
onChange={handleSeek}
className="w-full opacity-0 absolute cursor-pointer"
style={{ marginTop: "-6px", height: "12px" }}
aria-label="Buscar"
/>
</div>
{/* Controles */}
<div className="flex items-center gap-4 p-4 text-white">
<button
onClick={togglePlay}
className="text-xl"
aria-label={playing ? "Pausar" : "Reproducir"}
>
{playing ? "⏸" : "▶"}
</button>
<span className="text-sm font-mono">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex items-center gap-2 ml-auto">
<button onClick={toggleMute} aria-label={muted ? "Desmutecer" : "Mutearse"}>
{muted || volume === 0 ? "🔇" : "🔊"}
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onChange={handleVolumeSlider}
className="w-20"
aria-label="Volumen"
/>
</div>
</div>
</div>
);
}Lo que esto demuestra:
- Usar
onPlay,onPauseyonEndedpara rastrear el estado de reproducción - Usar
onTimeUpdatepara sincronizar una barra de progreso con el tiempo actual - Usar
onLoadedDatapara capturar la duración del video - Usar
onVolumeChangepara reflejar cambios de volumen y estado de silencio - Usar
onWaitingyonSeekedpara indicador de almacenamiento en búfer - Controlar el elemento de video mediante
refmientras escuchas eventos para sincronización de estado
Análisis Profundo
Cómo Funciona
- Los eventos de medios se envían por los elementos nativos
<video>y<audio>del navegador. React los envuelve como instancias deSyntheticEvent. onTimeUpdatese dispara aproximadamente 4 veces por segundo durante la reproducción (dependiendo del navegador, no vinculado arequestAnimationFrame).onLoadde imagen se dispara después de que la imagen se ha decodificado completamente y está lista para mostrar.onErrorse dispara si la URL de origen falla.onAnimationEndde CSS se dispara una vez por animación por elemento. Si se ejecutan varias animaciones en el mismo elemento, se dispara para cada una.onTransitionEndse dispara para cada propiedad CSS que realiza una transición. Transicionar todas las propiedades puede producir múltiples eventos.
Variaciones
Carga perezosa de imagen con desvanecimiento al cargar:
"use client";
import { useState, useCallback } from "react";
export function LazyImage({
src,
alt,
width,
height,
}: {
src: string;
alt: string;
width: number;
height: number;
}) {
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const handleLoad = useCallback(() => setLoaded(true), []);
const handleError = useCallback(() => setError(true), []);
return (
<div
className="relative overflow-hidden bg-gray-100"
style={{ width, height }}
>
{error ? (
<div className="flex items-center justify-center w-full h-full text-gray-400">
Error al cargar
</div>
) : (
<>
{!loaded && (
<div className="absolute inset-0 animate-pulse bg-gray-200" />
)}
<img
src={src}
alt={alt}
loading="lazy"
onLoad={handleLoad}
onError={handleError}
className={`w-full h-full object-cover transition-opacity duration-500 ${
loaded ? "opacity-100" : "opacity-0"
}`}
/>
</>
)}
</div>
);
}Encadenamiento de animaciones con onAnimationEnd:
"use client";
import { useState, useCallback } from "react";
type Phase = "idle" | "slideIn" | "pulse" | "slideOut" | "done";
export function AnimationChain() {
const [phase, setPhase] = useState<Phase>("idle");
const start = useCallback(() => setPhase("slideIn"), []);
const handleAnimationEnd = useCallback(
(e: React.AnimationEvent) => {
// Solo maneja animaciones en este elemento específico
if (e.target !== e.currentTarget) return;
const transitions: Record<string, Phase> = {
slideIn: "pulse",
pulse: "slideOut",
slideOut: "done",
};
const next = transitions[phase];
if (next) setPhase(next);
},
[phase]
);
if (phase === "done") {
return <button onClick={() => setPhase("idle")}>Restablecer</button>;
}
const animationClass: Record<Phase, string> = {
idle: "",
slideIn: "animate-slideInRight",
pulse: "animate-pulse",
slideOut: "animate-slideOutLeft",
done: "",
};
return (
<div>
{phase === "idle" ? (
<button onClick={start}>Iniciar Cadena de Animación</button>
) : (
<div
className={`p-8 bg-blue-500 text-white rounded-lg ${animationClass[phase]}`}
onAnimationEnd={handleAnimationEnd}
>
Fase: {phase}
</div>
)}
</div>
);
}Callback de finalización de transición:
"use client";
import { useState, useCallback } from "react";
export function CollapsePanel({
children,
title,
}: {
children: React.ReactNode;
title: string;
}) {
const [open, setOpen] = useState(false);
const [rendered, setRendered] = useState(false);
const toggle = useCallback(() => {
if (!open) {
setRendered(true); // montar contenido antes de expandir
requestAnimationFrame(() => setOpen(true));
} else {
setOpen(false);
}
}, [open]);
const handleTransitionEnd = useCallback(
(e: React.TransitionEvent) => {
// Solo responde a transición de altura, no a otras propiedades
if (e.propertyName === "max-height" && !open) {
setRendered(false); // desmontar después de colapsar completamente
}
},
[open]
);
return (
<div className="border rounded-lg">
<button onClick={toggle} className="w-full p-4 text-left font-semibold">
{title} {open ? "▲" : "▼"}
</button>
<div
className="overflow-hidden transition-[max-height] duration-300 ease-in-out"
style={{ maxHeight: open ? "500px" : "0px" }}
onTransitionEnd={handleTransitionEnd}
>
{rendered && <div className="p-4 border-t">{children}</div>}
</div>
</div>
);
}Visualizador de audio básico:
"use client";
import { useRef, useCallback, useEffect, useState } from "react";
export function AudioVisualizer({ src }: { src: string }) {
const audioRef = useRef<HTMLAudioElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const [initialized, setInitialized] = useState(false);
const initAudio = useCallback(() => {
if (initialized || !audioRef.current) return;
const ctx = new AudioContext();
const source = ctx.createMediaElementSource(audioRef.current);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyser.connect(ctx.destination);
analyserRef.current = analyser;
setInitialized(true);
}, [initialized]);
useEffect(() => {
if (!initialized) return;
const analyser = analyserRef.current;
const canvas = canvasRef.current;
if (!analyser || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
let animationId: number;
const draw = () => {
animationId = requestAnimationFrame(draw);
analyser.getByteFrequencyData(dataArray);
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = (canvas.width / bufferLength) * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
ctx.fillStyle = `hsl(${(i / bufferLength) * 360}, 80%, 50%)`;
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
};
draw();
return () => cancelAnimationFrame(animationId);
}, [initialized]);
return (
<div className="space-y-4">
<canvas ref={canvasRef} width={600} height={200} className="bg-black rounded-lg" />
<audio
ref={audioRef}
src={src}
controls
onPlay={initAudio}
crossOrigin="anonymous"
/>
</div>
);
}Barra de progreso de video con vista previa:
"use client";
import { useRef, useState, useCallback } from "react";
export function VideoProgressBar({
videoRef,
duration,
currentTime,
}: {
videoRef: React.RefObject<HTMLVideoElement | null>;
duration: number;
currentTime: number;
}) {
const barRef = useRef<HTMLDivElement>(null);
const [hoverTime, setHoverTime] = useState<number | null>(null);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const fraction = (e.clientX - rect.left) / rect.width;
if (videoRef.current) {
videoRef.current.currentTime = fraction * duration;
}
},
[videoRef, duration]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const fraction = (e.clientX - rect.left) / rect.width;
setHoverTime(fraction * duration);
},
[duration]
);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const formatTime = (s: number) =>
`${Math.floor(s / 60)}:${Math.floor(s % 60)
.toString()
.padStart(2, "0")}`;
return (
<div
ref={barRef}
className="relative h-2 bg-gray-700 rounded cursor-pointer group"
onClick={handleClick}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHoverTime(null)}
>
<div
className="h-full bg-blue-500 rounded"
style={{ width: `${progress}%` }}
/>
{hoverTime !== null && (
<div
className="absolute -top-8 bg-black text-white text-xs px-2 py-1 rounded -translate-x-1/2"
style={{ left: `${(hoverTime / duration) * 100}%` }}
>
{formatTime(hoverTime)}
</div>
)}
</div>
);
}Notas de TypeScript
// Evento de medios -- tipado al elemento específico
function handlePlay(e: React.SyntheticEvent<HTMLVideoElement>) {
const video: HTMLVideoElement = e.currentTarget;
const time: number = video.currentTime;
const dur: number = video.duration;
const paused: boolean = video.paused;
const vol: number = video.volume;
}
// Los eventos de elemento de audio utilizan el mismo tipo
function handleAudioEnd(e: React.SyntheticEvent<HTMLAudioElement>) {
const audio: HTMLAudioElement = e.currentTarget;
console.log("Audio terminó en", audio.duration);
}
// Carga de imagen/error
function handleImgLoad(e: React.SyntheticEvent<HTMLImageElement>) {
const img: HTMLImageElement = e.currentTarget;
const naturalWidth: number = img.naturalWidth;
const naturalHeight: number = img.naturalHeight;
}
// Eventos de animación
function handleAnimEnd(e: React.AnimationEvent<HTMLDivElement>) {
const name: string = e.animationName; // Nombre de animación CSS
const elapsed: number = e.elapsedTime; // Segundos
const pseudo: string = e.pseudoElement; // "::before", "::after", o ""
}
// Eventos de transición
function handleTransEnd(e: React.TransitionEvent<HTMLDivElement>) {
const property: string = e.propertyName; // p.ej., "opacity", "transform"
const elapsed: number = e.elapsedTime; // Segundos
const pseudo: string = e.pseudoElement;
}
// Tipado de Ref para elementos de medios
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);Gotchas
-
onTransitionEndse dispara una vez por propiedad -- Transicionarallo múltiples propiedades (p.ej.,opacityytransform) dispara el evento múltiples veces. Solución: Compruebae.propertyNamepara responder solo a la propiedad que te importa:if (e.propertyName !== "opacity") return;. -
onAnimationEndburbujea de elementos secundarios -- Si un elemento secundario tiene su propia animación CSS, el evento burbujea hasta tu manejador. Solución: Comparae.target === e.currentTargetpara asegurar que solo respondes a la animación de tu elemento. -
onTimeUpdateno se dispara cada fotograma -- Se dispara aproximadamente 4 veces por segundo, no 60. Esto lo hace demasiado impreciso para UI frame-accurate. Solución: Para barras de progreso suave, usarequestAnimationFrameconvideoRef.current.currentTimeen su lugar. -
onErrorde medios proporciona información mínima -- ElSyntheticEventno incluye un código de error directamente. Solución: Accede ae.currentTarget.error(un objetoMediaError) cone.currentTarget.error?.codeye.currentTarget.error?.messagepara obtener detalles. -
onLoaden<img>se dispara en imágenes en caché también -- Pero el tiempo es diferente: las imágenes en caché pueden dispararonLoadsincronously durante el renderizado. Solución: Siempre inicializa tu estado de carga afalsey deja queonLoadlo establezca entrue, en lugar de asumir tiempo asincrónico. -
Las animaciones CSS en elementos desmontados no disparan
onAnimationEnd-- Si eliminas el elemento del DOM antes de que finalice la animación, el evento nunca se dispara. Solución: Espera aonAnimationEndantes de establecer el estado que elimina el elemento, o usa la Web Animations API para control basado en promesas. -
play()devuelve una Promise que puede rechazarse -- Llamar avideoRef.current.play()devuelve una Promise. Si el usuario no ha interactuado con la página, las políticas de autoplay la rechazan. Solución: Siempreawaito.catch()la promise de play:videoRef.current.play().catch(() => { /* manejar bloqueo de autoplay */ }).
Alternativas
| Alternativa | Usar cuando | No usar cuando |
|---|---|---|
Web Animations API (element.animate()) | Necesitas animaciones impulsadas por JS con promesas y control de reproducción | Los keyframes CSS son suficientes |
Framer Motion (motion.div) | Quieres animaciones React declarativas con enter/exit/layout | Solo necesitas manejar eventos de medios nativos |
requestAnimationFrame | Necesitas rastreo de progreso frame-accurate para video | La precisión de onTimeUpdate es aceptable |
<video> con HLS.js / dash.js | Necesitas streaming adaptativo (ABR) | Un archivo MP4 único funciona bien |
Next.js <Image> | Quieres optimización automática, carga perezosa y blur placeholder | Necesitas control fino de onLoad / onError con lógica personalizada |
Preguntas Frecuentes
¿Cómo detecto cuándo un video se está almacenando en búfer?
- Usa
onWaitingpara detectar cuándo la reproducción se detiene debido a datos insuficientes - Usa
onSeekedoonCanPlaypara detectar cuándo hay suficientes datos disponibles para reanudar - Muestra un spinner de carga entre
onWaitingy el siguienteonSeeked/onCanPlay
¿Por qué video.play() a veces lanza un error?
- Las políticas de autoplay del navegador requieren interacción del usuario antes de que se pueda reproducir contenido con sonido
play()devuelve una Promise que se rechaza si se bloquea el autoplay- Siempre maneja el rechazo:
video.play().catch(() => { /* mostrar botón de reproducción */ }) - Los videos con atributo
mutedgeneralmente pueden reproducirse automáticamente
¿Cómo encadeno múltiples animaciones CSS en el mismo elemento?
- Escucha
onAnimationEndy cambia la clase CSS para activar la siguiente animación - Comprueba
e.animationNamepara saber qué animación acaba de terminar - Establece estado para aplicar la siguiente clase de animación, creando una cadena secuencial
¿Por qué onTransitionEnd se dispara múltiples veces?
- Se dispara una vez por propiedad CSS que realiza una transición
- Si transicionas
transformyopacity, obtienes dos eventos - Filtra por
e.propertyNamepara responder solo al que te importa
¿Cómo construyo una barra de progreso personalizada para un reproductor de video?
- Usa
onLoadedDatapara obtenervideo.duration - Usa
onTimeUpdatepara rastrearvideo.currentTime - Calcula
(currentTime / duration) * 100para el porcentaje de progreso - Para actualizaciones más suave, usa
requestAnimationFrameen lugar deonTimeUpdate
¿Cómo manejo errores de carga de imagen de forma elegante?
const [error, setError] = useState(false);
return error ? (
<div className="placeholder">Imagen no disponible</div>
) : (
<img
src={src}
alt={alt}
onError={() => setError(true)}
/>
);¿Puedo detectar cuándo se repite una animación CSS con onAnimationIteration?
- Sí,
onAnimationIterationse dispara al final de cada iteración para animaciones conanimation-iteration-countmayor que 1 - No se dispara al final de la última iteración -- usa
onAnimationEndpara eso - El evento incluye
animationNameyelapsedTimepara identificar qué ciclo se completó
Relacionados
- Pointer Events -- Entrada unificada en mouse, toque y lápiz
- Touch Events -- Manejo de gestos multi-toque para mobile
- Scroll Events -- Rastreo de posición de desplazamiento e infinite scroll