<section class="demo" id="stage"></section>.demo {
position: relative;
min-height: 100dvh;
width: 100%;
background: #08080a;
overflow: hidden;
}
.demo__dot {
position: absolute;
width: 4px;
height: 4px;
margin: -2px 0 0 -2px;
border-radius: 50%;
background: #f4f4f2;
opacity: 0.18;
will-change: transform, opacity;
}const stage = document.querySelector("#stage");
const GAP = 34;
const RADIUS = 130;
let dots = [];
function build() {
stage.innerHTML = "";
dots = [];
const cols = Math.ceil(stage.clientWidth / GAP) + 1;
const rows = Math.ceil(stage.clientHeight / GAP) + 1;
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < cols; col += 1) {
const dot = document.createElement("span");
dot.className = "demo__dot";
dot.style.left = col * GAP + "px";
dot.style.top = row * GAP + "px";
stage.appendChild(dot);
dots.push({
el: dot,
x: col * GAP,
y: row * GAP,
setX: gsap.quickTo(dot, "x", { duration: 0.8, ease: "power3" }),
setY: gsap.quickTo(dot, "y", { duration: 0.8, ease: "power3" }),
setO: gsap.quickTo(dot, "opacity", { duration: 0.6, ease: "power2" }),
});
}
}
// 유휴 상태의 숨결 — 대각선 방향으로 파동이 지나갑니다.
gsap.to(dots.map((dot) => dot.el), {
scale: 1.9,
duration: 1.4,
ease: "sine.inOut",
repeat: -1,
yoyo: true,
stagger: { each: 0.012, from: "start", grid: [rows, cols] },
});
}
stage.addEventListener("pointermove", (event) => {
const rect = stage.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
dots.forEach((dot) => {
const dx = dot.x - px;
const dy = dot.y - py;
const distance = Math.hypot(dx, dy);
if (distance > RADIUS) {
dot.setX(0); dot.setY(0); dot.setO(0.18);
return;
}
// 가까울수록 강하게 — 선형이 아니라 제곱으로 떨어뜨려야 자연스럽습니다.
const force = Math.pow(1 - distance / RADIUS, 2);
dot.setX((dx / (distance || 1)) * force * 46);
dot.setY((dy / (distance || 1)) * force * 46);
dot.setO(0.18 + force * 0.82);
});
});
build();
window.addEventListener("resize", () => gsap.delayedCall(0.2, build));