스크롤 기반 3D 인터랙션: ScrollControls와 useScroll
drei ScrollControls로 스크롤에 반응하는 3D 씬 — offset, range, curve 활용부터 HTML 오버레이까지
읽는 데 38분
- #scroll
- #ScrollControls
- #useScroll
- #react-three-fiber
- #drei
- #landing-page
이 문서의 목차
적용 환경: React 19+, Next.js 16+, three.js r182, @react-three/fiber 9+, @react-three/drei 10+
지금까지의 3D 데모는 모두 고정된 캔버스 안에 갇혀 있었습니다. 마우스로 회전하거나, 자동 애니메이션이 돌아가거나 — 어느 쪽이든 사용자가 "3D 뷰어 안에서" 상호작용하는 형태였습니다. 그러나 실제 프로덕션 3D 웹사이트를 떠올려 보면, 가장 많이 마주치는 패턴은 뷰어가 아닙니다. 스크롤입니다.
스크롤을 내리면 제품이 회전하고, 내부 구조가 드러나며, 텍스트와 함께 시퀀스가 전개되는 연출 — 이 모든 것이 스크롤 위치에 동기화된 3D 트랜스폼입니다. 포트폴리오 사이트, 자동차 브랜드 페이지, SaaS 랜딩 페이지에서 이 기법이 반복적으로 등장합니다. 스크롤은 웹에서 가장 보편적인 사용자 입력이기 때문에, 이를 3D 애니메이션의 타임라인으로 활용하면 별도의 UI 없이도 자연스러운 인터랙션을 만들 수 있습니다.
drei는 이 패턴을 ScrollControls와 useScroll이라는 두 가지 도구로 추상화합니다. 이 문서에서는 가장 단순한 "스크롤 → 회전" 매핑에서 출발해, 구간별 연출(range/curve), HTML 오버레이, 그리고 프로덕션 수준의 시네마틱 랜딩 페이지까지 단계별로 구축하는 과정을 다룹니다.
ScrollControls는 R3F Canvas 내부에 가상의 스크롤 영역을 생성하는 drei 컴포넌트입니다. 일반적인 페이지 스크롤과는 독립적으로 동작하며, 캔버스 위에 투명한 스크롤 가능 div를 덮어 네이티브 스크롤 이벤트를 캡처합니다. 캡처된 스크롤 위치는 0에서 1로 정규화되어 useScroll() 훅을 통해 하위 컴포넌트에 전달됩니다.
import { ScrollControls, Scroll } from '@react-three/drei'
<Canvas>
<ScrollControls pages={3} damping={0.25}>
{/* 3D 콘텐츠: useScroll()로 스크롤 데이터를 읽어 애니메이션 적용 */}
<MyModel />
{/* 물리적으로 스크롤되는 HTML 콘텐츠 */}
<Scroll html>
<h1 style={{ position: 'absolute', top: '33.33%' }}>두 번째 페이지</h1>
</Scroll>
</ScrollControls>
</Canvas>핵심 props는 두 가지입니다.
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
pages | number | 1 | 스크롤 영역의 길이 (캔버스 높이의 배수) |
damping | number | 0.25 | 스크롤 감속 강도 (작을수록 부드럽게 따라감) |
pages={3}으로 설정하면 캔버스 높이의 3배에 해당하는 스크롤 영역이 생성됩니다. 사용자가 이 영역을 처음부터 끝까지 스크롤하면 offset이 0에서 1까지 변화하며, 이 값을 3D 속성에 매핑하여 애니메이션을 구동합니다.
ScrollControls의 스크롤은 페이지 스크롤과 독립적
ScrollControls는 자체적인 스크롤 컨테이너를 생성하기 때문에, 캔버스 바깥의 페이지 스크롤과 충돌하지 않습니다. 사용자가 캔버스 영역 위에서 스크롤하면 ScrollControls가 이벤트를 캡처하고, 캔버스 바깥에서 스크롤하면 페이지가 정상적으로 스크롤됩니다.
ScrollControls 안에서 콘텐츠를 배치하는 방법은 세 가지입니다.
1. 직접 배치 (Scroll 없이): 3D 오브젝트가 제자리에 고정되고, useScroll()로 스크롤 데이터만 읽어 수동으로 애니메이션을 적용합니다. 가장 일반적인 패턴입니다.
2. <Scroll> 래퍼: 자식 3D 오브젝트가 스크롤에 따라 물리적으로 이동합니다. 스크롤하면 콘텐츠가 위로 밀려나는, 일반 웹 페이지의 스크롤과 유사한 동작입니다.
3. <Scroll html> 래퍼: DOM 요소를 3D 씬 위에 오버레이하면서 스크롤과 함께 이동시킵니다. "3D 모델 + 텍스트" 분할 레이아웃에 사용됩니다.
useScroll()은 ScrollControls의 스크롤 상태를 반환하는 훅입니다. 반환 객체에서 가장 자주 사용하는 속성과 메서드는 다음과 같습니다.
| 속성/메서드 | 타입 | 설명 |
|---|---|---|
offset | number | 전체 스크롤 진행률 (0 = 최상단, 1 = 최하단) |
delta | number | 이전 프레임 대비 스크롤 변화량 (속도 감지에 유용) |
range(from, distance) | (n, n) → number | 지정 구간에서 0→1로 선형 증가 |
curve(from, distance) | (n, n) → number | 지정 구간에서 0→1→0 사인 곡선 |
visible(from, distance) | (n, n) → boolean | 현재 스크롤이 해당 구간 안에 있는지 여부 |
useScroll은 반드시 useFrame 안에서 읽어야 합니다
useScroll()이 반환하는 값은 매 프레임 갱신됩니다. 이 값을 useEffect나 이벤트 핸들러에서 읽으면 React 렌더 사이클에서만 업데이트되기 때문에 부드러운 60fps 애니메이션이 불가능합니다. 반드시 useFrame 콜백 안에서 읽어야 매 프레임 최신 값이 반영됩니다.
가장 기본적인 사용 패턴은 offset을 읽어 3D 속성에 직접 매핑하는 것입니다.
import { useScroll } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
function RotatingModel() {
const scroll = useScroll()
const ref = useRef<THREE.Group>(null)
useFrame(() => {
if (!ref.current) return
// offset 0→1을 라디안 0→2π(한 바퀴)로 매핑
ref.current.rotation.y = scroll.offset * Math.PI * 2
})
return (
<group ref={ref}>
<primitive object={model} />
</group>
)
}offset은 0에서 1까지의 단일 값이므로, 곱하는 상수를 바꾸면 회전 수, 이동 거리, 스케일 범위 등을 자유롭게 조절할 수 있습니다.
퍼포먼스 팁: useFrame에서 DOM 조작 시 ref 직접 사용
스크롤에 연동된 HTML 요소(프로그레스 바 등)를 업데이트할 때는 useState 대신 ref.current.style을 직접 조작하는 것이 좋습니다. useFrame은 초당 60회 호출되므로, 매 프레임 setState를 하면 불필요한 React 리렌더가 반복됩니다. ref를 통한 DOM 직접 조작은 React 렌더 사이클을 우회하여 즉각 반영됩니다.
가장 단순한 형태의 스크롤 인터랙션은 offset 하나를 읽어 속성 하나를 구동하는 것입니다. 아래 데모에서는 offset을 Y축 회전에 매핑하여, 스크롤을 끝까지 내리면 모델이 정확히 한 바퀴 회전합니다.
핵심 코드는 단 세 줄입니다.
const scroll = useScroll()
useFrame(() => {
ref.current.rotation.y = scroll.offset * Math.PI * 2
})이 데모에서 <Scroll> 래퍼를 사용하지 않은 점에 주목할 필요가 있습니다. 모델이 물리적으로 이동할 필요 없이 제자리에서 회전만 하면 되기 때문에, ScrollControls 안에 직접 배치하고 useScroll()로 offset만 읽는 것으로 충분합니다. <Scroll>은 콘텐츠가 스크롤에 따라 물리적으로 이동해야 할 때만 필요합니다.
하단의 프로그레스 바는 <Html fullscreen>으로 구현되어 있으며, useFrame 안에서 ref.current.style.transform을 직접 조작하여 React 리렌더 없이 매 프레임 업데이트됩니다.
단일 offset 매핑은 간결하지만, 실제 프로덕션에서는 스크롤 구간에 따라 서로 다른 연출을 적용해야 하는 경우가 대부분입니다. 첫 번째 페이지에서는 모델이 등장하고, 두 번째 페이지에서는 색상이 변하고, 세 번째 페이지에서는 사라지는 식입니다. range()와 curve()는 이러한 "막(act)" 구조를 구현하기 위한 도구입니다.
// 스크롤이 from ~ from+distance 구간에 있을 때 0→1로 선형 증가
// 구간 전: 0, 구간 후: 1 (고정)
const act1 = scroll.range(0, 1/3) // 0~33%에서 0→1
const act2 = scroll.range(1/3, 1/3) // 33~66%에서 0→1
const act3 = scroll.range(2/3, 1/3) // 66~100%에서 0→1
// 용도: 한 방향으로 진행되는 변환
// 스케일 업, 색상 전환, 이동, 페이드// 같은 구간에서 0→1→0으로 사인 곡선 형태 반환
// 구간 중앙에서 최댓값(1), 구간 바깥에서 0
const wobble = scroll.curve(1/3, 1/3) // 33~66%에서 0→1→0
// 용도: 일시적인 효과
// 흔들림, 강조, 반짝임, 바운스range()는 구간을 통과하며 한 방향으로 진행되는 변환에 적합하고, curve()는 구간 중앙에서 최대가 되었다가 다시 사라지는 일시적인 효과에 적합합니다. 이 두 가지를 조합하면 복잡한 시퀀스도 선언적으로 구성할 수 있습니다.
이 데모는 3페이지를 세 개의 막(act)으로 나누어, 각 막에 서로 다른 조합의 변환을 할당합니다.
useFrame(() => {
// Act 1 (0~33%): 회전 + 스케일 업
const act1 = scroll.range(0, 1/3)
mesh.rotation.y = act1 * Math.PI * 2
mesh.scale.setScalar(lerp(0.6, 1.4, act1))
// Act 2 (33~66%): 색상 전환(range) + 흔들림(curve)
const act2 = scroll.range(1/3, 1/3)
const wobble = scroll.curve(1/3, 1/3)
material.color.copy(colorA).lerp(colorB, act2)
mesh.rotation.x = wobble * Math.sin(Date.now() * 0.003) * 0.4
// Act 3 (66~100%): 하강 + 페이드아웃
const act3 = scroll.range(2/3, 1/3)
mesh.position.y = lerp(0, -2, act3)
material.opacity = lerp(1, 0, act3)
})Act 2에서 range와 curve를 동시에 사용하는 부분이 핵심입니다. range로 색상을 인디고에서 오렌지로 영구 전환하면서, curve로 일시적인 흔들림을 함께 적용합니다. range가 1에 도달하면 색상 전환은 유지되지만, curve가 0으로 돌아가므로 흔들림은 사라집니다.
지금까지의 데모에서는 3D 오브젝트만 스크롤에 반응했습니다. 그러나 실제 웹사이트에서 3D 모델은 보통 텍스트, 스펙, CTA 버튼 같은 DOM 콘텐츠와 함께 배치됩니다. <Scroll html>은 이 패턴을 구현하기 위한 drei 컴포넌트로, 3D Canvas 위에 스크롤 가능한 HTML 레이어를 생성합니다.
이 데모의 레이아웃 패턴은 다음과 같습니다.
- 3D 모델: ScrollControls 안에 직접 배치하되
<Scroll>래퍼 없이 고정.useScroll()로 회전만 적용. - HTML 텍스트:
<Scroll html>안에 배치하여 스크롤과 함께 물리적으로 이동.
결과적으로 모델은 화면 왼쪽에 고정된 채 회전하고, 텍스트가 오른쪽에서 스크롤과 함께 흘러가는 분할 레이아웃이 완성됩니다.
<ScrollControls pages={3} damping={0.25}>
{/* 3D 모델은 고정, useScroll()로 회전만 적용 */}
<Environment preset="studio" />
<SteampunkCamera />
{/* HTML 텍스트는 스크롤과 함께 이동 */}
<Scroll html style={{ width: "100%", height: "300%" }}>
<section style={{ top: "5%", height: "28%" }}>페이지 1 텍스트</section>
<section style={{ top: "38%", height: "28%" }}>페이지 2 텍스트</section>
<section style={{ top: "71%", height: "28%" }}>페이지 3 텍스트</section>
</Scroll>
</ScrollControls>Scroll html 내부의 포지셔닝 규칙
<Scroll html>의 style prop으로 컨테이너의 height를 pages × 100%로 명시해야 합니다. pages=3이면 height: "300%"를 전달하면 컨테이너 높이가 캔버스 높이의 3배가 되며, 자식 요소의 top 값은 이 컨테이너를 기준으로 %로 지정합니다. 첫 번째 페이지는 033.33%, 두 번째는 33.3366.67%, 세 번째는 66.67~100%에 대응합니다. height를 생략하면 컨테이너 높이가 0이 되어 top: X% 포지셔닝이 동작하지 않습니다.
지금까지 다룬 개별 기법들 — offset 매핑, range()/curve() 구간 연출, <Scroll html> 오버레이 — 을 하나의 시퀀스로 결합하면 프로덕션 수준의 3D 랜딩 페이지를 구성할 수 있습니다. 아래 데모는 5페이지 분량의 시네마틱 시퀀스를 구현한 것입니다.
5막 구조로 설계된 이 시퀀스의 핵심 패턴은, 각 막(act)이 range()로 독립적인 0→1 값을 받되, 최종 막에서 앞선 변환을 역방향으로 되돌리는 것입니다.
useFrame(() => {
const enter = scroll.range(0, 1/5) // Act 1: 등장
const side = scroll.range(1/5, 1/5) // Act 2: 측면 회전
const zoom = scroll.range(2/5, 1/5) // Act 3: 줌 인
const tilt = scroll.range(3/5, 1/5) // Act 4: 틸트
const finale = scroll.range(4/5, 1/5) // Act 5: 복귀
// 위치: Act 1에서 세팅
group.position.y = lerp(-2, 0, enter)
group.scale.setScalar(lerp(0.3, 1.5, enter))
// 회전 Y: Act 2에서 세팅 - Act 5에서 차감
group.rotation.y = lerp(0, PI*0.75, side) - lerp(0, PI*0.75, finale)
// 틸트 X: Act 4에서 세팅 - Act 5에서 차감
group.rotation.x = lerp(0, 0.4, tilt) - lerp(0, 0.4, finale)
// Z축: Act 3에서 접근 - Act 5에서 차감
group.position.z = lerp(0, 1.5, zoom) - lerp(0, 1.5, finale)
})이 접근법의 장점은 각 range() 호출이 자기 구간에서만 값을 변화시키고, 구간 이후에는 1을 유지한다는 것입니다. Act 2에서 side가 1이 되면 회전이 0.75π로 고정되고, Act 3~4에서는 그 상태가 유지됩니다. Act 5에서 finale가 0에서 1로 진행되면 동일한 양이 차감되어 원래 값으로 돌아갑니다. 이렇게 하면 각 act의 로직이 독립적이면서도 전체 시퀀스가 자연스럽게 연결됩니다.
damping 튜닝 가이드
damping은 사용자의 물리적 스크롤과 3D 애니메이션 사이의 "탄성"을 결정합니다. 값이 작을수록 스크롤을 멈춘 뒤에도 3D 속성이 부드럽게 따라오며, 값이 클수록 즉각적으로 반응합니다.
| damping | 체감 | 적합한 용도 |
|---|---|---|
| 0.1 이하 | 매우 부드럽고 느린 추종 | 시네마틱 연출, 분위기 중심 페이지 |
| 0.2~0.3 | 적당한 탄성 | 대부분의 프로덕션 사이트 |
| 0.5 이상 | 거의 즉각 반응 | 정밀한 조작이 필요한 인터랙션 |
프로덕션에서는 0.2~0.3 범위에서 시작하여 콘텐츠 특성에 맞게 미세 조정하는 것이 일반적입니다.
모바일 터치 스크롤
ScrollControls는 터치 이벤트를 자동으로 처리하므로, 모바일 디바이스에서 별도의 설정 없이 스와이프로 동작합니다. 다만 스크롤 영역이 캔버스 전체를 덮기 때문에, 모바일에서 캔버스가 뷰포트 대부분을 차지하면 페이지 스크롤이 불가능해질 수 있습니다. 캔버스 크기를 적절히 조절하거나, 캔버스 밖에 충분한 여백을 두어 페이지 스크롤 경로를 확보하는 것이 좋습니다.
성능 고려사항
ScrollControls 자체의 오버헤드는 미미하지만, useFrame 안에서 매 프레임 실행되는 연산량에 주의해야 합니다. range()와 curve() 호출은 단순한 산술 연산이므로 성능 영향이 거의 없으나, 다수의 오브젝트에 복잡한 변환을 적용하는 경우 GPU 부하가 증가할 수 있습니다. 특히 material.color.lerp()처럼 매 프레임 새로운 색상 객체를 생성하는 패턴은 GC 압력을 유발할 수 있으므로, 색상 객체를 useMemo로 캐싱하고 copy().lerp() 패턴을 사용하는 것이 권장됩니다.
핵심 정리
ScrollControls는 Canvas 안에 가상 스크롤 영역을 생성하고, useScroll()은 스크롤 위치를 0~1의 정규화된 값으로 변환합니다.
offset: 전체 스크롤 진행률. 단일 속성 매핑에 적합range(from, distance): 특정 구간에서 0→1 선형 증가. "막(act)" 단위 연출의 핵심curve(from, distance): 같은 구간에서 0→1→0 사인 곡선. 일시적 효과에 사용<Scroll html>: 3D 씬 위에 스크롤 동기화된 HTML 오버레이
이 네 가지를 조합하면 프로덕션 수준의 3D 랜딩 페이지를 구현할 수 있습니다. 스크롤은 웹에서 가장 보편적인 입력이므로, 별도의 UI 없이도 직관적인 3D 인터랙션을 만들 수 있다는 것이 이 접근법의 가장 큰 장점입니다.
이 문서의 데모에 사용된 3D 모델입니다.
| 모델 | 파일 크기 | 라이선스 | 출처 |
|---|---|---|---|
| Steampunk Camera | 3.8 MB | MIT | three.js examples (새 창) |
- drei ScrollControls 문서 (새 창)
- drei useScroll 문서 (새 창)
- React Three Fiber 공식 문서 (새 창)
- three.js GLTFLoader (새 창)
- Sketchfab (새 창) — 3D 모델 라이브러리
- Poly Haven (새 창) — 무료 HDRI, 텍스처, 3D 에셋
관련 문서
글쓴이 mirunamu00



