배경을 뷰포트에 고정하고, 게이트 씬을 파이프라인으로 옮긴다
"스크롤할 때 이미지가 고정되어 원경처럼 보이는 게 패럴랙스의 꽃"이라는
지적이 맞았다. 느리게 같이 움직이는 것으로는 그 인상이 안 나온다 —
속도만 다를 뿐 방향이 같기 때문이다.
고정 배경
background-attachment: fixed 는 iOS Safari 가 GPU 메모리 때문에
throttle 해서 스크롤 중 배경이 튄다. 쓰지 않았다.
섹션에 clip-path: inset(0) 을 걸어 position: fixed 의 컨테이닝 블록으로
만들었다. 배경이 화면에 멈춘 채 그 섹션 안에서만 보인다.
실측: 스크롤 1000px 구간에서 viewportTop 이 계속 0.
대가 둘을 받아들였다
조상에 transform/filter/mask 를 못 쓴다 — 페이드 마스크를 포기했다
prefers-reduced-motion 에서는 absolute 로 되돌린다
전부 고정하지는 않았다. 종이는 손에 닿는 거리라 원경으로 두면 거짓말이다
재료·설치·프리플라이트 fixed 원경
사례 drift 두 결과물이 흐르는 자리
조판 drift 종이는 가깝다
게이트 씬을 파이프라인으로
히어로에서는 의미를 판독기가 자막으로 설명해야 했다. 그림이 스스로
말하지 못한다는 뜻이다. 옆에 여섯 단계가 적힌 자리로 옮기니 자막이
필요 없어졌다 — 판을 통과하면 그 단계가 켜진다.
캔버스는 sticky, 진행도는 섹션에서 읽는다.
travel 을 섹션 높이로 잡았더니 캔버스가 사라진 뒤에도 진행이 남아
6단계 중 3단계에서 멈췄다. (섹션 높이 - 뷰포트 높이)로 고쳤다.
캔버스 위 텍스트 대비 14.2:1.
스킬
motion.md 패럴랙스의 원형은 고정 · clip-path 기법과 그 대가
sticky 캔버스의 진행도 계산
three.md 씬의 의미는 옆의 콘텐츠가 반응할 때 성립한다.
자막이 필요하면 배치를 의심해라
This commit is contained in:
parent
1ed3c95542
commit
af3a08397e
12 changed files with 281 additions and 56 deletions
|
|
@ -221,6 +221,47 @@ WebGL 게이트: `prefers-reduced-motion` 이면 **아예 시작하지 않는다
|
||||||
한 층은 반드시 고정이다 — 파이프라인의 단계명은 움직이지 않는다.
|
한 층은 반드시 고정이다 — 파이프라인의 단계명은 움직이지 않는다.
|
||||||
전부 움직이면 기준이 없어 그냥 흔들리는 페이지가 된다.
|
전부 움직이면 기준이 없어 그냥 흔들리는 페이지가 된다.
|
||||||
|
|
||||||
|
### 패럴랙스를 "고정"으로 바꿨다
|
||||||
|
|
||||||
|
세 번 고친 뒤에도 티가 나지 않았다. 원인은 **방식 자체**였다 —
|
||||||
|
느리게 같이 움직이는 것으로는 원경이 안 된다. 속도만 다를 뿐 방향이 같기 때문이다.
|
||||||
|
|
||||||
|
**배경을 뷰포트에 고정한다.** `background-attachment: fixed` 는 iOS Safari 가 throttle 하므로
|
||||||
|
쓰지 않고, 섹션에 `clip-path: inset(0)` 을 걸어 **`position: fixed` 의 컨테이닝 블록**으로 만든다.
|
||||||
|
그러면 배경이 화면에 멈춘 채 그 섹션 안에서만 보인다.
|
||||||
|
|
||||||
|
실측: 스크롤 1000px 구간에서 배경의 `viewportTop` 이 계속 **0**.
|
||||||
|
|
||||||
|
대가가 둘 있고 둘 다 받아들였다.
|
||||||
|
|
||||||
|
- **조상에 `transform`·`filter`·`mask` 를 못 쓴다.** 그것들도 컨테이닝 블록을 만들어
|
||||||
|
고정을 깨뜨린다. 그래서 위아래 페이드 마스크를 포기하고 경계는 clip 으로 딱 자른다
|
||||||
|
- `prefers-reduced-motion` 에서는 `position: absolute` 로 되돌린다
|
||||||
|
|
||||||
|
**전부 고정하지는 않았다.** 원경과 중경을 나눴다.
|
||||||
|
|
||||||
|
| 섹션 | 모드 | 왜 |
|
||||||
|
|---|---|---|
|
||||||
|
| 재료 · 설치 · 프리플라이트 | `fixed` | 원경. 콘텐츠만 그 위를 지나간다 |
|
||||||
|
| 사례 | `drift` 0.4 | 두 결과물이 흐르는 자리라 배경도 같이 흐른다 |
|
||||||
|
| 조판 | `drift` 0.25 | **종이는 손에 닿는 거리다.** 원경으로 두면 거짓말이 된다 |
|
||||||
|
|
||||||
|
### 게이트 씬을 파이프라인으로 옮겼다
|
||||||
|
|
||||||
|
히어로에서 뺐던 유리판 여섯 장을 여기로 되살렸다. **같은 씬인데 자막이 필요 없어졌다.**
|
||||||
|
|
||||||
|
| | 히어로에 뒀을 때 | 파이프라인으로 옮긴 뒤 |
|
||||||
|
|---|---|---|
|
||||||
|
| 옆에 있는 것 | 헤드라인과 CTA | **여섯 단계의 실제 이름** |
|
||||||
|
| 의미 전달 | `게이트 01 / 06` 판독기 — 자막 | 판을 통과하면 그 단계가 켜진다 |
|
||||||
|
|
||||||
|
캔버스는 `position: sticky; height: 100dvh` 로 화면에 머물고 섹션이 지나간다.
|
||||||
|
**진행도는 섹션 높이가 아니라 (섹션 높이 − 뷰포트 높이)로 잰다.**
|
||||||
|
섹션 높이로 나눴더니 캔버스가 사라진 뒤에도 진행이 남아 6단계 중 **3단계에서 멈췄다.**
|
||||||
|
그리고 기준 요소는 캔버스가 아니라 섹션이다 — sticky 캔버스는 자기 rect 가 변하지 않는다.
|
||||||
|
|
||||||
|
캔버스 위 텍스트 대비 실측 **14.2:1**(캔버스 불투명도 0.7).
|
||||||
|
|
||||||
### 패럴랙스가 세 번 실패한 경위
|
### 패럴랙스가 세 번 실패한 경위
|
||||||
|
|
||||||
**1. 이동량이 모자랐다.** 여유를 28% 잡아놓고 `±46px`(총 92px)만 움직였다.
|
**1. 이동량이 모자랐다.** 여유를 28% 잡아놓고 `±46px`(총 92px)만 움직였다.
|
||||||
|
|
|
||||||
|
|
@ -8,47 +8,53 @@ import align from "../assets/bg/bg-align.webp";
|
||||||
/**
|
/**
|
||||||
* 배경은 섹션마다 다른 말을 해야 한다. 같은 그림을 전부에 깔면 그건 벽지다.
|
* 배경은 섹션마다 다른 말을 해야 한다. 같은 그림을 전부에 깔면 그건 벽지다.
|
||||||
* 넷 다 gpt-image-2 로 만들었고, 프롬프트에 이 페이지의 토큰 값을 그대로 넣었다.
|
* 넷 다 gpt-image-2 로 만들었고, 프롬프트에 이 페이지의 토큰 값을 그대로 넣었다.
|
||||||
*
|
|
||||||
* refraction 겹친 유리판을 통과한 빛 재질이 주제인 자리
|
|
||||||
* split 한 빔이 둘로 갈라진다 같은 절차, 다른 결과
|
|
||||||
* align 정렬선 하나가 어긋나 있다 자기 검사
|
|
||||||
* paper 무광 인쇄용지 밝은 밴드. 한글이 종이 위에 있어야 한다
|
|
||||||
*/
|
*/
|
||||||
const SOURCES = { refraction, paper, split, align } as const;
|
const SOURCES = { refraction, paper, split, align } as const;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variant?: keyof typeof SOURCES;
|
variant?: keyof typeof SOURCES;
|
||||||
/**
|
/**
|
||||||
* 패럴랙스 강도. 이미지를 컨테이너보다 이 비율만큼 크게 만들고,
|
* `fixed` — 뷰포트에 고정. 콘텐츠만 그 위를 지나간다. 무한히 먼 원경.
|
||||||
* 스크롤 구간 동안 정확히 그 초과분만큼 움직인다.
|
* `drift` — 스크롤보다 느리게 따라온다. 중경. 종이처럼 가까운 재질에 쓴다.
|
||||||
* 0.45 면 섹션 높이가 2000px 일 때 900px 을 이동한다.
|
|
||||||
*/
|
*/
|
||||||
|
mode?: "fixed" | "drift";
|
||||||
|
/** drift 전용. 이미지를 컨테이너보다 이 비율만큼 키우고 그 초과분 전체를 움직인다 */
|
||||||
depth?: number;
|
depth?: number;
|
||||||
/** 0~1 */
|
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
/** 좌우 반전. 같은 그림을 두 섹션에 쓸 때 되풀이로 안 읽히게 한다 */
|
|
||||||
flip?: boolean;
|
flip?: boolean;
|
||||||
}
|
}
|
||||||
const { variant = "refraction", depth = 0.45, opacity = 0.9, flip = false } = Astro.props;
|
const {
|
||||||
|
variant = "refraction",
|
||||||
|
mode = "fixed",
|
||||||
|
depth = 0.35,
|
||||||
|
opacity = 0.9,
|
||||||
|
flip = false,
|
||||||
|
} = Astro.props;
|
||||||
const src = SOURCES[variant];
|
const src = SOURCES[variant];
|
||||||
|
|
||||||
// 밝은 종이만 그대로 얹고, 어두운 광원 사진은 더하기 합성한다.
|
// 밝은 종이만 그대로 얹고, 어두운 광원 사진은 더하기 합성한다.
|
||||||
const additive = variant !== "paper";
|
const additive = variant !== "paper";
|
||||||
|
|
||||||
// 이미지 높이 = (1 + depth) 배. 이동량은 이미지 자기 높이 기준 비율로 환산한다.
|
|
||||||
// 예: depth 0.45 -> 높이 145%, 이동 -(0.45 / 1.45) = -31.03%
|
|
||||||
const scale = 1 + depth;
|
const scale = 1 + depth;
|
||||||
const shift = -(depth / scale) * 100;
|
const shift = -(depth / scale) * 100;
|
||||||
---
|
---
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
- `loading="lazy"` — 초기 번들에 들어가지 않는다. CSS background-image 로 깔면
|
패럴랙스의 원형은 "배경이 고정되어 원경처럼 보이는 것"이다.
|
||||||
이 통제권을 잃는다(항상 받고, 최적화도 안 된다). 그래서 <img> 를 절대배치한다
|
느리게 같이 움직이는 것으로는 그 인상이 안 나온다 — 속도만 다를 뿐 같은 방향이다.
|
||||||
- `aria-hidden` + 빈 alt — 장식이다. 보조기술이 읽을 것이 없다
|
|
||||||
|
`background-attachment: fixed` 는 쓰지 않는다. iOS Safari 가 GPU 메모리 때문에
|
||||||
|
그것을 throttle 해서 스크롤 중 배경이 튀거나 리셋된다.
|
||||||
|
대신 부모에 `clip-path: inset(0)` 을 걸어 **fixed 의 컨테이닝 블록**으로 만든다.
|
||||||
|
그러면 배경은 뷰포트에 고정되면서 그 섹션 영역에서만 보인다.
|
||||||
|
|
||||||
|
주의: 조상에 `transform`·`filter`·`mask` 가 있으면 그게 컨테이닝 블록이 되어
|
||||||
|
이 기법이 통째로 깨진다. 그래서 위아래 페이드 마스크를 포기했다 —
|
||||||
|
마스크를 걸면 고정이 사라진다. 경계는 clip 으로 딱 자른다.
|
||||||
*/}
|
*/}
|
||||||
<div
|
<div
|
||||||
class="bg-photo"
|
class="bg-photo"
|
||||||
class:list={{ flip, additive }}
|
class:list={[mode, { flip, additive }]}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={`--bg-opacity:${opacity}; --bg-h:${scale * 100}%; --bg-shift:${shift.toFixed(2)}%`}
|
style={`--bg-opacity:${opacity}; --bg-h:${scale * 100}%; --bg-shift:${shift.toFixed(2)}%`}
|
||||||
>
|
>
|
||||||
|
|
@ -57,56 +63,60 @@ const shift = -(depth / scale) * 100;
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.bg-photo {
|
.bg-photo {
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
grid-column: full;
|
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
/* overflow 를 걸지 않는다.
|
grid-column: full;
|
||||||
overflow: hidden 은 스크롤 컨테이너를 만들고, 그러면 자식의 view() 타임라인이
|
|
||||||
문서가 아니라 이 상자를 기준으로 잡힌다. 이미지는 상자 안에서 늘 같은 자리라
|
|
||||||
진행도가 고정되고 패럴랙스가 통째로 멈춘다. 실측에서 이동량이 0px 이었다.
|
|
||||||
잘라내는 일은 섹션의 `.has-bg { overflow: clip }` 이 한다. */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-photo :global(img) {
|
/* ---- 원경: 뷰포트에 고정 ---------------------------------- */
|
||||||
|
.fixed {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
.fixed :global(img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
opacity: var(--bg-opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 중경: 스크롤보다 느리게 ------------------------------ */
|
||||||
|
.drift {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
.drift :global(img) {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* 컨테이너보다 세로로 크다. 이 초과분이 곧 패럴랙스가 쓸 수 있는 거리다. */
|
/* 컨테이너보다 크다. 이 초과분이 곧 움직일 수 있는 거리다 */
|
||||||
height: var(--bg-h);
|
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 로 누르면 안 된다 — 빛까지 같이 죽는다.
|
어두운 배경 위의 광원 사진은 opacity 로 누르면 안 된다 — 빛까지 같이 죽는다.
|
||||||
screen 은 검정을 투명하게 만들고 밝은 곳만 더한다. 그래서 불투명도를 높게
|
screen 은 검정을 투명하게 만들고 밝은 곳만 더한다.
|
||||||
유지해도 본문 대비를 해치지 않는다. 실제로 opacity 0.5 로 눌렀을 때는
|
|
||||||
이미지가 있는지조차 보이지 않았다.
|
|
||||||
*/
|
*/
|
||||||
.additive :global(img) { mix-blend-mode: screen; }
|
.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) {
|
/* drift 만 움직인다. fixed 는 애초에 안 움직이는 것이 효과다 */
|
||||||
|
.drift :global(img) {
|
||||||
animation: bg-parallax linear both;
|
animation: bg-parallax linear both;
|
||||||
animation-timeline: view();
|
animation-timeline: view();
|
||||||
/* 섹션이 화면에 들어올 때부터 나갈 때까지 전 구간을 쓴다.
|
|
||||||
좁게 잡으면 대부분의 스크롤에서 이미지가 멈춰 있다. */
|
|
||||||
animation-range: cover 0% cover 100%;
|
animation-range: cover 0% cover 100%;
|
||||||
}
|
}
|
||||||
.flip :global(img) {
|
.drift.flip :global(img) { animation-name: bg-parallax-flip; }
|
||||||
animation-name: bg-parallax-flip;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 초과분 전체를 쓴다. 여유를 45% 잡아놓고 92px 만 움직이면 시차가 안 보인다 */
|
|
||||||
@keyframes bg-parallax {
|
@keyframes bg-parallax {
|
||||||
from { transform: translateY(0); }
|
from { transform: translateY(0); }
|
||||||
to { transform: translateY(var(--bg-shift)); }
|
to { transform: translateY(var(--bg-shift)); }
|
||||||
|
|
@ -116,8 +126,11 @@ const shift = -(depth / scale) * 100;
|
||||||
to { transform: translateY(var(--bg-shift)) scaleX(-1); }
|
to { transform: translateY(var(--bg-shift)) scaleX(-1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* screen 합성은 어두운 곳이 이미 투명하다. 투명도를 크게 깎을 이유가 없고,
|
/* 모션을 줄이는 설정에서는 고정 배경도 부담이 될 수 있다. 흐름에 되돌린다 */
|
||||||
깎으면 이미지가 있는지조차 안 보인다. 종이(불투명)만 더 낮춘다. */
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fixed { position: absolute; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-transparency: reduce) {
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
.additive :global(img) { opacity: calc(var(--bg-opacity) * 0.9); }
|
.additive :global(img) { opacity: calc(var(--bg-opacity) * 0.9); }
|
||||||
.bg-photo:not(.additive) :global(img) { opacity: calc(var(--bg-opacity) * 0.7); }
|
.bg-photo:not(.additive) :global(img) { opacity: calc(var(--bg-opacity) * 0.7); }
|
||||||
|
|
|
||||||
|
|
@ -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 variant="refraction" opacity={0.85} depth={0.4} flip />
|
<BgPhoto variant="refraction" mode="fixed" opacity={0.85} 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>
|
||||||
|
|
|
||||||
|
|
@ -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 variant="refraction" opacity={0.9} depth={0.45} />
|
<BgPhoto variant="refraction" mode="fixed" opacity={0.9} />
|
||||||
<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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,15 @@ 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" aria-labelledby="pipeline-h">
|
<section id="pipeline" class="section has-bg" aria-labelledby="pipeline-h">
|
||||||
|
{/* 히어로에 있던 게이트 씬을 여기로 옮겼다.
|
||||||
|
히어로에서는 그 은유를 판독기가 자막으로 설명해야 했다 — 그림이 스스로
|
||||||
|
말하지 못한다는 뜻이다. 여기서는 옆에 실제 여섯 단계가 적혀 있고,
|
||||||
|
판을 통과할 때 그 단계가 켜진다. 자막이 아니라 콘텐츠가 반응한다. */}
|
||||||
|
<div class="gate-bg" aria-hidden="true">
|
||||||
|
<div class="gate-fallback"></div>
|
||||||
|
<canvas id="pipeline-canvas" class="gate-canvas"></canvas>
|
||||||
|
</div>
|
||||||
{/* 제목·리드·브리프·스파인을 한 컨테이너에 담는다.
|
{/* 제목·리드·브리프·스파인을 한 컨테이너에 담는다.
|
||||||
섹션 기본 그리드에 두면 제목은 main 칼럼의 음수 마진으로 x=371,
|
섹션 기본 그리드에 두면 제목은 main 칼럼의 음수 마진으로 x=371,
|
||||||
리드는 x=435, 스파인은 x=143 에 서서 정렬선이 셋이 된다. */}
|
리드는 x=435, 스파인은 x=143 에 서서 정렬선이 셋이 된다. */}
|
||||||
|
|
@ -30,8 +38,8 @@ const { c } = Astro.props;
|
||||||
|
|
||||||
<ol role="list" class="spine">
|
<ol role="list" class="spine">
|
||||||
{
|
{
|
||||||
c.stages.map((s) => (
|
c.stages.map((s, i) => (
|
||||||
<li class="stop reveal">
|
<li class="stop reveal" data-stage={i}>
|
||||||
<div class="stop-head">
|
<div class="stop-head">
|
||||||
<h3 class="stop-name">{s.name}</h3>
|
<h3 class="stop-name">{s.name}</h3>
|
||||||
<p class="stop-does">{s.does}</p>
|
<p class="stop-does">{s.does}</p>
|
||||||
|
|
@ -54,6 +62,44 @@ const { c } = Astro.props;
|
||||||
.brief { max-width: var(--measure); }
|
.brief { max-width: var(--measure); }
|
||||||
#pipeline-h { max-width: 24ch; }
|
#pipeline-h { max-width: 24ch; }
|
||||||
|
|
||||||
|
/* ---- 게이트 배경 ------------------------------------------
|
||||||
|
sticky 로 화면에 머물고 음수 마진으로 레이아웃 자리는 차지하지 않는다.
|
||||||
|
섹션이 지나가는 동안 카메라가 판 여섯 장을 통과한다. */
|
||||||
|
.gate-bg {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
margin-bottom: -100dvh;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
grid-column: full;
|
||||||
|
}
|
||||||
|
.gate-fallback,
|
||||||
|
.gate-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.gate-fallback {
|
||||||
|
background:
|
||||||
|
radial-gradient(46% 54% at 28% 34%,
|
||||||
|
color-mix(in oklab, var(--accent) 20%, transparent), transparent 70%),
|
||||||
|
radial-gradient(42% 50% at 76% 68%,
|
||||||
|
color-mix(in oklab, var(--glow-2) 18%, transparent), transparent 68%);
|
||||||
|
}
|
||||||
|
.gate-canvas { opacity: 0; transition: opacity 1s var(--ease-soft); }
|
||||||
|
.gate-canvas.is-live { opacity: 0.7; }
|
||||||
|
|
||||||
|
/* 통과한 단계가 켜진다. 3D 와 텍스트를 잇는 유일한 연결이다 */
|
||||||
|
.stop { transition: opacity var(--dur-quick) var(--ease-out); }
|
||||||
|
.stop.is-active .stop-name { color: var(--accent); }
|
||||||
|
.stop.is-active::before {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* 스파인은 페이지의 본체다. main 칼럼(561px)에 가두면
|
/* 스파인은 페이지의 본체다. main 칼럼(561px)에 가두면
|
||||||
페이지에서 가장 긴 섹션이 가장 좁아진다.
|
페이지에서 가장 긴 섹션이 가장 좁아진다.
|
||||||
R1(zed.dev) 실측: 컨테이너 1120px 과 전체폭이 교대한다. */
|
R1(zed.dev) 실측: 컨테이너 1120px 과 전체폭이 교대한다. */
|
||||||
|
|
@ -173,3 +219,28 @@ const { c } = Astro.props;
|
||||||
이 섹션은 페이지의 본체다. 콘텐츠를 걸고 하는 연출은 하지 않는다. */
|
이 섹션은 페이지의 본체다. 콘텐츠를 걸고 하는 연출은 하지 않는다. */
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 게이트를 통과할 때 해당 단계를 켠다. 값이 바뀔 때만 DOM 을 쓴다.
|
||||||
|
import { mountHeroScene } from "../lib/hero-scene";
|
||||||
|
|
||||||
|
const canvas = document.getElementById("pipeline-canvas") as HTMLCanvasElement | null;
|
||||||
|
const section = document.getElementById("pipeline");
|
||||||
|
const stops = [...document.querySelectorAll<HTMLElement>("#pipeline .stop")];
|
||||||
|
|
||||||
|
if (canvas && section && stops.length) {
|
||||||
|
let last = -1;
|
||||||
|
mountHeroScene({
|
||||||
|
canvas,
|
||||||
|
accentVar: "--accent",
|
||||||
|
glowVar: "--glow-2",
|
||||||
|
// 캔버스는 sticky 라 rect 가 변하지 않는다. 진행도는 섹션에서 읽는다.
|
||||||
|
progressFrom: section,
|
||||||
|
onGate(index) {
|
||||||
|
if (index === last) return;
|
||||||
|
last = index;
|
||||||
|
stops.forEach((el, i) => el.classList.toggle("is-active", i === index));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -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={1} depth={0.55} />
|
<BgPhoto variant="align" mode="fixed" opacity={1} />
|
||||||
<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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.85} depth={0.5} />
|
<BgPhoto variant="split" mode="drift" depth={0.4} opacity={0.85} />
|
||||||
<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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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={1} depth={0.3} />
|
<BgPhoto variant="paper" mode="drift" depth={0.25} opacity={1} />
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ export interface HeroSceneOptions {
|
||||||
* WebGL 은 셰이더만 만진다. 매 프레임 DOM 을 쓰면 그 경계가 무너진다.
|
* WebGL 은 셰이더만 만진다. 매 프레임 DOM 을 쓰면 그 경계가 무너진다.
|
||||||
*/
|
*/
|
||||||
onGate?: (index: number, total: number) => void;
|
onGate?: (index: number, total: number) => void;
|
||||||
|
/**
|
||||||
|
* 스크롤 진행도를 이 요소로 잰다. 기본은 캔버스 자신.
|
||||||
|
* 캔버스를 sticky/fixed 로 화면에 붙여두면 캔버스의 rect 는 변하지 않으므로
|
||||||
|
* 진행도가 고정된다 — 그때는 실제로 지나가는 섹션을 넘겨야 한다.
|
||||||
|
*/
|
||||||
|
progressFrom?: HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOISE = `
|
const NOISE = `
|
||||||
|
|
@ -70,7 +76,7 @@ export function mountHeroScene(opts: HeroSceneOptions): void {
|
||||||
io.observe(canvas);
|
io.observe(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): Promise<void> {
|
async function boot({ canvas, accentVar, glowVar, onGate, progressFrom }: HeroSceneOptions): Promise<void> {
|
||||||
let THREE: typeof import("three");
|
let THREE: typeof import("three");
|
||||||
try {
|
try {
|
||||||
THREE = await import("three");
|
THREE = await import("three");
|
||||||
|
|
@ -305,10 +311,15 @@ async function boot({ canvas, accentVar, glowVar, onGate }: HeroSceneOptions): P
|
||||||
|
|
||||||
// 스크롤 진행도. addEventListener("scroll") 을 쓰지 않는다(하드 게이트 10).
|
// 스크롤 진행도. addEventListener("scroll") 을 쓰지 않는다(하드 게이트 10).
|
||||||
// rect 는 프레임당 한 번만 읽는다. 두 번 읽으면 레이아웃을 두 번 강제한다.
|
// rect 는 프레임당 한 번만 읽는다. 두 번 읽으면 레이아웃을 두 번 강제한다.
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = (progressFrom ?? canvas).getBoundingClientRect();
|
||||||
// 액자가 화면 위로 92% 지나갔을 때 마지막 게이트를 통과하게 잡았다.
|
// 진행 거리.
|
||||||
// 그 뒤로는 액자가 보이지 않으므로 계속 진행시킬 이유가 없다.
|
// progressFrom 이 있으면 캔버스는 sticky 로 화면에 붙어 있고 섹션이 지나간다.
|
||||||
const travel = rect.height * 0.92;
|
// 이때 캔버스가 실제로 화면에 머무는 거리는 (섹션 높이 - 뷰포트 높이)다.
|
||||||
|
// 섹션 높이를 그대로 쓰면 캔버스가 사라진 뒤에도 진행이 남아 마지막 게이트에
|
||||||
|
// 영영 도달하지 못한다 — 실측에서 6단계 중 3단계에서 멈췄다.
|
||||||
|
const travel = progressFrom
|
||||||
|
? Math.max(rect.height - innerHeight, 1)
|
||||||
|
: rect.height * 0.92;
|
||||||
const progress = Math.min(Math.max(-rect.top / travel, 0), 1);
|
const progress = Math.min(Math.max(-rect.top / travel, 0), 1);
|
||||||
|
|
||||||
bgUniforms.uProgress.value = progress;
|
bgUniforms.uProgress.value = progress;
|
||||||
|
|
|
||||||
|
|
@ -427,9 +427,19 @@ a:hover { color: var(--accent); }
|
||||||
.has-bg {
|
.has-bg {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
/* 배경 이미지는 컨테이너보다 크고 패럴랙스로 움직인다.
|
/* `clip-path: inset(0)` 는 두 가지를 동시에 한다.
|
||||||
clip 하지 않으면 그 초과분이 이웃 섹션 위로 넘친다. */
|
1) 배경이 섹션 밖으로 넘치지 않게 자른다
|
||||||
overflow: clip;
|
2) `position: fixed` 자식의 **컨테이닝 블록**이 된다
|
||||||
|
|
||||||
|
2번이 핵심이다. 그래서 배경이 뷰포트에 고정되면서도 이 섹션 안에서만 보인다 —
|
||||||
|
콘텐츠만 그 위를 지나가고 배경은 원경처럼 멈춰 있다.
|
||||||
|
|
||||||
|
`background-attachment: fixed` 를 쓰지 않은 이유는 iOS Safari 가 GPU 메모리
|
||||||
|
때문에 그것을 throttle 해서 스크롤 중 배경이 튀기 때문이다.
|
||||||
|
|
||||||
|
주의: 여기에 `transform`·`filter`·`mask` 를 추가하면 안 된다. 그것들도
|
||||||
|
컨테이닝 블록을 만들지만, 그 순간 fixed 가 섹션 기준이 되어 고정이 사라진다. */
|
||||||
|
clip-path: inset(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 유틸 -------------------------------------------------- */
|
/* ---- 유틸 -------------------------------------------------- */
|
||||||
|
|
|
||||||
|
|
@ -448,6 +448,38 @@ JS 쪽 판정도 한 곳에 모은다. 설정이 도중에 바뀌면 반영한
|
||||||
안 움직이는 섹션이 하나도 없으면 움직임에 의미가 없다는 뜻이고,
|
안 움직이는 섹션이 하나도 없으면 움직임에 의미가 없다는 뜻이고,
|
||||||
`design.md` 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다.
|
`design.md` 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다.
|
||||||
|
|
||||||
|
### 패럴랙스의 원형은 "고정"이다
|
||||||
|
|
||||||
|
**배경이 뷰포트에 멈춰 있고 콘텐츠만 그 위를 지나갈 때 원경이 된다.**
|
||||||
|
느리게 같이 움직이는 것으로는 그 인상이 안 나온다 — 속도만 다를 뿐 방향이 같기 때문이다.
|
||||||
|
"패럴랙스를 넣었는데 티가 안 난다"의 절반은 이것이다.
|
||||||
|
|
||||||
|
**`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`, 중경은 아래의 비율 이동, 근경은 콘텐츠와 함께.
|
||||||
|
|
||||||
### 이동량은 절대값이 아니라 컨테이너 대비 비율이다
|
### 이동량은 절대값이 아니라 컨테이너 대비 비율이다
|
||||||
|
|
||||||
**패럴랙스가 "안 보인다"의 대부분은 이동량이 모자란 것이다.**
|
**패럴랙스가 "안 보인다"의 대부분은 이동량이 모자란 것이다.**
|
||||||
|
|
@ -517,6 +549,25 @@ CSS-Tricks 의 예제도 `background-position: bottom 0px → bottom -400px`,
|
||||||
너무 강하게 넣으면 안전한 이미지가 아니라 **없는 이미지**가 나온다.
|
너무 강하게 넣으면 안전한 이미지가 아니라 **없는 이미지**가 나온다.
|
||||||
대비는 합성 방식으로 풀고, 이미지 자체는 볼 수 있게 만들어라.
|
대비는 합성 방식으로 풀고, 이미지 자체는 볼 수 있게 만들어라.
|
||||||
|
|
||||||
|
### 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 로 재면 진행도가 고정된다.
|
||||||
|
|
||||||
### 배경도 한 섹션에 하나다
|
### 배경도 한 섹션에 하나다
|
||||||
|
|
||||||
CSS 격자와 사진을 같이 깔았다가 걷어냈다. 두 배경이 서로 경쟁했고,
|
CSS 격자와 사진을 같이 깔았다가 걷어냈다. 두 배경이 서로 경쟁했고,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,34 @@ h1 이 36px 이어도 히어로가 압도적이다.
|
||||||
|
|
||||||
캔버스 위에 글자를 올려야 한다면 **하나만** 올려라. 그리고 거기엔 바탕을 깔아라.
|
캔버스 위에 글자를 올려야 한다면 **하나만** 올려라. 그리고 거기엔 바탕을 깔아라.
|
||||||
|
|
||||||
|
### 씬의 의미는 옆의 콘텐츠가 반응할 때 성립한다
|
||||||
|
|
||||||
|
3D 에 은유를 담았는데 **그 의미를 자막으로 설명하고 있다면, 그림이 스스로 말하지 못한다는 뜻**이다.
|
||||||
|
자막을 다듬을 게 아니라 배치를 의심해라.
|
||||||
|
|
||||||
|
실측 사례 — 여섯 단계 파이프라인을 유리판 여섯 장으로 표현한 씬:
|
||||||
|
|
||||||
|
| | 히어로에 뒀을 때 | 파이프라인 섹션으로 옮긴 뒤 |
|
||||||
|
|---|---|---|
|
||||||
|
| 옆에 있는 것 | 헤드라인과 CTA | **여섯 단계의 실제 이름과 설명** |
|
||||||
|
| 의미 전달 | `게이트 01 / 06` 판독기 — **자막** | 판을 통과하면 그 단계가 켜진다 |
|
||||||
|
| 사용자 반응 | "왜 저 상자가 있는지 모르겠다" | — |
|
||||||
|
|
||||||
|
**같은 씬인데 자리를 옮기니 자막이 필요 없어졌다.** 콘텐츠가 반응하기 때문이다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
mountScene({
|
||||||
|
canvas,
|
||||||
|
progressFrom: section, // sticky 캔버스는 자기 rect 로 진행도를 못 잰다
|
||||||
|
onGate(index) { // 값이 바뀔 때만 DOM 을 쓴다
|
||||||
|
stops.forEach((el, i) => el.classList.toggle("is-active", i === index));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**씬을 넣을 자리를 고를 때 "여기에 두면 무엇이 반응하는가"를 먼저 물어라.**
|
||||||
|
아무것도 반응하지 않으면 그것은 재질이지 주인공이 아니다.
|
||||||
|
|
||||||
### 재질일 때 — 배경 전면
|
### 재질일 때 — 배경 전면
|
||||||
|
|
||||||
주인공이 아니라 공간을 만드는 표면이다. 규칙이 반대가 된다.
|
주인공이 아니라 공간을 만드는 표면이다. 규칙이 반대가 된다.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue