# motion-parallax — 패럴랙스·스크롤 연동 깊이 [motion.md](motion.md)에서 분리한 하위 문서다. 4-4에서 **패럴랙스나 sticky 캔버스처럼 스크롤 진행도에 깊이를 물리기로 이미 정했을 때만** 읽는다. 넣을지 말지는 motion.md §0에서 먼저 정한다 — 섹션마다 리빌·시차를 거는 것은 기본값이 아니다. duration·이징 토큰, 감소 모션 정본(코드 H), `@supports` 가드 원칙은 motion.md를 따르고 여기서 다시 정의하지 않는다. ## 이 문서를 읽는 법 | 상황 | 읽을 곳 | |---|---| | 섹션별로 무엇을 움직일지(또는 정지할지) 정한다 | §1 | | 패럴랙스를 넣었는데 티가 안 난다 | §2 고정 원형 → §3 이동량 → §8 기준면 | | 패럴랙스가 조용히 멈췄다 | §4 `overflow: hidden` | | 어두운 배경에 사진·광원을 깐다 | §5 | | sticky 캔버스·단계형 스크롤 진행도를 만든다 | §6 → §10 → §11 | | 배경 층을 몇 개 둘지 정한다 | §7·§8 | | CSS 구현 골격 | §9 | | 구현을 마무리할 때 | §12 자기 점검 + motion.md §5. 납품 판정은 [preflight.md](preflight.md) X1·X2 | ## 1. 섹션마다 다른 말을 해야 한다 같은 패럴랙스를 모든 섹션에 똑같이 걸면 그건 문법이 아니라 벽지다. **그 섹션이 주장하는 것이 무엇인지 먼저 말하고, 그 주장을 움직임으로 옮겨라.** 실측 사례 — designpaca 소개 페이지의 섹션별 배정: | 섹션 | 주장 | 움직임 | 왜 | |---|---|---|---| | 히어로 | "결정하지 않으면 기본값이 나온다" | 글자 ↑ / 배경 ↓ (**반대 방향**) | 결정이 떠오르고 기본값이 가라앉는다. 같은 방향이면 그냥 느린 스크롤로 읽힌다 | | 파이프라인 | 단계를 밟으면 결과가 나온다 | 단계명 **고정**, 산출물만 지연 | 옆의 것이 고정이어야 "저것 때문에 이것"이 보인다. 둘 다 움직이면 인과가 사라진다 | | 사례 | 서로 다른 두 결과물 | 두 판이 다른 속도로 어긋남 | 같은 스킬에서 다른 것이 나온다 | | 재료 | 표면은 한 겹이 아니다 | 타일이 서로 다른 깊이 | 주장 자체가 레이어다 | | 프리플라이트 | 시간순 기록 | 뒤 항목일수록 더 늦게 따라옴 | 로그는 흐른다 | | 수치 | 공개한 값은 검증 가능하다 | **없음** | 숫자가 흔들리면 값도 흔들려 보인다. 계기판의 바늘은 떨지 않는다 | **마지막 줄이 가장 중요하다.** 움직이지 않는 것도 결정이다. 안 움직이는 섹션이 하나도 없으면 움직임에 의미가 없다는 뜻이고, `design.md` 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다. ## 2. 원형은 "고정"이다 **배경이 뷰포트에 멈춰 있고 콘텐츠만 그 위를 지나갈 때 원경이 된다.** 느리게 같이 움직이는 것으로는 그 인상이 안 나온다 — 속도만 다를 뿐 방향이 같기 때문이다. "패럴랙스를 넣었는데 티가 안 난다"의 절반은 이것이다. **`background-attachment: fixed` 는 쓰지 마라.** iOS Safari 가 GPU 메모리 때문에 그것을 throttle 해서 스크롤 중 배경이 튀거나 리셋된다. 대신 **부모를 `position: fixed` 의 컨테이닝 블록으로 만든다**: ```css .section { position: relative; clip-path: inset(0); } /* 컨테이닝 블록 + 클립 */ .section > .bg { position: fixed; inset: 0; z-index: -1; } .section > .bg img { width: 100%; height: 100%; object-fit: cover; } ``` `clip-path` 는 두 가지를 동시에 한다 — 배경을 섹션 밖으로 못 나가게 자르고, fixed 자식의 기준을 뷰포트가 아니라 **자기 자신**으로 잡아준다. 그래서 배경이 화면에 고정되면서도 그 섹션 안에서만 보인다. **두 가지 대가가 있다. 알고 써라.** 1. **조상에 `transform`·`filter`·`mask` 를 추가하면 즉시 깨진다.** 그것들도 컨테이닝 블록을 만들기 때문에 fixed 가 섹션 기준이 되어 고정이 사라진다. 위아래 페이드 마스크를 얹고 싶어도 못 얹는다 — 경계는 clip 으로 딱 잘리는 것을 받아들여야 한다 2. `prefers-reduced-motion` 에서는 `position: absolute` 로 되돌려라. 고정 배경은 스크롤 중 시각 부하가 크다 **전부 고정하지는 마라.** 가까이 있어야 할 재질(종이·질감)까지 원경으로 두면 거짓말이 된다. 원경은 `fixed`, 중경은 아래의 비율 이동, 근경은 콘텐츠와 함께. ## 3. 이동량은 절대값이 아니라 컨테이너 대비 비율이다 **패럴랙스가 "안 보인다"의 대부분은 이동량이 모자란 것이다.** 공식은 하나다. **배경을 컨테이너보다 크게 만들고, 그 초과분 전체를 움직인다.** ``` 이미지 높이 = 컨테이너 × (1 + depth) 이동량 = 컨테이너 × depth = 이미지 높이의 depth / (1 + depth) ``` `depth: 0.45` 면 이미지는 145%, 이동은 이미지 자기 높이의 **31%**. 섹션이 2000px 이면 **900px** 을 움직인다. ```css .bg img { height: 145%; /* 1 + depth */ animation: bg-par 1ms linear both; /* 1ms: Firefox는 0이 아닌 duration을 요구한다(motion.md 코드 B) */ animation-timeline: view(); animation-range: cover 0% cover 100%; } @keyframes bg-par { from { transform: translateY(0); } to { transform: translateY(-31.03%); } /* depth / (1 + depth) */ } ``` `translateY(%)` 는 **자기 크기 기준**이라 컨테이너 높이를 몰라도 정확히 맞는다. **실측 사례**: 여유를 28% 잡아놓고 `±46px`(총 92px)만 움직였더니 스크롤 1800px 구간에서 이동 비율이 5% 였다. 아무도 시차를 느끼지 못했다. 같은 구간에서 **374px** 로 올리자 보였다. CSS-Tricks 의 예제도 `background-position: bottom 0px → bottom -400px`, 즉 400px 이다. > 참고: `background-position` 을 애니메이션하는 방법도 있다(CSS-Tricks). 다만 그러면 > `` 의 지연 로딩과 반응형 `srcset` 을 잃는다. 무거운 사진이면 `` 를 > 절대배치하고 `transform` 을 쓰는 쪽이 낫다. ## 4. `overflow: hidden` 이 view() 타임라인을 죽인다 **이것 하나에 패럴랙스가 통째로 멈춘다. 그리고 조용히 멈춘다.** `overflow: hidden` 은 **스크롤 컨테이너를 만든다.** 그러면 그 안에 있는 자식의 `animation-timeline: view()` 는 문서가 아니라 **그 상자**를 기준으로 잡힌다. 자식은 상자 안에서 위치가 변하지 않으므로 진행도가 고정되고, 애니메이션은 어중간한 한 프레임에서 굳는다. - 콘솔 에러 없음. `animation-name`·`animation-timeline` computed 값도 전부 정상 - `getComputedStyle(el).transform` 을 두 스크롤 위치에서 재봐야 알 수 있다. **실측에서 이동 0px** **잘라내야 한다면 `overflow: clip` 을 쓰거나 조상 섹션에서 처리해라.** `clip` 은 스크롤 컨테이너를 만들지 않는다. ## 5. 어두운 배경 위 광원 이미지는 opacity 로 누르지 마라 `opacity` 는 이미지 전체를 균일하게 죽인다 — 검정도, 빛도 같이. 그래서 어두운 광원 사진을 `opacity: 0.5` 로 깔면 **있는지조차 안 보인다.** ```css .bg img { mix-blend-mode: screen; opacity: 0.9; } ``` `screen` 은 **검정을 투명하게 만들고 밝은 곳만 더한다.** 그래서 불투명도를 높게 유지해도 본문 대비를 해치지 않는다. 밝은 이미지(종이·질감)는 반대로 `normal` 이 맞다. **생성형 이미지를 배경으로 쓸 때 특히 조심해라.** 프롬프트에 "본문 뒤에 깔리니 어둡게"를 너무 강하게 넣으면 안전한 이미지가 아니라 **없는 이미지**가 나온다. 대비는 합성 방식으로 풀고, 이미지 자체는 볼 수 있게 만들어라. ## 6. sticky 캔버스의 진행도는 섹션 높이가 아니다 캔버스를 `position: sticky; top: 0; height: 100dvh` 로 붙여두고 섹션이 지나가게 하는 패턴에서, 진행도를 **섹션 높이로 나누면 안 된다.** 캔버스가 실제로 화면에 머무는 거리는 **(섹션 높이 − 뷰포트 높이)** 다. 섹션 높이를 쓰면 캔버스가 위로 사라진 뒤에도 진행이 남아 **끝에 영영 도달하지 못한다.** ```js const travel = Math.max(section.getBoundingClientRect().height - innerHeight, 1); const progress = clamp(-section.getBoundingClientRect().top / travel, 0, 1); ``` 실측: 섹션 1493px · 뷰포트 900px 에서 섹션 높이로 나눴더니 6단계 중 **3단계에서 멈췄다.** `height - innerHeight` 로 바꾸자 6단계를 다 돌았다. 그리고 진행도의 기준 요소는 **캔버스가 아니라 섹션**이다. sticky/fixed 캔버스는 rect 가 변하지 않으므로 자기 rect 로 재면 진행도가 고정된다. ## 7. 배경도 한 섹션에 하나다 CSS 격자와 사진을 같이 깔았다가 걷어냈다. 두 배경이 서로 경쟁했고, 격자는 배경이라기보다 **표**로 읽혔다. 앞에서 "한 층은 고정"이라고 한 것과 같은 이야기다 — 층을 늘리는 것과 층이 싸우게 두는 것은 다르다. ## 8. 기준면이 필요하다 **배경이 단색이면 패럴랙스는 성립하지 않는다.** 앞의 것이 몇 px 어긋나 봐야 비교할 대상이 없어서 아무도 못 느낀다. 시차는 두 층 사이에서만 보인다. 그렇다고 배경 이미지를 사 오지 마라. **그 페이지가 이미 주장하는 것을 배경으로 그려라.** | 페이지가 주장하는 것 | 배경으로 그릴 것 | |---|---| | 값이 토큰 체계에서 나온다 | **간격 눈금 격자**. 셀 크기를 실제 space 토큰으로 | | 재질·표면이 주제다 | 광원 블롭. 섹션마다 자리를 옮긴다 | | 데이터·계측이 주제다 | 눈금, 축, 등고선 | | 문서·아카이브다 | 괘선, 여백선 | 격자를 그릴 때 두 가지를 지켜라. 1. **가장자리를 마스크로 지운다.** 화면 끝까지 격자가 가면 배경이 아니라 **표**가 된다 2. **배경이 가장 적게 움직인다.** 앞의 것보다 크게 움직이면 앞으로 튀어나와 보인다 ```css .bg-grid::before { content: ""; position: absolute; inset: -12% 0; z-index: -1; background-image: repeating-linear-gradient(to right, var(--line-strong) 0 1px, transparent 1px var(--grid-cell)), repeating-linear-gradient(to bottom, var(--line-strong) 0 1px, transparent 1px var(--grid-cell)); mask-image: radial-gradient(135% 95% at 50% 45%, black 22%, transparent 92%); } ``` 세 층이 되면 시차가 확실해진다 — 실측: 배경 격자 **+14.9px**, 산출물 **-14px**, 단계명 **0**. **한 층은 반드시 고정이어야 한다.** 전부 움직이면 기준이 없어 그냥 흔들리는 페이지다. ## 9. 구현은 scroll-driven animation 으로 ```css @media (prefers-reduced-motion: no-preference) { @supports (animation-timeline: view()) { .par-slow, .par-fast, .par-lag { animation: par 1ms linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; } @keyframes par { from { transform: translate3d(0, var(--par-from), 0); } to { transform: translate3d(0, var(--par-to), 0); } } } } ``` **두 가지를 지켜라.** 1. **`transform` 만 애니메이션한다.** `opacity` 를 걸면 범위 끝에 도달하지 못한 요소가 영영 투명하게 남는다 — 지원 안 되는 브라우저에서는 제자리에 그대로 있을 뿐이지만, `opacity` 는 콘텐츠를 지운다. 실측에서 6단계 중 3개가 그렇게 사라졌다. 2. **페이지 최상단 요소는 `view()` 가 아니라 `scroll()` 을 써라.** 히어로는 처음부터 보이므로 `entry` 구간이 이미 지나 있어 시작점이 확정되지 않는다. `animation-timeline: scroll(root block); animation-range: 0 92vh;` 새 클래스를 만들 때 **애니메이션 셀렉터에 추가하는 것을 잊지 마라.** 변수만 정의하면 아무 일도 일어나지 않고, 조용히 실패해서 검출도 안 된다. ## 10. 읽기 간격과 스크롤 구간은 같은 손잡이가 아니다 sticky 캔버스의 진행도는 `섹션 높이 − 뷰포트` 다. 그래서 "스크롤 구간이 짧다" 는 문제를 만나면 **목록 항목의 간격을 벌려 섹션을 늘리고 싶어진다.** 실측에서 그렇게 했다. - `--space-6`(24px): 섹션 1493px → sticky 이동 593px. 조금만 굴려도 여섯 단계를 다 지나침 - `--space-24`(96px): 게이트당 300px 확보. 대신 항목 사이가 **224px** 로 벌어져 여섯 개가 한 목록으로 안 읽힘. 사용자 지적: "각 아이템별로 마진 공간이 너무 크다" **하나의 값으로 두 가지를 맞추려 하면 둘 다 나빠진다.** 나눠라. ```css .stop { padding-block: var(--stop-pad); } /* 읽기 리듬 — 눈으로 정한다 */ .after { padding-bottom: 62dvh; } /* 스크롤 구간 — 계산으로 정한다 */ ``` 판정 기준: **게이트 하나당 200px 이상**. 휠 한 번이 100~150px 이므로 그 아래면 "조금만 스크롤해도 다 지나가는" 과민한 반응이 된다. ## 11. 스크롤 구간이 모자라면 여백이 아니라 **진행도 공식**을 고쳐라 위 문제를 처음에는 목록 뒤에 여백을 넣어 풀었다. `padding-bottom: 62dvh`. 계산은 맞았고 게이트는 끝까지 돌았는데, 화면 절반이 넘는 빈 공간이 남았다. 사용자가 본 것은 그 빈칸이었다 — "어마어마하게 큰 이상한 게 있는데요?" **그 여백은 콘텐츠가 아니라 스크롤 연료였다.** 원인은 진행도를 섹션 높이로 잰 것이다. 그러면 진행 구간이 섹션 길이에 묶이고, 구간을 늘리는 유일한 방법이 섹션을 늘리는 것뿐이다. 진행도를 **읽는 대상이 화면을 지나는 동안**으로 바꾸면 여백이 필요 없어진다. ```js // 섹션이 아니라 목록에서 읽는다. 게이트가 도는 구간과 읽는 구간이 같아진다. const rect = list.getBoundingClientRect(); const start = innerHeight * 0.7; // 목록 위쪽이 여기 오면 진행 0 const end = innerHeight * 0.3; // 목록 아래쪽이 여기를 빠져나가면 진행 1 const travel = rect.height + (start - end); const progress = clamp01((start - rect.top) / travel); ``` `(start - end)` 만큼이 공짜로 얻는 구간이다. 뷰포트의 40% 를 쓰면 목록 963px 짜리가 1323px 구간이 되고, 게이트 6 개면 하나당 220px 이다. **여백 558px 을 지우고도 기준을 넘겼다.** > 확인할 것: sticky 요소가 `progress === 1` 에 닿기 전에 사라지지 않아야 한다. > 목록 아래쪽이 화면 30% 에 있을 때 섹션 하단이 아직 뷰포트 안이면 안전하다. ## 12. 체크리스트 (구현 중 자기 점검 — 납품 판정은 preflight.md X2) 납품 판정은 [preflight.md](preflight.md) X2(감소 모션의 정지 배경 포함)와 X1(첫 화면 즉시 읽힘)이 한다. - 섹션마다 "무엇을 주장하는지"와 움직임을 한 줄로 대응시켰고, 의도적으로 정지한 섹션을 `design.md`에 적었다(§1) - 한 층 이상이 고정(기준면)이다. 전부 움직이지 않는다(§2·§8) - `background-attachment: fixed`를 쓰지 않았다(§2) - 패럴랙스 요소의 조상에 `overflow: hidden`이 없거나 `overflow: clip`으로 바꿨다. 두 스크롤 위치에서 transform 값이 실제로 달라지는 것을 재봤다(§4) - 패럴랙스 애니메이션은 transform만 쓴다. opacity를 섞지 않았다(§9) - sticky 진행도는 (섹션 높이 − 뷰포트) 또는 읽는 대상 기준으로 계산했고 끝 단계까지 도달하는 것을 확인했다(§6·§11)