mimizae 님의 블로그
ticker 구현기 본문
37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트에서 맡은 부분을 구현한 과정을 담은 글입니다! 🙌🏻

💡 내가 구현해야 할 UI
내가 맡은 공통 컴포넌트 중 하나인, ticker는 사용자의 건강검진 상태에 맞게 맞춤 건강 팁들을 홈에서 세로로 자동 스크롤 되며 보여주는 기능을 갖고 있다.

이 컴포넌트를 처음 보고 vetical로 자동으로 올라가는 걸 어떻게 구현할지 고민하다가 자동으로 올라간다는 것과 방향만 다를 뿐 이게 가로였다면... 캐러셀 그 자체인 것!!을 깨닫고 캐러셀의 원리를 공부하고 로직을 구성했다!
💡 기능 구현
이 ticker의 핵심 원리는 위치 이동 + 상태 관리이다.
기본 아이디어는 다음과 같다!
- 여러 개의 텍스트를 세로로 쭉 나열해 두고
- 지금 보여줘야 할 요소의 index를 state로 관리
- translateY를 이용해 컨테이너 자체를 위로 이동시켜 마치 한 줄씩 위로 올라가는 것처럼 보이게 하자.
1️⃣ 팁 배열 확장 (무한 루프를 위한 준비)
const tips = ["팁1", "팁2", "팁3"];
const extendedTips = ["팁1", "팁2", "팁3", "팁1"]; // 0번째 index를 확장한 버전
우선 마지막에 첫 번째 팁을 한 번 더 복제한 배열을 만든다.
이 복제된 팁은 새로운 데이터를 보여주기 위한 게 아니라 마지막에서 자연스럽게 한 칸 더 내려가는 애니메이션을 만들기 위한 장치이다!!
2️⃣ 자동으로 index를 증가시키는 로직
useEffect(() => {
const autoScrollInterval = setInterval(() => {
setIsTransitionEnabled(true);
setVisibleIndex((prev) => prev + 1);
}, 3000);
return () => clearInterval(autoScrollInterval);
}, []);
3초마다 visibleIndex를 1씩 증가시킨다. 이때는 항상 transition을 켜둔 상태로 이동한다.
결과적으로 translateY(-visibleIndex * ITEM_HEIGHT) 값이 바뀌면서 한 줄씩 위로 스르륵 이동한다!!
3️⃣ 마지막에 도달했을 때 순간이동 처리
이 부분이 Ticker의 핵심 포인트이다!!
visibleIndex === tips.length가 되는 순간, 즉 팁 배열의 마지막 아이템까지 보여준 순간은 복제된 팁 1이 화면에 보인 상태이다.
이 상태에서 transition을 끄고 index만을 강제로 0으로 되돌린다.
이때, transition이 꺼져 있고, index만 수정하는 것이기 때문에 사용자는 이 위치 변경을 전혀 인식하지 못하며!!!!! 실제로 화면에서는 다음과 같이 보일 것이다.
0px
↓
-35px (한 줄 아래로 -> 팁 2로)
↓
-70px (두 줄 아래로 -> 팁 3으로)
↓
-105px (복제 팁1으로 )
↓
(순간이동...)
0px (강제로, index만 변경!! 화면은 여전히 복제된 팁 1이 보인다)
↓
-35px (한 줄 아래로 -> 팁 2로)
복제된 팁 1 → 진짜 팁 1로 바뀌는 순간 화면에는 동일한 내용이 보이기 때문에 사용자는 위로 튕겨 다시 올라갔다는 느낌 없이 계속 아래로 흐르는 것처럼 인식하게 된다!!
(index가 교체될 뿐, 애니메이션은 없는 상태)
그 다음 interval에서 index가 다시 증가하면서 자연스럽게 팁 2 → 팁 3 → … 순서로 이어진다!!!
💡 UI
<div
className={cn(
"will-change-transform",
isTransitionEnabled && "transition-transform duration-400 ease-in-out",
)}
style={{ transform: `translateY(-${visibleIndex * ITEM_HEIGHT}px)` }}>
{extendedTips.map((tip) => (
<div
key={tip.id}
className="h-[3.5rem] overflow-hidden text-ellipsis whitespace-nowrap">
💭 모든 문자열을 세로로 쭉 늘어놓고, 컨테이너를 통째로 위로 밀어 올리면서 input 창 안에 보이는 한 줄만 바꿔 보여주는 구조
1️⃣ 위치 이동 방식 (translateY)
transform: translateY(-${visibleIndex * ITEM_HEIGHT}px)
각 팁은 고정된 높이(ITEM_HEIGHT)를 가지고 세로로 나열되어 있다.
현재 보여줄 팁의 index(visibleIndex)에 따라 translateY(-index * height) 값을 계산해서 컨테이너 전체를 아래로 이동시킨다!
실제 DOM 구조는 그대로 두고, 위치만 transform으로 이동하기 때문에 리플로우나 리페인트를 발생시키지 않고 합성 단계에서만 처리되어 성능상 유리하다고 판단했다!
2️⃣ inline style을 사용한 이유
style={{ transform: `translateY(-${visibleIndex * ITEM_HEIGHT}px)` }}
이 부분은 의도적으로 inline style을 사용했다.
translateY 값은 visibleIndex * ITEM_HEIGHT처럼 런타임 계산이 필요한 값인데, Tailwind 유틸 클래스는 정적인 값만 처리 가능하기 때문에 이 경우 순수 유틸로 표현하기가 어려웠다.
따라서 위치 이동처럼 상태에 따라 계속 바뀌는 값은 inline style로 처리하는 것이 가장 명확하고 안전한 방식이라고 판단했다!!
💡 구현 결과
import { Ticker } from "@/shared/ui/ticker/ticker";
const tips = [
{
id: 1,
title: '건강 팁 한 줄 쪼로록!'
},
{
id: 2,
title: '건강 팁이 한 줄이 넘어갈 경우에는 말 줄임표를 쓸래 말래 쓸래말래쓸래말래'
},
{
id: 3,
title: '단 거 적당히 드세요 ㅋㅋ'
},
]
export const HomePage = () => {
return (
<div className="p-[2rem] bg-primary-50">
<h1>CareNA</h1>
<Ticker tips={tips}/>
</div>
);
};

💡ETC
'FE' 카테고리의 다른 글
| button 공통 컴포넌트 구현 (0) | 2026.02.16 |
|---|---|
| 마이페이지 min-height 문제 (0) | 2026.02.10 |
| RouteHandle은 뭐고, 왜 라우트 단에서 헤더를 관리할 수 있을까? (0) | 2026.02.03 |
| React Route 초기 세팅 (0) | 2026.02.02 |
| Tanstack Query 초기 세팅 (0) | 2026.01.29 |