GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로

스크롤 트리거 애니메이션이란 화면을 내리다가 요소가 뷰포트에 들어오는 순간 움직임을 시작하게 하는 장치입니다. GSAP 없이 IntersectionObserver 하나로 아홉 개의 엔진을 직접 만들었고, 아래 격자에서 지금 돌아갑니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개를 뽑은 축은 엔진이 갖춰지는 차례입니다. 관찰자 본체가 먼저 서고(01), 임계값을 재는 눈금이 달리고(02), 한 번만 켤지 매번 켤지(03)와 카운터(04)라는 첫 소비자가 붙습니다. 그다음부터는 시각이 화면을 씁니다 — 단어가 순서대로 흐르고(05), 방향(06)과 속도(07)를 알아차리고, 층이 갈라져 깊이가 생기고(08), 마지막에 섹션이 화면에 붙습니다(09). 관찰자라는 부품은 CSS 스크롤 애니메이션 9종과 CSS 패럴랙스 스크롤 9종이 JS 0줄로 밀던 자리를 정반대에서 채우는 축입니다 — 그 두 편은 브라우저에게 맡기고, 이 편은 직접 엔진을 돌립니다. 읽는 위치를 표시하는 쪽은 스크롤 인디케이터 9종이, 장면을 핀으로 고정하는 쪽은 스크롤 스토리텔링 섹션 9종이 따로 다룹니다.

01트리거 엔진 코어

화면에 들어왔는지 지켜보다가 is-visible 클래스를 붙이는 엔진 본체로, 진입 방향 네 가지와 임계값·한 번만·미리 감지 여백을 전부 data-scroll 데이터 속성으로 HTML 에 적습니다.

트리거 엔진 코어
새 창에서 열기

01 · 트리거 엔진 코어 — 측정 누적면적 20.597% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

IntersectionObserverdata-thresholdis-visible

관찰자 한 벌이 무엇을 읽는가

function attach(card) {
  var threshold = parseFloat(card.getAttribute('data-threshold')) || 0;
  var once = card.getAttribute('data-once') === 'true';
  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (entry.isIntersecting) {
        entry.target.classList.add('is-visible');
        if (once) io.unobserve(entry.target);
      } else if (!once) {
        entry.target.classList.remove('is-visible');
      }
    });
  }, { root: view, rootMargin: '0px 0px -8px 0px', threshold: threshold });
  io.observe(card);
  observers.push(io);
}

root 를 페이지가 아니라 스크롤 상자로 지정한 것이 첫 줄의 차이이고, 아래쪽 -8px 는 요소가 뷰포트 맨 아래에 걸렸을 때 8px 더 들어와야 켜지게 미는 값이라 실제 페이지에서는 -10% 처럼 퍼센트로 쓰는 편입니다.

CSS 는 클래스 하나만 알면 된다

.te__card {
  opacity: 0;
  transition: opacity $dur-enter $easing, transform $dur-enter $easing;
}
.te__card[data-scroll="up"] { transform: translateY(18px); }
.te__card[data-scroll="left"] { transform: translateX(-24px); }
.te__card.is-visible {
  opacity: 1;
  transform: none;
}

data-scroll 값마다 처음 위치가 다르지만 끝은 transform: none 하나로 같고, 시점은 관찰자·모양은 transition 이라는 이중 구조가 트리거 계열의 css 스크롤 내리면 나타나는 애니메이션 전부를 돌립니다.

02임계값 게이지

요소가 화면에 얼마나 들어왔는지를 intersectionRatio 0 에서 1 로 게이지와 숫자로 실측해 보여주고, 임계값 후보를 바꾸면 불이 켜지는 시점이 달라집니다.

임계값 게이지
새 창에서 열기

02 · 임계값 게이지 — 측정 누적면적 20.001% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

intersectionRatiothreshold: [0, 0.25, 0.5, 1]scaleX

값은 계단으로만 온다

function paint(ratio, isIn) {
  numEl.textContent = ratio.toFixed(2);
  fill.style.transform = 'scaleX(' + ratio + ')';
  var on = isIn && ratio >= selected - 0.01;
  led.classList.toggle('is-on', on);
  stateEl.textContent = on ? '임계값 도달' : '대기';
}
var io = new IntersectionObserver(function (entries) {
  entries.forEach(function (entry) {
    paint(entry.intersectionRatio, entry.isIntersecting);
  });
}, { root: view, threshold: [0, 0.25, 0.5, 1] });
io.observe(target);

관찰자는 임계값을 지날 때마다 콜백을 부르므로 값은 연속이 아니라 계단으로 갱신되는데, 실측에서도 스크롤을 내리면 숫자가 0.00 → 0.07 → 0.25(점등) → 0.61 → 1.00 다섯 칸으로 끊겨 갱신됐고 게이지의 scaleX 는 항상 숫자와 같은 값을 그렸습니다.

threshold 배열이 곧 콜백 지도

threshold: [0, 0.25, 0.5, 1] 은 비율이 이 네 값을 가로지를 때마다 알려달라는 주문이라, 후보를 0.25 에서 1 로 바꾸면 같은 동작에서 scrollTop 92 까지 와서야 불이 켜졌습니다 — 콜백이 오는 시점 자체가 배열이 정하고, 카드가 뷰포트보다 크면 비율이 1 에 도달하지 못하니 긴 섹션에는 임계값 1 을 쓰면 안 됩니다.

03한 번만 vs 매번

같은 진입 애니메이션을 한쪽은 unobserve 로 딱 한 번 재생하고, 다른 쪽은 지날 때마다 다시 켭니다.

한 번만 vs 매번
새 창에서 열기

03 · 한 번만 vs 매번 — 측정 누적면적 23.665% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

unobserveoffsetWidthdata-once

리플로 트릭으로 애니메이션을 되살린다

var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (entry.isIntersecting) {
        card.classList.remove('is-in');
        void card.offsetWidth; // 리플로 트릭 — 여기서 강제 재계산이 일어나야 클래스를 다시 붙일 때 애니메이션이 처음부터 돈다
        card.classList.add('is-in');
        plays += 1;
        dots.forEach(function (d, i) { d.classList.toggle('is-lit', i < Math.min(plays, 3)); });
        if (once) io.unobserve(card); // 왼쪽 — 딱 한 번 재생하고 관찰을 멈춘다
      } else if (!once) {
        card.classList.remove('is-in'); // 오른쪽 — 벗어나면 초기화, 다음 진입 때 다시 재생된다
      }
    });
  }, { root: view, threshold: 0.5 });

한 번 붙었던 클래스를 지웠다 다시 붙여도 브라우저는 같은 스타일 변경으로 보고 transition 을 다시 돌리지 않으므로, 그 사이에 void card.offsetWidth 로 강제 재계산을 한 번 일으켜 다음 부착이 새 transition 이 되게 합니다.

세 번 왕복을 재면

실측에서 세 번 왕복 후 왼쪽 칸의 재생은 1회, 오른쪽은 3회였고 transitionstart 이벤트로 세면 1 대 6 — 오른쪽은 진입과 퇴장이 매 왕복마다 두 번씩 transition 을 시작하기 때문입니다.

04카운터 트리거

뷰포트에 들어온 순간 requestAnimationFrame 으로 숫자를 0 부터 목표까지 올리고, 끝나면 관찰을 멈춥니다.

카운터 트리거
새 창에서 열기

04 · 카운터 트리거 — 측정 누적면적 13.421% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFrameeaseOutCubicunobserve

requestAnimationFrame 이 초를 잰다

function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
// 관찰 콜백 안에서:
      io.unobserve(entry.target); // 여기서 관찰을 멈춘다 — 다시 올라와도 두 번 세지 않는다
      var t0 = null;
      function tick(ts) {
        if (t0 === null) t0 = ts;
        var p = Math.min((ts - t0) / MS, 1);
        var eased = easeOutCubic(p);
        numEl.textContent = String(Math.round(TARGET * eased));
        bar.style.transform = 'scaleX(' + eased + ')';
        if (p < 1) requestAnimationFrame(tick);
        else done.classList.add('is-shown');
      }
      requestAnimationFrame(tick);

시간은 setInterval 이 아니라 프레임 타임스탬프 ts 의 차이로 재는 것이 핵심이라 탭이 백그라운드에서 쉬면 프레임도 시간도 함께 쉬어서 숫자가 건너뛰지 않습니다.

감속 곡선을 얹는 이유

실측에서 카운터는 0 → 42 → 92 → 138 → 248 로 단조 증가하며 easeOutCubic 의 감속 곡선을 그렸고, 위로 되돌렸다 다시 내려와도 248 그대로라 두 번 세지 않았습니다 — 곡선이 없으면 끝이 갑자기 멈춰 보입니다.

05타임라인 시퀀스

문장을 단어로 쪼개 각 조각이 transition-delay 를 순번 곱으로 받게 하면, 진입 한 번에 타임라인처럼 흘러갑니다.

타임라인 시퀀스
새 창에서 열기

05 · 타임라인 시퀀스 — 측정 누적면적 27.501% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

transition-delayvar(--i)data-stagger

단어 쪼개기는 여섯 줄

var words = line.textContent.trim().split(/\s+/);
line.textContent = '';
words.forEach(function (word, i) {
  var span = document.createElement('span');
  span.className = 'sw__word';
  span.textContent = word;
  span.style.setProperty('--i', i);
  line.appendChild(span);
  if (i < words.length - 1) line.appendChild(document.createTextNode(' '));
});

JS 는 각 조각에 순번 --i 만 적고, 시차를 주는 다른 방법들이 궁금하면 CSS 순차적 애니메이션 9종이 같은 기둥의 이웃입니다.

지연은 CSS 계산식 하나로

.sw__word {
  display: inline-block;
  opacity: 0;
  transform: translateY(14px);
  transition-property: opacity, transform;
  transition-duration: $dur-quick, 420ms;
  transition-timing-function: ease-out, $easing;
  transition-delay: calc(var(--i) * var(--stagger, 90ms)), calc(var(--i) * var(--stagger, 90ms));
}
.sw__card.is-visible .sw__word {
  opacity: 1;
  transform: none;
}

단어가 여섯 개면 실측에서 지연이 0 / 0.09 / 0.18 / 0.27 / 0.36 / 0.45 초의 계단으로 잡혔고, 지연은 CSS 가·시점은 엔진이 맡는다는 분업이 그대로 수치로 나타났습니다.

06방향 감지 진입·퇴장

직전 스크롤 위치와 비교해 위로 가는지 아래로 가는지를 알아내고, 들어올 때와 나갈 때 다른 모습을 씁니다.

방향 감지 진입·퇴장
새 창에서 열기

06 · 방향 감지 진입·퇴장 — 측정 누적면적 34.152% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

isIntersectingscrollTopis-exit

방향은 단 두 줄의 비교

function readDir() {
  // 직전 스크롤 위치와 비교해 진행 방향을 정한다 — 아래로 가면 down, 위로 가면 up
  var y = view.scrollTop;
  if (y > lastY) curDir = 'down';
  else if (y < lastY) curDir = 'up';
  root.setAttribute('data-dir', curDir);
  lastY = y;
}

관찰 콜백은 isIntersecting 이 바뀔 때마다 이때의 curDir 을 카드에 찍는데, 실측에서 아래로 스크롤하면 카드가 is-visible 을 받으며 뿌리에 is-enter 가, 화면을 벗어나면 is-exit 와 is-leave 가 붙었고 다시 위로 올리면 방향 up 이 찍혀 내려올 때와 다른 등장이 됐습니다.

퇴장은 한 번 들어온 뒤에만

처음 페이지를 열 때 화면 밖에 있던 요소가 퇴장으로 기록되는 일을 막으려고, 퇴장 분기는 '한 번이라도 들어온 뒤'에만 동작하게 met 깃발을 둡니다.

07속도 반응

프레임 사이 이동량으로 스크롤 속도를 재고, 빠를수록 글자가 눕고 벌어지게 --vel 에 넘깁니다.

속도 반응
새 창에서 열기

07 · 속도 반응 — 측정 누적면적 25.483% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

skewYclamp()--vel

속도는 정규화해서 넘긴다

function tick() {
  var y = view.scrollTop;
  var dy = Math.abs(y - lastY);
  lastY = y;
  // 이동량을 0~1 로 정규화해 여유있게 따라간다 — 급변하지 않고 멈추면 감쇠해 0 으로 돌아온다
  var target = Math.min(dy / 26, 1);
  vel += (target - vel) * 0.16;
  if (vel < 0.004) vel = 0;
  root.style.setProperty('--vel', vel.toFixed(4));
  requestAnimationFrame(tick);
}

실측에서 빠른 스크롤은 --vel 0.98 에 기울기 성분 0.105, 느린 스크롤은 0.099 에 0.012 였고 손을 멈추면 0 으로 감쇠해 글자가 제자리로 돌아왔습니다.

CSS 는 그 값을 각도로 쓴다

@property --vel {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
.vl__line:nth-child(odd) {
  transform: skewY(clamp(-6deg, calc(var(--vel, 0) * -7deg), 6deg)) translateY(calc(var(--vel, 0) * -4px));
}

@property 로 --vel 을 숫자로 등록해야 CSS 함수 안에서 값이 계산되고, 홀수 줄과 짝수 줄의 부호가 반대라 빠르게 내릴수록 줄들이 엇갈리며 눕습니다.

08패럴랙스 3층

먼 별·중간 언덕·앞 글자가 각기 다른 계수로 translate3d 로 움직이고, 매 프레임 lerp 로 목표를 부드럽게 따라가므로 빠른 스크롤에도 끊기지 않습니다.

패럴랙스 3층
새 창에서 열기

08 · 패럴랙스 3층 — 측정 누적면적 20.681% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translate3dlerpwill-change

층마다 계수는 HTML 이 갖는다

<div class="px__layer px__layer--stars" data-rate="0.12" aria-hidden="true"></div>
<div class="px__layer px__layer--hills" data-rate="0.3" aria-hidden="true">
<!-- 그 아래 글자 층은 data-rate="0.55" -->

실측에서 scrollTop 120 에 세 층이 수렴한 위치는 -14.4 / -36 / -66px 로 계수 0.12·0.3·0.55 의 정확한 곱이었고, 60 에서는 절반인 -7.2 / -18 / -33px 로 비율이 그대로 유지됐습니다.

lerp 로 매 프레임 좇는다

function lerp(a, b, t) {
  return a + (b - a) * t;
}
function frame() {
  var target = view.scrollTop;
  // 매 프레임 목표의 12 퍼센트씩 다가가므로 빠른 스크롤에도 끊기지 않고, 0.1px 안으로 좁혀지면 딱 맞춘다
  current = Math.abs(target - current) < 0.1 ? target : lerp(current, target, 0.12);
  for (var i = 0; i < layers.length; i++) {
    var rate = parseFloat(layers[i].getAttribute('data-rate')) || 0;
    layers[i].style.transform = 'translate3d(0, ' + (-current * rate).toFixed(2) + 'px, 0)';
  }
  requestAnimationFrame(frame);
}

translate3d 는 합성기에 맡기는 변환이라 스크롤처럼 매 프레임 도는 곳에 어울리며, 시차를 JS 없이 만드는 축은 CSS 패럴랙스 스크롤 9종이 따로 다룹니다.

09스냅 섹션 전환

scroll-snap-type 으로 섹션이 화면에 딱 붙게 하고, 관찰자가 중앙에 온 섹션을 골라 aria-current 로 표시합니다.

스냅 섹션 전환
새 창에서 열기

09 · 스냅 섹션 전환 — 측정 누적면적 35.251% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scroll-snap-typescroll-snap-alignaria-current

중앙 감지는 rootMargin 이 한다

function pick(entries) {
  entries.forEach(function (entry) {
    if (!entry.isIntersecting) return;
    var i = panels.indexOf(entry.target);
    if (i < 0) return;
    panels.forEach(function (panel, j) {
      if (j === i) { panel.setAttribute('aria-current', 'true'); }
      else { panel.removeAttribute('aria-current'); }
    });
  });
}
// 화면 가운데 10퍼센트 띠에 걸친 패널이 중앙 패널이다
var io = new IntersectionObserver(pick, { root: view, rootMargin: '-45% 0px -45% 0px', threshold: 0 });

위아래로 45% 씩 마진을 잘라내면 화면 한가운데 10% 띠만 남는데, 실측에서 스냅점은 0 / 160 / 320 — scrollTop 을 80 에 놓으면 0 으로, 240 에 놓으면 160 으로 붙었고 aria-current 가 매번 중앙 패널로 옮겨 붙었습니다.

스냅 CSS 는 세 줄

.ss__view {
  scroll-snap-type: y mandatory;
}
.ss__panel {
  scroll-snap-align: center;
}

미리보기 동안만 scroll-snap-type: none 으로 풀고 손이 닿으면 되돌리는 처리도 들어 있습니다 — 스냅이 트랙의 움직임을 다시 잡아당겨 미리보기가 통째로 상쇄되는 일을 막습니다.

스크롤 이벤트 감지는 리스너와 관찰자 중 어디에 두나?

답부터 쓰면 들어옴·나감을 감지할 때는 관찰자, 연속값이 필요할 때는 리스너입니다. 스크롤 이벤트 감지를 scroll 리스너로 하면 콜백이 프레임마다 불려서 그 안에서 getBoundingClientRect() 를 부르는 순간 레이아웃을 다시 계산합니다. IntersectionObserver 는 브라우저가 자기 레이아웃 시점에 여유 있게 값을 계산해 넘겨주므로, 01~06 처럼 '언제 켜고 끄는가'의 문제에는 관찰자가 정답입니다. 뒤집어 07·08 처럼 매 순간의 위치·속도라는 연속값이 필요하면 관찰자는 답이 없습니다 — 콜백은 임계값을 지날 때만 오기 때문입니다. 이때는 리스너 대신 requestAnimationFrame 루프에서 scrollTop 을 읽고, 읽는 일은 모아서 한 번만 합니다. 스크롤 트리거 사용법의 갈림길은 결국 이 하나이고, 스크롤 애니메이션 효과의 대부분이 여기서 시작합니다.

어디서 깨지는가 — 미리보기는 도는데 손에서는 안 도는 코드

격자의 움직임은 CSS 키프레임이고 사람이 스크롤하는 순간은 관찰자 코드입니다. 둘이 별개 경로라 한쪽이 죽어 있어도 렌더 측정은 통과합니다.

네 카드가 한꺼번에 켜졌다

01 의 첫 실측은 미리보기에서 카드가 순서대로 등장하는 것처럼 보였지만, 조작 프로브로 스크롤을 30 까지 내렸을 때 네 카드가 1111 로 한꺼번에 발화했습니다. 뷰 높이 156px 가 카드 네 장을 한 화면에 다 담아버려 임계값을 동시에 넘긴 것입니다. 뷰를 100px 로 낮추고 카드를 키운 뒤에야 20·60·120 단계에서 1100→1110→1111 로 순서대로 걸렸습니다. 미리보기의 animation-delay 계단은 이 순서를 흉내 냈을 뿐, 실제 순서를 만든 것은 뷰포트와 임계값의 기하였습니다.

페이드가 대표 이미지에 유령으로 남았다

01 첫 판은 카드 나타남을 opacity 페이드 하나로 묶었습니다. 24프레임은 2초를 4.2% 간격으로 샘플링하므로 페이드의 중간 장이 그대로 프레임이 되었고, 대표 이미지에 반쯤 보이는 카드가 겹쳐 찍혔습니다 — 수리 전 측정은 누적면적 29.19%·강도 114.4. 나타남을 steps(1, end) 컷으로, 이동을 transform 이징으로 나눠 보니 같은 장면에서 강도가 171.4 로 올라가고 유령이 사라졌습니다. 아홉 개 압축 파일의 비밀번호는 82xy9zm9 이고, 02 의 세 줄로 쪼개진 상태 라벨·04 의 하단 문구 잘림도 같은 회차의 육안 검수에서 걸러냈습니다.

포스터는 한 장만 뽑힌다

02 는 게이지 숫자가 1.00 인데 활성 임계값 후보는 0.25 로 강조된 장면이 대표 이미지가 되기도 했습니다 — 각각 따로 놀던 두 요소의 루프 위상을 맞춰 해결했습니다. 격자 미리보기에서 눈으로 확인하는 일이 측정값을 대신할 수 없는 이유입니다. 스크롤 인디케이터 9종을 만들 때도 같은 교훈이 반복됐습니다 — 정확한 기준은 MDN 의 IntersectionObserver 문서와 scroll 이벤트 문서입니다.

변형 세 가지와 접근성

duration·easing·강조색 세 변수만 바꿔도 아홉 엔진의 인상이 한꺼번에 바뀝니다.

이름 바꾼 값 느낌
duration $dur-enter .6s → .3s 섹션 등장이 두 배로 빨라져 스치듯 지나간다
easing $ease-spring → $ease-pop 등장이 한 번 더 튀어오른다
color $subject-blue → $stage-orange 강조색이 차가운 파랑에서 따뜻한 오렌지로 바뀐다

접근성은 두 갈래로 나뉩니다. 등장 계열(01·03·05·09)은 prefers-reduced-motion: reduce 에서 transition 과 애니메이션을 끄고 요소가 처음부터 보이는 상태로 둡니다 — 내용이 사라지면 안 되기 때문입니다. 연속 계열(07·08)은 속도 효과만 끄고 위치 계산은 그대로 둡니다. 카운터(04)는 reduce 에서 0 부터 세지 않고 목표값을 바로 찍습니다. aria-current (09)는 움직임이 아니라 상태 표시이므로 어느 쪽에서도 유지됩니다.

FAQ

스크롤 트리거 예제를 그대로 복사해도 바로 돌아가나요?

네. 압축 파일의 vanilla/ 폴더는 index.html 과 style.css·style.scss 세 파일이 전부라 폴더째 브라우저에서 열면 됩니다. 관찰자가 붙는 대상은 data-scroll 속성을 가진 요소들이므로, HTML 에 클래스와 속성만 옮기면 다른 페이지에도 붙습니다.

GSAP ScrollTrigger 를 쓰는 게 낫지 않나요?

규모가 커지면 Scrub(스크롤 진행률을 애니메이션 시간에 묶기)이나 타임라인 정렬 같은 기능이 편리합니다. 하지만 이 편의 아홉 개처럼 진입 감지·카운터·스냅 수준이면 관찰자 수십 줄로 충분하고, 라이브러리 로딩과 학습 비용이 사라집니다. 절충안은 이 편의 엔진으로 시작해 Scrub 이 필요해진 순간에만 확장하는 것입니다.

아이폰 사파리에서도 IntersectionObserver 가 되나요?

됩니다. 관찰자는 2019 년부터 사파리를 포함한 모든 주요 브라우저에 들어 있습니다. scroll-snap-type (09)도 마찬가지입니다. 조심할 것은 지원이 아니라 성능입니다 — 스크롤 리스너 안에서 DOM 을 읽으면 모바일에서 먼저 느려지므로, 07·08 처럼 읽기를 모아 requestAnimationFrame 으로 돌리는 패턴을 그대로 쓰는 편이 안전합니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.