Eventos de Mídia e Animação
Manipule mudanças de estado de reprodução de vídeo/áudio, carregamento de imagens, transições CSS e eventos de ciclo de vida de animação.
Busque em todas as páginas da documentação
Manipule mudanças de estado de reprodução de vídeo/áudio, carregamento de imagens, transições CSS e eventos de ciclo de vida de animação.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| Evento | Dispara Quando | Objeto do Evento |
|---|---|---|
onPlay | A reprodução começa ou retoma | React.SyntheticEvent |
onPause | A reprodução é pausada | React.SyntheticEvent |
onEnded | A reprodução chega ao fim | React.SyntheticEvent |
onTimeUpdate | O tempo de reprodução atual muda (dispara frequentemente) | React.SyntheticEvent |
onVolumeChange | O volume ou o estado mudo mudam | React.SyntheticEvent |
onLoadedData | O primeiro frame da mídia foi carregado | React.SyntheticEvent |
onError | Falha ao carregar a mídia | React.SyntheticEvent |
onWaiting | A reprodução para devido a buffering | React.SyntheticEvent |
onSeeking | Uma operação de busca começa | React.SyntheticEvent |
onSeeked | Uma operação de busca é concluída | React.SyntheticEvent |
| Evento | Dispara Quando | Objeto do Evento |
|---|---|---|
onLoad | A imagem termina de carregar | React.SyntheticEvent |
onError | A imagem falha ao carregar | React.SyntheticEvent |
| Evento | Dispara Quando | Objeto do Evento |
|---|---|---|
onAnimationStart | Uma animação CSS começa | React.AnimationEvent |
onAnimationEnd | Uma animação CSS é concluída | React.AnimationEvent |
onAnimationIteration | Um loop de animação CSS reinicia | React.AnimationEvent |
| Evento | Dispara Quando | Objeto do Evento |
|---|---|---|
onTransitionEnd | Uma transição CSS é concluída | React.TransitionEvent |
Cartão de receita de referência rápida -- pronto para copiar e colar.
// Play/pause de vídeo
"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 ? "Pause" : "Play"}</button>
</div>
);
}// onLoad / onError de imagem
"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">Falha ao carregar imagem</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 fim de animação
"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>Eu vou desaparecer gradualmente</p>
<button onClick={handleDismiss}>Dispensar</button>
</div>
);
}Quando usar isso: Você precisa sincronizar o estado da UI com a reprodução de mídia, o ciclo de vida de carregamento de imagens ou a conclusão de animações/transições 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 progresso */}
<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="Seek"
/>
</div>
{/* Controles */}
<div className="flex items-center gap-4 p-4 text-white">
<button
onClick={togglePlay}
className="text-xl"
aria-label={playing ? "Pause" : "Play"}
>
{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 ? "Unmute" : "Mute"}>
{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="Volume"
/>
</div>
</div>
</div>
);
}O que isso demonstra:
onPlay, onPause e onEnded para rastrear o estado da reproduçãoonTimeUpdate para sincronizar uma barra de progresso com o tempo atualonLoadedData para capturar a duração do vídeoonVolumeChange para refletir as mudanças de volume e estado mudoonWaiting e onSeeked para o indicador de bufferingref enquanto escuta eventos para sincronização de estado<video> e <audio> nativos do navegador. O React os envolve como instâncias SyntheticEvent.onTimeUpdate dispara aproximadamente 4 vezes por segundo durante a reprodução (dependente do navegador, não ligado a requestAnimationFrame).onLoad de imagem dispara após a imagem ser totalmente decodificada e pronta para exibição. onError dispara se a URL da origem falhar.onAnimationEnd de CSS dispara uma vez por animação por elemento. Se múltiplas animações forem executadas no mesmo elemento, ele dispara para cada uma.onTransitionEnd dispara para cada propriedade CSS que transiciona. Transicionar all propriedades pode produzir múltiplos eventos.Lazy loading de imagem com fade-in ao carregar:
"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">
Falha ao carregar
</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>
);
}Encadeamento de animação com 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) => {
// Lida apenas com animações neste 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")}>Resetar</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 Cadeia de Animação</button>
) : (
<div
className={`p-8 bg-blue-500 text-white rounded-lg ${animationClass[phase]}`}
onAnimationEnd={handleAnimationEnd}
>
Fase: {phase}
</div>
)}
</div>
);
}Callback de conclusão de transição:
"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); // monta o conteúdo antes de expandir
requestAnimationFrame(() => setOpen(true));
} else {
setOpen(false);
}
}, [open]);
const handleTransitionEnd = useCallback(
(e: React.TransitionEvent) => {
// Responde apenas à transição de altura, não a outras propriedades
if (e.propertyName === "max-height" && !open) {
setRendered(false); // desmonta após a conclusão do colapso
}
},
[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>
);
}Noções básicas de visualização de áudio:
"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 progresso de vídeo com pré-visualização:
"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 mídia -- tipado para o 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;
}
// Eventos de elemento de áudio usam o mesmo tipo
function handleAudioEnd(e: React.SyntheticEvent<HTMLAudioElement>) {
const audio: HTMLAudioElement = e.currentTarget;
console.log("Áudio terminou em", audio.duration);
}
// Carga/erro de imagem
function handleImgLoad(e: React.SyntheticEvent<HTMLImageElement>) {
const img: HTMLImageElement = e.currentTarget;
const naturalWidth: number = img.naturalWidth;
const naturalHeight: number = img.naturalHeight;
}
// Eventos de animação
function handleAnimEnd(e: React.AnimationEvent<HTMLDivElement>) {
const name: string = e.animationName; // Nome da animação CSS
const elapsed: number = e.elapsedTime; // segundos
const pseudo: string = e.pseudoElement; // "::before", "::after", ou ""
}
// Eventos de transição
function handleTransEnd(e: React.TransitionEvent<HTMLDivElement>) {
const property: string = e.propertyName; // ex: "opacity", "transform"
const elapsed: number = e.elapsedTime; // segundos
const pseudo: string = e.pseudoElement;
}
// Tipagem de ref para elementos de mídia
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);onTransitionEnd dispara uma vez por propriedade -- Transicionar all ou múltiplas propriedades (ex: opacity e transform) dispara o evento múltiplas vezes. Correção: Verifique e.propertyName para responder apenas à propriedade que você se importa: if (e.propertyName !== "opacity") return;.
onAnimationEnd "borbulha" de elementos filhos -- Se um filho tem sua própria animação CSS, o evento "borbulha" para o seu manipulador. Correção: Compare e.target === e.currentTarget para garantir que você responde apenas à animação do seu elemento.
onTimeUpdate não dispara a cada frame -- Dispara aproximadamente 4 vezes por segundo, não 60. Isso o torna muito impreciso para UI de precisão de frame. Correção: Para barras de progresso suaves, use requestAnimationFrame com videoRef.current.currentTime em vez disso.
onError de mídia fornece informações mínimas -- O SyntheticEvent não inclui um código de erro diretamente. Correção: Acesse e.currentTarget.error (um objeto MediaError) com e.currentTarget.error?.code e e.currentTarget.error?.message para detalhes.
onLoad em <img> dispara também em imagens em cache -- Mas o tempo é diferente: imagens em cache podem disparar onLoad de forma síncrona durante a renderização. Correção: Sempre inicialize seu estado de carregamento como false e deixe onLoad defini-lo como true, em vez de assumir um tempo assíncrono.
Animações CSS em elementos desmontados não disparam onAnimationEnd -- Se você remover o elemento do DOM antes que a animação termine, o evento nunca dispara. Correção: Espere por onAnimationEnd antes de definir o estado que remove o elemento, ou use a Web Animations API para controle baseado em Promises.
play() retorna uma Promise que pode rejeitar -- Chamar videoRef.current.play() retorna uma Promise. Se o usuário não interagiu com a página, as políticas de autoplay a rejeitam. Correção: Sempre use await ou .catch() na promise de play: videoRef.current.play().catch(() => { /* lida com bloqueio de autoplay */ }).
| Alternativa | Use Quando | Não Use Quando |
|---|---|---|
Web Animations API (element.animate()) | Você precisa de animações controladas por JS com Promises e controle de reprodução | Keyframes CSS são suficientes |
Framer Motion (motion.div) | Você quer animações declarativas no React com entrada/saída/layout | Você só precisa lidar com eventos de mídia nativos |
requestAnimationFrame | Você precisa de rastreamento de progresso com precisão de frame para vídeo | A precisão de onTimeUpdate é aceitável |
<video> com HLS.js / dash.js | Você precisa de streaming adaptativo (ABR) | Um único arquivo MP4 funciona bem |
Next.js <Image> | Você quer otimização automática, lazy loading e placeholder de blur | Você precisa de controle granular de onLoad / onError com lógica personalizada |
onWaiting para detectar quando a reprodução para devido a dados insuficientesonSeeked ou onCanPlay para detectar quando dados suficientes estão disponíveis para retomaronWaiting e o próximo onSeeked/onCanPlayplay() retorna uma Promise que rejeita se o autoplay for bloqueadovideo.play().catch(() => { /* mostra botão de play */ })muted geralmente têm permissão para autoplayonAnimationEnd e mude a classe CSS para disparar a próxima animaçãoe.animationName para saber qual animação acabou de terminartransform e opacity, você obtém dois eventose.propertyName para responder apenas àquele que você se importaonLoadedData para obter video.durationonTimeUpdate para rastrear video.currentTime(currentTime / duration) * 100 para a porcentagem de progressorequestAnimationFrame em vez de onTimeUpdateconst [error, setError] = useState(false);
return error ? (
<div className="placeholder">Imagem indisponível</div>
) : (
<img
src={src}
alt={alt}
onError={() => setError(true)}
/>
);onAnimationIteration dispara no final de cada iteração para animações com animation-iteration-count maior que 1onAnimationEnd para issoanimationName e elapsedTime para identificar qual ciclo foi concluídoRevisado por Chris St. John·Última atualização: 7 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥