09Top Glass Navbar Scene That Responds to the Scroll Value
As the scroll value grows, the top glass bar's refraction and frosted thickness increase together, so the glass looks thicker the further you scroll over the content.
Best forSite's fixed top navigation
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">The more you scroll the scene below, the stronger the navbar's refraction and ripple become</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 Top Glass Navbar Scene That Responds to the Scroll Value — Measured area 74.716% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Liquid Glass Effect CSS: 9 Refraction Patterns
The post this demo lives in
Liquid Glass Effect CSS: 9 Refraction PatternsA liquid glass effect css panel bends only its edges. The edge color split in item 03, the elastic snap-back in item 04, See all demos in this post
If you like this effect - similar demos
Creative UI09 Hero Desk Scene That Combines the Page-turn Techniques AbovePage Turn Animation CSS — 9 Paper Curl Patterns
Creative UI09 Logo hero scene — a front door hiding the brand in plain sightMagic Eye Stereograms: 9 Depth Map Demos
Creative UI09 Hero graph — a front door that knits a product's features together by forceForce Directed Graph: 9 Network Layout Demos