designpaca/packages/skill/references/motion-parallax.md
Yun Chan 88c11682b1 feat(skill): route fixes, delivery report, composition diversity, platform refresh
- route table: extension route for products with an existing visual
  language, feedback row, derived state tokens stay local
- completion: blocking 0 plus reported unverified; hard-gate surface is
  the touched scope; delivery report starts with what and how to open
- design.md created at step 0 with a skeleton, fingerprint line and
  change log; product-surface rubric reading; direction delegation
  states the chosen card and reason
- layout: hero families, nav forms, footer forms; art-direction page
  convergence 6 axes; genre sections; image treatments; placeholder
  conventions; multi-screen order; Tailwind theme mapping
- platform: scroll-driven examples, anchor positioning, canvas OT,
  three/R3F pins, shadcn style prefixes, Safari 26 notes, subgrid
- design-gate: four result states, exit codes, cwd module resolution,
  --url/--out-dir/--strict, oklch contrast, dead selector word match
- Korean font budget split from the Latin baseline
2026-10-06 16:55:38 +09:00

282 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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). 다만 그러면
> `<img loading="lazy">` 의 지연 로딩과 반응형 `srcset` 을 잃는다. 무거운 사진이면 `<img>` 를
> 절대배치하고 `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)