리액트에서 마퀴를 구현할 상황이 생겨서 간단하게 구현해보았다.

내가 원하는 효과는 다음과 같았다.
필요한 package들은 다음과 같다.
yarn add framer-motion
yarn add @emotion/styled @emotion/react
yarn add react-use-measure
스타일 파일과 로직 파일을 구분해서 구현하였다.
// src/Components/MarqueeText.styled.tsx
import styled from "@emotion/styled";
const MarqueeContainer = styled.div`
  position: relative;
  overflow: hidden;
`;
const MarqueeContent = styled.div<{ gap: number }>`
  display: flex;
  flex-direction: row;
  gap: ${({ gap }) => `${gap}px`};
`;
const MarqueeTextContent = styled.p`
  white-space: nowrap;
`;
const FadedRight = styled.div`
  position: absolute;
  top: 0;
  right: 0;
  width: 24px;
  height: 100%;
  background: linear-gradient(90deg, transparent, white);
`;
export { MarqueeContainer, MarqueeContent, MarqueeTextContent, FadedRight };
/** React */
import React, { useRef } from "react";
/** Hooks */
import { useAnimationFrame } from "framer-motion";
/** Utils */
import useMeasure from "react-use-measure";
/** Styled Components */
import {
  MarqueeContainer,
  MarqueeContent,
  MarqueeTextContent,
  FadedRight,
} from "./MarqueeText.styled";
type MarqueeTextProp = {
  stopDuration?: number;
  nextPositionGap?: number;
  initialStop?: boolean;
  children: React.ReactNode;
};
const MarqueeText = ({
  stopDuration = 3,
  nextPositionGap = 48,
  initialStop = true,
  children,
}: MarqueeTextProp) => {
  // Text to be moving
  const animateRef = useRef<HTMLDivElement>(null);
  // Container and Text Width
  const [containerRef, { width: containerWidth }] = useMeasure();
  const [textRef, { width: textWidth }] = useMeasure();
  // Whether marquee is needed or not
  const isMarquee = textWidth > containerWidth;
  // Variable about stop-interval
  const isStopMoving = useRef(initialStop);
  const lastSec = useRef(0);
  // Amount of moving
  const moveAmount = useRef(0);
  useAnimationFrame((time, _) => {
    if (!isMarquee) return;
    moveAmount.current += 0.35;
    const isReachLeft =
      moveAmount.current >=
      containerWidth + (textWidth - containerWidth + nextPositionGap);
    if (isReachLeft && !isStopMoving.current) {
      isStopMoving.current = true;
      lastSec.current = time;
    }
    if (isStopMoving.current) {
      const stopDuractionValue = (time - lastSec.current) / 1000;
      if (stopDuractionValue >= stopDuration) {
        isStopMoving.current = false;
        moveAmount.current = 0;
        lastSec.current = 0;
      }
    } else {
      animateRef.current.style.transform = `translateX(-${moveAmount.current}px)`;
    }
  });
  return (
    <MarqueeContainer ref={containerRef}>
      <MarqueeContent ref={animateRef} gap={nextPositionGap}>
        <MarqueeTextContent ref={textRef}>{children}</MarqueeTextContent>
        {isMarquee && <MarqueeTextContent>{children}</MarqueeTextContent>}
      </MarqueeContent>
      <FadedRight />
    </MarqueeContainer>
  );
};
export default MarqueeText;
주요 포인트는 아래와 같다.
요 Hooks은 Framer Motion에서 제공하는 Hook인데, 잘은 모르겠지만 javascript에서 제공하는 requestAnimationFrame과 엇비슷한 느낌이 아닐까 싶다.
requestAnimationFrame
The window.requestAnimationFrame() method tells the browser that you wish to perform an animation and requests that the browser calls a specified function to update an animation right before the next repaint. The method takes a callback as an argument to be invoked before the repaint.
useAnimationFrame
An animation loop that outputs the latest frame time to the provided callback.
Runs a callback once every animation frame
브라우저는 60프레임을 기준(모니터 주사율에 따라 다르겠지만 일반적으로)으로 애니메이팅을 하는데, 이 60프레임 안에 애니메이션이 끝나야 최적의 애니메이션을 보여줄 수 있다. 만약 60프레임이 넘어가버리면 브라우저에서는 다음 animationframe에서 마저 처리하게 되는데, 이 간극에서 끊기는 듯한 애니메이션이 발생한다.
이를 방지하기 위해 적절한 animationframe에서 애니메이팅 하게 도와주는 머~ 그런 역할이라고 생각하면 될 것 같다.
계속 x가 움직이게 하는 건 어려운 일이 아닌데, 잠깐 멈추게 만드는 것에서 약간 헤맸다. 처음에 x를 움직이게 하는 건 useAnimationFrame에서 넘겨주는 time으로 지정해주고 있었다.
const sec = time / 1000;
const movingAmount = sec * 24
이렇게 한 다음, movingAmount이 다음 텍스트가 맨 왼쪽에 다다랐는가를 검사하고, 만약 닿았다면 Stop Flag를 세워서 애니메이션을 하지 않게 하는 방법이었다.
const isReachLeft = movingAmount >= containerWidth + (textWidth - containerWidth) + positionGap
let isStopMoving = false;
if (isReachLeft) {
  isStopMoving = true;
}
그런 다음 reach했을 때의 time을 저장해두고, 현재 time과 비교하여 3초가 지났다면 다시 isStopMoving을 풀고 하는 방식이었다.
왜 이런 방식으로 했냐면,, 변수를 최대한 쓰지 않고 제공하는 변수인 time으로 계산을 끝내고 싶었다. 그리고 이 생각은 날 삽질로 이끌었고 ...
머 암튼 그래서 이 방식으론 구현할 수 없겠다라는 판단이 들어서 따로 useRef로 변수를 두고 amount를 직접 올려주는 방법으로 수정하였다.
이것도 어떻게 구현할까 고민이 많았는데~ 내가 생각했을 때 가장 정확한 방법은 mix-blend-mode를 쓰는 것이었다. linear하게 white-black을 가진 div를 만들고, 오른쪽에다가 multiply를 하면 자연스럽게 사라지니깐
const FadedLeft = {
  position: absolute;
  top: 0;
  right: 0;
  width: 24px;
  height: 100%;
  background: linear-gradient(90deg, white, black);
  mix-blend-mode: multiply;
}
다만 이 방법은 몇 번 굴려보니까 조금 문제가 있더라요.
mix-blend-mode가 적용이 돼서, 그 전까진 white-black이 그대로 나타난다.multiply를 하는 거라, 만약 단색이 아니라면 조금 이상하게 보일 수도 있다.그래서 그냥~ 유연하지 않게 transparent로 처리했다.
background: linear-gradient(90deg, transparent, white);
MarqueeText 구현 끝!