Efeito de máquina de escrever com React usando requestAnimationFrame

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5175

    #1

    Efeito de máquina de escrever com React usando requestAnimationFrame

    Quando navegamos em sites com apelo visual — portfólios, landing pages, hero sections — é comum vermos efeitos de texto digitando automaticamente, como uma máquina de escrever. Embora pareça simples, muitos exemplos ainda usam setTimeout ou setInterval para isso.


    Mas se buscamos algo mais preciso, performático e responsivo, o requestAnimationFrame é o caminho certo.





    O problema de setTimeout ou setInterval

    Essas abordagens funcionam, mas têm sérias desvantagens:
    • Não são sincronizadas com o render do navegador
    • Continuam rodando mesmo se a aba estiver em segundo plano
    • Consomem mais CPU em animações longas
    • Podem criar efeitos instáveis se o usuário mudar de aba ou se a aplicação for pesada





    Usar requestAnimationFrame é o caminho

    O requestAnimationFrame é uma API nativa do navegador feita para isso. Ele:
    • Executa o código antes de cada frame ser renderizado
    • Suspende automaticamente quando a aba fica em segundo plano
    • Garante maior precisão temporal e fluidez
    • É a base de qualquer animação moderna, como Framer Motion, GSAP, etc.





    O hook useTypeWriter com React

    Vamos criar um hook chamado useTypeWriter, que:
    • Digita e apaga uma lista de frases
    • Permite configurar a velocidade de digitação e remoção
    • Suporta loop e pausas entre as frases
    • Usa apenas React e requestAnimationFrame (sem libs externas)





    import { useEffect, useRef, useState } from "react";

    interface UseTypeWriterProvider {
    texts: string[];
    writeSpeed?: number;
    eraseSpeed?: number;
    pauseBeforeDelete?: number;
    pauseBetweenPhrases?: number;
    loop?: boolean;
    onCycleComplete?: () => void;
    }

    export function useTypeWriter({
    texts,
    writeSpeed = 100,
    eraseSpeed = 50,
    pauseBeforeDelete = 1000,
    pauseBetweenPhrases = 500,
    loop = false,
    onCycleComplete = () => { }
    }: UseTypeWriterProvider) {
    const [displayed, setDisplayed] = useState(""); // Current visible text

    // Refs to control typing state without causing re-renders
    const animationFrameRef = useRefnumber | null>(null); // ID from requestAnimationFrame
    const lastFrameTimeRef = useRefnumber>(0); // Timestamp of the last frame
    const charIndexRef = useRefnumber>(0); // Current character index
    const phraseIndexRef = useRefnumber>(0); // Current phrase index
    const isDeletingRef = useRefboolean>(false); // Whether we are currently deleting
    const pauseUntilRef = useRefnumber | null>(null); // Pause between transitions

    useEffect(() => {
    let isCancelled = false;

    // Cancel any ongoing animation before starting a new one
    if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);

    const step = (time: number) => {
    if (isCancelled) return;
    const currentText = texts[phraseIndexRef.current] || "";

    // Handle pauses (e.g., between typing and deleting)
    if (pauseUntilRef.current && time pauseUntilRef.current) {
    animationFrameRef.current = requestAnimationFrame(step);
    return;
    };

    const delta = time - lastFrameTimeRef.current;
    const speed = isDeletingRef.current ? eraseSpeed : writeSpeed;

    // Continue only if the delay time has passed
    if (delta >= speed) {
    if (!isDeletingRef.current) {
    // Typing mode
    charIndexRef.current = Math.min(charIndexRef.current + 1, currentText.length);
    setDisplayed(currentText.slice(0, charIndexRef.current));

    // Reached the end of the phrase
    if (charIndexRef.current >= currentText.length) {
    isDeletingRef.current = true;
    pauseUntilRef.current = time + pauseBeforeDelete; // Wait before deleting
    }
    } else {
    // Deleting mode
    charIndexRef.current -= 1;
    setDisplayed(currentText.slice(0, charIndexRef.current));

    // Finished deleting
    if (charIndexRef.current 0) {
    isDeletingRef.current = false;
    const nextIndex = phraseIndexRef.current + 1;

    if (nextIndex >= texts.length) {
    if (loop) {
    phraseIndexRef.current = 0;
    } else {
    onCycleComplete?.(); // Notify parent
    return; // Stop animation
    }
    } else {
    phraseIndexRef.current = nextIndex;
    }
    charIndexRef.current = 0;
    pauseUntilRef.current = time + pauseBetweenPhrases; // Pause briefly before typing the next phrase
    }
    }
    lastFrameTimeRef.current = time; // Update last action time
    }

    // Keep animation going
    animationFrameRef.current = requestAnimationFrame(step);
    };

    // Start animation loop
    animationFrameRef.current = requestAnimationFrame(step);

    // Clean up on unmount or re-run
    return () => {
    isCancelled = true;
    if (animationFrameRef.current) {
    cancelAnimationFrame(animationFrameRef.current);
    }
    };

    }, [writeSpeed, eraseSpeed, loop, texts, onCycleComplete, pauseBeforeDelete, pauseBetweenPhrases]);

    return displayed
    }











    Exemplos de uso





    import { useTypeWriter } from "./hooks"

    export function App() {
    const text = useTypeWriter({
    texts: ["Hello, world!", "Welcome to my site."],
    writeSpeed: 100,
    eraseSpeed: 50,
    loop: true
    })

    return (
    main className="bg-zinc-900 w-full h-screen flex items-center justify-center">
    span className="text-zinc-100 text-7xl">{text}span>
    main>
    )
    }











    Conclusão

    Esse hook é muito bom para:
    • Hero sections de portfólios
    • Interfaces de onboarding
    • Aplicações com personalidade e movimento
      E sem depender de libs externas.


    Curtiu o hook? Veja o código no GitHub e compartilhe com alguém que curte animações em React:

    github.com/evenilson/use-typewriter




    More...
Working...