07Glass Where Surface Refraction Ripples Out From the Pressed Spot
Centered on the pressed point, the surface's refraction spreads outward in rings that settle down toward the edge.
Best forPayment confirmation button
activePressPRESS_SCHEDULERIPPLE_AMPLITUDE

<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">Press the panel and the background ripples outward from that point</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.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: pointer;
}
.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: #ff4d1f;
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: pointer;
}
.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 () {
// 기법: 누른 지점까지의 거리 d 와 누른 뒤 지난 시간 age 로 sin(d*주파수 - age*속도) 를
// 만들고, 시간·거리에 따라 감쇠(envelope)시켜 방사 방향(법선) 오프셋으로 배경을 샘플링한다
// — 원형 파동이 퍼지며 잦아드는 물결 굴절.
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 = document.createElement('canvas');
scene.width = CALC_W; scene.height = CALC_H;
(function drawScene() {
var pctx = scene.getContext('2d');
var rand = seedRand(421071);
var sky = pctx.createLinearGradient(0, 0, 0, CALC_H);
sky.addColorStop(0, '#dfe8ff');
sky.addColorStop(1, '#c9f3e6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, CALC_W, CALC_H);
var cols = ['#2f6df6', '#4cd4a6', '#b9a0ff'];
for (var i = 0; i < cols.length; i += 1) {
pctx.beginPath();
pctx.arc(CALC_W * (0.2 + i * 0.3), CALC_H * (0.3 + (i % 2) * 0.4), CALC_H * 0.24, 0, Math.PI * 2);
pctx.fillStyle = cols[i];
pctx.globalAlpha = 0.4;
pctx.fill();
}
pctx.globalAlpha = 1;
for (var k = 0; k < 50; k += 1) {
var px = rand() * CALC_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.18) + ')';
pctx.fill();
}
}());
var sceneCtx = scene.getContext('2d');
var sceneImg = sceneCtx.getImageData(0, 0, CALC_W, CALC_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 > CALC_H - 1 ? CALC_H - 1 : sy);
var i = (Math.round(sy) * CALC_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 = 12;
var GLASS_EDGE_BEND = 4;
var RIPPLE_AMPLITUDE = 6;
var PERIOD_MS = 2000;
var PRESS_SCHEDULE = [40, 740, 1380];
var CENTER_X = (PANEL_X0 + PANEL_X1) / 2;
var CENTER_Y = (PANEL_Y0 + PANEL_Y1) / 2;
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 };
}
var pressT = -Infinity;
var pressX = CENTER_X, pressY = CENTER_Y;
function activePress(t) {
if (t - pressT < 900) return { t: pressT, x: pressX, y: pressY };
var cyc = t % PERIOD_MS;
var best = PRESS_SCHEDULE[0];
for (var i = 0; i < PRESS_SCHEDULE.length; i += 1) {
if (PRESS_SCHEDULE[i] <= cyc) best = PRESS_SCHEDULE[i];
}
var loopBase = t - cyc;
return { t: loopBase + best, x: CENTER_X, y: CENTER_Y };
}
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 press = activePress(t);
var age = Math.max(0, t - press.t);
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 >= 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 dx = x - press.x, dy = y - press.y;
var dist = Math.sqrt(dx * dx + dy * dy) + 0.0001;
var envelope = Math.exp(-age / 260) * Math.max(0, 1 - dist / 95);
var wave = Math.sin(dist * 0.3 - age * 0.02) * envelope * RIPPLE_AMPLITUDE;
var nx = dx / dist, ny = dy / dist;
var offX = e.nx * (e.f * GLASS_EDGE_BEND) + nx * wave;
var offY = e.ny * (e.f * GLASS_EDGE_BEND) + ny * wave;
rgb = sampleScene(x - offX, y - offY);
var shine = e.f * 16 + Math.max(0, wave) * 8;
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) {
var g = toGrid(e);
pressX = Math.max(PANEL_X0, Math.min(PANEL_X1, g.x));
pressY = Math.max(PANEL_Y0, Math.min(PANEL_Y1, g.y));
pressT = lastElapsed;
if (reduce) draw(lastElapsed);
});
wrap.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
pressX = CENTER_X; pressY = CENTER_Y;
pressT = 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);
})();// RippleTouchPress — vanilla 판(run/421/demo/07-liquid-glass-ripple-touch-press/index.html) 의 React 포트.
// 누른 지점에서 거리 기반 감쇠 사인파(물결)를 만들어 그 자리를 중심으로 배경 샘플
// 좌표를 방사형으로 밀어낸다. 가장자리 굴절은 항상 약하게 겹쳐 있다.
import { useEffect, useRef } from "react";
import styles from "./RippleTouchPress.module.scss";
type Box<T> = { current: T };
type Edge = { f: number; nx: number; ny: number };
type PtrEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
const CALC_W = 160, CALC_H = 100;
const PANEL_X0 = 20, PANEL_Y0 = 16, PANEL_X1 = 140, PANEL_Y1 = 84;
const EDGE_BAND = 12;
const GLASS_EDGE_BEND = 4;
const RIPPLE_AMPLITUDE = 6;
const PERIOD_MS = 2000;
const PRESS_SCHEDULE = [40, 740, 1380];
const CENTER_X = (PANEL_X0 + PANEL_X1) / 2;
const CENTER_Y = (PANEL_Y0 + PANEL_Y1) / 2;
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;
pressT: number;
pressX: number;
pressY: 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 };
}
export default function RippleTouchPress() {
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,
pressT: -Infinity, pressX: CENTER_X, pressY: CENTER_Y,
}) 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 > CALC_H - 1 ? CALC_H - 1 : sy;
const i = (Math.round(cy) * CALC_W + Math.round(cx)) * 4;
return [buf[i], buf[i + 1], buf[i + 2]];
}
function activePress(t: number): { x: number; y: number; t: number } {
const s = st.current;
if (t - s.pressT < 900) return { x: s.pressX, y: s.pressY, t: s.pressT };
const cyc = t % PERIOD_MS;
const loopBase = t - cyc;
let best = PRESS_SCHEDULE[PRESS_SCHEDULE.length - 1] - PERIOD_MS;
for (const ps of PRESS_SCHEDULE) {
if (ps <= cyc) best = ps;
}
return { x: CENTER_X, y: CENTER_Y, t: loopBase + best };
}
function renderFrame(t: number): void {
const outBuf = st.current.outBuf!;
const press = activePress(t);
const age = Math.max(0, t - press.t);
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 >= 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 dx = x - press.x, dy = y - press.y;
const dist = Math.sqrt(dx * dx + dy * dy) + 0.0001;
const envelope = Math.exp(-age / 260) * Math.max(0, 1 - dist / 95);
const wave = Math.sin(dist * 0.3 - age * 0.02) * envelope * RIPPLE_AMPLITUDE;
const nx = dx / dist, ny = dy / dist;
const offX = e.nx * (e.f * GLASS_EDGE_BEND) + nx * wave;
const offY = e.ny * (e.f * GLASS_EDGE_BEND) + ny * wave;
rgb = sampleScene(x - offX, y - offY);
const shine = e.f * 16 + Math.max(0, wave) * 8;
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): { x: number; y: number } {
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 clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
function onPointerDown(e: PtrEvt): void {
const s = st.current;
const g = toGrid(e);
s.pressX = clamp(g.x, PANEL_X0, PANEL_X1);
s.pressY = clamp(g.y, PANEL_Y0, PANEL_Y1);
s.pressT = s.lastElapsed;
if (s.reduce) draw(s.lastElapsed);
}
function onKeyDown(e: KeyEvt): void {
const s = st.current;
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
s.pressX = CENTER_X;
s.pressY = CENTER_Y;
s.pressT = 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 = CALC_H;
const pctx = scene.getContext("2d")!;
const rand = seedRand(421071);
const sky = pctx.createLinearGradient(0, 0, CALC_W, CALC_H);
sky.addColorStop(0, "#dfe8ff");
sky.addColorStop(1, "#c9f3e6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, CALC_W, CALC_H);
const cols = ["#2f6df6", "#4cd4a6", "#b9a0ff"];
for (let i = 0; i < cols.length; i += 1) {
pctx.beginPath();
pctx.arc(CALC_W * (0.22 + i * 0.3), CALC_H * (0.3 + (i % 2) * 0.34), CALC_H * 0.26, 0, Math.PI * 2);
pctx.fillStyle = cols[i];
pctx.globalAlpha = 0.45;
pctx.fill();
}
pctx.globalAlpha = 1;
for (let k = 0; k < 50; k += 1) {
const px = rand() * CALC_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, CALC_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("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("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>
);
}07 Glass Where Surface Refraction Ripples Out From the Pressed Spot — Measured area 26.01% · 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 UI01 The basic hex cluster — one layer of DLA, random-walk particles sticking where they touchSnowflake Generator, 9 Ways (DLA, No Library)
Creative UI01 Page Corner That Curls From Where the Pointer DragsPage Turn Animation CSS — 9 Paper Curl Patterns
Creative UI05 A Dithered Pond Where Wave-Equation Ripples Are Stamped in Four TonesCSS Dither: 9 Scenes Drawn by Threshold Math