09스크롤 값에 반응하는 상단 유리 내비게이션 장면
스크롤 값이 커질수록 상단 유리 막대의 굴절 정도와 뿌옇게 번지는 두께감이 함께 늘어나, 내용 위를 지날수록 유리가 두꺼워 보인다.
쓰임사이트 상단 고정 내비게이션
wantProgressEXTRA_BENDtriangleWave

<main class="stage">
<div class="lg is-demo" id="lgRoot" tabindex="0">
<canvas class="lg__canvas" id="lgCanvas" width="480" height="300"></canvas>
<p class="lg__caption">아래 장면을 굴려 넘길수록 위 내비게이션 바의 굴절과 물결이 짙어진다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.lg {
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;
outline: none;
cursor: ns-resize;
}
.lg__canvas {
display: block;
width: 100%;
height: 100%;
}
.lg__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .68);
color: $stage-paper;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.lg__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.lg { cursor: pointer; }
}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);
}
.lg {
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);
outline: none;
cursor: ns-resize;
}
.lg__canvas {
display: block;
width: 100%;
height: 100%;
}
.lg__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.68);
color: #ffffff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.lg__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.lg {
cursor: pointer;
}
}(function () {
// 기법: 스크롤 진행도(0~1)를 배경 세로 샘플 오프셋에 그대로 써서 장면이 흘러가게 하고,
// 같은 진행도를 상단 내비 바의 가장자리 굴절 세기·물결 진폭에 곱해 스크롤이 깊어질수록
// 바가 더 짙게 굴절하도록 만든다.
var root = document.getElementById('lgRoot');
var canvas = document.getElementById('lgCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var CALC_W = 160, CALC_H = 100;
var SCENE_H = 220;
var scene = document.createElement('canvas');
scene.width = CALC_W; scene.height = SCENE_H;
(function drawScene() {
var pctx = scene.getContext('2d');
var rand = seedRand(421091);
var sky = pctx.createLinearGradient(0, 0, 0, SCENE_H);
sky.addColorStop(0, '#6ec8ff');
sky.addColorStop(0.5, '#b9a0ff');
sky.addColorStop(1, '#fff2da');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, CALC_W, SCENE_H);
var cols = ['#ffffff', '#4cd4a6', '#ff9ebb', '#ffd23f'];
for (var i = 0; i < 6; i += 1) {
var cy = SCENE_H * ((i + 0.5) / 6);
pctx.beginPath();
pctx.arc(CALC_W * (0.2 + (i % 2) * 0.55), cy, 14, 0, Math.PI * 2);
pctx.fillStyle = cols[i % cols.length];
pctx.globalAlpha = 0.55;
pctx.fill();
}
pctx.globalAlpha = 1;
for (var k = 0; k < 90; k += 1) {
var px = rand() * CALC_W, py = rand() * SCENE_H, r = rand() * 1 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.08 + rand() * 0.16) + ')';
pctx.fill();
}
}());
var sceneCtx = scene.getContext('2d');
var sceneImg = sceneCtx.getImageData(0, 0, CALC_W, SCENE_H);
var sceneBuf = sceneImg.data;
function sampleScene(sx, sy) {
sx = sx < 0 ? 0 : (sx > CALC_W - 1 ? CALC_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > SCENE_H - 1 ? SCENE_H - 1 : sy);
var i = (Math.round(sy) * CALC_W + Math.round(sx)) * 4;
return [sceneBuf[i], sceneBuf[i + 1], sceneBuf[i + 2]];
}
var NAV_Y0 = 0, NAV_Y1 = 26;
var EDGE_BAND = 10;
var BASE_BEND = 2, EXTRA_BEND = 7;
var RIPPLE_AMP = 3;
var PAN_RANGE = SCENE_H - CALC_H;
var PERIOD_MS = 2000;
function boxEdgeSample(x, y, x0, y0, x1, y1, band) {
var dl = x - x0, dr = x1 - x, dt = y - y0, db = y1 - y;
var dmin = Math.min(dl, dr, dt, db);
if (dmin >= band || dmin < 0) return { f: 0, nx: 0, ny: 0 };
var nx = 0, ny = 0;
if (dl < band) nx -= (band - dl);
if (dr < band) nx += (band - dr);
if (dt < band) ny -= (band - dt);
if (db < band) ny += (band - db);
var len = Math.sqrt(nx * nx + ny * ny);
if (len > 0.0001) { nx /= len; ny /= len; }
return { f: Math.max(0, (band - dmin) / band), nx: nx, ny: ny };
}
function triangleWave(t, period) {
var p = (t % period) / period;
return p < 0.5 ? p * 2 : (1 - p) * 2;
}
var realProgress = 0;
var lastInteractionT = -Infinity;
function wantProgress(t) {
if (t - lastInteractionT < 2500) return realProgress;
return triangleWave(t, PERIOD_MS);
}
var out = document.createElement('canvas');
out.width = CALC_W; out.height = CALC_H;
var outCtx = out.getContext('2d');
var outImg = outCtx.createImageData(CALC_W, CALC_H);
var outBuf = outImg.data;
function renderFrame(t, progress) {
var panY = progress * PAN_RANGE;
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var rgb;
if (y >= NAV_Y0 && y <= NAV_Y1) {
var e = boxEdgeSample(x, y, NAV_Y0 - 999, NAV_Y0, NAV_Y0 + 1998, NAV_Y1, EDGE_BAND);
var bendMag = (BASE_BEND + EXTRA_BEND * progress) * (e.f || 1) * 0.6 + BASE_BEND * 0.4;
var ripple = Math.sin(x * 0.16 - t * 0.006) * RIPPLE_AMP * progress;
rgb = sampleScene(x, (y + panY) - e.ny * bendMag - ripple);
var frost = 60 * progress;
rgb = [
Math.min(255, rgb[0] * (1 - progress * 0.25) + frost),
Math.min(255, rgb[1] * (1 - progress * 0.25) + frost),
Math.min(255, rgb[2] * (1 - progress * 0.25) + frost)
];
} else {
rgb = sampleScene(x, y + panY);
}
outBuf[idx] = rgb[0]; outBuf[idx + 1] = rgb[1]; outBuf[idx + 2] = rgb[2]; outBuf[idx + 3] = 255;
idx += 4;
}
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(t) {
lastElapsed = t;
renderFrame(t, wantProgress(t));
outCtx.putImageData(outImg, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); }
wrap.addEventListener('wheel', function (e) {
e.preventDefault();
realProgress = Math.max(0, Math.min(1, realProgress + e.deltaY * 0.0015));
lastInteractionT = lastElapsed;
if (reduce) draw(lastElapsed);
}, { passive: false });
wrap.addEventListener('keydown', function (e) {
var moved = true;
if (e.key === 'ArrowUp' || e.key === 'PageUp') realProgress = Math.max(0, realProgress - 0.12);
else if (e.key === 'ArrowDown' || e.key === 'PageDown') realProgress = Math.min(1, realProgress + 0.12);
else moved = false;
if (moved) {
e.preventDefault();
lastInteractionT = lastElapsed;
if (reduce) draw(lastElapsed);
}
});
var lastTs = null;
var elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
elapsed += dt;
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
if (!reduce) requestAnimationFrame(tick);
})();// HeroNavbarScene — vanilla 판(run/421/demo/09-liquid-glass-hero-navbar-scene/index.html) 의 React 포트.
// 아래 장면을 스크롤(휠)해 넘길수록 위 내비게이션 바의 굴절 강도·물결·서리(frost) 블렌드가
// 함께 짙어진다. 내비바 사각형은 좌우로 아주 넓게 잡아(가짜 폭) 위아래 가장자리만 반응한다.
import { useEffect, useRef } from "react";
import styles from "./HeroNavbarScene.module.scss";
type Box<T> = { current: T };
type Edge = { f: number; nx: number; ny: number };
type WheelEvt = { deltaY: number; preventDefault(): void };
type KeyEvt = { key: string; preventDefault(): void };
const CALC_W = 160, CALC_H = 100;
const SCENE_H = 220;
const NAV_Y0 = 0, NAV_Y1 = 26;
const EDGE_BAND = 10;
const BASE_BEND = 2, EXTRA_BEND = 7;
const RIPPLE_AMP = 3;
const PAN_RANGE = SCENE_H - CALC_H;
const PERIOD_MS = 2000;
interface State {
ctx: CanvasRenderingContext2D | null;
wrap: HTMLElement | null;
sceneBuf: Uint8ClampedArray | null;
outCtx: CanvasRenderingContext2D | null;
out: HTMLCanvasElement | null;
outImg: ImageData | null;
outBuf: Uint8ClampedArray | null;
cw: number; ch: number;
lastElapsed: number;
lastTs: number | null;
elapsed: number;
raf: number;
reduce: boolean;
realProgress: number;
lastInteractionT: number;
}
function seedRand(seed: number): () => number {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function boxEdgeSample(x: number, y: number, x0: number, y0: number, x1: number, y1: number, band: number): Edge {
const dl = x - x0, dr = x1 - x, dt = y - y0, db = y1 - y;
const dmin = Math.min(dl, dr, dt, db);
if (dmin >= band || dmin < 0) return { f: 0, nx: 0, ny: 0 };
let nx = 0, ny = 0;
if (dl < band) nx -= (band - dl);
if (dr < band) nx += (band - dr);
if (dt < band) ny -= (band - dt);
if (db < band) ny += (band - db);
const len = Math.sqrt(nx * nx + ny * ny);
if (len > 0.0001) { nx /= len; ny /= len; }
return { f: Math.max(0, (band - dmin) / band), nx, ny };
}
function triangleWave(t: number, period: number): number {
const cyc = (t % period) / period;
return cyc < 0.5 ? cyc * 2 : (1 - cyc) * 2;
}
function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
export default function HeroNavbarScene() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const st = useRef({
ctx: null, wrap: null, sceneBuf: null, outCtx: null, out: null, outImg: null, outBuf: null,
cw: 480, ch: 300, lastElapsed: 0, lastTs: null, elapsed: 0, raf: 0, reduce: false,
realProgress: 0, lastInteractionT: -Infinity,
}) as Box<State>;
function sampleScene(sx: number, sy: number): number[] {
const buf = st.current.sceneBuf!;
const cx = sx < 0 ? 0 : sx > CALC_W - 1 ? CALC_W - 1 : sx;
const cy = sy < 0 ? 0 : sy > SCENE_H - 1 ? SCENE_H - 1 : sy;
const i = (Math.round(cy) * CALC_W + Math.round(cx)) * 4;
return [buf[i], buf[i + 1], buf[i + 2]];
}
function wantProgress(t: number): number {
const s = st.current;
if (t - s.lastInteractionT < 2500) return s.realProgress;
return triangleWave(t, PERIOD_MS);
}
function renderFrame(t: number): void {
const outBuf = st.current.outBuf!;
const progress = wantProgress(t);
const panY = progress * PAN_RANGE;
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
let rgb: number[];
if (y >= NAV_Y0 && y <= NAV_Y1) {
const e = boxEdgeSample(x, y, NAV_Y0 - 999, NAV_Y0, NAV_Y0 + 1998, NAV_Y1, EDGE_BAND);
const bendMag = (BASE_BEND + EXTRA_BEND * progress) * (e.f || 1) * 0.6 + BASE_BEND * 0.4;
const ripple = Math.sin(x * 0.16 - t * 0.006) * RIPPLE_AMP * progress;
rgb = sampleScene(x, (y + panY) - e.ny * bendMag - ripple);
const frost = 60 * progress;
rgb = [
Math.min(255, rgb[0] * (1 - progress * 0.25) + frost),
Math.min(255, rgb[1] * (1 - progress * 0.25) + frost),
Math.min(255, rgb[2] * (1 - progress * 0.25) + frost),
];
} else {
rgb = sampleScene(x, y + panY);
}
outBuf[idx] = rgb[0]; outBuf[idx + 1] = rgb[1]; outBuf[idx + 2] = rgb[2]; outBuf[idx + 3] = 255;
idx += 4;
}
}
}
function draw(t: number): void {
const s = st.current;
s.lastElapsed = t;
renderFrame(t);
s.outCtx!.putImageData(s.outImg!, 0, 0);
s.ctx!.clearRect(0, 0, s.cw, s.ch);
s.ctx!.drawImage(s.out!, 0, 0, CALC_W, CALC_H, 0, 0, s.cw, s.ch);
}
function fit(): void {
const s = st.current;
const canvas = canvasRef.current;
if (!s.wrap || !canvas) return;
const r = s.wrap.getBoundingClientRect();
s.cw = Math.max(1, Math.round(r.width));
s.ch = Math.max(1, Math.round(r.height));
canvas.width = s.cw; canvas.height = s.ch;
draw(s.lastElapsed);
}
function tick(ts: number): void {
const s = st.current;
if (s.lastTs === null) s.lastTs = ts;
const dt = ts - s.lastTs;
s.lastTs = ts;
s.elapsed += dt;
draw(s.elapsed);
s.raf = requestAnimationFrame(tick);
}
function onWheel(e: WheelEvt): void {
e.preventDefault();
const s = st.current;
s.realProgress = clamp(s.realProgress + e.deltaY * 0.0015, 0, 1);
s.lastInteractionT = s.lastElapsed;
if (s.reduce) draw(s.lastElapsed);
}
function onKeyDown(e: KeyEvt): void {
const s = st.current;
let moved = true;
if (e.key === "ArrowUp" || e.key === "PageUp") s.realProgress = clamp(s.realProgress - 0.12, 0, 1);
else if (e.key === "ArrowDown" || e.key === "PageDown") s.realProgress = clamp(s.realProgress + 0.12, 0, 1);
else moved = false;
if (moved) {
e.preventDefault();
s.lastInteractionT = s.lastElapsed;
if (s.reduce) draw(s.lastElapsed);
}
}
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
const wrap = canvas.parentElement;
if (!ctx || !wrap) return;
const scene = document.createElement("canvas");
scene.width = CALC_W; scene.height = SCENE_H;
const pctx = scene.getContext("2d")!;
const rand = seedRand(421091);
const sky = pctx.createLinearGradient(0, 0, 0, SCENE_H);
sky.addColorStop(0, "#6ec8ff");
sky.addColorStop(0.55, "#b9a0ff");
sky.addColorStop(1, "#fff2da");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, CALC_W, SCENE_H);
const cols = ["#5b8cf8", "#4cd4a6", "#ffd23f", "#ff9ebb", "#b9a0ff", "#6ec8ff"];
for (let i = 0; i < cols.length; i += 1) {
pctx.beginPath();
pctx.arc(CALC_W * (0.15 + (i % 3) * 0.35), SCENE_H * (0.15 + Math.floor(i / 3) * 0.4), SCENE_H * 0.1, 0, Math.PI * 2);
pctx.fillStyle = cols[i];
pctx.globalAlpha = 0.4;
pctx.fill();
}
pctx.globalAlpha = 1;
for (let k = 0; k < 90; k += 1) {
const px = rand() * CALC_W, py = rand() * SCENE_H, r = rand() * 1 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.08 + rand() * 0.18) + ")";
pctx.fill();
}
const sceneImg = pctx.getImageData(0, 0, CALC_W, SCENE_H);
const out = document.createElement("canvas");
out.width = CALC_W; out.height = CALC_H;
const outCtx = out.getContext("2d")!;
const outImg = outCtx.createImageData(CALC_W, CALC_H);
const s = st.current;
s.ctx = ctx; s.wrap = wrap; s.sceneBuf = sceneImg.data;
s.outCtx = outCtx; s.out = out; s.outImg = outImg; s.outBuf = outImg.data;
s.reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (s.reduce) { root.classList.remove("is-demo"); }
wrap.addEventListener("wheel", onWheel as unknown as EventListener, { passive: false });
wrap.addEventListener("keydown", onKeyDown as unknown as EventListener);
fit();
window.addEventListener("resize", fit);
draw(s.elapsed);
if (!s.reduce) s.raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener("resize", fit);
wrap.removeEventListener("wheel", onWheel as unknown as EventListener);
wrap.removeEventListener("keydown", onKeyDown as unknown as EventListener);
cancelAnimationFrame(st.current.raf);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.lg} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.lg__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.lg__caption}>아래 장면을 굴려 넘길수록 위 내비게이션 바의 굴절과 물결이 짙어진다</p>
</div>
</main>
);
}09 스크롤 값에 반응하는 상단 유리 내비게이션 장면 — 측정 누적면적 74.716% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 리퀴드 글라스 효과 9가지 — 굴절 계산으로 만드는 리퀴드 글라스 디자인
이 데모가 속한 글
리퀴드 글라스 효과 9가지 — 굴절 계산으로 만드는 리퀴드 글라스 디자인리퀴드 글라스 효과는 캔버스 픽셀을 다시 배치해 유리 표면처럼 배경을 휘게 만드는 굴절 계산이다. 03번 가장자리 색분리와 04번 탄성 복귀, 08번 눌림 두께 변화는 전부 같은 굴절 계산 하나에서 갈라져 나온 결과 이 글의 데모 전부 보기


