04당기면 늘어났다 탄성으로 되돌아오는 유리
판 모서리를 당긴 채로 있으면 그 방향으로 늘어나 있다가, 놓으면 출렁이며 원래 모양으로 되돌아온다.
쓰임드래그 가능한 위젯 창
GLASS_ELASTIC_RETURN_MScurrentOffsetvirtualOffset

<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-paper);
.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: grab;
touch-action: 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: pointer; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
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: grab;
touch-action: 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: pointer;
}
}(function () {
// 기법: 드래그 오프셋만큼 판(사각형) 자체를 옮기고 이동 방향으로 살짝 늘린 뒤,
// boxEdgeSample 로 그 판의 가장자리 굴절을 다시 계산한다. 손을 떼면 오프셋을
// GLASS_ELASTIC_RETURN_MS 를 시정수로 하고 GLASS_ELASTIC_OSC_MS 를 진동 주기로 한
// 언더댐프 스프링(exp(-t/τ) * cos(ωt))으로 2~3 회 오버슛하며 0 까지 되돌린다.
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 = 160;
var scene = document.createElement('canvas');
scene.width = SCENE_W; scene.height = CALC_H;
(function drawScene() {
var pctx = scene.getContext('2d');
var rand = seedRand(421041);
var sky = pctx.createLinearGradient(0, 0, SCENE_W, CALC_H);
sky.addColorStop(0, '#e8f6ff');
sky.addColorStop(1, '#fff0e6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, SCENE_W, CALC_H);
var cols = ['#6ec8ff', '#ff9ebb', '#4cd4a6'];
for (var i = 0; i < cols.length; i += 1) {
pctx.beginPath();
pctx.arc(SCENE_W * (0.2 + i * 0.3), CALC_H * (0.3 + (i % 2) * 0.4), CALC_H * 0.26, 0, Math.PI * 2);
pctx.fillStyle = cols[i];
pctx.globalAlpha = 0.6;
pctx.fill();
}
pctx.globalAlpha = 1;
for (var k = 0; k < 46; k += 1) {
var px = rand() * SCENE_W, py = rand() * CALC_H, r = rand() * 1 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.18) + ')';
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 BASE_X0 = 40, BASE_Y0 = 25, BASE_X1 = 120, BASE_Y1 = 75;
var EDGE_BAND = 12;
var GLASS_EDGE_BEND = 5;
var GLASS_ELASTIC_RETURN_MS = 180;
var GLASS_ELASTIC_OSC_MS = 130;
var DRAG_MAX_X = 26, DRAG_MAX_Y = 14;
var PERIOD_MS = 2000;
var DRAG_PHASE_MS = 550;
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 elasticSpring(rt) {
var decay = Math.exp(-rt / GLASS_ELASTIC_RETURN_MS);
var osc = Math.cos((2 * Math.PI / GLASS_ELASTIC_OSC_MS) * rt);
return decay * osc;
}
function virtualOffset(t) {
var cyc = t % PERIOD_MS;
if (cyc < DRAG_PHASE_MS) {
var p = cyc / DRAG_PHASE_MS;
var e = 1 - Math.pow(1 - p, 3);
return { x: e * DRAG_MAX_X, y: e * DRAG_MAX_Y };
}
var rt = cyc - DRAG_PHASE_MS;
var k = elasticSpring(rt);
return { x: DRAG_MAX_X * k, y: DRAG_MAX_Y * k };
}
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;
var dragging = false;
var dragStartPt = { x: 0, y: 0 };
var dragOffset = { x: 0, y: 0 };
var releaseAt = -Infinity;
var releasedOffset = { x: 0, y: 0 };
function currentOffset(t) {
if (dragging) return dragOffset;
var rt = t - releaseAt;
if (rt < GLASS_ELASTIC_RETURN_MS * 5) {
var k = elasticSpring(rt);
return { x: releasedOffset.x * k, y: releasedOffset.y * k };
}
return virtualOffset(t);
}
function renderFrame(t) {
var off = currentOffset(t);
var mag = Math.sqrt(off.x * off.x + off.y * off.y);
var stretch = Math.min(0.35, mag / 44);
var ux = mag > 0.001 ? off.x / mag : 0;
var uy = mag > 0.001 ? off.y / mag : 0;
var cx = (BASE_X0 + BASE_X1) / 2 + off.x;
var cy = (BASE_Y0 + BASE_Y1) / 2 + off.y;
var halfW = ((BASE_X1 - BASE_X0) / 2) * (1 + stretch * Math.abs(ux));
var halfH = ((BASE_Y1 - BASE_Y0) / 2) * (1 + stretch * Math.abs(uy));
var x0 = cx - halfW, x1 = cx + halfW, y0 = cy - halfH, y1 = cy + halfH;
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var rgb;
if (x >= x0 && x <= x1 && y >= y0 && y <= y1) {
var e = boxEdgeSample(x, y, x0, y0, x1, y1, EDGE_BAND);
var bendMag = e.f * GLASS_EDGE_BEND;
var sx = (x - off.x * 0.35) - e.nx * bendMag;
var sy = (y - off.y * 0.35) - e.ny * bendMag;
rgb = sampleScene(sx, sy);
var shine = e.f * 20;
rgb = [Math.min(255, rgb[0] + shine), Math.min(255, rgb[1] + shine), Math.min(255, rgb[2] + shine)];
} else {
rgb = sampleScene(x, 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'); }
function toGrid(e) {
var r = wrap.getBoundingClientRect();
return {
x: (e.clientX - r.left) / r.width * CALC_W,
y: (e.clientY - r.top) / r.height * CALC_H
};
}
wrap.addEventListener('pointerdown', function (e) {
dragging = true;
dragStartPt = toGrid(e);
dragOffset = { x: 0, y: 0 };
wrap.setPointerCapture(e.pointerId);
if (reduce) draw(lastElapsed);
});
wrap.addEventListener('pointermove', function (e) {
if (!dragging) return;
var g = toGrid(e);
dragOffset = {
x: Math.max(-DRAG_MAX_X * 1.4, Math.min(DRAG_MAX_X * 1.4, g.x - dragStartPt.x)),
y: Math.max(-DRAG_MAX_Y * 1.4, Math.min(DRAG_MAX_Y * 1.4, g.y - dragStartPt.y))
};
if (reduce) draw(lastElapsed);
});
function release(t) {
if (!dragging) return;
dragging = false;
releasedOffset = dragOffset;
releaseAt = t;
if (reduce) draw(t);
}
wrap.addEventListener('pointerup', function () { release(lastElapsed); });
wrap.addEventListener('pointerleave', function () { release(lastElapsed); });
wrap.addEventListener('keydown', function (e) {
var moved = true;
if (e.key === 'ArrowLeft') dragOffset = { x: -DRAG_MAX_X, y: 0 };
else if (e.key === 'ArrowRight') dragOffset = { x: DRAG_MAX_X, y: 0 };
else if (e.key === 'ArrowUp') dragOffset = { x: 0, y: -DRAG_MAX_Y };
else if (e.key === 'ArrowDown') dragOffset = { x: 0, y: DRAG_MAX_Y };
else moved = false;
if (moved) {
e.preventDefault();
dragging = true;
release(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);
})();// ElasticMorphDrag — vanilla 판(run/421/demo/04-liquid-glass-elastic-morph-drag/index.html) 의 React 포트.
// 판을 끌면 이동 방향으로 살짝 늘어나며 따라오고, 놓으면 언더댐프 스프링
// (exp(-t/τ) * cos(ωt), τ=GLASS_ELASTIC_RETURN_MS·ω=GLASS_ELASTIC_OSC_MS 기반)으로
// 2~3 회 오버슛하며 제자리에 되돌아온다. 가장자리 굴절은 이동한 사각형 기준으로 다시 계산한다.
import { useEffect, useRef } from "react";
import styles from "./ElasticMorphDrag.module.scss";
type Box<T> = { current: T };
type Edge = { f: number; nx: number; ny: number };
type Vec = { x: number; y: number };
type PtrEvt = { clientX: number; clientY: number; pointerId: number };
type KeyEvt = { key: string; preventDefault(): void };
const CALC_W = 160, CALC_H = 100;
const SCENE_W = 160;
const BASE_X0 = 40, BASE_Y0 = 25, BASE_X1 = 120, BASE_Y1 = 75;
const EDGE_BAND = 12;
const GLASS_EDGE_BEND = 5;
const GLASS_ELASTIC_RETURN_MS = 180;
const GLASS_ELASTIC_OSC_MS = 130;
const DRAG_MAX_X = 26, DRAG_MAX_Y = 14;
const PERIOD_MS = 2000;
const DRAG_PHASE_MS = 550;
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;
dragging: boolean;
dragStartPt: Vec;
dragOffset: Vec;
releaseAt: number;
releasedOffset: Vec;
}
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 clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
function elasticSpring(rt: number): number {
const decay = Math.exp(-rt / GLASS_ELASTIC_RETURN_MS);
const osc = Math.cos((2 * Math.PI / GLASS_ELASTIC_OSC_MS) * rt);
return decay * osc;
}
function virtualOffset(t: number): Vec {
const cyc = t % PERIOD_MS;
if (cyc < DRAG_PHASE_MS) {
const p = cyc / DRAG_PHASE_MS;
const e = 1 - Math.pow(1 - p, 3);
return { x: DRAG_MAX_X * e, y: DRAG_MAX_Y * e };
}
const rt = cyc - DRAG_PHASE_MS;
const k = elasticSpring(rt);
return { x: DRAG_MAX_X * k, y: DRAG_MAX_Y * k };
}
export default function ElasticMorphDrag() {
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,
dragging: false, dragStartPt: { x: 0, y: 0 }, dragOffset: { x: 0, y: 0 },
releaseAt: -Infinity, releasedOffset: { x: 0, y: 0 },
}) 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 currentOffset(t: number): Vec {
const s = st.current;
if (s.dragging) return s.dragOffset;
const rt = t - s.releaseAt;
if (rt < GLASS_ELASTIC_RETURN_MS * 5) {
const k = elasticSpring(rt);
return { x: s.releasedOffset.x * k, y: s.releasedOffset.y * k };
}
return virtualOffset(t);
}
function renderFrame(t: number): void {
const outBuf = st.current.outBuf!;
const off = currentOffset(t);
const mag = Math.sqrt(off.x * off.x + off.y * off.y);
const stretch = Math.min(0.35, mag / 44);
const ux = mag > 0.0001 ? off.x / mag : 0;
const uy = mag > 0.0001 ? off.y / mag : 0;
const midX = (BASE_X0 + BASE_X1) / 2, midY = (BASE_Y0 + BASE_Y1) / 2;
const cx = midX + off.x, cy = midY + off.y;
const baseHalfW = (BASE_X1 - BASE_X0) / 2, baseHalfH = (BASE_Y1 - BASE_Y0) / 2;
const halfW = baseHalfW * (1 + stretch * Math.abs(ux));
const halfH = baseHalfH * (1 + stretch * Math.abs(uy));
const rx0 = cx - halfW, ry0 = cy - halfH, rx1 = cx + halfW, ry1 = cy + halfH;
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 (x >= rx0 && x <= rx1 && y >= ry0 && y <= ry1) {
const e = boxEdgeSample(x, y, rx0, ry0, rx1, ry1, EDGE_BAND);
const bendMag = e.f * GLASS_EDGE_BEND;
const sx = (x - off.x * 0.35) - e.nx * bendMag;
const sy = (y - off.y * 0.35) - e.ny * bendMag;
rgb = sampleScene(sx, sy);
const shine = e.f * 20;
rgb = [Math.min(255, rgb[0] + shine), Math.min(255, rgb[1] + shine), Math.min(255, rgb[2] + shine)];
} else {
rgb = sampleScene(x, 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);
}
function toGrid(e: PtrEvt): Vec {
const r = st.current.wrap!.getBoundingClientRect();
return {
x: ((e.clientX - r.left) / r.width) * CALC_W,
y: ((e.clientY - r.top) / r.height) * CALC_H,
};
}
function release(t: number): void {
const s = st.current;
s.dragging = false;
s.releasedOffset = s.dragOffset;
s.releaseAt = t;
if (s.reduce) draw(s.lastElapsed);
}
function onPointerDown(e: PtrEvt): void {
const s = st.current;
s.dragging = true;
s.dragStartPt = toGrid(e);
s.dragOffset = { x: 0, y: 0 };
try { s.wrap!.setPointerCapture(e.pointerId); } catch { /* noop */ }
if (s.reduce) draw(s.lastElapsed);
}
function onPointerMove(e: PtrEvt): void {
const s = st.current;
if (!s.dragging) return;
const g = toGrid(e);
s.dragOffset = {
x: clamp(g.x - s.dragStartPt.x, -DRAG_MAX_X * 1.4, DRAG_MAX_X * 1.4),
y: clamp(g.y - s.dragStartPt.y, -DRAG_MAX_Y * 1.4, DRAG_MAX_Y * 1.4),
};
if (s.reduce) draw(s.lastElapsed);
}
function onPointerUp(): void {
release(st.current.lastElapsed);
}
function onKeyDown(e: KeyEvt): void {
const s = st.current;
let moved = true;
if (e.key === "ArrowLeft") s.dragOffset = { x: -DRAG_MAX_X, y: 0 };
else if (e.key === "ArrowRight") s.dragOffset = { x: DRAG_MAX_X, y: 0 };
else if (e.key === "ArrowUp") s.dragOffset = { x: 0, y: -DRAG_MAX_Y };
else if (e.key === "ArrowDown") s.dragOffset = { x: 0, y: DRAG_MAX_Y };
else moved = false;
if (moved) {
e.preventDefault();
release(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 = SCENE_W; scene.height = CALC_H;
const pctx = scene.getContext("2d")!;
const rand = seedRand(421041);
const sky = pctx.createLinearGradient(0, 0, SCENE_W, CALC_H);
sky.addColorStop(0, "#e8f6ff");
sky.addColorStop(1, "#fff0e6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, SCENE_W, CALC_H);
const cols = ["#6ec8ff", "#ffd23f", "#ff9ebb"];
for (let i = 0; i < cols.length; i += 1) {
pctx.beginPath();
pctx.arc(SCENE_W * (0.24 + i * 0.28), CALC_H * (0.3 + (i % 2) * 0.34), CALC_H * 0.28, 0, Math.PI * 2);
pctx.fillStyle = cols[i];
pctx.globalAlpha = 0.5;
pctx.fill();
}
pctx.globalAlpha = 1;
for (let k = 0; k < 46; k += 1) {
const px = rand() * SCENE_W, py = rand() * CALC_H, r = rand() * 1 + 0.3;
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"); }
wrap.addEventListener("pointerdown", onPointerDown as unknown as EventListener);
wrap.addEventListener("pointermove", onPointerMove as unknown as EventListener);
wrap.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerUp);
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("pointerdown", onPointerDown as unknown as EventListener);
wrap.removeEventListener("pointermove", onPointerMove as unknown as EventListener);
wrap.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerleave", onPointerUp);
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>
);
}04 당기면 늘어났다 탄성으로 되돌아오는 유리 — 측정 누적면적 31.812% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 리퀴드 글라스 효과 9가지 — 굴절 계산으로 만드는 리퀴드 글라스 디자인
이 데모가 속한 글
리퀴드 글라스 효과 9가지 — 굴절 계산으로 만드는 리퀴드 글라스 디자인리퀴드 글라스 효과는 캔버스 픽셀을 다시 배치해 유리 표면처럼 배경을 휘게 만드는 굴절 계산이다. 03번 가장자리 색분리와 04번 탄성 복귀, 08번 눌림 두께 변화는 전부 같은 굴절 계산 하나에서 갈라져 나온 결과 이 글의 데모 전부 보기


