
회사에서 이번 분기 목표 중 하나는 신규 기능의 전환율을 높이는 사용자 몰입 경험을 만드는 것이었습니다.
그래서 팀이 함께 잡은 방향은 스크롤을 내릴수록 콘텐츠에 몰입되는 카드 스택 UI였습니다
카드 스택 UI는 아래와 같은 요구사항이 있었습니다
즉, 구현 핵심은 스크롤 스냅 + 스케일 변경 + 고정(sticky)의 완벽한 조합이었습니다
처음엔 이렇게 단순하게 접근했습니다
.card-section {
scroll-snap-align: start;
position: sticky;
transform: scale(0.9);
}
하지만 실제 스크롤을 해봤을때.. 스크롤 스냅이 버벅이는 현상이 발생했습니다.
문제의 상황을 claude ai를 통해 재현한 화면입니다
스크롤을 위로 올릴 경우에는 스무스해 보이나, 다시 아래로 내릴 경우 스냅이 되지 않고 버벅이는 모습입니다.

문제를 제대로 파악하기 위해 렌더링 과정을 뜯어봤습니다
- Style → 2. Layout → 3. Paint → 4. Composite(GPU layer) → 5. Scroll behaviors
각 속성이 개입하는 시점은 다음과 같습니다.
| 속성 | 렌더링 단계 | 설명 |
|---|---|---|
sticky | Layout | 스크롤 위치에 따라 top 기준으로 고정됨 |
scroll-snap | Scroll | Layout 결과를 기반으로 스냅 위치 계산 |
transform: scale() | Composite | GPU에서 시각적 변형만 수행 (Layout에 영향 없음) |
문제는 scale()이 Layout 결과를 변경하지 않는다는 점이었습니다.
눈으로는 작아졌지만 브라우저는 여전히 이전 크기 그대로 존재한다고 착각합니다
결과적으로 다음과 같은 문제기 생깁니다.
1. 브라우저 : sticky 요소는 이미 top: 0에 고정되어 있음
2. 사용자는 아직 화면 아래에 떠 있는 카드를 보고 있음
3. scroll-snap은 어느 시점에 스냅해야 할지 판단을 못함
브라우저는 카드가 화면의 어느 위치에 있는지에 대해 정확히 판단하지 못합니다.
결과적으로 scroll-snap의 기준점이 엇나가서 제대로 동작하지 않았습니다.
여러 번의 실험 끝에 내린 결론은 명확했습니다.
scroll-snap, sticky, transform(scale) 이 세개의 속성은 동시에 완벽하게 동작하지 않습니다
| 조합 | 동작 여부 |
|---|---|
| scroll-snap + sticky | 정상 작동 |
| sticky + scale | 정상 작동 |
| scroll-snap + scale | 정상 작동 |
| scroll-snap + sticky + scale | 비정상 |
결국 브라우저의 기본 스크롤 동작만으로는 원하는 형태의 전환을 구현하기 어렵다는 결론에 도달했습니다
요구사항과 완전히 일치하는 레퍼런스를 많이 찾아봤지만, scroll-snap과 sticky, scale을 동시에 다룬 사례는 없었습니다.
그렇게 오랜 생각 끝에.. 스크롤 스냅을 대체할만한 아이디어를 생각했습니다.
모든 섹션을 실제로 스크롤시키는 대신, 터치/휠 이벤트에 따라 각 섹션의 translateY 값을 직접 모두 커스텀해서 스크롤 스냅 기능을 똑같이 구현해보기로 했습니다.
먼저, 모든 카드를 absolute로 쌓아두고 top: 0 위치에 둡니다.
그 대신 translateY 값으로 각 카드의 실제 위치를 수동 계산합니다.
section.style.transform = `translateY(${translateY}px) scale(${scale})`;
각 섹션의 translateY는 현재 오프셋(offset)과 인덱스(idx)에 따라 계산됩니다.
스크롤을 내릴수록 오프셋이 음수로 커지고,카드의 위치와 스케일이 동시에 갱신됩니다.
| 변수 | 역할 |
|---|---|
offset | 전체 스크롤 위치 (음수 방향으로 증가) |
SECTION_GAP | 섹션 간 거리 (기준 단위) |
scale | 스크롤 진행도(progress)에 따라 점진적으로 감소 |
이제 모든 이동은 브라우저의 scrollTop이 아니라 offset 상태값으로 표현됩니다
결국 스크롤을 흉내 낸 transform 애니메이션인 셈인것입니다.
브라우저의 scroll-snap 대신, 휠 이벤트를 직접 감지해
한 번에 한 섹션씩만 이동하도록 제한했습니다.
const handleWheel = (e) => {
if (isAnimatingRef.current) return;
const direction = e.deltaY > 0 ? 1 : -1;
const currentSection = Math.round(-offset / SECTION_GAP);
const targetSection = clamp(
currentSection + direction,
0,
SECTION_COUNT - 1
);
setOffset(-targetSection * SECTION_GAP);
isAnimatingRef.current = true;
setTimeout(() => (isAnimatingRef.current = false), 500); // snap interval
};
작은 휠 입력(터치패드 등)은 무시하고, 한 번 스크롤에 정확히 한 카드만 전환되도록 했습니다.
모바일에서는 동일한 로직을 touchstart, touchmove, touchend로 구현했습니다
핵심은 스크롤 방향과 이동 거리를 감지해 어느 섹션으로 스냅할지를 판단하는 부분입니다.
const handleTouchEnd = () => {
if (Math.abs(dragDeltaY.current) > THRESHOLD) {
const direction = dragDeltaY.current < 0 ? 1 : -1;
const currentSection = Math.floor(-startOffsetRef.current / SECTION_GAP);
const targetSection = clamp(
currentSection + direction,
0,
SECTION_COUNT - 1
);
setOffset(-targetSection * SECTION_GAP);
} else {
const targetSection = Math.round(-offset / SECTION_GAP);
setOffset(-targetSection * SECTION_GAP);
}
};
결과적으로 PC와 모바일에서 동일한 스냅 경험을 제공할 수 있습니다
사용자는 자연스럽게 스와이프하거나 휠을 돌리는 것만으로 한 장씩 넘기는 카드 인터랙션을 경험하게 됩니다.
마지막으로, 카드가 상단에 닿기 시작하면 점진적으로 축소되도록 했습니다.
const progress = clamp(rawY / MAX_SHRINK_Y, 0, 1);
const scale = exceeded ? 1 - 0.2 * progress : 1;
스크롤의 진행도(progress)를 기반으로, 최대 20%까지 축소되게 설정했습니다
이때 카드 하단에는 살짝 어두운 overlay를 깔아 시각적으로 다음 카드가 덮여 올라오는 느낌을 강조했습니다
transform 애니메이션은 GPU 레벨에서 동작하므로 기본적으로 빠르지만 repaint가 발생합니다
이를 방지하기 위해 다음과 같은 최적화를 적용했습니다.
짠~ 완성 결과입니다.

한계를 마주하면서도 결국 스크롤 구조를 새로 설계하는 쪽으로 방향을 틀었고,
이래저래 많이 헤맸지만 결국은 요구사항을 완성하여 꽤나 인상적인 프로젝트였습니다.
스크롤뿐만 아니라 카드별로 다양한 애니메이션을 구현하면서 재밌었던 경험이었습니다!
잘봤습니다!ㅎㅎ
혹시 개인프로젝트에 사용하고싶은데, 데모 소스 같은게 있을지 그리고 공유가능할까용..?