DEV Community

Evenilson Liandro
Evenilson Liandro

Posted on

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 = useRef<number | null>(null);  // ID from requestAnimationFrame 
  const lastFrameTimeRef = useRef<number>(0);             // Timestamp of the last frame
  const charIndexRef = useRef<number>(0);                 // Current character index
  const phraseIndexRef = useRef<number>(0);               // Current phrase index   
  const isDeletingRef = useRef<boolean>(false);           // Whether we are currently deleting
  const pauseUntilRef = useRef<number | 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
}

Enter fullscreen mode Exit fullscreen mode

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>
  )
}

Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.