01가장자리만 배경을 휘게 하는 굴절 렌즈
판 안쪽은 배경이 그대로 보이고, 가장자리 두께만큼 배경 화면이 안쪽으로 휘어 보인다.
쓰임히어로 섹션 유리 카드
GLASS_REFRACTION_STRENGTHboxEdgeSamplepanX

<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;
}
.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: default; }
}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;
}
.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: default;
}
}(function () {
// 기법: 유리 판 사각형의 가장자리 거리(사각 SDF 근사)를 재고, 그 거리로 만든 법선 방향을
// 따라 배경 샘플 좌표를 안쪽으로 밀어(굴절 오프셋) putImageData 로 그린다. 판 중심은 배경 그대로.
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_W = 260;
var scene = document.createElement('canvas');
scene.width = SCENE_W; scene.height = CALC_H;
(function drawScene() {
var pctx = scene.getContext('2d');
var rand = seedRand(421011);
var sky = pctx.createLinearGradient(0, 0, 0, CALC_H);
sky.addColorStop(0, '#dfe8ff');
sky.addColorStop(1, '#f7f0ff');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, SCENE_W, CALC_H);
var glow = pctx.createRadialGradient(SCENE_W * 0.3, CALC_H * 0.2, 0, SCENE_W * 0.3, CALC_H * 0.2, CALC_H * 0.9);
glow.addColorStop(0, 'rgba(255,255,255,.6)');
glow.addColorStop(1, 'rgba(255,255,255,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, SCENE_W, CALC_H);
var bands = ['#b9a0ff', '#6ec8ff', '#4cd4a6'];
for (var i = 0; i < bands.length; i += 1) {
var baseY = CALC_H * (0.42 + i * 0.17);
pctx.beginPath();
pctx.moveTo(0, CALC_H);
var step = 6;
for (var x = 0; x <= SCENE_W; x += step) {
var n = Math.sin(x * 0.05 + i * 2.1) * CALC_H * 0.05;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(SCENE_W, CALC_H);
pctx.closePath();
pctx.fillStyle = bands[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * SCENE_W, py = rand() * CALC_H, r = rand() * 1.1 + 0.4;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.1 + rand() * 0.2) + ')';
pctx.fill();
}
}());
var sceneCtx = scene.getContext('2d');
var sceneImg = sceneCtx.getImageData(0, 0, SCENE_W, CALC_H);
var sceneBuf = sceneImg.data;
function sampleScene(sx, sy) {
sx = sx < 0 ? 0 : (sx > SCENE_W - 1 ? SCENE_W - 1 : sx);
sy = sy < 0 ? 0 : (sy > CALC_H - 1 ? CALC_H - 1 : sy);
var i = (Math.round(sy) * SCENE_W + Math.round(sx)) * 4;
return [sceneBuf[i], sceneBuf[i + 1], sceneBuf[i + 2]];
}
var PANEL_X0 = 20, PANEL_Y0 = 16, PANEL_X1 = 140, PANEL_Y1 = 84;
var EDGE_BAND = 16;
var GLASS_REFRACTION_STRENGTH = 8;
var SCENE_OFFSET_X = (SCENE_W - CALC_W) / 2;
var PAN_AMP = 34;
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 panX(t) {
return PAN_AMP * Math.sin(t * 2 * Math.PI / 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) {
var pan = panX(t);
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var baseSx = x + SCENE_OFFSET_X + pan;
var rgb;
if (x >= PANEL_X0 && x <= PANEL_X1 && y >= PANEL_Y0 && y <= PANEL_Y1) {
var e = boxEdgeSample(x, y, PANEL_X0, PANEL_Y0, PANEL_X1, PANEL_Y1, EDGE_BAND);
var ripple = Math.sin((x + y) * 0.12 - t * 0.006) * 0.25 * e.f;
var mag = (e.f + ripple) * GLASS_REFRACTION_STRENGTH;
rgb = sampleScene(baseSx - e.nx * mag, y - e.ny * mag);
var shine = e.f * 26 - Math.max(0, ripple) * 10;
rgb = [Math.min(255, rgb[0] + shine), Math.min(255, rgb[1] + shine), Math.min(255, rgb[2] + shine)];
} else {
rgb = sampleScene(baseSx, y);
}
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);
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'); }
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);
})();// EdgeRefractionLens — vanilla 판(run/421/demo/01-liquid-glass-edge-refraction-lens/index.html) 의 React 포트.
// 유리 판 사각형의 가장자리 거리(사각 SDF 근사)를 재고, 그 거리로 만든 법선 방향을
// 따라 배경 샘플 좌표를 안쪽으로 밀어(굴절 오프셋) putImageData 로 그린다. 판 중심은 배경 그대로.
import { useEffect, useRef } from "react";
import styles from "./EdgeRefractionLens.module.scss";
type Box<T> = { current: T };
type Edge = { f: number; nx: number; ny: number };
const CALC_W = 160, CALC_H = 100;
const SCENE_W = 260;
const PANEL_X0 = 20, PANEL_Y0 = 16, PANEL_X1 = 140, PANEL_Y1 = 84;
const EDGE_BAND = 16;
const GLASS_REFRACTION_STRENGTH = 8;
const SCENE_OFFSET_X = (SCENE_W - CALC_W) / 2;
const PAN_AMP = 34;
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;
}
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 panX(t: number): number {
return PAN_AMP * Math.sin((t * 2 * Math.PI) / PERIOD_MS);
}
export default function EdgeRefractionLens() {
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,
}) as Box<State>;
function sampleScene(sx: number, sy: number): number[] {
const buf = st.current.sceneBuf!;
const cx = sx < 0 ? 0 : sx > SCENE_W - 1 ? SCENE_W - 1 : sx;
const cy = sy < 0 ? 0 : sy > CALC_H - 1 ? CALC_H - 1 : sy;
const i = (Math.round(cy) * SCENE_W + Math.round(cx)) * 4;
return [buf[i], buf[i + 1], buf[i + 2]];
}
function renderFrame(t: number): void {
const outBuf = st.current.outBuf!;
const pan = panX(t);
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
const baseSx = x + SCENE_OFFSET_X + pan;
let rgb: number[];
if (x >= PANEL_X0 && x <= PANEL_X1 && y >= PANEL_Y0 && y <= PANEL_Y1) {
const e = boxEdgeSample(x, y, PANEL_X0, PANEL_Y0, PANEL_X1, PANEL_Y1, EDGE_BAND);
const ripple = Math.sin((x + y) * 0.12 - t * 0.006) * 0.25 * e.f;
const mag = (e.f + ripple) * GLASS_REFRACTION_STRENGTH;
rgb = sampleScene(baseSx - e.nx * mag, y - e.ny * mag);
const shine = e.f * 26 - Math.max(0, ripple) * 10;
rgb = [Math.min(255, rgb[0] + shine), Math.min(255, rgb[1] + shine), Math.min(255, rgb[2] + shine)];
} else {
rgb = sampleScene(baseSx, y);
}
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);
}
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 = SCENE_W; scene.height = CALC_H;
const pctx = scene.getContext("2d")!;
const rand = seedRand(421011);
const sky = pctx.createLinearGradient(0, 0, 0, CALC_H);
sky.addColorStop(0, "#dfe8ff");
sky.addColorStop(1, "#f7f0ff");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, SCENE_W, CALC_H);
const glow = pctx.createRadialGradient(SCENE_W * 0.3, CALC_H * 0.2, 0, SCENE_W * 0.3, CALC_H * 0.2, CALC_H * 0.9);
glow.addColorStop(0, "rgba(255,255,255,.6)");
glow.addColorStop(1, "rgba(255,255,255,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, SCENE_W, CALC_H);
const bands = ["#b9a0ff", "#6ec8ff", "#4cd4a6"];
for (let i = 0; i < bands.length; i += 1) {
const baseY = CALC_H * (0.42 + i * 0.17);
pctx.beginPath();
pctx.moveTo(0, CALC_H);
const step = 6;
for (let x = 0; x <= SCENE_W; x += step) {
const n = Math.sin(x * 0.05 + i * 2.1) * CALC_H * 0.05;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(SCENE_W, CALC_H);
pctx.closePath();
pctx.fillStyle = bands[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * SCENE_W, py = rand() * CALC_H, r = rand() * 1.1 + 0.4;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.1 + rand() * 0.2) + ")";
pctx.fill();
}
const sceneImg = pctx.getImageData(0, 0, SCENE_W, CALC_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"); }
fit();
window.addEventListener("resize", fit);
draw(s.elapsed);
if (!s.reduce) s.raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener("resize", fit);
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>
);
}01 가장자리만 배경을 휘게 하는 굴절 렌즈 — 측정 누적면적 23.072% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 리퀴드 글라스 효과 9가지 — 굴절 계산으로 만드는 리퀴드 글라스 디자인
이 데모가 속한 글
리퀴드 글라스 효과 9가지 — 굴절 계산으로 만드는 리퀴드 글라스 디자인리퀴드 글라스 효과는 캔버스 픽셀을 다시 배치해 유리 표면처럼 배경을 휘게 만드는 굴절 계산이다. 03번 가장자리 색분리와 04번 탄성 복귀, 08번 눌림 두께 변화는 전부 같은 굴절 계산 하나에서 갈라져 나온 결과 이 글의 데모 전부 보기


