스크롤이 카메라가 되는 웹사이트, 처음부터 만드는 법

스크롤이 카메라가 되는 웹사이트, 처음부터 만드는 법

스크롤을 내리면 화면이 바뀌는 사이트는, 방문자가 가장 먼저 “와” 하고 반응하는 종류입니다. 동시에 가장 많이 실패하는 종류이기도 합니다. 흔한 실패는 화려함이 부족한 게 아닙니다. 스크롤과 화면이 서로 다른 이야기를 하는 겁니다. 카메라는 앞으로 가는데 카피는 “위로 올라갑니다”라고 말하고, 클립과 클립 사이에서 화면이 한 번 튀고, 모바일에서는 영상이 빈 화면이 됩니다.

이 글은 그 사이트를 처음부터 만드는 방법입니다. 개념만 훑지 않습니다. 어떤 엔진을 고를지, 페이지 높이를 어떻게 잡을지, 스크롤 값을 시간으로 어떻게 바꿀지, 이음매가 왜 깨지는지까지 코드와 함께 적었습니다. 끝까지 읽고 나면 빈 HTML 하나에서 출발해, 스크롤에 묶인 페이지를 실제로 붙일 수 있어야 합니다.

도구 이야기도 빼지 않겠습니다. 에이전트용 스킬 두 개가 이 작업을 서로 다른 방식으로 자동화합니다. Scroll-Film Studio는 브랜드마다 페이지를 처음부터 설계하고, 코드만으로 된 필름(Lane A)과 생성 영상 필름(Lane B) 두 길을 엽니다. scroll-world는 장면 정지 컷과 카메라 클립을 미리 만든 뒤, 프레임워크에 묶이지 않은 스크럽 엔진에 끼워 넣습니다. 겉모습은 비슷해 보여도 엔진이 다릅니다. 그 차이가 이 글의 핵심입니다.

먼저 고를 것: 무엇을 스크롤에 묶을 것인가

스크롤 애니메이션은 한 가지 기술이 아닙니다. 스크롤이라는 입력에 무엇을 연결하느냐에 따라 전혀 다른 제품이 됩니다. 현장에서 실제로 쓰는 길은 세 가지입니다.

패럴랙스 레이어, 고정된 스테이지, 캔버스 프레임 스크럽을 나란히 놓은 세 가지 스크롤 엔진 모형
왼쪽부터 패럴랙스, 고정 스테이지(GSAP), 프레임 스크럽. 스크롤은 하나고 엔진은 셋입니다.
  • 패럴랙스 — 같은 장면을 여러 겹으로 나누고, 가까운 겹일수록 더 많이 움직이게 합니다. 구현이 가볍고, “깊이”를 싸게 살 수 있습니다. 여정은 없습니다. 한 장면이 숨 쉬는 느낌입니다.
  • GSAP + Lenis + ScrollTrigger — 스크롤을 타임라인의 재생헤드로 씁니다. 화면을 고정(pin)한 채 마스크가 열리고, 가로로 달리고, 글자가 올라옵니다. 영상 생성비가 없고, 브랜드마다 완전히 다른 페이지를 만들 수 있습니다. Scroll-Film Studio의 Lane A가 이 길입니다.
  • 미리 렌더한 카메라 — 진짜 영상을 프레임 단위로 스크롤에 묶습니다. 카메라는 이미 촬영이 끝났고, 페이지는 그 시간을 재생할 뿐입니다. Scroll-Film Studio의 Lane B와 scroll-world가 이 길입니다. 둘의 차이는 뒤에서 따로 풉니다.

세 길을 섞을 수는 있습니다. 다만 한 페이지의 주인공은 하나여야 합니다. 카메라가 이미 도시를 날고 있는데 뒤에 패럴랙스 산이 따로 움직이면, 방문자는 어디를 봐야 할지 모릅니다. 주인공을 고른 뒤에 나머지는 조연으로만 쓰세요.

공통 뼈대: 긴 페이지 + 달라붙는 화면

엔진이 달라도 뼈대는 같습니다. 페이지를 아주 길게 만들고, 그 안의 무대를 position: sticky; top: 0; height: 100vh로 뷰포트에 붙입니다. 방문자는 스크롤을 내리지만, 눈앞의 사각형은 그대로입니다. 그 사각형 안에서만 장면이 바뀝니다.

기다란 페이지 안에 뷰포트 크기의 사각 무대가 눈높이에 고정되어 있는 건축 모형
스크롤은 페이지를 밀어 올리고, 무대는 창문에 붙어 있습니다. 진행률은 그 창이 페이지의 어디를 지나는지로 계산합니다.

진행률 p는 이 공식 하나로 충분합니다. film은 긴 스크롤 드라이버, 보통 챕터당 1.5~1.8 뷰포트 높이입니다. 5챕터면 대략 850vh입니다.

const r = film.getBoundingClientRect();
const p = Math.max(0, Math.min(1, -r.top / (r.height - innerHeight)));

p = 0은 필름의 첫 프레임, p = 1은 마지막 프레임입니다. 이 값을 어디로 보내는지가 엔진의 정체입니다. GSAP는 타임라인의 progress로, 캔버스 스크럽은 프레임 인덱스로, 비디오 스크럽은 currentTime으로 보냅니다.

직접 매핑하면 기계적으로 느껴집니다. 재생헤드는 한 칸 늦게 따라오게 하세요. 계수는 취향이지만, 코드 필름은 0.09 근처, 프레임 스크럽은 0.14 근처가 많이 쓰입니다.

current += (target - current) * 0.14;

이 한 줄이 “스크롤이 카메라가 된다”는 말의 실체입니다. 스크롤은 목표 시각을 정하고, 화면은 그 시각을 부드럽게 쫓습니다.

패럴랙스부터: 가장 싼 깊이

패럴랙스(parallax)는 가까운 물체가 더 빨리 움직이는 착시입니다. 웹에서는 배경·중간·전경을 세 겹으로 쌓고, 스크롤량에 서로 다른 계수를 곱하면 됩니다.

들판과 집, 언덕이 서로 다른 깊이로 떨어져 떠 있는 분해도. 패럴랙스의 겹 구조
같은 풍경을 네 겹으로 쪼갠 모습입니다. 가까운 꽃은 많이, 먼 언덕은 거의 움직이지 않습니다.
<section class="hero">
  <div class="layer far" data-speed="0.15"></div>
  <div class="layer mid" data-speed="0.4"></div>
  <div class="layer near" data-speed="0.8"></div>
  <h1 class="layer copy" data-speed="0.25">Brand</h1>
</section>
const layers = [...document.querySelectorAll("[data-speed]")];
addEventListener("scroll", () => {
  const y = scrollY;
  for (const el of layers) {
    const s = Number(el.dataset.speed);
    el.style.transform = `translate3d(0, ${y * -s}px, 0)`;
  }
}, { passive: true });

규칙 네 가지입니다.

  1. 움직이는 속성은 transformopacity만 씁니다. top이나 margin을 스크롤마다 바꾸면 레이아웃이 다시 계산됩니다.
  2. 계수는 0과 1 사이에 두세요. 1보다 크면 손가락보다 화면이 빨리 달아납니다.
  3. 겹의 속도 차이는 고르게 하세요. 0.1 / 0.8 / 0.85처럼 한 겹만 튀면 깊이가 아니라 오류로 보입니다.
  4. prefers-reduced-motion: reduce에서는 계수를 0으로 두거나, 겹을 한 장으로 합치세요.

패럴랙스는 히어로와 전환에 좋습니다. 여정 전체를 맡기면 금방 지겨워집니다. 카메라는 가지 않고 배경만 미끄러지기 때문입니다.

Lenis가 하는 일, GSAP가 하는 일

두 라이브러리를 한 덩어리로 부르는 사람이 많습니다. 역할은 다릅니다.

Lenis는 스크롤 자체를 부드럽게 만듭니다. 휠 한 칸이 페이지를 몇 픽셀 밀지, 그 밀림이 얼마나 늦게 따라올지를 정합니다. 기본값은 대략 이런 식입니다.

const lenis = new Lenis({ lerp: 0.09, smoothWheel: true });
function raf(time) {
  lenis.raf(time);
  requestAnimationFrame(raf);
}
requestAnimationFrame(raf);

lerp가 작을수록 더 늦게, 더 묵직하게 따라옵니다. 0.09는 “고급 사이트”에서 자주 보는 무게입니다. 0.2로 올리면 거의 기본 스크롤에 가까워집니다. 터치 디바이스에서는 브라우저 고유 스크롤을 존중하는 편이 안전합니다. Lenis도 그걸 전제로 설계되어 있습니다.

GSAP은 애니메이션 엔진입니다. 무엇을, 어떤 이징으로, 얼마 동안 바꿀지를 타임라인에 적습니다. 스크롤과 연결하는 플러그인이 ScrollTrigger입니다. 둘을 같이 쓰려면 Lenis의 스크롤 이벤트를 ScrollTrigger에 알려 주고, GSAP 틱에서 Lenis를 돌리면 됩니다.

const lenis = new Lenis({ lerp: 0.09, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);

이 네 줄이 Lane A의 심장입니다. 이제 스크롤은 GSAP 타임라인의 재생헤드입니다.

고정 장면 — pin + scrub

가장 많이 쓰는 패턴입니다. 섹션을 화면에 고정한 채, 스크롤이 타임라인을 밀어 줍니다.

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: "#scene-hive",
    start: "top top",
    end: "+=140%",
    pin: true,
    scrub: true,
    anticipatePin: 1,
  },
});

tl.from(".wordmark span", { yPercent: 120, stagger: 0.04, ease: "power4.out" })
  .to(".mask", { clipPath: "inset(0% 0% 0% 0%)", ease: "none" }, 0.15)
  .to(".orb", { rotate: 28, scale: 1.12, ease: "none" }, 0);

end: "+=140%"는 “이 장면을 스크롤로 1.4 화면만큼 재생한다”는 뜻입니다. 숫자가 클수록 같은 동작이 천천히 unfolding 됩니다. scrub: true는 스크롤과 1:1, scrub: 1.2는 1.2초 늦게 따라옵니다. 필름처럼 보이게 하려면 true 또는 작은 숫자입니다.

가로 질주

세로 스크롤을 가로 이동으로 바꿉니다. 트랙 너비에서 창 너비를 뺀 만큼 왼쪽으로 밀면 됩니다.

const track = document.querySelector(".h-track");
gsap.to(track, {
  x: () => -(track.scrollWidth - innerWidth),
  ease: "none",
  scrollTrigger: {
    trigger: ".h-wrap",
    start: "top top",
    end: () => "+=" + (track.scrollWidth - innerWidth),
    pin: true,
    scrub: true,
    invalidateOnRefresh: true,
  },
});

자식에게 따로 패럴랙스를 주고 싶다면 containerAnimation에 위 트윈을 넘깁니다. 그러면 가로로 달리는 동안에도 카드마다 속도가 달라집니다. 창 크기가 바뀌면 거리가 달라지므로 invalidateOnRefresh: true는 필수입니다.

그 밖에 바로 쓸 어휘

  • 글자 분해 — 워드마크를 글자 단위 span으로 쪼개고 yPercent: 120 → 0을 스태거합니다.
  • clip-path 리빌inset(0 0 100% 0)에서 inset(0)으로 열면 편집 잡지처럼 행이 드러납니다.
  • 속도 왜곡ScrollTrigger.getVelocity()를 클램프해서 마퀴에 skewX를 줍니다. 빨리 내릴수록 글이 기울어집니다.
  • 카운터once: true로 한 번만 재생하고 snap: { textContent: 1 }로 정수를 찍습니다.

순서가 entrapment인 이유

ScrollTrigger는 만든 순서대로 위치를 다시 계산합니다. 고정 장면은 자기 뒤에 보이지 않는 스페이서를 집어넣습니다. 배경 트리거를 먼저 만들면, 그 스페이서가 생기기 전의 좌표를 기준으로 동작합니다. 결과는 “효과가 수천 픽셀 일찍 터지는” 버그입니다. 고치는 법은 단순합니다. 고정 장면을 전부 만든 뒤에 배경·패럴랙스·마퀴를 만드세요.

성능 규칙도 짧습니다. GPU가 다루는 속성만 움직입니다. will-change는 실제로 움직이는 몇 개에만 붙입니다. 틱 안에서 getBoundingClientRect를 남발하지 않습니다.

두 스킬이 갈라지는 지점

여기까지는 공용 기초입니다. 이제 두 스킬이 영상을 다루는 방식이 갈립니다. 둘 다 “스크롤하면 카메라가 움직인다”고 말합니다. 재생 장치가 다릅니다.

Scroll-Film Studio / Lane B는 영상을 JPEG 낱장으로 뽑은 뒤 <canvas>에 그립니다. <video>currentTime은 쓰지 않습니다. 시크 지연 때문에 스크롤이 끊기기 때문입니다. 재생헤드는 프레임 인덱스이고, 디코딩은 createImageBitmap으로 메인 스레드 밖에서 합니다.

scroll-world는 반대로 <video>를 그대로 스크럽합니다. 장면마다 mp4를 Blob으로 받아 시크 가능하게 만들고, requestAnimationFrame에서 currentTime을 목표 시각으로 밉니다. 장면 안으로 들어가는 클립(dive)과 장면 사이를 잇는 클립(connector)을 번갈아 재생합니다. 미니어처 세계를 날아다니는 그 느낌이 이 구조에서 나옵니다.

한 줄로 고르면 이렇습니다. 한 편의 연속 샷을 만들고 싶으면 Studio의 캔버스 길. 섬과 섬을 날아 다니는 세계를 만들고 싶으면 scroll-world의 비디오 길. 전자는 프레임 수가 품질이고, 후자는 이음매의 픽셀 일치가 품질입니다.

길 1 — 코드 필름 (Studio Lane A)

계정과 크레딧이 필요 없습니다. HTML 하나, GSAP, ScrollTrigger, Lenis면 됩니다. CDN으로 시작해도 되고, 배포 전에는 파일을 로컬에 복사하세요.

<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1/dist/lenis.min.js"></script>

페이지는 템플릿을 복사하지 않습니다. 브랜드마다 여정이 다르기 때문입니다. 순서는 이렇게 고정하는 편이 안전합니다.

  1. 한 줄 느낌과 여정을 문장으로 적습니다. “카메라는 오직 안으로만 들어간다.”처럼 방향이 보여야 합니다.
  2. 팔레트 hex, 디스플레이+본문 서체, 로고 SVG를 정합니다. 시스템 폰트만으로 된 스크롤 사이트는 전부 같아 보입니다.
  3. 챕터를 5개 안팎으로 나눕니다. 각 챕터는 고정 장면 하나, 또는 가로 질주 하나입니다.
  4. 고정 장면의 ScrollTrigger를 먼저 만들고, 그다음 패럴랙스와 마퀴를 붙입니다.
  5. 필름이 끝난 뒤에야 일반 섹션(이야기, 가격, CTA)이 나옵니다. 필름 아래에 또 필름을 두지 마세요.

복붙해서 바로 열리는 최소 골격입니다. 브랜드 색과 카피만 바꾸면 첫 장면이 동작합니다.

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Brand</title>
  <style>
    html, body { margin: 0; background: #0b0d10; color: #eef1f4; }
    #scene { height: 100vh; display: grid; place-items: center; overflow: hidden; }
    .orb { width: 42vmin; height: 42vmin; border-radius: 50%;
           background: radial-gradient(circle at 30% 30%, #d7c4a3, #3a2a1c); }
    .word { font: 600 12vw/1 Georgia, serif; letter-spacing: -.04em; }
    section { min-height: 100vh; padding: 18vh 8vw; }
  </style>
</head>
<body>
  <div id="scene">
    <div class="orb"></div>
    <h1 class="word">Brand</h1>
  </div>
  <section>
    <p>필름이 끝난 자리입니다. 여기서부터는 제품 이야기만 적습니다.</p>
  </section>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/lenis@1/dist/lenis.min.js"></script>
  <script>
    const lenis = new Lenis({ lerp: 0.09, smoothWheel: true });
    lenis.on("scroll", ScrollTrigger.update);
    gsap.ticker.add((t) => lenis.raf(t * 1000));
    gsap.ticker.lagSmoothing(0);

    gsap.timeline({
      scrollTrigger: {
        trigger: "#scene",
        start: "top top",
        end: "+=160%",
        pin: true,
        scrub: true,
      },
    })
    .from(".word", { y: 80, opacity: 0, ease: "none" })
    .to(".orb", { scale: 3.2, rotate: 18, ease: "none" }, 0);
  </script>
</body>
</html>

이 골격 위에 장면을 더할 때는 “동작의 목록”이 아니라 “한 방향의 여정”으로 붙이세요. 첫 장면이 들어갔으면 다음 장면은 더 들어가거나, 더 가까워지거나, 더 밝아져야 합니다. 순서를 바꿔도 어색하지 않으면 여정이 아니라 나열입니다.

길 2 — 한 편의 필름을 캔버스에 긋기 (Studio Lane B)

이 길은 영상이 먼저입니다. 페이지는 그 영상을 스크롤로 재생하는 영사기입니다. 기본 길이는 클립 5개 × 5초 = 25초, 24fps면 약 600프레임입니다. 더 길게 만들고 싶으면 클립을 늘리세요. 클립 하나를 9초로 늘리는 쪽이 아닙니다. 한 클립이 장소와 조명과 방향을 한꺼번에 바꾸면, 모델은 이동하는 대신 순간이동합니다.

클립 하나 = 카메라 방향 하나, 장소 하나, 조명 하나. 셋 중 하나라도 바뀌면 클립을 나눕니다. 영화 전체의 진행 방향은 한 문장으로 먼저 정합니다.

프롬프트는 장면이 아니라 이동을 씁니다. “벌집 입구 샷”이 아니라 “같은 전진을 이어가며 벌집 벽을 지나간다”입니다. 안쪽 단어(into, deeper, down)와 바깥 단어(away, back, up)를 한 클립에 같이 넣지 마세요. 카메라가 스스로 방향을 뒤집습니다.

이음매는 픽셀이 같아야 한다

클립을 이어 붙일 때 가장 흔한 실수는, 미리 그려 둔 예쁜 키프레임을 다음 클립의 시작으로 넣는 것입니다. 다음 클립의 시작은 앞 클립의 실제 마지막 프레임이어야 합니다. 이상적인 그림이 아닙니다. 렌더된 픽셀입니다.

다섯 가닥의 필름이 한 줄로 이어져 있고, 한 이음매만 어긋나 있는 사진
네 곳은 마지막 칸과 다음 첫 칸이 같은 그림입니다. 한 곳만 어긋나면 그 지점에서 화면이 튑니다.

검사는 눈으로만 하지 않습니다. 두 프레임의 SSIM을 재고, 0.80 아래면 다시 뽑습니다. 맨 끝 프레임은 아티팩트가 끼기 쉬우니 살짝 앞에서 뽑습니다. 전 구간을 미리 설계한 경우에는 0.05초 앞, 끝을 열어 둔 방식에서는 0.15초 앞입니다.

이음매가 맞아도 클립 한가운데가 점프할 수 있습니다. 시크 게이트는 양 끝만 보기 때문입니다. 그래서 이어 붙인 뒤에는 8프레임마다 한 번씩 중간을 샘플링하는 연속성 검사가 필요합니다. 국소 변화가 45%를 넘거나, 거의 같은 프레임이 반복되면 그 클립은 다시 뽑습니다.

낱장을 뽑는 법

마스터를 만든 뒤 프레임은 원본 프레임레이트로 뽑습니다. 600장을 300장으로 줄이면 스크롤이 슬라이드쇼가 됩니다. 용량은 가로 폭으로 줄이세요. 1024px, JPEG 품질 6이 1280px 고품질보다 스크롤에서는 더 좋아 보입니다. 움직임이 선명함보다 먼저 보이기 때문입니다.

ffmpeg -v error -y -i master.mp4 \
  -vf "fps=24,scale=1024:-2" -q:v 6 frames/f_%04d.jpg

생성 영상은 아직 움직이지 않는 장면으로 시작하는 경우가 많습니다. 앞 1~2초를 프레임 단위로 보고, 이미 이동이 시작된 장에서 자르세요. 자른 뒤에는 FRAME_COUNT를 꼭 고칩니다. 없는 프레임을 찾다가 스크롤 끝에서 화면이 비워집니다.

마지막 프레임의 아래 12%에서 평균색을 뽑아 다음 섹션 배경으로 씁니다. 필름이 끝나고 본문이 시작될 때 색이 튀지 않습니다.

캔버스 스크럽 엔진

<video currentTime>은 쓰지 않습니다. JPEG를 캔버스에 그립니다. 핵심은 ImageBitmap 슬라이딩 윈도우입니다. drawImage(HTMLImageElement)는 첫 페인트와 캐시 축출 때마다 메인 스레드에서 JPEG를 디코딩합니다. 그 스파이크가 “한 장씩 끊기는” 느낌의 정체입니다.

const FRAME_COUNT = 600;          // 자른 뒤의 실제 장수
const LERP = 0.14;
const B_AHEAD = 48;               // 약 2초 (24fps)
const B_KEEP = 32;

const images = new Array(FRAME_COUNT);
const bitmaps = new Map();
const decoding = new Set();
let current = 0;
let target = 0;
let bmpCenter = -999;

function frameUrl(i) {
  return `frames/f_${String(i + 1).padStart(4, "0")}.jpg`;
}

// 동시 10장씩만 받습니다. 전부 기다렸다가 그리면 첫 화면이 늦습니다.
let nextToLoad = 0, inFlight = 0;
function pump() {
  while (inFlight < 10 && nextToLoad < FRAME_COUNT) {
    const i = nextToLoad++;
    inFlight++;
    const img = new Image();
    img.onload = () => { images[i] = img; inFlight--; pump(); };
    img.onerror = () => { inFlight--; pump(); };
    img.src = frameUrl(i);
  }
}
pump();

function ensureBitmaps(center) {
  if (Math.abs(center - bmpCenter) < 3) return;
  bmpCenter = center;
  const lo = Math.max(0, center - B_AHEAD);
  const hi = Math.min(FRAME_COUNT - 1, center + B_AHEAD);
  for (let i = lo; i <= hi; i++) {
    if (bitmaps.has(i) || decoding.has(i) || !images[i]) continue;
    decoding.add(i);
    createImageBitmap(images[i]).then((b) => {
      decoding.delete(i);
      if (Math.abs(i - bmpCenter) > B_KEEP) { b.close(); return; }
      bitmaps.set(i, b);
    }).catch(() => decoding.delete(i));
  }
  for (const k of [...bitmaps.keys()]) {
    if (k < center - B_KEEP || k > center + B_KEEP) {
      bitmaps.get(k).close();
      bitmaps.delete(k);
    }
  }
}

function nearest(i) {
  if (bitmaps.has(i)) return bitmaps.get(i);
  if (images[i]) return images[i];
  for (let d = 1; d < 24; d++) {
    if (bitmaps.has(i - d)) return bitmaps.get(i - d);
    if (bitmaps.has(i + d)) return bitmaps.get(i + d);
    if (images[i - d]) return images[i - d];
    if (images[i + d]) return images[i + d];
  }
  return null;
}

const canvas = document.querySelector("#frame");
const ctx = canvas.getContext("2d", { alpha: false });
const film = document.querySelector("#film");

function fit(src) {
  const MAX_CROP = 0.22;
  const cw = canvas.width, ch = canvas.height;
  const sCover = Math.max(cw / src.width, ch / src.height);
  const crop = 1 - Math.min(cw / (src.width * sCover), ch / (src.height * sCover));
  const s = crop > MAX_CROP ? Math.min(cw / src.width, ch / src.height) : sCover;
  const w = src.width * s, h = src.height * s;
  ctx.drawImage(src, (cw - w) / 2, (ch - h) / 2, w, h);
}

function resize() {
  const dpr = Math.min(devicePixelRatio || 1, 1.5);
  canvas.width = innerWidth * dpr;
  canvas.height = innerHeight * dpr;
  canvas.style.width = innerWidth + "px";
  canvas.style.height = innerHeight + "px";
}
addEventListener("resize", resize);
resize();

function tick() {
  const r = film.getBoundingClientRect();
  target = Math.max(0, Math.min(1, -r.top / (r.height - innerHeight))) * (FRAME_COUNT - 1);
  current += (target - current) * LERP;
  const i = Math.round(current);
  ensureBitmaps(i);
  const src = nearest(i);
  if (src) fit(src);
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

마크업은 이것으로 충분합니다. 필름 높이는 챕터 수에 비례합니다.

<div id="film" style="height:850vh">
  <div id="stage" style="position:sticky;top:0;height:100vh">
    <canvas id="frame"></canvas>
  </div>
</div>
<section style="background:#1a0f08">...</section>

모바일에서 cover로 그리면 16:9 필름의 양쪽이 잘립니다. 잘리는 비율이 22%를 넘으면 contain으로 바꿔 레터박스를 허용하세요. 가운데 26%만 남긴 채 1.5배로 확대하면, 멀쩡한 스크럽도 흔들려 보입니다.

윈도우 크기는 프레임 수가 아니라 초 단위로 잡습니다. 24fps에서 앞으로 2초면 48장입니다. 300장짜리 필름에 맞춘 18장을 1,700장짜리 필름에 그대로 쓰면, 빠른 플릭 한 번에 창 밖으로 나갑니다.

길 3 — 세계 위를 날아 들어가기 (scroll-world)

scroll-world는 한 편의 긴 필름이 아닙니다. 장면 N개의 정지 컷, 각 장면으로 들어가는 클립 N개, 장면과 장면을 잇는 클립 N-1개입니다. 카메라는 가게 밖에서 안으로 들어가고, 지붕 위로 빠져나와 다음 섬으로 날아갑니다. 미니어처·아이소메트릭 세계에 맞는 문법입니다. 실제 복도를 걷는 영상에 쓰면 되감기처럼 보입니다.

엔진은 바닐라 JS 한 파일입니다. 컨테이너 하나만 넘기면 DOM과 CSS를 스스로 만듭니다. Next든 정적 HTML이든 같습니다.

mountScrollWorld(document.getElementById("world"), {
  brand: { name: "Pearl & Co.", href: "#top" },
  diveScroll: 1.3,
  connScroll: 0.9,
  hint: "scroll",
  sections: [
    {
      id: "farms",
      label: "Farms",
      still: "stills/01.webp",
      clip: "clips/dive-01.mp4",
      accent: "#8FB98A",
      eyebrow: "Origin",
      title: "The farms",
      body: "Leaves before the cup.",
      tags: ["harvest", "leaf"],
    },
    // …
  ],
  connectors: ["clips/conn-01.mp4", "clips/conn-02.mp4"],
});

엔진이 하는 일은 명확합니다. 스크롤 위치를 세그먼트(진입/이동)로 나누고, 각 세그먼트의 로컬 진행률을 만든 뒤, 비디오의 currentTime을 그 진행률로 밉니다. 세그먼트 경계에서는 이웃 클립을 짧게 크로스페이드합니다. 복사는 장면 한가운데에서 가장 진해집니다.

// 엔진 내부의 핵심만 옮기면 이렇습니다.
s.cur += (s.target - s.cur) * 0.18;
const t = clamp(s.cur, 0, 0.999) * s.video.duration;
if (Math.abs(s.video.currentTime - t) > 0.008) {
  s.video.currentTime = t;
}

비디오 스크럽이 버벅이지 않게 하는 장치가 몇 가지 있습니다. 그대로 가져가야 하는 것들입니다.

  • 클립은 fetch로 받아 Blob URL을 붙입니다. HTTP 바이트 레인지에 의존하지 않습니다.
  • 디코더가 이전 시크를 처리 중이면 다음 시크를 넣지 않습니다. 빠른 플릭이 쌓이면 화면이 멈춥니다.
  • 모바일은 더 큰 시크 간격(0.02초)과 더 짧은 GOP(-g 4)의 720p 파일을 씁니다. 폰 디코더의 비용은 해상도보다 키프레임까지의 거리입니다.
  • iOS는 첫 터치에서 muted play→pause로 디코더를 깨웁니다. 그렇지 않으면 첫 시크가 빈 화면입니다.
  • 포스터 정지 컷은 첫 프레임이 실제로 그려질 때까지 내리지 않습니다.
  • 주소창이 접히며 발생하는 높이-only 리사이즈는 무시합니다. 트랙 높이를 다시 계산하면 스크롤이 튕깁니다.

카메라 문법도 선택이 아니라 세계관입니다. 미니어처면 들어가고 빠져나오는 비행이 자연스럽습니다. 실사 공간이면 한 방향으로만 미끄러지는 워크스루가 맞습니다. 고정 아이소메트릭은 각도를 바꾸지 않고 세계가 미끄러지게 합니다. 세 문법을 한 페이지에서 섞지 마세요.

미니어처 도시에 두 갈래 카메라 경로가 금색 선으로 표시된 디오라마
왼쪽은 들어가고 빠져나오는 비행, 오른쪽은 뒤로 빠지지 않는 한 줄 활공입니다. 세계가 다르면 카메라도 달라야 합니다.

이음매를 손으로 이을 때도 규칙은 같습니다. 다음 클립의 시작은 앞 클립의 실제 마지막 프레임이고, 클립 하나는 방향·장소·조명이 하나여야 하며, 프롬프트는 장면이 아니라 이동을 적습니다. 이 세 줄을 어기면 검사는 통과해도 화면은 점프합니다.

페이지가 자기 설명을 하면 실패다

기술적으로 완벽한 페이지가 실패하는 가장 흔한 이유는 카피입니다. “스크롤하면 화면이 좁아집니다”, “한 번의 연속 하강입니다”, “이 페이지 읽는 법” 같은 문장은 브리프를 방문객에게 읽어 주는 일입니다. 영상을 못 보는 사람이 읽어도 모든 문장이 제품 광고여야 합니다.

확인하는 법은 단순합니다. 화면을 끄고 카피만 읽습니다. 브랜드와 제품 이야기만 남아야 합니다. 스크롤, 프레임, 카메라, 이음매라는 단어가 본문에 있으면 아직 브리프입니다. (이 글은 튜토리얼이라 예외입니다. 여러분이 만드는 랜딩 페이지는 예외가 아닙니다.)

모바일, 모션 축소, 성능

데스크톱 필름을 가운데만 잘라 폰에 넣지 마세요. 구도는 처음부터 깨집니다. 모바일 버전이 필요하면 9:16을 따로 찍습니다. 크레딧이 없다면 크롭은 임시 표기를 하고 양해를 받으세요. 엔진의 폰 대응(시크 합치기, iOS 프라임, safe-area)은 “모바일 버전”이 아닙니다. 페이지가 깨지지 않게 하는 기본값입니다.

prefers-reduced-motion: reduce에서는 스크럽을 멈추고 포스터 정지 컷만 크로스페이드하세요. Lenis의 부드러운 휠도 끄고, GSAP 타임라인은 최종 상태로 점프합니다. 움직임이 곧 내용인 페이지일수록 이 분기가 중요합니다.

성능은 평균 fps로 판단하지 않습니다. 60의 평균은 80ms 디코드 스파이크를 가립니다. rAF 간격의 p95와 max를 보세요. max가 50ms를 넘으면 방문자는 끊김을 느낍니다. devicePixelRatio는 1.5에서 자르세요. 2.0은 blit 비용만 두 배입니다.

오늘 만들 수 있는 최소 순서

  1. 여정을 한 문장으로 적습니다. 방향이 보여야 합니다.
  2. 크레딧이 없으면 Lane A로 갑니다. 위 골격을 붙여 고정 장면 하나와 본문 하나를 만듭니다.
  3. 영상이 있으면 클립 규칙을 먼저 검사합니다. 방향 단어가 충돌하면 생성하지 않습니다.
  4. 클립을 순차로 뽑습니다. 다음 시작은 앞 클립의 실제 마지막 프레임입니다.
  5. 이음매 SSIM 0.80, 도입부 트리밍, 원본 fps 낱장, FRAME_COUNT 갱신.
  6. 캔버스 스크럽 또는 scroll-world 엔진에 연결합니다. 페이지 카피에서 기계 설명을 지웁니다.
  7. 모션 축소와 모바일에서 한 번씩 스크롤합니다. 빈 화면과 점프가 없어야 합니다.

스크롤 사이트는 라이브러리 자랑이 아닙니다. 방문자의 손가락이 카메라가 되게 만드는 일입니다. 엔진은 고르면 됩니다. 고른 뒤에는 그 엔진이 요구하는 규칙을 끝까지 지키면 됩니다. 그 규칙이 이 글의 전부입니다.

#스크롤 애니메이션#GSAP#Lenis#패럴랙스#스크롤 필름