09A Parallax Hero of Mountain Ridges, Aurora and Lake Reflection
Three ridge layers built by randomly nudging midpoints are lerped toward the pointer by different depths, with 01's curtain and 04's reflection layered into one scene so the whole first screen becomes a night lakeside.
Best forFirst screen of a travel package landing page
RIDGE_LAYERSRIDGE_ROUGHNESSPARALLAX_LERP

<main class="stage">
<div class="au is-demo" id="au" tabindex="0" role="group" aria-label="Lakeside scene — arrow keys shift the view">
<canvas class="au__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="au__hero">
<p class="au__kicker">Three nights by a lake</p>
<h2 class="au__title">Nights of rest under the aurora</h2>
</div>
<button class="au__pause" data-role="pause" type="button" aria-pressed="false">Pause motion</button>
<p class="au__status" data-role="status" aria-live="off">The ridges follow the pointer by depth</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.au {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
// 05·09 는 무대 자체가 초점을 받는다(화살표 키) — 고리는 크림색으로 어두운 하늘 위에서 보이게
.au:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
.au__canvas {
display: block;
width: 100%;
height: 100%;
// 손가락 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
touch-action: pan-y;
}
.au__pause {
position: absolute;
top: $sp-2;
right: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border: 1px solid rgba(255, 247, 230, .45);
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: $subject-cream;
font-family: inherit;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.au__pause:hover {
background: rgba(23, 20, 26, .92);
}
.au__pause:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
.au__pause[hidden] {
display: none;
}
.au__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 54%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.au__status { font-size: 10px; padding: $sp-1 $sp-2; }
.au__pause { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 움직임은 스크립트가 matchMedia 로 멈춘다(노이즈 시간을 세운 한 장면). CSS 쪽은 멈춤 버튼을 감추는 hidden 만 쓴다
@media (prefers-reduced-motion: reduce) {
.au__pause { transition: none; }
}
// 09 히어로 문구 — 하늘 왼쪽 위(어두운 쪽)에 둔다
.au__hero {
position: absolute;
left: $sp-4;
top: $sp-4;
max-width: 58%;
pointer-events: none;
}
.au__kicker {
margin: 0 0 $sp-1;
color: $subject-cream;
font-weight: 600;
font-size: 11px;
letter-spacing: .04em;
}
.au__title {
margin: 0;
color: $subject-cream;
font-weight: 700;
font-size: clamp(15px, 5vw, 26px);
line-height: 1.2;
word-break: keep-all;
text-shadow: 0 1px 8px rgba(8, 10, 24, .8);
}
@media (max-width: 340px) {
.au__hero { left: $sp-3; top: $sp-3; }
.au__kicker { font-size: 10px; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.au {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.au:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.au__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: pan-y;
}
.au__pause {
position: absolute;
top: 8px;
right: 8px;
margin: 0;
padding: 4px 12px;
border: 1px solid rgba(255, 247, 230, 0.45);
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-family: inherit;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.au__pause:hover {
background: rgba(23, 20, 26, 0.92);
}
.au__pause:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.au__pause[hidden] {
display: none;
}
.au__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 54%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.au__status {
font-size: 10px;
padding: 4px 8px;
}
.au__pause {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.au__pause {
transition: none;
}
}
.au__hero {
position: absolute;
left: 16px;
top: 16px;
max-width: 58%;
pointer-events: none;
}
.au__kicker {
margin: 0 0 4px;
color: #fff7e6;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.04em;
}
.au__title {
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(15px, 5vw, 26px);
line-height: 1.2;
word-break: keep-all;
text-shadow: 0 1px 8px rgba(8, 10, 24, 0.8);
}
@media (max-width: 340px) {
.au__hero {
left: 12px;
top: 12px;
}
.au__kicker {
font-size: 10px;
}
}(function () {
const root = document.getElementById("au");
if (!root) return;
// 장면 좌표 — 캔버스 내부는 이 크기 x 배율(dpr)로 잡고, 그림은 늘 480x300 좌표로 그린다
const W = 480;
const H = 300;
// 고정 틱(ms) — 화면 주사율과 상관없이 같은 빠르기로 흐른다
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 동작 줄이기에서 멈춰 둘 장면 시각(ms) — 이 시각의 한 장면만 그린다
const STILL_MS = 5200;
// 밤하늘 — 위는 짙은 남색, 지평선 쪽은 한 단계 밝은 남보라
const SKY_TOP = [8, 10, 24];
const SKY_LOW = [24, 28, 60];
// 오로라 빛 — 채도를 낮춘 파스텔이지만 어두운 하늘과의 명도 차는 크게 둔다
const MINT = [150, 240, 200];
const TEAL = [126, 214, 236];
const VIOLET = [192, 168, 255];
const PINK = [255, 178, 214];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
// 부드러운 계단 3x² - 2x³ — 0 과 1 에서 기울기가 0 이라 이음매가 보이지 않는다
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자점 해시 — 정수 좌표 (ix, iy) 와 시드에서 0..1 값 하나를 만든다. 같은 점은 언제나 같은 값
function hash2(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1013904223)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 정수 격자점마다 해시 값을 두고, 칸 안에서는 네 모서리 값을 부드러운 계단으로 보간한다. 결과 0..1
function noise2(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = hash2(ix, iy, seed);
const b = hash2(ix + 1, iy, seed);
const c = hash2(ix, iy + 1, seed);
const d = hash2(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
// 옥타브 합 — 주파수를 두 배로, 세기를 절반으로 줄여 가며 n 겹 더한 뒤 세기 합으로 나눈다. 결과 0..1
function octaves(x, y, n, seed) {
let sum = 0;
let amp = 0.5;
let norm = 0;
let f = 1;
for (let k = 0; k < n; k += 1) {
sum += amp * noise2(x * f + k * 17.13, y * f + k * 9.71, seed + k * 31);
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function textOf(el) { return (el.textContent || '').trim(); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const pauseBtn = pauseEl;
// 화면 글자는 DOM 에 있다 — 문구를 바꾸면 버튼·상태 줄도 같이 바뀐다
const STATUS0 = textOf(status);
const PAUSE_LABEL = textOf(pauseBtn);
const RESUME_LABEL = 'Resume motion';
const TXT_PAUSED = 'Paused — use the button to resume';
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 사람이 멈춤 버튼을 눌렀는가
let paused = false;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 캔버스 배율 — 화면 배율을 따르되 2 에서 자른다(배율 3 기기에서 픽셀 일이 2.25배로 늘지 않게)
let dpr = 1;
function fitCanvas() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
}
function begin() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
// 작은 픽셀 버퍼 — 계산은 이 크기에서 하고 화면에는 늘려 그린다
function makeBuffer(w, h) {
const b = document.createElement('canvas');
b.width = w;
b.height = h;
const c = b.getContext('2d');
return { cv: b, c: c, img: c.createImageData(w, h), w: w, h: h };
}
// 버퍼를 무대 크기로 키워 더한다 — 늘릴 때 브라우저의 이미지 보간이 칸 사이를 메우고, 'lighter' 는 밤하늘 위에 빛을 더한다
function blitBuffer(b, x, y, w, h) {
b.c.putImageData(b.img, 0, 0);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(b.cv, x, y, w, h);
ctx.restore();
}
// 높이 y 에서 하늘 색 — 버퍼 계산에서 밝기를 잴 때도 같은 식을 쓴다
function skyAt(y, h) { return mix(SKY_TOP, SKY_LOW, clamp(y / h, 0, 1)); }
function drawSky(c, w, h) {
const g = c.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
c.fillStyle = g;
c.fillRect(0, 0, w, h);
}
// 장면 시계(ms) — rAF 루프가 고정 틱으로만 늘린다. 동작 줄이기에서는 STILL_MS 에 멈춘다
let simMs = 0;
function nowS() { return simMs / 1000; }
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) / Math.max(1, r.width) * W, (ev.clientY - r.top) / Math.max(1, r.height) * H];
}
// 겹치는 DOM 층의 자리 — 장면 좌표를 무대 백분율로 바꿔 넣는다
function place(el, r) {
el.style.left = (r.x / W * 100).toFixed(3) + '%';
el.style.top = (r.y / H * 100).toFixed(3) + '%';
el.style.width = (r.w / W * 100).toFixed(3) + '%';
el.style.height = (r.h / H * 100).toFixed(3) + '%';
}
// 커튼 겹 수 — 겹마다 중심선 높이·노이즈 시드·밝기가 다르다
const CURTAIN_LAYERS = 3;
// 노이즈 결 — 장면 1px 당 노이즈 좌표가 움직이는 양. 작을수록 주름이 넓고 느긋하다
const NOISE_SCALE = 0.004;
// 세로 감쇠(1px 당) — 중심선에서 위로 d px 올라간 곳의 알파 = exp(-RAY_FALLOFF x d)
const RAY_FALLOFF = 0.018;
// 세로 줄 간격(px) — 줄마다 세로 그라데이션 하나
const STROKE_STEP = 3;
// 중심선 높이 — 옥타브 합(3겹) 값 노이즈 0..1 을 -1..1 로 펴서 진폭을 곱한다. 시간은 노이즈 둘째 좌표로 흘린다
function curtainCenter(o, layer, x, t) {
const n = octaves(x * NOISE_SCALE + t * o.speed * 0.4, t * o.speed + layer * 3.7, 3, o.seed + layer * 11);
return o.base - layer * 16 + (n - 0.5) * 2 * o.amp;
}
// 주름 밝기 — 결을 2.6배 잘게 읽은 노이즈를 문턱 0.25 위로 펴고 1.8 제곱해 밝은 주름과 어두운 틈을 가른다
function curtainFold(o, layer, x, t) {
const n = octaves(x * NOISE_SCALE * 2.6 - t * o.speed * 0.9, layer * 5.1 + t * o.speed * 0.5, 2, o.seed + 50 + layer);
return Math.pow(smooth((n - 0.25) / 0.6), 1.8);
}
// 커튼 — 겹마다 STROKE_STEP 간격으로 세로 줄을 긋는다. 줄의 아래 끝이 중심선, 위로 갈수록 지수로 옅어진다
function drawCurtain(c, o, t, w) {
c.save();
c.globalCompositeOperation = 'lighter';
for (let layer = 0; layer < CURTAIN_LAYERS; layer += 1) {
const k = CURTAIN_LAYERS > 1 ? layer / (CURTAIN_LAYERS - 1) : 0;
const layerAlpha = o.alpha * (1 - 0.35 * k);
for (let x = -STROKE_STEP; x <= w + STROKE_STEP; x += STROKE_STEP) {
const fold = curtainFold(o, layer, x, t);
if (fold < 0.02) {
continue;
}
const yc = curtainCenter(o, layer, x, t);
const len = o.len * (0.55 + 0.75 * fold) * (1 - 0.25 * k);
const top = yc - len;
const g = c.createLinearGradient(0, yc + 6, 0, top);
g.addColorStop(0, rgba(o.low, 0));
// 아래 가장자리 6px — 짧게 밝아져 커튼 밑단이 또렷하다
g.addColorStop(6 / (len + 6), rgba(o.low, layerAlpha * fold));
for (let s = 1; s <= 4; s += 1) {
const d = len * s / 4;
g.addColorStop((6 + d) / (len + 6), rgba(mix(o.low, o.high, s / 4), layerAlpha * fold * Math.exp(-RAY_FALLOFF * d)));
}
c.fillStyle = g;
c.fillRect(x, top, STROKE_STEP + 0.6, len + 6);
}
}
c.restore();
}
// 물결 진폭(px) — 화면 맨 아래 줄(물가에서 가장 먼 줄)의 옆 밀림. 물가 쪽은 이 값의 0.25배
const RIPPLE_AMP = 3;
// 물결 촘촘함 — 물가에서 1px 내려갈 때 사인 위상이 바뀌는 양(라디안)
const RIPPLE_FREQ = 0.32;
// 깊이 어둡게 — 화면 맨 아래 줄을 덮는 어두운 막의 알파(물가는 0.08)
const REFLECT_DIM = 0.55;
// 물결이 흐르는 빠르기(rad/s)
const RIPPLE_SPEED = 2.6;
// 반사를 옮겨 그리는 줄 높이(px)
const REFLECT_ROW = 2;
const WATER = [6, 8, 20];
// 반사 — 하늘 그림(src, 높이 horizon)을 거꾸로 읽어 물 위에 줄 단위로 옮긴다. 줄마다 사인 물결만큼 옆으로 민다
function drawReflection(c, src, horizon, t) {
const waterH = H - horizon;
const sd = src.width / W;
c.save();
c.fillStyle = rgba(WATER, 1);
c.fillRect(0, horizon, W, waterH);
for (let y = 0; y < waterH; y += REFLECT_ROW) {
const depth = y / waterH;
// 원근 — 가까운(아래) 물일수록 물결이 크고 성기다
const amp = RIPPLE_AMP * (0.25 + 0.75 * depth);
const ph = y * RIPPLE_FREQ * (1 - 0.45 * depth);
const dx = amp * Math.sin(ph + t * RIPPLE_SPEED) + amp * 0.45 * Math.sin(ph * 2.3 - t * RIPPLE_SPEED * 1.4);
// 거울 — 물가에서 y 만큼 내려간 줄은 하늘에서 y 만큼 위의 줄을 비춘다
const sy = clamp(horizon - REFLECT_ROW - y, 0, horizon - REFLECT_ROW);
c.globalAlpha = 0.88 - 0.3 * depth;
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx, horizon + y, W, REFLECT_ROW);
// 흐리게 — 깊을수록 옆으로 벌린 사본 두 장을 반투명으로 더 겹친다
const spread = 1 + 4 * depth;
c.globalAlpha = 0.22 * depth;
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx - spread, horizon + y, W, REFLECT_ROW);
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx + spread, horizon + y, W, REFLECT_ROW);
}
c.globalAlpha = 1;
// 깊이별 어둡게 — 물가 0.08 에서 화면 아래 REFLECT_DIM 까지
const g = c.createLinearGradient(0, horizon, 0, H);
g.addColorStop(0, rgba(WATER, 0.08));
g.addColorStop(1, rgba(WATER, REFLECT_DIM));
c.fillStyle = g;
c.fillRect(0, horizon, W, waterH);
c.restore();
}
// 능선 겹 수 — 먼 겹부터 그린다
const RIDGE_LAYERS = 3;
// 거칠기 — 중점 흔들기 단계마다 흔들림 폭에 곱하는 값. 클수록 능선이 잘게 들쭉날쭉하다
const RIDGE_ROUGHNESS = 0.55;
// 시차 따라가기 — 틱마다 목표 밀림까지 남은 거리의 이 비율만큼 간다
const PARALLAX_LERP = 0.08;
// 능선 점 수 — 2⁶ + 1 (중점을 여섯 번 나눈다)
const RIDGE_POINTS = 65;
// 능선이 화면 양옆으로 남는 폭(px) — 밀려도 끝이 보이지 않게
const RIDGE_MARGIN = 40;
const HORIZON_09 = 196;
// 겹마다 바닥 높이·흔들림 폭·최대 밀림(px)·색 — 가까울수록 많이 밀리고 짙다
const RIDGE_BASE = [150, 168, 186];
const RIDGE_AMP = [70, 52, 36];
const RIDGE_DEPTH_PX = [6, 14, 26];
const RIDGE_RGB = [[52, 58, 104], [30, 34, 70], [14, 16, 38]];
// 하늘 커튼은 능선보다 더 멀다 — 밀림 3px
const SKY_DEPTH_PX = 3;
const CURTAIN_09 = { base: 140, amp: 34, len: 118, low: MINT, high: VIOLET, alpha: 0.5, speed: 0.22, seed: 909 };
const rng09 = makeRng(909);
// 중점 흔들기 — 양 끝 높이에서 시작해 구간 가운데 점을 두 끝 평균 ± 흔들림으로 정하고, 단계마다 흔들림 폭을 RIDGE_ROUGHNESS 배로 줄인다
function midpointRidge(rng, amp) {
const n = RIDGE_POINTS;
const a = new Float32Array(n);
a[0] = (rng() - 0.5) * amp;
a[n - 1] = (rng() - 0.5) * amp;
let step = n - 1;
let spread = amp;
while (step > 1) {
const half = step / 2;
for (let i = half; i < n; i += step) {
a[i] = (a[i - half] + a[i + half]) / 2 + (rng() - 0.5) * spread;
}
step = half;
spread *= RIDGE_ROUGHNESS;
}
return a;
}
const ridges = [];
for (let k = 0; k < RIDGE_LAYERS; k += 1) {
ridges.push(midpointRidge(rng09, RIDGE_AMP[k]));
}
// 하늘의 작은 별 — 시드 고정 자리
const SKY_STARS = [];
for (let i = 0; i < 70; i += 1) {
SKY_STARS.push(rng09() * W, rng09() * 150, 0.3 + rng09() * 0.6);
}
const vpPhase09 = rng09() * TAU;
const sky09 = document.createElement('canvas');
const skyCtx09 = sky09.getContext('2d');
// 시차 — off 는 지금 밀림(-1..1), offTarget 은 포인터가 정한 목표
let off = 0;
let offTarget = 0;
let live09 = false;
let keyLive09 = false;
let ptrX09 = W / 2;
function virtualX09(t) { return W / 2 + 200 * Math.sin(t * 1.5 + vpPhase09); }
function sizeSky09() {
const w = Math.round(W * dpr);
const h = Math.round(HORIZON_09 * dpr);
if (sky09.width !== w || sky09.height !== h) {
sky09.width = w;
sky09.height = h;
}
}
function drawRidge09(c, k) {
const a = ridges[k];
const shift = -off * RIDGE_DEPTH_PX[k];
const span = W + RIDGE_MARGIN * 2;
c.beginPath();
c.moveTo(-RIDGE_MARGIN + shift, HORIZON_09);
for (let i = 0; i < RIDGE_POINTS; i += 1) {
c.lineTo(-RIDGE_MARGIN + shift + span * i / (RIDGE_POINTS - 1), RIDGE_BASE[k] + a[i]);
}
c.lineTo(W + RIDGE_MARGIN + shift, HORIZON_09);
c.closePath();
c.fillStyle = rgba(RIDGE_RGB[k], 1);
c.fill();
}
function itemInit() { sizeSky09(); }
function itemStep(dt) {
const x = live09 ? ptrX09 : virtualX09(nowS());
offTarget = clamp((x - W / 2) / (W / 2), -1, 1);
off += (offTarget - off) * PARALLAX_LERP;
}
function itemDraw() {
const t = nowS();
sizeSky09();
const c = skyCtx09;
c.setTransform(dpr, 0, 0, dpr, 0, 0);
drawSky(c, W, HORIZON_09);
c.fillStyle = 'rgba(246,244,255,0.7)';
for (let i = 0; i < SKY_STARS.length; i += 3) {
c.fillRect(SKY_STARS[i] - off * SKY_DEPTH_PX, SKY_STARS[i + 1], SKY_STARS[i + 2] * 2, SKY_STARS[i + 2] * 2);
}
c.save();
c.translate(-off * SKY_DEPTH_PX, 0);
drawCurtain(c, CURTAIN_09, t, W + SKY_DEPTH_PX * 2);
c.restore();
for (let k = 0; k < RIDGE_LAYERS; k += 1) {
drawRidge09(c, k);
}
ctx.drawImage(sky09, 0, 0, W, HORIZON_09);
drawReflection(ctx, sky09, HORIZON_09, t);
}
function itemDemo(t) { }
function itemStatus() { return STATUS0; }
function itemSnap() {
if (live09) {
offTarget = clamp((ptrX09 - W / 2) / (W / 2), -1, 1);
}
off = offTarget;
}
function itemPointer(p, on) {
if (on) {
live09 = true;
keyLive09 = false;
ptrX09 = p[0];
return true;
}
if (!keyLive09) {
live09 = false;
}
return false;
}
// 화살표 키 — 시점을 30px 씩 옆으로 옮긴다
function itemKey(key) {
const d = key === 'ArrowLeft' ? -30 : key === 'ArrowRight' ? 30 : 0;
if (d === 0) {
return false;
}
if (!live09) {
ptrX09 = virtualX09(nowS());
}
live09 = true;
keyLive09 = true;
ptrX09 = clamp(ptrX09 + d, 0, W);
return true;
}
function render() {
begin();
itemDraw();
if (!paused) {
say(itemStatus());
}
}
// 루프가 멈춰 있을 때(동작 줄이기·멈춤) 입력이 들어오면 — 움직이던 값을 목표로 바로 옮겨 한 장 그린다
function after() {
if (rafId) {
return;
}
itemSnap();
render();
}
function setPaused(p) {
paused = p;
pauseBtn.setAttribute('aria-pressed', p ? 'true' : 'false');
pauseBtn.textContent = p ? RESUME_LABEL : PAUSE_LABEL;
if (p) {
stopLoop();
say(TXT_PAUSED);
}
else {
say(itemStatus());
startLoop();
}
}
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); });
cv.addEventListener('pointermove', function (ev) {
if (itemPointer(toScene(ev), true)) {
wake();
after();
}
});
cv.addEventListener('pointerdown', function (ev) {
wake();
if (itemPointer(toScene(ev), true)) {
after();
}
});
cv.addEventListener('pointerleave', function () { itemPointer([W / 2, H / 2], false); });
root.addEventListener('keydown', function (ev) {
if (ev.target !== root) {
return;
}
if (itemKey(ev.key)) {
ev.preventDefault();
wake();
after();
}
});
function onResize() {
fitCanvas();
render();
}
fitCanvas();
itemInit();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
simMs += FIXED_DT;
if (demo) {
itemDemo(simMs);
}
itemStep(FIXED_DT);
acc -= FIXED_DT;
n += 1;
}
if (n === 6) {
acc = 0;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId || still || paused) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 순간 한 장면으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
pauseBtn.hidden = still;
if (still) {
stopLoop();
itemSnap();
render();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
pauseBtn.hidden = still;
if (still) {
// 동작 줄이기 — 노이즈 시간을 STILL_MS 에 세운 한 장면. 입력은 after() 가 그 자리에서 반영한다
simMs = STILL_MS;
itemSnap();
render();
}
else {
render();
startLoop();
}
return function () {
stopLoop();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// RidgeLakeParallaxHero - vanilla 판(09-aur-ridge-lake-parallax-hero) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./RidgeLakeParallaxHero.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RidgeLakeParallaxHero() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
type Rect = { x: number; y: number; w: number; h: number };
type PixelBuffer = { cv: HTMLCanvasElement; c: CanvasRenderingContext2D; img: ImageData; w: number; h: number };
// 장면 좌표 — 캔버스 내부는 이 크기 x 배율(dpr)로 잡고, 그림은 늘 480x300 좌표로 그린다
const W = 480;
const H = 300;
// 고정 틱(ms) — 화면 주사율과 상관없이 같은 빠르기로 흐른다
const FIXED_DT = 16;
const TAU = Math.PI * 2;
// 동작 줄이기에서 멈춰 둘 장면 시각(ms) — 이 시각의 한 장면만 그린다
const STILL_MS = 5200;
// 밤하늘 — 위는 짙은 남색, 지평선 쪽은 한 단계 밝은 남보라
const SKY_TOP: RGB = [8, 10, 24];
const SKY_LOW: RGB = [24, 28, 60];
// 오로라 빛 — 채도를 낮춘 파스텔이지만 어두운 하늘과의 명도 차는 크게 둔다
const MINT: RGB = [150, 240, 200];
const TEAL: RGB = [126, 214, 236];
const VIOLET: RGB = [192, 168, 255];
const PINK: RGB = [255, 178, 214];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
// 부드러운 계단 3x² - 2x³ — 0 과 1 에서 기울기가 0 이라 이음매가 보이지 않는다
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 격자점 해시 — 정수 좌표 (ix, iy) 와 시드에서 0..1 값 하나를 만든다. 같은 점은 언제나 같은 값
function hash2(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1013904223)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 정수 격자점마다 해시 값을 두고, 칸 안에서는 네 모서리 값을 부드러운 계단으로 보간한다. 결과 0..1
function noise2(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = hash2(ix, iy, seed);
const b = hash2(ix + 1, iy, seed);
const c = hash2(ix, iy + 1, seed);
const d = hash2(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
// 옥타브 합 — 주파수를 두 배로, 세기를 절반으로 줄여 가며 n 겹 더한 뒤 세기 합으로 나눈다. 결과 0..1
function octaves(x: number, y: number, n: number, seed: number): number {
let sum = 0;
let amp = 0.5;
let norm = 0;
let f = 1;
for (let k = 0; k < n; k += 1) {
sum += amp * noise2(x * f + k * 17.13, y * f + k * 9.71, seed + k * 31);
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function textOf(el: HTMLElement): string { return (el.textContent || '').trim(); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const pauseBtn: HTMLButtonElement = pauseEl;
// 화면 글자는 DOM 에 있다 — 문구를 바꾸면 버튼·상태 줄도 같이 바뀐다
const STATUS0 = textOf(status);
const PAUSE_LABEL = textOf(pauseBtn);
const RESUME_LABEL = '움직임 다시 켜기';
const TXT_PAUSED = '멈췄어요 — 버튼으로 다시 켜기';
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 아래 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 사람이 멈춤 버튼을 눌렀는가
let paused = false;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 캔버스 배율 — 화면 배율을 따르되 2 에서 자른다(배율 3 기기에서 픽셀 일이 2.25배로 늘지 않게)
let dpr = 1;
function fitCanvas(): void {
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
}
function begin(): void {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
// 작은 픽셀 버퍼 — 계산은 이 크기에서 하고 화면에는 늘려 그린다
function makeBuffer(w: number, h: number): PixelBuffer {
const b = document.createElement('canvas');
b.width = w;
b.height = h;
const c = b.getContext('2d') as CanvasRenderingContext2D;
return { cv: b, c: c, img: c.createImageData(w, h), w: w, h: h };
}
// 버퍼를 무대 크기로 키워 더한다 — 늘릴 때 브라우저의 이미지 보간이 칸 사이를 메우고, 'lighter' 는 밤하늘 위에 빛을 더한다
function blitBuffer(b: PixelBuffer, x: number, y: number, w: number, h: number): void {
b.c.putImageData(b.img, 0, 0);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(b.cv, x, y, w, h);
ctx.restore();
}
// 높이 y 에서 하늘 색 — 버퍼 계산에서 밝기를 잴 때도 같은 식을 쓴다
function skyAt(y: number, h: number): RGB { return mix(SKY_TOP, SKY_LOW, clamp(y / h, 0, 1)); }
function drawSky(c: CanvasRenderingContext2D, w: number, h: number): void {
const g = c.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
c.fillStyle = g;
c.fillRect(0, 0, w, h);
}
// 장면 시계(ms) — rAF 루프가 고정 틱으로만 늘린다. 동작 줄이기에서는 STILL_MS 에 멈춘다
let simMs = 0;
function nowS(): number { return simMs / 1000; }
function toScene(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) / Math.max(1, r.width) * W, (ev.clientY - r.top) / Math.max(1, r.height) * H];
}
// 겹치는 DOM 층의 자리 — 장면 좌표를 무대 백분율로 바꿔 넣는다
function place(el: HTMLElement, r: Rect): void {
el.style.left = (r.x / W * 100).toFixed(3) + '%';
el.style.top = (r.y / H * 100).toFixed(3) + '%';
el.style.width = (r.w / W * 100).toFixed(3) + '%';
el.style.height = (r.h / H * 100).toFixed(3) + '%';
}
// 커튼 겹 수 — 겹마다 중심선 높이·노이즈 시드·밝기가 다르다
const CURTAIN_LAYERS = 3;
// 노이즈 결 — 장면 1px 당 노이즈 좌표가 움직이는 양. 작을수록 주름이 넓고 느긋하다
const NOISE_SCALE = 0.004;
// 세로 감쇠(1px 당) — 중심선에서 위로 d px 올라간 곳의 알파 = exp(-RAY_FALLOFF x d)
const RAY_FALLOFF = 0.018;
// 세로 줄 간격(px) — 줄마다 세로 그라데이션 하나
const STROKE_STEP = 3;
type Curtain = { base: number; amp: number; len: number; low: RGB; high: RGB; alpha: number; speed: number; seed: number };
// 중심선 높이 — 옥타브 합(3겹) 값 노이즈 0..1 을 -1..1 로 펴서 진폭을 곱한다. 시간은 노이즈 둘째 좌표로 흘린다
function curtainCenter(o: Curtain, layer: number, x: number, t: number): number {
const n = octaves(x * NOISE_SCALE + t * o.speed * 0.4, t * o.speed + layer * 3.7, 3, o.seed + layer * 11);
return o.base - layer * 16 + (n - 0.5) * 2 * o.amp;
}
// 주름 밝기 — 결을 2.6배 잘게 읽은 노이즈를 문턱 0.25 위로 펴고 1.8 제곱해 밝은 주름과 어두운 틈을 가른다
function curtainFold(o: Curtain, layer: number, x: number, t: number): number {
const n = octaves(x * NOISE_SCALE * 2.6 - t * o.speed * 0.9, layer * 5.1 + t * o.speed * 0.5, 2, o.seed + 50 + layer);
return Math.pow(smooth((n - 0.25) / 0.6), 1.8);
}
// 커튼 — 겹마다 STROKE_STEP 간격으로 세로 줄을 긋는다. 줄의 아래 끝이 중심선, 위로 갈수록 지수로 옅어진다
function drawCurtain(c: CanvasRenderingContext2D, o: Curtain, t: number, w: number): void {
c.save();
c.globalCompositeOperation = 'lighter';
for (let layer = 0; layer < CURTAIN_LAYERS; layer += 1) {
const k = CURTAIN_LAYERS > 1 ? layer / (CURTAIN_LAYERS - 1) : 0;
const layerAlpha = o.alpha * (1 - 0.35 * k);
for (let x = -STROKE_STEP; x <= w + STROKE_STEP; x += STROKE_STEP) {
const fold = curtainFold(o, layer, x, t);
if (fold < 0.02) { continue; }
const yc = curtainCenter(o, layer, x, t);
const len = o.len * (0.55 + 0.75 * fold) * (1 - 0.25 * k);
const top = yc - len;
const g = c.createLinearGradient(0, yc + 6, 0, top);
g.addColorStop(0, rgba(o.low, 0));
// 아래 가장자리 6px — 짧게 밝아져 커튼 밑단이 또렷하다
g.addColorStop(6 / (len + 6), rgba(o.low, layerAlpha * fold));
for (let s = 1; s <= 4; s += 1) {
const d = len * s / 4;
g.addColorStop((6 + d) / (len + 6), rgba(mix(o.low, o.high, s / 4), layerAlpha * fold * Math.exp(-RAY_FALLOFF * d)));
}
c.fillStyle = g;
c.fillRect(x, top, STROKE_STEP + 0.6, len + 6);
}
}
c.restore();
}
// 물결 진폭(px) — 화면 맨 아래 줄(물가에서 가장 먼 줄)의 옆 밀림. 물가 쪽은 이 값의 0.25배
const RIPPLE_AMP = 3;
// 물결 촘촘함 — 물가에서 1px 내려갈 때 사인 위상이 바뀌는 양(라디안)
const RIPPLE_FREQ = 0.32;
// 깊이 어둡게 — 화면 맨 아래 줄을 덮는 어두운 막의 알파(물가는 0.08)
const REFLECT_DIM = 0.55;
// 물결이 흐르는 빠르기(rad/s)
const RIPPLE_SPEED = 2.6;
// 반사를 옮겨 그리는 줄 높이(px)
const REFLECT_ROW = 2;
const WATER: RGB = [6, 8, 20];
// 반사 — 하늘 그림(src, 높이 horizon)을 거꾸로 읽어 물 위에 줄 단위로 옮긴다. 줄마다 사인 물결만큼 옆으로 민다
function drawReflection(c: CanvasRenderingContext2D, src: HTMLCanvasElement, horizon: number, t: number): void {
const waterH = H - horizon;
const sd = src.width / W;
c.save();
c.fillStyle = rgba(WATER, 1);
c.fillRect(0, horizon, W, waterH);
for (let y = 0; y < waterH; y += REFLECT_ROW) {
const depth = y / waterH;
// 원근 — 가까운(아래) 물일수록 물결이 크고 성기다
const amp = RIPPLE_AMP * (0.25 + 0.75 * depth);
const ph = y * RIPPLE_FREQ * (1 - 0.45 * depth);
const dx = amp * Math.sin(ph + t * RIPPLE_SPEED) + amp * 0.45 * Math.sin(ph * 2.3 - t * RIPPLE_SPEED * 1.4);
// 거울 — 물가에서 y 만큼 내려간 줄은 하늘에서 y 만큼 위의 줄을 비춘다
const sy = clamp(horizon - REFLECT_ROW - y, 0, horizon - REFLECT_ROW);
c.globalAlpha = 0.88 - 0.3 * depth;
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx, horizon + y, W, REFLECT_ROW);
// 흐리게 — 깊을수록 옆으로 벌린 사본 두 장을 반투명으로 더 겹친다
const spread = 1 + 4 * depth;
c.globalAlpha = 0.22 * depth;
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx - spread, horizon + y, W, REFLECT_ROW);
c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx + spread, horizon + y, W, REFLECT_ROW);
}
c.globalAlpha = 1;
// 깊이별 어둡게 — 물가 0.08 에서 화면 아래 REFLECT_DIM 까지
const g = c.createLinearGradient(0, horizon, 0, H);
g.addColorStop(0, rgba(WATER, 0.08));
g.addColorStop(1, rgba(WATER, REFLECT_DIM));
c.fillStyle = g;
c.fillRect(0, horizon, W, waterH);
c.restore();
}
// 능선 겹 수 — 먼 겹부터 그린다
const RIDGE_LAYERS = 3;
// 거칠기 — 중점 흔들기 단계마다 흔들림 폭에 곱하는 값. 클수록 능선이 잘게 들쭉날쭉하다
const RIDGE_ROUGHNESS = 0.55;
// 시차 따라가기 — 틱마다 목표 밀림까지 남은 거리의 이 비율만큼 간다
const PARALLAX_LERP = 0.08;
// 능선 점 수 — 2⁶ + 1 (중점을 여섯 번 나눈다)
const RIDGE_POINTS = 65;
// 능선이 화면 양옆으로 남는 폭(px) — 밀려도 끝이 보이지 않게
const RIDGE_MARGIN = 40;
const HORIZON_09 = 196;
// 겹마다 바닥 높이·흔들림 폭·최대 밀림(px)·색 — 가까울수록 많이 밀리고 짙다
const RIDGE_BASE: number[] = [150, 168, 186];
const RIDGE_AMP: number[] = [70, 52, 36];
const RIDGE_DEPTH_PX: number[] = [6, 14, 26];
const RIDGE_RGB: RGB[] = [[52, 58, 104], [30, 34, 70], [14, 16, 38]];
// 하늘 커튼은 능선보다 더 멀다 — 밀림 3px
const SKY_DEPTH_PX = 3;
const CURTAIN_09: Curtain = { base: 140, amp: 34, len: 118, low: MINT, high: VIOLET, alpha: 0.5, speed: 0.22, seed: 909 };
const rng09 = makeRng(909);
// 중점 흔들기 — 양 끝 높이에서 시작해 구간 가운데 점을 두 끝 평균 ± 흔들림으로 정하고, 단계마다 흔들림 폭을 RIDGE_ROUGHNESS 배로 줄인다
function midpointRidge(rng: () => number, amp: number): Float32Array {
const n = RIDGE_POINTS;
const a = new Float32Array(n);
a[0] = (rng() - 0.5) * amp;
a[n - 1] = (rng() - 0.5) * amp;
let step = n - 1;
let spread = amp;
while (step > 1) {
const half = step / 2;
for (let i = half; i < n; i += step) {
a[i] = (a[i - half] + a[i + half]) / 2 + (rng() - 0.5) * spread;
}
step = half;
spread *= RIDGE_ROUGHNESS;
}
return a;
}
const ridges: Float32Array[] = [];
for (let k = 0; k < RIDGE_LAYERS; k += 1) { ridges.push(midpointRidge(rng09, RIDGE_AMP[k])); }
// 하늘의 작은 별 — 시드 고정 자리
const SKY_STARS: number[] = [];
for (let i = 0; i < 70; i += 1) { SKY_STARS.push(rng09() * W, rng09() * 150, 0.3 + rng09() * 0.6); }
const vpPhase09 = rng09() * TAU;
const sky09: HTMLCanvasElement = document.createElement('canvas');
const skyCtx09 = sky09.getContext('2d') as CanvasRenderingContext2D;
// 시차 — off 는 지금 밀림(-1..1), offTarget 은 포인터가 정한 목표
let off = 0;
let offTarget = 0;
let live09 = false;
let keyLive09 = false;
let ptrX09 = W / 2;
function virtualX09(t: number): number { return W / 2 + 200 * Math.sin(t * 1.5 + vpPhase09); }
function sizeSky09(): void {
const w = Math.round(W * dpr);
const h = Math.round(HORIZON_09 * dpr);
if (sky09.width !== w || sky09.height !== h) {
sky09.width = w;
sky09.height = h;
}
}
function drawRidge09(c: CanvasRenderingContext2D, k: number): void {
const a = ridges[k];
const shift = -off * RIDGE_DEPTH_PX[k];
const span = W + RIDGE_MARGIN * 2;
c.beginPath();
c.moveTo(-RIDGE_MARGIN + shift, HORIZON_09);
for (let i = 0; i < RIDGE_POINTS; i += 1) {
c.lineTo(-RIDGE_MARGIN + shift + span * i / (RIDGE_POINTS - 1), RIDGE_BASE[k] + a[i]);
}
c.lineTo(W + RIDGE_MARGIN + shift, HORIZON_09);
c.closePath();
c.fillStyle = rgba(RIDGE_RGB[k], 1);
c.fill();
}
function itemInit(): void { sizeSky09(); }
function itemStep(dt: number): void {
const x = live09 ? ptrX09 : virtualX09(nowS());
offTarget = clamp((x - W / 2) / (W / 2), -1, 1);
off += (offTarget - off) * PARALLAX_LERP;
}
function itemDraw(): void {
const t = nowS();
sizeSky09();
const c = skyCtx09;
c.setTransform(dpr, 0, 0, dpr, 0, 0);
drawSky(c, W, HORIZON_09);
c.fillStyle = 'rgba(246,244,255,0.7)';
for (let i = 0; i < SKY_STARS.length; i += 3) { c.fillRect(SKY_STARS[i] - off * SKY_DEPTH_PX, SKY_STARS[i + 1], SKY_STARS[i + 2] * 2, SKY_STARS[i + 2] * 2); }
c.save();
c.translate(-off * SKY_DEPTH_PX, 0);
drawCurtain(c, CURTAIN_09, t, W + SKY_DEPTH_PX * 2);
c.restore();
for (let k = 0; k < RIDGE_LAYERS; k += 1) { drawRidge09(c, k); }
ctx.drawImage(sky09, 0, 0, W, HORIZON_09);
drawReflection(ctx, sky09, HORIZON_09, t);
}
function itemDemo(t: number): void { }
function itemStatus(): string { return STATUS0; }
function itemSnap(): void {
if (live09) { offTarget = clamp((ptrX09 - W / 2) / (W / 2), -1, 1); }
off = offTarget;
}
function itemPointer(p: Pt, on: boolean): boolean {
if (on) {
live09 = true;
keyLive09 = false;
ptrX09 = p[0];
return true;
}
if (!keyLive09) { live09 = false; }
return false;
}
// 화살표 키 — 시점을 30px 씩 옆으로 옮긴다
function itemKey(key: string): boolean {
const d = key === 'ArrowLeft' ? -30 : key === 'ArrowRight' ? 30 : 0;
if (d === 0) { return false; }
if (!live09) { ptrX09 = virtualX09(nowS()); }
live09 = true;
keyLive09 = true;
ptrX09 = clamp(ptrX09 + d, 0, W);
return true;
}
function render(): void {
begin();
itemDraw();
if (!paused) { say(itemStatus()); }
}
// 루프가 멈춰 있을 때(동작 줄이기·멈춤) 입력이 들어오면 — 움직이던 값을 목표로 바로 옮겨 한 장 그린다
function after(): void {
if (rafId) { return; }
itemSnap();
render();
}
function setPaused(p: boolean): void {
paused = p;
pauseBtn.setAttribute('aria-pressed', p ? 'true' : 'false');
pauseBtn.textContent = p ? RESUME_LABEL : PAUSE_LABEL;
if (p) {
stopLoop();
say(TXT_PAUSED);
} else {
say(itemStatus());
startLoop();
}
}
pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); });
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (itemPointer(toScene(ev), true)) { wake(); after(); }
});
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
if (itemPointer(toScene(ev), true)) { after(); }
});
cv.addEventListener('pointerleave', function (): void { itemPointer([W / 2, H / 2], false); });
root.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.target !== root) { return; }
if (itemKey(ev.key)) {
ev.preventDefault();
wake();
after();
}
});
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
itemInit();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
simMs += FIXED_DT;
if (demo) { itemDemo(simMs); }
itemStep(FIXED_DT);
acc -= FIXED_DT;
n += 1;
}
if (n === 6) { acc = 0; }
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId || still || paused) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 순간 한 장면으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
pauseBtn.hidden = still;
if (still) {
stopLoop();
itemSnap();
render();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
pauseBtn.hidden = still;
if (still) {
// 동작 줄이기 — 노이즈 시간을 STILL_MS 에 세운 한 장면. 입력은 after() 가 그 자리에서 반영한다
simMs = STILL_MS;
itemSnap();
render();
} else {
render();
startLoop();
}
return function (): void {
stopLoop();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("au") + " is-demo"} ref={rootRef} tabIndex={0} role="group" aria-label="호숫가 풍경 — 화살표 키로 시점 옮기기">
<canvas className={cx("au__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("au__hero")}>
<p className={cx("au__kicker")}>밤 호숫가에서 보내는 사흘</p>
<h2 className={cx("au__title")}>오로라 아래에서 쉬어 가는 밤</h2>
</div>
<button className={cx("au__pause")} data-role="pause" type="button" aria-pressed="false">움직임 멈추기</button>
<p className={cx("au__status")} data-role="status" aria-live="off">포인터를 따라 능선이 깊이별로 움직여요</p>
</div>
);
}09 A Parallax Hero of Mountain Ridges, Aurora and Lake Reflection — Measured area 41.267% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Aurora Background CSS: 9 Living Night-Sky Scenes
The post this demo lives in
Aurora Background CSS: 9 Living Night-Sky ScenesAurora background css in canvas. One gradient gives an aurora that sits like wallpaper. Warp noise with noise and 03's s See all demos in this post
If you like this effect - similar demos
Creative UI06 Layered Ridges That Melt Into the Sky Color With DistanceCSS Sunset Gradient: 9 Demos With a Living Sky
Creative UI09 Checker floor — shadow, fog and reflection in one scene9 Ray Marching Scenes in Canvas (No WebGL)
Creative UI09 Fractal hero scene — a front-door stage layered with type and starlightMandelbrot Zoom, 9 Ways (Escape-Time, No Library)