05A Sideways Parallax Landscape Where Noise-Drawn Ridges, Hills and Tree Layers Drift at Speeds Set by Depth
Each layer has a depth value and its offset is divided by that depth, so far layers move slower. Ridge heights sum several octaves of 1D noise so they continue without seams.
Best forTravel and outdoor landing heroes
SEEDOCTAVESDEPTHSFREQS

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A landscape of five noise-drawn mountain ridges flowing sideways at different speeds by depth"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Resting</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">Back to start</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
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;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
}
.hs__canvas:active {
cursor: grabbing;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
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;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
}
.hs__canvas:active {
cursor: grabbing;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 카드를 얹어 명도 차로 읽힌다
const BG = [29, 26, 44];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
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;
}
}
// 휠 한 번의 이동량을 px 로 — 줄 단위(deltaMode 1)·쪽 단위(deltaMode 2) 장치도 같은 눈금으로 맞춘다.
// 가로·세로 중 큰 쪽을 쓴다: 트랙패드의 가로 쓸기와 마우스 휠의 세로 굴림이 같은 방향으로 들어온다
function wheelPx(ev) {
const unit = ev.deltaMode === 1 ? 16 : (ev.deltaMode === 2 ? W : 1);
const d = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
return d * unit;
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
const dpr = window.devicePixelRatio || 1;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function roundRectPath(c, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
c.beginPath();
c.moveTo(x + rr, y);
c.lineTo(x + w - rr, y);
c.quadraticCurveTo(x + w, y, x + w, y + rr);
c.lineTo(x + w, y + h - rr);
c.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
c.lineTo(x + rr, y + h);
c.quadraticCurveTo(x, y + h, x, y + h - rr);
c.lineTo(x, y + rr);
c.quadraticCurveTo(x, y, x + rr, y);
c.closePath();
}
// 카드 한 장 — 사진 대신 계산한 그림 칸(그라디언트 하늘·해·언덕) + 글줄 막대 두 개 + 번호
function drawCard(c, x, y, w, h, i, light) {
const t1 = TINTS[i % TINTS.length];
const t2 = TINTS[(i + 2) % TINTS.length];
const k = clamp(light, 0, 1);
roundRectPath(c, x, y, w, h, 10);
c.fillStyle = rgba(mix(BG, PAPER, k), 1);
c.fill();
const ih = h * 0.58;
const g = c.createLinearGradient(x, y, x + w, y + ih);
g.addColorStop(0, rgba(mix(BG, t1, k), 1));
g.addColorStop(1, rgba(mix(BG, t2, k * 0.92), 1));
c.save();
roundRectPath(c, x + 6, y + 6, w - 12, ih - 6, 7);
c.fillStyle = g;
c.fill();
c.clip();
c.fillStyle = rgba(mix(BG, PAPER, k * 0.9), 0.9);
c.beginPath();
c.arc(x + w * 0.7, y + ih * 0.36, Math.min(w, ih) * 0.13, 0, Math.PI * 2);
c.fill();
c.fillStyle = rgba(mix(BG, INK, k), 0.55);
c.beginPath();
c.moveTo(x, y + ih);
c.lineTo(x + w * 0.32, y + ih * 0.55);
c.lineTo(x + w * 0.55, y + ih * 0.8);
c.lineTo(x + w * 0.78, y + ih * 0.62);
c.lineTo(x + w, y + ih);
c.closePath();
c.fill();
c.restore();
c.fillStyle = rgba(mix(BG, INK, k), 0.75);
c.fillRect(x + 10, y + ih + 12, w * 0.62, 6);
c.fillStyle = rgba(mix(BG, INK, k), 0.4);
c.fillRect(x + 10, y + ih + 24, w * 0.44, 5);
c.fillStyle = rgba(mix(BG, INK, k), 0.9);
c.font = '700 13px sans-serif';
c.textAlign = 'right';
c.textBaseline = 'alphabetic';
c.fillText(String(i + 1).padStart(2, '0'), x + w - 9, y + h - 10);
}
const LAYER_N = 5;
const SEED = 462;
const OCTAVES = 4;
const DEPTHS = [8, 5, 3.2, 2, 1.2];
const BASES = [120, 148, 174, 204, 238];
const AMPS = [44, 38, 34, 30, 26];
const FREQS = [0.006, 0.008, 0.01, 0.013, 0.017];
const TRAVEL = 6000;
const S_GAIN = 1.2;
const S_LERP = 0.08;
const DEMO_LOOP = 6000;
const TXT_FWD = 'Walking forward';
const TXT_BACK = 'Walking back';
const TXT_REST = 'Resting';
const homeBtn = root.querySelector('[data-role="btn"]');
let target = 0;
let scroll = 0;
let lastDir = 0;
// 정수 격자 한 칸의 값 — 시드가 같으면 언제나 같은 수 (정수 섞기 해시)
function hash1(n, seed) {
let h = (Math.imul(n | 0, 374761393) + Math.imul(seed, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 1D 값 노이즈 — 격자값 두 개를 smoothstep 으로 잇는다. 격자가 끝없이 이어지므로 이음매가 없다
function vnoise(x, seed) {
const i = Math.floor(x);
const f = x - i;
const u = f * f * (3 - 2 * f);
return lerp(hash1(i, seed), hash1(i + 1, seed), u);
}
// fBm — 주파수를 2배, 세기를 절반씩 OCTAVES 겹 쌓아 0~1 로 맞춘다
function fbm(x, seed) {
let sum = 0;
let amp = 0.5;
let freq = 1;
let norm = 0;
for (let o = 0; o < OCTAVES; o += 1) {
sum += amp * vnoise(x * freq, seed + o * 101);
norm += amp;
amp *= 0.5;
freq *= 2;
}
return sum / norm;
}
// 층 l 의 화면 x 자리 능선 높이 — 먼 층일수록 scroll 을 depth 로 나눠 덜 움직인다
function ridgeY(l, sx, sc) {
const wx = sx + sc / DEPTHS[l];
return BASES[l] - AMPS[l] * (fbm(wx * FREQS[l], SEED + l * 17) - 0.5) * 2;
}
function step() {
const prev = scroll;
scroll += (target - scroll) * S_LERP;
if (Math.abs(target - scroll) < 0.05) {
scroll = target;
}
const dv = scroll - prev;
const dir = Math.abs(dv) < 0.05 ? 0 : Math.sign(dv);
if (dir !== lastDir) {
lastDir = dir;
say(dir > 0 ? TXT_FWD : (dir < 0 ? TXT_BACK : TXT_REST));
}
}
function settle() {
scroll = target;
say(TXT_REST);
}
function demoTick(t) {
const p = t % DEMO_LOOP;
if (p >= 200 && p <= 2600) {
target = (p - 200) * 0.75;
}
else if (p >= 3200 && p <= 5600) {
target = 1800 - (p - 3200) * 0.75;
}
}
const SKY_TOP = mix(LILAC, BG, 0.35);
const SKY_LOW = mix(PEACH, PINK, 0.3);
const HAZE = mix(PEACH, LILAC, 0.5);
const NEAR = mix(BG, LILAC, 0.18);
function draw() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(0.55, rgba(SKY_LOW, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// 해 — 가장 먼 층보다 더 멀어 아주 조금만 움직인다
ctx.fillStyle = rgba(PAPER, 0.85);
ctx.beginPath();
ctx.arc(340 - scroll / 40, 84, 26, 0, Math.PI * 2);
ctx.fill();
for (let l = 0; l < LAYER_N; l += 1) {
// 먼 층은 하늘색에 가깝게, 가까운 층은 짙게 — 공기 원근
const k = l / (LAYER_N - 1);
ctx.fillStyle = rgba(mix(HAZE, NEAR, k), 1);
ctx.beginPath();
ctx.moveTo(0, H);
for (let sx = 0; sx <= W; sx += 4) {
ctx.lineTo(sx, ridgeY(l, sx, scroll));
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fill();
}
// 가장 가까운 층 위 나무 — 능선 위 고정 간격 자리에 꽂혀 함께 흐른다
ctx.fillStyle = rgba(mix(NEAR, BG, 0.5), 1);
const near = LAYER_N - 1;
const off = scroll / DEPTHS[near];
const gap = 46;
for (let wx = Math.floor(off / gap) * gap; wx < off + W + gap; wx += gap) {
const sx = wx - off;
const y = ridgeY(near, sx, scroll);
const hgt = 14 + hash1(Math.round(wx / gap), SEED) * 16;
ctx.beginPath();
ctx.moveTo(sx - 6, y + 2);
ctx.lineTo(sx, y - hgt);
ctx.lineTo(sx + 6, y + 2);
ctx.closePath();
ctx.fill();
}
// 여정 막대
ctx.fillStyle = rgba(PAPER, 0.35);
ctx.fillRect(24, 22, W - 48, 4);
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.fillRect(24, 22, (W - 48) * (scroll / TRAVEL), 4);
}
function canMove(d) { return d > 0 ? target < TRAVEL : target > 0; }
function moveTo(p) {
target = clamp(p, 0, TRAVEL);
if (still) {
settle();
draw();
}
}
function onWheel(ev) {
const d = wheelPx(ev);
// 여정의 처음·끝에서는 가로채지 않는다
if (d === 0 || !canMove(d)) {
return;
}
ev.preventDefault();
wake();
moveTo(target + d * S_GAIN);
}
cv.addEventListener('wheel', onWheel, { passive: false });
let dragId = -1;
let dragX = 0;
cv.addEventListener('pointerdown', function (ev) {
wake();
dragId = ev.pointerId;
cv.setPointerCapture(ev.pointerId);
dragX = toScene(ev)[0];
});
cv.addEventListener('pointermove', function (ev) {
if (ev.pointerId !== dragId) {
return;
}
const sx = toScene(ev)[0];
// 가장 가까운 층이 손가락을 따라가도록 depth 만큼 곱한다
moveTo(target - (sx - dragX) * DEPTHS[LAYER_N - 1]);
dragX = sx;
});
function endDrag(ev) { if (ev.pointerId === dragId) {
dragId = -1;
} }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
cv.addEventListener('keydown', function (ev) {
let goal = NaN;
if (ev.key === 'ArrowRight') {
goal = target + 240;
}
else if (ev.key === 'ArrowLeft') {
goal = target - 240;
}
else if (ev.key === 'Home') {
goal = 0;
}
else if (ev.key === 'End') {
goal = TRAVEL;
}
if (Number.isNaN(goal) || !canMove(goal - target)) {
return;
}
ev.preventDefault();
wake();
moveTo(goal);
});
if (homeBtn) {
homeBtn.addEventListener('click', function () { wake(); moveTo(0); });
}
say(TXT_REST);
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// HscDepthParallaxRidgeNoiseLayers - vanilla 판(05-hsc-depth-parallax-ridge-noise-layers) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./HscDepthParallaxRidgeNoiseLayers.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HscDepthParallaxRidgeNoiseLayers() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 카드를 얹어 명도 차로 읽힌다
const BG: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
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; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
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)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
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; }
}
// 휠 한 번의 이동량을 px 로 — 줄 단위(deltaMode 1)·쪽 단위(deltaMode 2) 장치도 같은 눈금으로 맞춘다.
// 가로·세로 중 큰 쪽을 쓴다: 트랙패드의 가로 쓸기와 마우스 휠의 세로 굴림이 같은 방향으로 들어온다
function wheelPx(ev: WheelEvent): number {
const unit = ev.deltaMode === 1 ? 16 : (ev.deltaMode === 2 ? W : 1);
const d = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
return d * unit;
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
const dpr = window.devicePixelRatio || 1;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function roundRectPath(c: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
c.beginPath();
c.moveTo(x + rr, y);
c.lineTo(x + w - rr, y);
c.quadraticCurveTo(x + w, y, x + w, y + rr);
c.lineTo(x + w, y + h - rr);
c.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
c.lineTo(x + rr, y + h);
c.quadraticCurveTo(x, y + h, x, y + h - rr);
c.lineTo(x, y + rr);
c.quadraticCurveTo(x, y, x + rr, y);
c.closePath();
}
// 카드 한 장 — 사진 대신 계산한 그림 칸(그라디언트 하늘·해·언덕) + 글줄 막대 두 개 + 번호
function drawCard(c: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, i: number, light: number): void {
const t1 = TINTS[i % TINTS.length];
const t2 = TINTS[(i + 2) % TINTS.length];
const k = clamp(light, 0, 1);
roundRectPath(c, x, y, w, h, 10);
c.fillStyle = rgba(mix(BG, PAPER, k), 1);
c.fill();
const ih = h * 0.58;
const g = c.createLinearGradient(x, y, x + w, y + ih);
g.addColorStop(0, rgba(mix(BG, t1, k), 1));
g.addColorStop(1, rgba(mix(BG, t2, k * 0.92), 1));
c.save();
roundRectPath(c, x + 6, y + 6, w - 12, ih - 6, 7);
c.fillStyle = g;
c.fill();
c.clip();
c.fillStyle = rgba(mix(BG, PAPER, k * 0.9), 0.9);
c.beginPath();
c.arc(x + w * 0.7, y + ih * 0.36, Math.min(w, ih) * 0.13, 0, Math.PI * 2);
c.fill();
c.fillStyle = rgba(mix(BG, INK, k), 0.55);
c.beginPath();
c.moveTo(x, y + ih);
c.lineTo(x + w * 0.32, y + ih * 0.55);
c.lineTo(x + w * 0.55, y + ih * 0.8);
c.lineTo(x + w * 0.78, y + ih * 0.62);
c.lineTo(x + w, y + ih);
c.closePath();
c.fill();
c.restore();
c.fillStyle = rgba(mix(BG, INK, k), 0.75);
c.fillRect(x + 10, y + ih + 12, w * 0.62, 6);
c.fillStyle = rgba(mix(BG, INK, k), 0.4);
c.fillRect(x + 10, y + ih + 24, w * 0.44, 5);
c.fillStyle = rgba(mix(BG, INK, k), 0.9);
c.font = '700 13px sans-serif';
c.textAlign = 'right';
c.textBaseline = 'alphabetic';
c.fillText(String(i + 1).padStart(2, '0'), x + w - 9, y + h - 10);
}
const LAYER_N = 5;
const SEED = 462;
const OCTAVES = 4;
const DEPTHS = [8, 5, 3.2, 2, 1.2];
const BASES = [120, 148, 174, 204, 238];
const AMPS = [44, 38, 34, 30, 26];
const FREQS = [0.006, 0.008, 0.01, 0.013, 0.017];
const TRAVEL = 6000;
const S_GAIN = 1.2;
const S_LERP = 0.08;
const DEMO_LOOP = 6000;
const TXT_FWD = '앞으로 걷는 중';
const TXT_BACK = '뒤로 걷는 중';
const TXT_REST = '쉬는 중';
const homeBtn = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
let target = 0;
let scroll = 0;
let lastDir = 0;
// 정수 격자 한 칸의 값 — 시드가 같으면 언제나 같은 수 (정수 섞기 해시)
function hash1(n: number, seed: number): number {
let h = (Math.imul(n | 0, 374761393) + Math.imul(seed, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 1D 값 노이즈 — 격자값 두 개를 smoothstep 으로 잇는다. 격자가 끝없이 이어지므로 이음매가 없다
function vnoise(x: number, seed: number): number {
const i = Math.floor(x);
const f = x - i;
const u = f * f * (3 - 2 * f);
return lerp(hash1(i, seed), hash1(i + 1, seed), u);
}
// fBm — 주파수를 2배, 세기를 절반씩 OCTAVES 겹 쌓아 0~1 로 맞춘다
function fbm(x: number, seed: number): number {
let sum = 0;
let amp = 0.5;
let freq = 1;
let norm = 0;
for (let o = 0; o < OCTAVES; o += 1) {
sum += amp * vnoise(x * freq, seed + o * 101);
norm += amp;
amp *= 0.5;
freq *= 2;
}
return sum / norm;
}
// 층 l 의 화면 x 자리 능선 높이 — 먼 층일수록 scroll 을 depth 로 나눠 덜 움직인다
function ridgeY(l: number, sx: number, sc: number): number {
const wx = sx + sc / DEPTHS[l];
return BASES[l] - AMPS[l] * (fbm(wx * FREQS[l], SEED + l * 17) - 0.5) * 2;
}
function step(): void {
const prev = scroll;
scroll += (target - scroll) * S_LERP;
if (Math.abs(target - scroll) < 0.05) { scroll = target; }
const dv = scroll - prev;
const dir = Math.abs(dv) < 0.05 ? 0 : Math.sign(dv);
if (dir !== lastDir) {
lastDir = dir;
say(dir > 0 ? TXT_FWD : (dir < 0 ? TXT_BACK : TXT_REST));
}
}
function settle(): void {
scroll = target;
say(TXT_REST);
}
function demoTick(t: number): void {
const p = t % DEMO_LOOP;
if (p >= 200 && p <= 2600) { target = (p - 200) * 0.75; }
else if (p >= 3200 && p <= 5600) { target = 1800 - (p - 3200) * 0.75; }
}
const SKY_TOP = mix(LILAC, BG, 0.35);
const SKY_LOW = mix(PEACH, PINK, 0.3);
const HAZE = mix(PEACH, LILAC, 0.5);
const NEAR = mix(BG, LILAC, 0.18);
function draw(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(0.55, rgba(SKY_LOW, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// 해 — 가장 먼 층보다 더 멀어 아주 조금만 움직인다
ctx.fillStyle = rgba(PAPER, 0.85);
ctx.beginPath();
ctx.arc(340 - scroll / 40, 84, 26, 0, Math.PI * 2);
ctx.fill();
for (let l = 0; l < LAYER_N; l += 1) {
// 먼 층은 하늘색에 가깝게, 가까운 층은 짙게 — 공기 원근
const k = l / (LAYER_N - 1);
ctx.fillStyle = rgba(mix(HAZE, NEAR, k), 1);
ctx.beginPath();
ctx.moveTo(0, H);
for (let sx = 0; sx <= W; sx += 4) { ctx.lineTo(sx, ridgeY(l, sx, scroll)); }
ctx.lineTo(W, H);
ctx.closePath();
ctx.fill();
}
// 가장 가까운 층 위 나무 — 능선 위 고정 간격 자리에 꽂혀 함께 흐른다
ctx.fillStyle = rgba(mix(NEAR, BG, 0.5), 1);
const near = LAYER_N - 1;
const off = scroll / DEPTHS[near];
const gap = 46;
for (let wx = Math.floor(off / gap) * gap; wx < off + W + gap; wx += gap) {
const sx = wx - off;
const y = ridgeY(near, sx, scroll);
const hgt = 14 + hash1(Math.round(wx / gap), SEED) * 16;
ctx.beginPath();
ctx.moveTo(sx - 6, y + 2);
ctx.lineTo(sx, y - hgt);
ctx.lineTo(sx + 6, y + 2);
ctx.closePath();
ctx.fill();
}
// 여정 막대
ctx.fillStyle = rgba(PAPER, 0.35);
ctx.fillRect(24, 22, W - 48, 4);
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.fillRect(24, 22, (W - 48) * (scroll / TRAVEL), 4);
}
function canMove(d: number): boolean { return d > 0 ? target < TRAVEL : target > 0; }
function moveTo(p: number): void {
target = clamp(p, 0, TRAVEL);
if (still) { settle(); draw(); }
}
function onWheel(ev: WheelEvent): void {
const d = wheelPx(ev);
// 여정의 처음·끝에서는 가로채지 않는다
if (d === 0 || !canMove(d)) { return; }
ev.preventDefault();
wake();
moveTo(target + d * S_GAIN);
}
cv.addEventListener('wheel', onWheel, { passive: false });
let dragId = -1;
let dragX = 0;
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragId = ev.pointerId;
cv.setPointerCapture(ev.pointerId);
dragX = toScene(ev)[0];
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (ev.pointerId !== dragId) { return; }
const sx = toScene(ev)[0];
// 가장 가까운 층이 손가락을 따라가도록 depth 만큼 곱한다
moveTo(target - (sx - dragX) * DEPTHS[LAYER_N - 1]);
dragX = sx;
});
function endDrag(ev: PointerEvent): void { if (ev.pointerId === dragId) { dragId = -1; } }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
let goal = NaN;
if (ev.key === 'ArrowRight') { goal = target + 240; }
else if (ev.key === 'ArrowLeft') { goal = target - 240; }
else if (ev.key === 'Home') { goal = 0; }
else if (ev.key === 'End') { goal = TRAVEL; }
if (Number.isNaN(goal) || !canMove(goal - target)) { return; }
ev.preventDefault();
wake();
moveTo(goal);
});
if (homeBtn) { homeBtn.addEventListener('click', function (): void { wake(); moveTo(0); }); }
say(TXT_REST);
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="노이즈로 그린 산 능선 다섯 층이 깊이에 따라 다른 속도로 옆으로 흐르는 풍경"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">쉬는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="btn" type="button">처음으로</button>
</div>
</div>
);
}05 A Sideways Parallax Landscape Where Noise-Drawn Ridges, Hills and Tree Layers Drift at Speeds Set by Depth — Measured area 37.122% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Horizontal Scroll Animation CSS: 9 Inertia Scenes
The post this demo lives in
Horizontal Scroll Animation CSS: 9 Inertia ScenesHorizontal scroll animation css is more than turning wheel input sideways — scene 01 glides it on inertia, 06 projects c See all demos in this post


