패럴랙스를 실제로 보이게 고치고 격자를 걷어낸다

"티가 안 난다"는 지적이 맞았다. 세 가지를 동시에 잘못하고 있었다.

1. 이동량이 모자랐다
   여유를 28% 잡아놓고 92px 만 움직였다. 스크롤 1800px 구간에서 5%다.
   공식은 이미지를 컨테이너보다 크게 만들고 초과분 전체를 움직이는 것.
   depth 0.45 -> 높이 145%, translateY(-31.03%). 같은 구간에서 374px.
   translateY(%) 는 자기 크기 기준이라 컨테이너 높이를 몰라도 맞는다.

2. overflow: hidden 이 view() 타임라인을 죽이고 있었다
   overflow: hidden 은 스크롤 컨테이너를 만들고, 그러면 자식의 view() 가
   문서가 아니라 그 상자를 기준으로 잡힌다. 이미지는 상자 안에서 늘 같은
   자리라 진행도가 고정된다. 콘솔 에러도 없고 computed 값도 정상이라
   두 스크롤 위치에서 transform 을 재보고서야 이동 0px 인 것을 알았다.
   잘라내는 일은 섹션의 overflow: clip 에 맡겼다.

3. opacity 로 눌러 이미지를 지웠다
   어두운 광원 사진을 opacity 0.5 로 깔면 검정도 빛도 같이 죽는다.
   mix-blend-mode: screen 으로 바꿨다 — 검정은 투명, 밝은 곳만 더해진다.
   그래서 불투명도 0.85~1.0 을 써도 본문 대비가 안전하다.
   생성 프롬프트도 같은 함정이었다. "어둡게"를 너무 강하게 넣어서
   안전한 이미지가 아니라 없는 이미지를 만들었다.

격자 배경 제거
   사진이 들어온 뒤로 배경이 두 겹이라 서로 경쟁했고 표로 읽혔다.
   배경도 한 섹션에 하나다.

스킬 motion.md 에 넷 다 반영
   이동량 공식 · overflow 함정 · screen 합성 · 배경은 한 겹
This commit is contained in:
Yun Chan 2026-08-20 15:57:40 +09:00
parent 5a18806fb6
commit 1ed3c95542
11 changed files with 181 additions and 85 deletions

View file

@ -213,14 +213,35 @@ WebGL 게이트: `prefers-reduced-motion` 이면 **아예 시작하지 않는다
| 레이어 | 무엇 | 어디 | 근거 | | 레이어 | 무엇 | 어디 | 근거 |
|---|---|---|---| |---|---|---|---|
| `.bg-grid` | 간격 눈금 격자. 셀이 `--grid-cell = --space-6` | 파이프라인 · 수치 | "모든 값이 토큰에서 나온다"가 배경에서도 성립해야 한다. 계기판의 눈금이기도 하다 | 기준면은 `BgPhoto` 컴포넌트가 만든다(아래 표).
| `.bg-glow` | 광원 블롭 두 개. 섹션마다 `--glow-x` 로 자리를 옮긴다 | 재료 · 설치 | 히어로의 빛이 페이지를 관통해 마지막 CTA 에서 돌아온다 |
격자는 가장자리를 마스크로 지운다 — 화면 끝까지 가면 배경이 아니라 **표**가 된다. **CSS 격자를 한동안 같이 깔았다가 걷어냈다.** 사진이 들어온 뒤로 배경이 두 겹이 되어
배경이 가장 적게 움직인다. 앞의 것보다 크게 움직이면 앞으로 튀어나와 보인다. 서로 경쟁했고, 격자는 배경이라기보다 **표**로 읽혔다. 배경도 한 섹션에 하나다.
**실측 3층** (스크롤 1000 → 1600): 격자 **+14.9px** · 산출물 **-14px** · 단계명 **0**. 한 층은 반드시 고정이다 — 파이프라인의 단계명은 움직이지 않는다.
한 층은 반드시 고정이다. 전부 움직이면 기준이 없어 그냥 흔들리는 페이지가 된다. 전부 움직이면 기준이 없어 그냥 흔들리는 페이지가 된다.
### 패럴랙스가 세 번 실패한 경위
**1. 이동량이 모자랐다.** 여유를 28% 잡아놓고 `±46px`(총 92px)만 움직였다.
스크롤 1800px 구간에서 이동 비율 5% — 아무도 시차를 느끼지 못했다.
공식은 **이미지를 컨테이너보다 크게 만들고 그 초과분 전체를 움직이는 것**이다.
`depth 0.45` → 이미지 145%, 이동 `translateY(-31.03%)`. 같은 구간에서 **374px**.
**2. `overflow: hidden``view()` 타임라인을 죽이고 있었다.**
`overflow: hidden` 은 스크롤 컨테이너를 만들고, 그러면 자식의 `view()` 가 문서가 아니라
그 상자를 기준으로 잡힌다. 이미지는 상자 안에서 늘 같은 자리라 진행도가 고정된다.
**콘솔 에러도 없고 computed 값도 정상이라** 두 스크롤 위치에서 `transform` 을 재보고서야
이동 0px 인 것을 알았다. 잘라내는 일은 섹션의 `overflow: clip` 에 맡겼다 —
`clip` 은 스크롤 컨테이너를 만들지 않는다.
**3. `opacity` 로 눌러 이미지를 지웠다.** 어두운 광원 사진을 `opacity: 0.5` 로 깔면
검정도 빛도 같이 죽어 **있는지조차 보이지 않는다.** `mix-blend-mode: screen` 으로 바꿨다 —
검정은 투명해지고 밝은 곳만 더해지므로 불투명도를 0.85~1.0 으로 유지해도 본문 대비가 안전하다.
밝은 종이만 `normal` 이다.
**생성 프롬프트에도 같은 함정이 있었다.** "본문 뒤에 깔리니 어둡게"를 너무 강하게 넣어서
안전한 이미지가 아니라 **없는 이미지**를 만들었다. 대비는 합성 방식으로 풀 문제였다.
### 배경 사진 넉 장 (gpt-image-2 로 생성) ### 배경 사진 넉 장 (gpt-image-2 로 생성)

View file

@ -18,60 +18,108 @@ const SOURCES = { refraction, paper, split, align } as const;
interface Props { interface Props {
variant?: keyof typeof SOURCES; variant?: keyof typeof SOURCES;
/** 0~1. 본문 뒤에 깔리므로 기본값을 낮게 둔다 */ /**
* 패럴랙스 강도. 이미지를 컨테이너보다 이 비율만큼 크게 만들고,
* 스크롤 구간 동안 정확히 그 초과분만큼 움직인다.
* 0.45 면 섹션 높이가 2000px 일 때 900px 을 이동한다.
*/
depth?: number;
/** 0~1 */
opacity?: number; opacity?: number;
/** 좌우 반전. 같은 그림을 두 섹션에 쓸 때 되풀이로 안 읽히게 한다 */ /** 좌우 반전. 같은 그림을 두 섹션에 쓸 때 되풀이로 안 읽히게 한다 */
flip?: boolean; flip?: boolean;
} }
const { variant = "refraction", opacity = 0.45, flip = false } = Astro.props; const { variant = "refraction", depth = 0.45, opacity = 0.9, flip = false } = Astro.props;
const src = SOURCES[variant]; const src = SOURCES[variant];
// 밝은 종이만 그대로 얹고, 어두운 광원 사진은 더하기 합성한다.
const additive = variant !== "paper";
// 이미지 높이 = (1 + depth) 배. 이동량은 이미지 자기 높이 기준 비율로 환산한다.
// 예: depth 0.45 -> 높이 145%, 이동 -(0.45 / 1.45) = -31.03%
const scale = 1 + depth;
const shift = -(depth / scale) * 100;
--- ---
{/* {/*
- `loading="lazy"` — 초기 번들에 들어가지 않는다. CSS background-image 로 깔면 - `loading="lazy"` — 초기 번들에 들어가지 않는다. CSS background-image 로 깔면
이 통제권을 잃는다(항상 받고, 최적화도 안 된다). 그래서 <img> 를 절대배치한다 이 통제권을 잃는다(항상 받고, 최적화도 안 된다). 그래서 <img> 를 절대배치한다
- `aria-hidden` + 빈 alt — 장식이다. 보조기술이 읽을 것이 없다 - `aria-hidden` + 빈 alt — 장식이다. 보조기술이 읽을 것이 없다
- opacity 기본 0.45 — 본문 대비를 지키는 선. 올리려면 그 위 글자를 다시 재라
*/} */}
<div class="bg-photo" class:list={{ flip }} aria-hidden="true" style={`--bg-opacity:${opacity}`}> <div
class="bg-photo"
class:list={{ flip, additive }}
aria-hidden="true"
style={`--bg-opacity:${opacity}; --bg-h:${scale * 100}%; --bg-shift:${shift.toFixed(2)}%`}
>
<Image src={src} alt="" widths={[860, 1400, 1920]} sizes="100vw" loading="lazy" decoding="async" /> <Image src={src} alt="" widths={[860, 1400, 1920]} sizes="100vw" loading="lazy" decoding="async" />
</div> </div>
<style> <style>
.bg-photo { .bg-photo {
position: absolute; position: absolute;
/* 패럴랙스로 오르내리는 만큼 위아래에 여유를 둔다 */ inset: 0;
inset: -14% 0; grid-column: full;
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
overflow: hidden; /* overflow 를 걸지 않는다.
/* 섹션 경계에서 뚝 끊기지 않게 위아래를 지운다 */ overflow: hidden 은 스크롤 컨테이너를 만들고, 그러면 자식의 view() 타임라인이
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 18%, black 78%, transparent 100%); 문서가 아니라 이 상자를 기준으로 잡힌다. 이미지는 상자 안에서 늘 같은 자리라
mask-image: linear-gradient(to bottom, transparent 0%, black 18%, black 78%, transparent 100%); 진행도가 고정되고 패럴랙스가 통째로 멈춘다. 실측에서 이동량이 0px 이었다.
잘라내는 일은 섹션의 `.has-bg { overflow: clip }` 이 한다. */
} }
.bg-photo :global(img) { .bg-photo :global(img) {
position: absolute;
top: 0;
left: 0;
width: 100%; width: 100%;
height: 100%; /* 컨테이너보다 세로로 크다. 이 초과분이 곧 패럴랙스가 쓸 수 있는 거리다. */
height: var(--bg-h);
object-fit: cover; object-fit: cover;
display: block; display: block;
opacity: var(--bg-opacity); opacity: var(--bg-opacity);
will-change: transform;
} }
/*
어두운 배경 위의 광원 사진은 opacity 로 누르면 안 된다 — 빛까지 같이 죽는다.
screen 은 검정을 투명하게 만들고 밝은 곳만 더한다. 그래서 불투명도를 높게
유지해도 본문 대비를 해치지 않는다. 실제로 opacity 0.5 로 눌렀을 때는
이미지가 있는지조차 보이지 않았다.
*/
.additive :global(img) { mix-blend-mode: screen; }
.flip :global(img) { transform: scaleX(-1); } .flip :global(img) { transform: scaleX(-1); }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) { @supports (animation-timeline: view()) {
/* 배경이 가장 느리다. 앞의 것보다 크게 움직이면 앞으로 튀어나와 보인다 */ .bg-photo :global(img) {
.bg-photo { animation: bg-parallax linear both;
--par-from: -46px;
--par-to: 46px;
animation: par linear both;
animation-timeline: view(); animation-timeline: view();
/* 섹션이 화면에 들어올 때부터 나갈 때까지 전 구간을 쓴다.
좁게 잡으면 대부분의 스크롤에서 이미지가 멈춰 있다. */
animation-range: cover 0% cover 100%; animation-range: cover 0% cover 100%;
} }
.flip :global(img) {
animation-name: bg-parallax-flip;
}
} }
} }
/* 초과분 전체를 쓴다. 여유를 45% 잡아놓고 92px 만 움직이면 시차가 안 보인다 */
@keyframes bg-parallax {
from { transform: translateY(0); }
to { transform: translateY(var(--bg-shift)); }
}
@keyframes bg-parallax-flip {
from { transform: translateY(0) scaleX(-1); }
to { transform: translateY(var(--bg-shift)) scaleX(-1); }
}
/* screen 합성은 어두운 곳이 이미 투명하다. 투명도를 크게 깎을 이유가 없고,
깎으면 이미지가 있는지조차 안 보인다. 종이(불투명)만 더 낮춘다. */
@media (prefers-reduced-transparency: reduce) { @media (prefers-reduced-transparency: reduce) {
.bg-photo :global(img) { opacity: calc(var(--bg-opacity) * 0.5); } .additive :global(img) { opacity: calc(var(--bg-opacity) * 0.9); }
.bg-photo:not(.additive) :global(img) { opacity: calc(var(--bg-opacity) * 0.7); }
} }
</style> </style>

View file

@ -9,7 +9,7 @@ const { c, ui } = Astro.props;
<section id="install" class="full install has-bg" aria-labelledby="install-h"> <section id="install" class="full install has-bg" aria-labelledby="install-h">
<BgPhoto opacity={0.55} flip /> <BgPhoto variant="refraction" opacity={0.85} depth={0.4} flip />
<div class="install-grid"> <div class="install-grid">
<div class="install-head"> <div class="install-head">
<h2 id="install-h">{c.h2}</h2> <h2 id="install-h">{c.h2}</h2>

View file

@ -12,7 +12,7 @@ const { c } = Astro.props;
둘 다 설명이 아니라 지금 이 화면에서 돌고 있는 것이다. 둘 다 설명이 아니라 지금 이 화면에서 돌고 있는 것이다.
*/} */}
<section class="section materials has-bg" aria-labelledby="mat-h"> <section class="section materials has-bg" aria-labelledby="mat-h">
<BgPhoto opacity={0.6} /> <BgPhoto variant="refraction" opacity={0.9} depth={0.45} />
<h2 id="mat-h" class="reveal">{c.h2}</h2> <h2 id="mat-h" class="reveal">{c.h2}</h2>
<p class="lead mat-lead reveal">{c.lead}</p> <p class="lead mat-lead reveal">{c.lead}</p>

View file

@ -19,7 +19,7 @@ const rest = c.items.filter((m) => !m.feature);
흔들리면 그 값도 흔들리는 것처럼 읽힌다. 계기판의 바늘은 떨지 않는다. 흔들리면 그 값도 흔들리는 것처럼 읽힌다. 계기판의 바늘은 떨지 않는다.
움직임이 의미를 만드는 자리가 있으면, 움직이지 않는 것이 의미인 자리도 있다. 움직임이 의미를 만드는 자리가 있으면, 움직이지 않는 것이 의미인 자리도 있다.
*/} */}
<section id="metrics" class="section metrics bg-grid" aria-labelledby="metrics-h"> <section id="metrics" class="section metrics" aria-labelledby="metrics-h">
<div class="head"> <div class="head">
<p class="eyebrow reveal">{c.eyebrow}</p> <p class="eyebrow reveal">{c.eyebrow}</p>
<h2 id="metrics-h" class="reveal">{c.h2}</h2> <h2 id="metrics-h" class="reveal">{c.h2}</h2>

View file

@ -14,7 +14,7 @@ const { c } = Astro.props;
③ 의미 — <ol role="list"> — 화면에 번호는 없지만 보조기술은 "6개 중 n번째"로 읽는다 ③ 의미 — <ol role="list"> — 화면에 번호는 없지만 보조기술은 "6개 중 n번째"로 읽는다
role="list" 는 list-style:none 일 때 Safari/VoiceOver 가 목록 의미를 버리는 것을 되돌린다 role="list" 는 list-style:none 일 때 Safari/VoiceOver 가 목록 의미를 버리는 것을 되돌린다
*/} */}
<section id="pipeline" class="section bg-grid" aria-labelledby="pipeline-h"> <section id="pipeline" class="section" aria-labelledby="pipeline-h">
{/* 제목·리드·브리프·스파인을 한 컨테이너에 담는다. {/* 제목·리드·브리프·스파인을 한 컨테이너에 담는다.
섹션 기본 그리드에 두면 제목은 main 칼럼의 음수 마진으로 x=371, 섹션 기본 그리드에 두면 제목은 main 칼럼의 음수 마진으로 x=371,
리드는 x=435, 스파인은 x=143 에 서서 정렬선이 셋이 된다. */} 리드는 x=435, 스파인은 x=143 에 서서 정렬선이 셋이 된다. */}

View file

@ -12,7 +12,7 @@ const { c } = Astro.props;
왼쪽 세로선이 로그라는 형식을 만든다. 왼쪽 세로선이 로그라는 형식을 만든다.
*/} */}
<section class="section preflight has-bg" aria-labelledby="pf-h"> <section class="section preflight has-bg" aria-labelledby="pf-h">
<BgPhoto variant="align" opacity={0.8} /> <BgPhoto variant="align" opacity={1} depth={0.55} />
<h2 id="pf-h" class="reveal">{c.h2}</h2> <h2 id="pf-h" class="reveal">{c.h2}</h2>
<p class="lead pf-lead reveal">{c.lead}</p> <p class="lead pf-lead reveal">{c.lead}</p>

View file

@ -12,7 +12,7 @@ const shots = [shotCoffee, shotSaas];
{/* 레이아웃 패밀리: Z축 캐스케이드. 두 판이 서로 겹치고 각도가 다르다 */} {/* 레이아웃 패밀리: Z축 캐스케이드. 두 판이 서로 겹치고 각도가 다르다 */}
<section id="showcase" class="section showcase has-bg" aria-labelledby="showcase-h"> <section id="showcase" class="section showcase has-bg" aria-labelledby="showcase-h">
<BgPhoto variant="split" opacity={0.5} /> <BgPhoto variant="split" opacity={0.85} depth={0.5} />
<h2 id="showcase-h" class="reveal">{c.h2}</h2> <h2 id="showcase-h" class="reveal">{c.h2}</h2>
<p class="lead showcase-lead reveal">{c.lead}</p> <p class="lead showcase-lead reveal">{c.lead}</p>

View file

@ -9,7 +9,7 @@ const { c } = Astro.props;
--- ---
<section class="section band-inverse has-bg" aria-labelledby="type-h"> <section class="section band-inverse has-bg" aria-labelledby="type-h">
<BgPhoto variant="paper" opacity={0.85} /> <BgPhoto variant="paper" opacity={1} depth={0.3} />
<p class="eyebrow reveal">{c.eyebrow}</p> <p class="eyebrow reveal">{c.eyebrow}</p>
<h2 id="type-h" class="reveal">{c.h2}</h2> <h2 id="type-h" class="reveal">{c.h2}</h2>
<p class="lead type-lead reveal">{c.lead}</p> <p class="lead type-lead reveal">{c.lead}</p>

View file

@ -419,67 +419,19 @@ a:hover { color: var(--accent); }
패럴랙스는 깊이 차가 보여야 성립한다. 배경이 단색이면 앞의 것이 패럴랙스는 깊이 차가 보여야 성립한다. 배경이 단색이면 앞의 것이
px 어긋나 봐야 아무도 느낀다 기준면이 없기 때문이다. px 어긋나 봐야 아무도 느낀다 기준면이 없기 때문이다.
그래서 배경을 깐다. 다만 이미지가 아니라 페이지가 주장하는 , 기준면은 `BgPhoto` 컴포넌트가 만든다.
간격 눈금 자체를 격자로 그린다. --grid-cell --space-5 . */
.bg-grid, CSS 그린 격자를 한동안 같이 깔았는데 걷어냈다. 사진이 들어온 뒤로는
.bg-glow, 배경이 겹이 되어 서로 경쟁했고, 격자는 배경이라기보다 표로 읽혔다.
배경도 섹션에 하나여야 한다. */
.has-bg { .has-bg {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
/* 배경 레이어는 위아래로 14% 삐져나온다(패럴랙스 여유). /* 배경 이미지는 컨테이너보다 크고 패럴랙스로 움직인다.
clip 하지 않으면 삐져나온 부분이 이웃 섹션 위에 얹힌다 clip 하지 않으면 초과분이 이웃 섹션 위로 넘친다. */
조판 섹션의 종이가 섹션 하단에 밝은 띠로 나타났다. */
overflow: clip; overflow: clip;
} }
/* 섹션은 subgrid 이고 `.section > *` 전부 main 칼럼(561px) 배치된다.
절대배치라도 그리드 영역이 잡히면 그게 컨테이닝 블록이 되므로,
배경이 화면 가운데 561px 띠로만 깔렸다. 전체 폭으로 되돌린다. */
.bg-photo { grid-column: full; }
.bg-grid::before,
.bg-glow::before {
content: "";
position: absolute;
inset: -12% 0;
z-index: -1;
pointer-events: none;
}
.bg-grid::before {
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));
/* 가장자리에서 지운다. 화면 끝까지 격자가 가면 표가 되어버린다 */
-webkit-mask-image: radial-gradient(135% 95% at 50% 45%, black 22%, transparent 92%);
mask-image: radial-gradient(135% 95% at 50% 45%, black 22%, transparent 92%);
opacity: 0.7;
}
/* 히어로의 두 광원이 페이지를 관통한다. 섹션마다 자리가 다르다 */
.bg-glow::before {
background:
radial-gradient(42% 46% at var(--glow-x, 78%) var(--glow-y, 32%),
color-mix(in oklab, var(--accent) 15%, transparent), transparent 70%),
radial-gradient(38% 42% at calc(100% - var(--glow-x, 78%)) 78%,
color-mix(in oklab, var(--glow-2) 13%, transparent), transparent 68%);
}
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
/* 배경이 가장 느리다. 콘텐츠보다 적게 움직여야 뒤에 있는 것으로 읽힌다 */
.bg-grid::before { --par-from: -34px; --par-to: 34px; }
.bg-glow::before { --par-from: -58px; --par-to: 58px; }
.bg-grid::before,
.bg-glow::before {
animation: par linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
}
}
/* ---- 유틸 -------------------------------------------------- */ /* ---- 유틸 -------------------------------------------------- */
.visually-hidden { .visually-hidden {
position: absolute; position: absolute;

View file

@ -448,6 +448,81 @@ JS 쪽 판정도 한 곳에 모은다. 설정이 도중에 바뀌면 반영한
안 움직이는 섹션이 하나도 없으면 움직임에 의미가 없다는 뜻이고, 안 움직이는 섹션이 하나도 없으면 움직임에 의미가 없다는 뜻이고,
`design.md` 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다. `design.md` 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다.
### 이동량은 절대값이 아니라 컨테이너 대비 비율이다
**패럴랙스가 "안 보인다"의 대부분은 이동량이 모자란 것이다.**
공식은 하나다. **배경을 컨테이너보다 크게 만들고, 그 초과분 전체를 움직인다.**
```
이미지 높이 = 컨테이너 × (1 + depth)
이동량 = 컨테이너 × depth
= 이미지 높이의 depth / (1 + depth)
```
`depth: 0.45` 면 이미지는 145%, 이동은 이미지 자기 높이의 **31%**.
섹션이 2000px 이면 **900px** 을 움직인다.
```css
.bg img {
height: 145%; /* 1 + depth */
animation: bg-par linear both;
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` 을 쓰는 쪽이 낫다.
### `overflow: hidden` 이 view() 타임라인을 죽인다
**이것 하나에 패럴랙스가 통째로 멈춘다. 그리고 조용히 멈춘다.**
`overflow: hidden`**스크롤 컨테이너를 만든다.** 그러면 그 안에 있는 자식의
`animation-timeline: view()` 는 문서가 아니라 **그 상자**를 기준으로 잡힌다.
자식은 상자 안에서 위치가 변하지 않으므로 진행도가 고정되고, 애니메이션은
어중간한 한 프레임에서 굳는다.
- 콘솔 에러 없음. `animation-name`·`animation-timeline` computed 값도 전부 정상
- `getComputedStyle(el).transform` 을 두 스크롤 위치에서 재봐야 알 수 있다. **실측에서 이동 0px**
**잘라내야 한다면 `overflow: clip` 을 쓰거나 조상 섹션에서 처리해라.**
`clip` 은 스크롤 컨테이너를 만들지 않는다.
### 어두운 배경 위 광원 이미지는 opacity 로 누르지 마라
`opacity` 는 이미지 전체를 균일하게 죽인다 — 검정도, 빛도 같이. 그래서 어두운 광원 사진을
`opacity: 0.5` 로 깔면 **있는지조차 안 보인다.**
```css
.bg img { mix-blend-mode: screen; opacity: 0.9; }
```
`screen`**검정을 투명하게 만들고 밝은 곳만 더한다.** 그래서 불투명도를 높게 유지해도
본문 대비를 해치지 않는다. 밝은 이미지(종이·질감)는 반대로 `normal` 이 맞다.
**생성형 이미지를 배경으로 쓸 때 특히 조심해라.** 프롬프트에 "본문 뒤에 깔리니 어둡게"를
너무 강하게 넣으면 안전한 이미지가 아니라 **없는 이미지**가 나온다.
대비는 합성 방식으로 풀고, 이미지 자체는 볼 수 있게 만들어라.
### 배경도 한 섹션에 하나다
CSS 격자와 사진을 같이 깔았다가 걷어냈다. 두 배경이 서로 경쟁했고,
격자는 배경이라기보다 **표**로 읽혔다. 앞에서 "한 층은 고정"이라고 한 것과 같은 이야기다 —
층을 늘리는 것과 층이 싸우게 두는 것은 다르다.
### 패럴랙스에는 기준면이 필요하다 ### 패럴랙스에는 기준면이 필요하다
**배경이 단색이면 패럴랙스는 성립하지 않는다.** 앞의 것이 몇 px 어긋나 봐야 **배경이 단색이면 패럴랙스는 성립하지 않는다.** 앞의 것이 몇 px 어긋나 봐야