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.
Busca en todas las páginas de la documentación
Maneja cambios de estado de reproducción de video/audio, carga de imágenes, transiciones CSS y eventos del ciclo de vida de animaciones.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| 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 |
| Evento | Se dispara cuando | Objeto de Evento |
|---|---|---|
onLoad | La imagen termina de cargarse | React.SyntheticEvent |
onError | Falla la carga de la imagen | React.SyntheticEvent |
| 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 |
| Evento | Se dispara cuando | Objeto de Evento |
|---|---|---|
onTransitionEnd | Se completa una transición CSS | React.TransitionEvent |
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.
// 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:
onPlay, onPause y onEnded para rastrear el estado de reproducciónonTimeUpdate para sincronizar una barra de progreso con el tiempo actualonLoadedData para capturar la duración del videoonVolumeChange para reflejar cambios de volumen y estado de silencioonWaiting y onSeeked para indicador de almacenamiento en búferref mientras escuchas eventos para sincronización de estado<video> y <audio> del navegador. React los envuelve como instancias de SyntheticEvent.onTimeUpdate se dispara aproximadamente 4 veces por segundo durante la reproducción (dependiendo del navegador, no vinculado a requestAnimationFrame).onLoad de imagen se dispara después de que la imagen se ha decodificado completamente y está lista para mostrar. onError se dispara si la URL de origen falla.onAnimationEnd de CSS se dispara una vez por animación por elemento. Si se ejecutan varias animaciones en el mismo elemento, se dispara para cada una.onTransitionEnd se dispara para cada propiedad CSS que realiza una transición. Transicionar todas las propiedades puede producir múltiples eventos.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>
);
}// 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);onTransitionEnd se dispara una vez por propiedad -- Transicionar all o múltiples propiedades (p.ej., opacity y transform) dispara el evento múltiples veces. Solución: Comprueba e.propertyName para responder solo a la propiedad que te importa: if (e.propertyName !== "opacity") return;.
onAnimationEnd burbujea de elementos secundarios -- Si un elemento secundario tiene su propia animación CSS, el evento burbujea hasta tu manejador. Solución: Compara e.target === e.currentTarget para asegurar que solo respondes a la animación de tu elemento.
onTimeUpdate no 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, usa requestAnimationFrame con videoRef.current.currentTime en su lugar.
onError de medios proporciona información mínima -- El SyntheticEvent no incluye un código de error directamente. Solución: Accede a e.currentTarget.error (un objeto MediaError) con e.currentTarget.error?.code y e.currentTarget.error?.message para obtener detalles.
onLoad en <img> se dispara en imágenes en caché también -- Pero el tiempo es diferente: las imágenes en caché pueden disparar onLoad sincronously durante el renderizado. Solución: Siempre inicializa tu estado de carga a false y deja que onLoad lo establezca en true, 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 a onAnimationEnd antes 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 a videoRef.current.play() devuelve una Promise. Si el usuario no ha interactuado con la página, las políticas de autoplay la rechazan. Solución: Siempre await o .catch() la promise de play: videoRef.current.play().catch(() => { /* manejar bloqueo de autoplay */ }).
| 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 |
onWaiting para detectar cuándo la reproducción se detiene debido a datos insuficientesonSeeked o onCanPlay para detectar cuándo hay suficientes datos disponibles para reanudaronWaiting y el siguiente onSeeked/onCanPlayplay() devuelve una Promise que se rechaza si se bloquea el autoplayvideo.play().catch(() => { /* mostrar botón de reproducción */ })muted generalmente pueden reproducirse automáticamenteonAnimationEnd y cambia la clase CSS para activar la siguiente animacióne.animationName para saber qué animación acaba de terminartransform y opacity, obtienes dos eventose.propertyName para responder solo al que te importaonLoadedData para obtener video.durationonTimeUpdate para rastrear video.currentTime(currentTime / duration) * 100 para el porcentaje de progresorequestAnimationFrame en lugar de onTimeUpdateconst [error, setError] = useState(false);
return error ? (
<div className="placeholder">Imagen no disponible</div>
) : (
<img
src={src}
alt={alt}
onError={() => setError(true)}
/>
);onAnimationIteration se dispara al final de cada iteración para animaciones con animation-iteration-count mayor que 1onAnimationEnd para esoanimationName y elapsedTime para identificar qué ciclo se completóRevisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥