본문 바로가기
Next.js/개발 노트

[React/Next.js] 여러 줄의 타이핑(multi line typing effect) 효과

by tokkiC 2023. 12. 31.

개인 포트폴리오 프로젝트 중 구현한 문자열 배열의 여러 줄 타이핑 효과이다.

한 줄만 타이핑 하는 효과의 구현은 검색으로 매우 쉽게 찾아 볼 수 있었지만, 항상 뭔가 더 추가로 더 해보고 싶은 마음에
여러 줄을 한번에 이어서 타이핑 하도록 구현해보았다.
거기에 추가로 여러개의 문자열을 번갈아가며 타이핑하도록 구현하였다.

태핑 타이핑!

핵심 원리를 요약하자면, useEffect내에서 상태로 관리 중인 문자열의 인덱스를 확인하고 settimeout을 사용해서 지연 시간 후에 다음 문자를 추가해준다. 근데 의존성 배열에 갱신되는 문자를 넣어서 지연시간 후에 문자가 갱신되면 다시 랜더링 되면서 useEffect로 같은 로직이 반복되는 원리이다.

코드의 각 줄마다 상세 설명을 주석으로 남겨두었다.
위의 코드는 문자열 배열을 타이핑할때이고, 아래엔  추가로 문자열 하나만 타이핑 할때의 코드도 작성해두었다.

Next.js 14와 타입스크립트를 사용하였지만, React에도 "use client" 를 제거하면 그대로 사용 가능하다.

"use client";

import React, { useEffect, useState } from "react";
import styles from "./MultiTypingEffect.module.css";

type MultiTypingEffectProps = {
  texts: string[];
};

export default function MultiTypingEffect({ texts }: MultiTypingEffectProps) {
  const [displayText, setDisplayText] = useState("");
  const [charIndex, setCharIndex] = useState(0);
  const [textIndex, setTextIndex] = useState(0); // props로 받은 문자열 배열에서의 인덱스
  const [isDeleting, setIsDeleting] = useState(false);

  const typingDelay = 60;
  const deletingDelay = 30;
  const endDelay = 1500;

  useEffect(() => {
    const currentText = texts[textIndex];
    // 타이머들을 관리하기 위한 변수를 선언함
    let timer: NodeJS.Timeout;

    // 현재 인덱스보다 한 글자 더 많은 문자를 displayText에 저장하여 타이핑 구현
    const typeChar = () => {
      setDisplayText(currentText.substring(0, charIndex + 1));
      setCharIndex(charIndex + 1);
    };

    // 현재 인덱스보다 한 글자 더 적은 문자를 displayText에 저장하여 삭제 구현
    const deleteChar = () => {
      setDisplayText(currentText.substring(0, charIndex - 1));
      setCharIndex(charIndex - 1);
    };

    // 삭제중이 아니고, charIndex가 전체 텍스트 길이보다 작으면 타이핑을 함
    if (!isDeleting && charIndex < currentText.length) {
      // 지연 시간 후에 한 글자를 추가하도록 함
      timer = setTimeout(typeChar, typingDelay);
      // 아래처럼 구현할 수도 있음. 아래의 경우 추가적인 기능을 추가하기 용이함
      // timer = setTimeout(() => {
      //   typeChar();
      // }, typingDelay);

      // 삭제 중이고 charIndex가 0보다 크면(삭제할 글자가 남아 있다면) 지연 시간 후에 한 글자를 삭제하도록 함
    } else if (isDeleting && charIndex > 0) {
      // 지연 시간 후에 한 글자를 삭제하도록 함
      timer = setTimeout(deleteChar, deletingDelay);

      // 삭제 중이고 charIndex가 0이면(삭제할 글자가 없다면) 다시 글자를 작성하기 위해 삭제중 여부를 false로 함
    } else if (isDeleting) {
      setIsDeleting(false);
      // 문자열의 텍스트를 모두 출력하면 다음 문자열 배열로 넘어가지만, 이미 배열의 마지막 요소라면 모듈러 연산으로 다시 0번째 요소로 넘어가게 됨
      setTextIndex((prevIndex) => (prevIndex + 1) % texts.length);

      // 삭제중이 아니고, charIndex가 전체 텍스트길이가 같은 경우이므로 텍스트를 모두 타이핑 한 경우임
    } else {
      // 다 쳤으니 잠시 지연 시간 동안 기다린 후에 삭제하도록 삭제 중을 true로 전환함
      timer = setTimeout(() => setIsDeleting(true), endDelay);
    }

    // 컴포넌트 언마운트, 혹은 의존성 배열의 것들이 하나라도 변경되면 클린업 함수를 실행하여 설정된 타이머를 지워버림
    return () => clearTimeout(timer);
    // 지연시간 후에 textIndex, charIndex, isDeleting 들이 변경되며 재랜더링 되므로, useEffect가 계속 반복하며 타이핑 효과를 갖도록 함
  }, [texts, textIndex, charIndex, isDeleting]);

  return (
    <div className={styles.typingEffect}>
      {displayText}
      <span className={styles.cursor} />
    </div>
  );
}

문자열 배열이 아닌, 하나의 문자열만 출력하고자 한다면 아래와 같이 구현이 가능하다. 뭐 큰 차이는 없다

"use client";

import React, { useEffect, useState } from "react";
import styles from "./TypingEffect.module.css";

type TypingEffectProps = {
  text: string;
};

export default function TypingEffect({ text }: TypingEffectProps) {
  const [displayText, setDisplayText] = useState(""); // 화면에 표시될 텍스트를 저장함
  const [charIndex, setCharIndex] = useState(0); // 현재 처리중인 문자의 인덱스
  const [isDeleting, setIsDeleting] = useState(false); // 타이핑이 끝나면 true로 바꾸어 삭제를 진행함

  const typingDelay = 80;
  const deletingDelay = 40;

  useEffect(() => {
    // 타이머들을 관리하기 위한 변수를 선언함
    let timer: NodeJS.Timeout;

    // 현재 인덱스보다 한 글자 더 많은 문자를 displayText에 저장하여 타이핑 구현
    const typeChar = () => {
      setDisplayText(text.substring(0, charIndex + 1));
      setCharIndex(charIndex + 1);
    };

    // 현재 인덱스보다 한 글자 더 적은 문자를 displayText에 저장하여 삭제 구현
    const deleteChar = () => {
      setDisplayText(text.substring(0, charIndex - 1));
      setCharIndex(charIndex - 1);
    };

    // 삭제중이 아니고, charIndex가 전체 텍스트 길이보다 작으면 타이핑을 함
    if (!isDeleting && charIndex < text.length) {
      // 지연 시간 후에 한 글자를 추가하도록 함
      timer = setTimeout(() => {
        typeChar();
      }, typingDelay);

      // 삭제 중이고 charIndex가 0보다 크면(삭제할 글자가 남아 있다면) 지연 시간 후에 한 글자를 삭제하도록 함
    } else if (isDeleting && charIndex > 0) {
      // 지연 시간 후에 한 글자를 삭제하도록 함
      timer = setTimeout(() => {
        deleteChar();
      }, deletingDelay);

      // 삭제 중이고 charIndex가 0이면(삭제할 글자가 없다면) 다시 글자를 작성하기 위해 삭제중 여부를 false로 함
    } else if (isDeleting) {
      setIsDeleting(false);

      // 삭제중이 아니고, charIndex가 전체 텍스트길이가 같은 경우이므로 텍스트를 모두 타이핑 한 경우임
    } else {
      // 다 쳤으니 잠시 지연 시간 동안 기다린 후에 삭제하도록 삭제 중을 true로 전환함
      timer = setTimeout(() => {
        setIsDeleting(true);
      }, 1500);
    }

    // 컴포넌트 언마운트, 혹은 의존성 배열의 것들이 하나라도 변경되면 클린업 함수를 실행하여 설정된 타이머를 지워버림
    return () => {
      clearTimeout(timer);
    };
    // 지연시간 후에 charIndex나 isDeleting를 변경하도록 하여, 다시 재랜더링 되어 useEffect가 계속 재랜더링 되며 타이핑 효과를 갖도록 함.
  }, [text, charIndex, isDeleting]);

  return (
    <div className={styles.typingEffect}>
      {displayText}
      <span className={styles.cursor} />
    </div>
  );
}
.typingEffect {
  white-space: pre-wrap; /* 개행 문자(줄바꿈.엔터)를 인식하고 자동 줄바꿈 처리 */
}

/* 깜박이는 커서 효과. caret은 텍스트의 커서를 뜻하는 영어임 */
.cursor {
  border-right-width: 0.2rem;
  border-right-style: solid;
  animation: blink-caret 0.75s step-end infinite; // step-end를 사용하여 커서 색이 점차 변하지 않게, 한번에 변해서 깜박이는 효과를 구현함
}

/* 커서가 투명해졌다 흰색이 됐다를 반복함. 내 프로젝트 배경이 검정이므로 커서 색을 흰색으로 설정 */
@keyframes blink-caret {
  from,
  to {
    border-color: transparent;
  }
  50% {
    border-color: white;
  }
}

댓글