04マグネットパーティクル
カーソルが磁石になって鉄粉パーティクルを押したり引いたりします。力は距離の二乗に反比例し、毎フレーム translate で動かすので群れが崩れません。
使いどころヒーローセクションの背景、カーソルまわりに集まるナビ ハイライト
requestAnimationFramepointermovetranslate

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-magnet" viewBox="0 0 256 256" fill="currentColor"><g><path d="m168.36 200.36l-30 29.35a8 8 0 0 1-11.26-.05L98.46 201a8 8 0 0 1 .08-11.4l30-29Zm-142-82.76a8 8 0 0 0 0 11.28L55 157.54a8 8 0 0 0 11.38-.06l29.18-29.92l-39.79-39.79Z" opacity=".2"/><path d="M207 50.25A87.46 87.46 0 0 0 144.6 24h-.33A87.48 87.48 0 0 0 82 49.81l-31.89 32.3L20.61 112a16 16 0 0 0 .06 22.56l28.66 28.66a15.92 15.92 0 0 0 11.32 4.69h.09a16 16 0 0 0 11.36-4.82l60.9-62.4a16.08 16.08 0 0 1 22.41-.21a15.6 15.6 0 0 1 4.73 11.19a16.9 16.9 0 0 1-4.85 12L93 183.88a16 16 0 0 0-.17 22.79l28.66 28.66a16.06 16.06 0 0 0 22.52.12l61.8-60.45c34.45-34.5 34.98-90.44 1.19-124.75M60.7 156.47l-27-27L55.87 108l27 27Zm98.46-71.25a24 24 0 0 0-34.29.44l-30 30.72l-27-27l30.11-30.42A71.5 71.5 0 0 1 144.36 40h.27a71.49 71.49 0 0 1 50.71 21.53c.51.52 1 1.05 1.5 1.59l-37.68 22.1Zm53.76 53.76L151 200l-27-27l61.81-60.46l.24-.24c.4-.4.79-.8 1.17-1.21a24 24 0 0 0-17.3-40.48l25.6-15A71.66 71.66 0 0 1 213 138.87Z"/></g></symbol>
<symbol id="ico-reset" viewBox="0 0 256 256" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="mg is-demo" id="mg">
<p class="mg__hint"><svg class="mg__ico" aria-hidden="true"><use href="#ico-magnet"/></svg><span>板の上でマウスを動かすと、鉄粉がカーソルの磁石に押されたり引かれたりします</span></p>
<div class="mg__field" id="field" role="application" aria-label="磁石の実験台" tabindex="0">
<span class="mg__magnet" id="magnet" aria-hidden="true"><svg class="mg__magnetIco"><use href="#ico-magnet"/></svg></span>
<span class="mg__dots" id="dots" aria-hidden="true"></span>
</div>
<div class="mg__bar">
<button class="mg__mode" id="mode" type="button" aria-pressed="false">
<svg class="mg__ico" aria-hidden="true"><use href="#ico-magnet"/></svg><span id="modeLabel">引き寄せる</span>
</button>
<button class="mg__reset" id="reset" type="button">
<svg class="mg__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>元に戻す</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 자석 파티클 — 커서가 자석. 힘 = 부호 / (r² + 연화) 의 쿨롱 간이 + 제자리 스프링.
// 철가루는 격자 자리(var(--hx), var(--hy)) 로 돌아오고 translate 하나로만 움직인다.
// 격자 미리보기(.is-demo)는 당김 단계와 밀어냄 단계를 같은 루프에 담은 표본을 키프레임으로 재생한다 —
// 각 점의 이동량은 (중심 - 제자리) 의 비율이라 점마다 방향이 다르다. 사람이 판에 들어오면 JS 가 실시간 힘을 계산한다.
$duration: $dur-loop;
$easing: ease-in-out;
$color: $subject-ink;
@include stage($stage-paper);
.mg { --cx: 212px; --cy: 88px; display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.mg__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.mg__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.mg__field {
position: relative; height: 176px; border-radius: $r-card;
background: rgba($subject-blue, .06);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .14);
overflow: hidden; touch-action: none;
}
.mg__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.mg__dot {
position: absolute; left: 0; top: 0; width: 10px; height: 10px; border-radius: 50%;
background: $color;
transform: translate(var(--hx), var(--hy));
}
.mg__magnet {
position: absolute; left: 0; top: 0; width: 24px; height: 24px;
display: grid; place-items: center; border-radius: 50%;
background: $subject-blue; color: $stage-paper;
box-shadow: $shadow-press;
transform: translate(212px, 88px);
}
.mg__magnetIco { width: 14px; height: 14px; }
.mg__bar { display: flex; align-items: center; justify-content: flex-end; gap: $sp-2; flex-wrap: wrap; }
.mg__mode, .mg__reset {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.mg__mode { background: $subject-blue; color: $stage-paper; }
.mg__mode .mg__ico { color: $stage-paper; }
.mg__reset { background: $color; color: $stage-paper; }
.mg__reset .mg__ico { color: $stage-paper; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 0~44% 당김(중심 방향 38%), 58~92% 밀어냄(중심 반대 26%), 나머지는 제자리.
// 이동량은 점마다 (중심 - 제자리) 비율이라 격자가 통째로 숨쉬듯 모였다 흩어진다.
// 점 32개를 -62.5ms 간격 위상으로 흩어 파도가 도는 느낌을 낸다 (음수 지연이라 루프가 닫혀 있다).
.mg.is-demo .mg__dot {
animation-name: mgSwoop; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -62.5ms);
}
@keyframes mgSwoop {
0% { transform: translate(var(--hx), var(--hy)) scale(1); }
10% { transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * .2), calc(var(--hy) + (var(--cy) - var(--hy)) * .2)) scale(1.14); }
22% { transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * .38), calc(var(--hy) + (var(--cy) - var(--hy)) * .38)) scale(1.32); }
36% { transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * .12), calc(var(--hy) + (var(--cy) - var(--hy)) * .12)) scale(1.06); }
46% { transform: translate(var(--hx), var(--hy)) scale(1); }
58% { transform: translate(var(--hx), var(--hy)) scale(1); }
70% { transform: translate(calc(var(--hx) + (var(--hx) - var(--cx)) * .26), calc(var(--hy) + (var(--hy) - var(--cy)) * .26)) scale(.94); }
84% { transform: translate(calc(var(--hx) + (var(--hx) - var(--cx)) * .1), calc(var(--hy) + (var(--hy) - var(--cy)) * .1)) scale(1); }
94% { transform: translate(var(--hx), var(--hy)) scale(1); }
100% { transform: translate(var(--hx), var(--hy)) scale(1); }
}
// 자석 아이콘은 제자리에서 살짝 숨쉰다 — 커지는 건 자기 중심에서 (blog-demo 규칙 8)
.mg.is-demo .mg__magnet {
animation-name: mgBreath; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
transform: translate(var(--cx), var(--cy));
}
@keyframes mgBreath {
0%, 46%, 58%, 100% { transform: translate(var(--cx), var(--cy)) scale(1); }
22% { transform: translate(var(--cx), var(--cy)) scale(1.16); }
70% { transform: translate(var(--cx), var(--cy)) scale(.9); }
}
@media (max-width: 340px) {
.mg { gap: $sp-2; --cx: 147px; --cy: 52px; }
.mg__field { height: 104px; }
.mg__dot { width: 8px; height: 8px; }
.mg__magnet { width: 20px; height: 20px; }
.mg__magnetIco { width: 12px; height: 12px; }
}
// reduce 에서는 격자 자리에 멈춘 정지 상태 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.mg.is-demo .mg__dot,
.mg.is-demo .mg__magnet { animation: none !important; }
.mg.is-demo .mg__dot { transform: translate(var(--hx), var(--hy)) !important; }
.mg.is-demo .mg__magnet { transform: translate(var(--cx), var(--cy)) !important; }
}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);
}
.mg {
--cx: 212px;
--cy: 88px;
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.mg__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.mg__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.mg__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(47, 109, 246, 0.06);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.14);
overflow: hidden;
touch-action: none;
}
.mg__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.mg__dot {
position: absolute;
left: 0;
top: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background: #17141a;
transform: translate(var(--hx), var(--hy));
}
.mg__magnet {
position: absolute;
left: 0;
top: 0;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 50%;
background: #2f6df6;
color: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(212px, 88px);
}
.mg__magnetIco {
width: 14px;
height: 14px;
}
.mg__bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.mg__mode, .mg__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.mg__mode {
background: #2f6df6;
color: #ffffff;
}
.mg__mode .mg__ico {
color: #ffffff;
}
.mg__reset {
background: #17141a;
color: #ffffff;
}
.mg__reset .mg__ico {
color: #ffffff;
}
.mg.is-demo .mg__dot {
animation-name: mgSwoop;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -62.5ms);
}
@keyframes mgSwoop {
0% {
transform: translate(var(--hx), var(--hy)) scale(1);
}
10% {
transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * 0.2), calc(var(--hy) + (var(--cy) - var(--hy)) * 0.2)) scale(1.14);
}
22% {
transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * 0.38), calc(var(--hy) + (var(--cy) - var(--hy)) * 0.38)) scale(1.32);
}
36% {
transform: translate(calc(var(--hx) + (var(--cx) - var(--hx)) * 0.12), calc(var(--hy) + (var(--cy) - var(--hy)) * 0.12)) scale(1.06);
}
46% {
transform: translate(var(--hx), var(--hy)) scale(1);
}
58% {
transform: translate(var(--hx), var(--hy)) scale(1);
}
70% {
transform: translate(calc(var(--hx) + (var(--hx) - var(--cx)) * 0.26), calc(var(--hy) + (var(--hy) - var(--cy)) * 0.26)) scale(0.94);
}
84% {
transform: translate(calc(var(--hx) + (var(--hx) - var(--cx)) * 0.1), calc(var(--hy) + (var(--hy) - var(--cy)) * 0.1)) scale(1);
}
94% {
transform: translate(var(--hx), var(--hy)) scale(1);
}
100% {
transform: translate(var(--hx), var(--hy)) scale(1);
}
}
.mg.is-demo .mg__magnet {
animation-name: mgBreath;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
transform: translate(var(--cx), var(--cy));
}
@keyframes mgBreath {
0%, 46%, 58%, 100% {
transform: translate(var(--cx), var(--cy)) scale(1);
}
22% {
transform: translate(var(--cx), var(--cy)) scale(1.16);
}
70% {
transform: translate(var(--cx), var(--cy)) scale(0.9);
}
}
@media (max-width: 340px) {
.mg {
gap: 8px;
--cx: 147px;
--cy: 52px;
}
.mg__field {
height: 104px;
}
.mg__dot {
width: 8px;
height: 8px;
}
.mg__magnet {
width: 20px;
height: 20px;
}
.mg__magnetIco {
width: 12px;
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg.is-demo .mg__dot,
.mg.is-demo .mg__magnet {
animation: none !important;
}
.mg.is-demo .mg__dot {
transform: translate(var(--hx), var(--hy)) !important;
}
.mg.is-demo .mg__magnet {
transform: translate(var(--cx), var(--cy)) !important;
}
}var root = document.getElementById('mg');
var field = document.getElementById('field');
var magnet = document.getElementById('magnet');
var dotsBox = document.getElementById('dots');
var modeBtn = document.getElementById('mode');
var modeLabel = document.getElementById('modeLabel');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// 철가루 32개 — 8×4 격자에 지터를 준 자리가 제자리(home)다. 커서는 자석.
var COLS = 8, ROWS = 4, dots = [];
function homeXY(c, r) {
return {
x: (c + 0.5) * field.clientWidth / COLS - 5 + (r % 2) * 8 - 4,
y: (r + 0.5) * field.clientHeight / ROWS - 5 + ((c % 3) - 1) * 6
};
}
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var el = document.createElement('i');
var home = homeXY(c, r);
el.className = 'mg__dot';
el.style.setProperty('--hx', home.x.toFixed(1) + 'px');
el.style.setProperty('--hy', home.y.toFixed(1) + 'px');
el.style.setProperty('--i', String(r * COLS + c));
dotsBox.appendChild(el);
dots.push({ el: el, c: c, r: r, hx: home.x, hy: home.y, x: home.x, y: home.y, vx: 0, vy: 0 });
}
}
var mag = { x: 212, y: 88 }, attract = false, last = 0, running = false, idleAt = 0;
function paintMagnet() {
magnet.style.transform = 'translate(' + mag.x + 'px, ' + mag.y + 'px)';
}
function paintDots() {
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
d.el.style.transform = 'translate(' + d.x + 'px, ' + d.y + 'px)';
}
}
function wake() {
root.classList.remove('is-demo');
if (reduce.matches) { paintMagnet(); paintDots(); return false; }
idleAt = performance.now();
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
return true;
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var lively = now - idleAt < 1600, allCalm = true;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var dx = d.x - mag.x, dy = d.y - mag.y;
var r2 = dx * dx + dy * dy;
// 쿨롱 간이: 힘 = 부호 / (r² + 연화) — 거리 제곱에 반비례하고 0 나누기를 막는다
var f = (attract ? -5200 : 4200) / (r2 + 900);
var len = Math.sqrt(r2) || 1;
d.vx += (dx / len) * f * dt * 60;
d.vy += (dy / len) * f * dt * 60;
// 제자리 스프링 + 減衰 — 철가루가 흩어지지 않고 돌아온다
d.vx += (d.hx - d.x) * 46 * dt;
d.vy += (d.hy - d.y) * 46 * dt;
d.vx *= 0.86; d.vy *= 0.86;
d.x += d.vx * dt; d.y += d.vy * dt;
if (Math.abs(d.vx) + Math.abs(d.vy) > 3 || Math.abs(d.hx - d.x) + Math.abs(d.hy - d.y) > 0.4) { allCalm = false; }
d.el.style.transform = 'translate(' + d.x + 'px, ' + d.y + 'px)';
}
if (!lively && allCalm) { paintDots(); running = false; return; }
requestAnimationFrame(step);
}
function moveMagnet(x, y, fromPointer) {
mag.x = Math.max(-8, Math.min(field.clientWidth - 24, x));
mag.y = Math.max(-8, Math.min(field.clientHeight - 24, y));
paintMagnet();
wake();
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
moveMagnet(e.clientX - r.left - 12, e.clientY - r.top - 12, true);
});
field.addEventListener('keydown', function (e) {
var JUMP = 28;
if (e.key === 'ArrowLeft') { moveMagnet(mag.x - JUMP, mag.y); }
else if (e.key === 'ArrowRight') { moveMagnet(mag.x + JUMP, mag.y); }
else if (e.key === 'ArrowUp') { moveMagnet(mag.x, mag.y - JUMP); }
else if (e.key === 'ArrowDown') { moveMagnet(mag.x, mag.y + JUMP); }
else { return; }
e.preventDefault();
});
modeBtn.addEventListener('click', function () {
attract = !attract;
modeBtn.setAttribute('aria-pressed', attract ? 'true' : 'false');
modeLabel.textContent = attract ? '押しのける' : '引き寄せる';
wake();
});
document.getElementById('reset').addEventListener('click', function () {
for (var i = 0; i < dots.length; i++) {
var home = homeXY(dots[i].c, dots[i].r);
dots[i].hx = home.x; dots[i].hy = home.y;
dots[i].x = home.x; dots[i].y = home.y; dots[i].vx = 0; dots[i].vy = 0;
dots[i].el.style.setProperty('--hx', home.x.toFixed(1) + 'px');
dots[i].el.style.setProperty('--hy', home.y.toFixed(1) + 'px');
}
mag.x = field.clientWidth / 2 - 12; mag.y = field.clientHeight / 2 - 12;
paintMagnet(); paintDots();
});
window.addEventListener('resize', function () {
document.getElementById('reset').click();
});
paintMagnet(); paintDots();// MagnetParticles — vanilla 판(run/292/demo/04-physics-magnet-particles/index.html) 의 React 포트.
// 커서가 자석이다. 힘 = 부호 / (r² + 연화) 의 쿨롱 간이로 밀거나 당기고,
// 제자리 스프링(46)과 감쇠(0.86)가 철가루를 격자 자리로 되돌린다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./MagnetParticles.module.scss";
export type MagnetParticlesProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type DotVars = CSSProperties & { "--i"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type Dot = { el: HTMLElement; c: number; r: number; hx: number; hy: number; x: number; y: number; vx: number; vy: number };
// 철가루 32개 — 8×4 격자에 지터를 준 자리가 제자리(home)다
const COLS = 8;
const ROWS = 4;
const JUMP = 28; // 방향키 한 번에 옮기는 자석 거리
const FIELD_LABEL = "자석 실험판";
const INDEXES: number[] = [];
for (let n = 0; n < COLS * ROWS; n++) INDEXES.push(n);
export default function MagnetParticles({
duration = 2000,
easing = "ease-in-out",
color = "#17141a",
}: MagnetParticlesProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const magnetRef = useRef<HTMLSpanElement | null>(null);
const dotsBoxRef = useRef<HTMLSpanElement | null>(null);
const dotsRef = useRef<Dot[]>([]) as Box<Dot[]>;
const magRef = useRef<{ x: number; y: number }>({ x: 212, y: 88 }) as Box<{ x: number; y: number }>;
const attractRef = useRef<boolean>(false) as Box<boolean>;
const lastRef = useRef<number>(0) as Box<number>;
const idleAtRef = useRef<number>(0) as Box<number>;
const rafRef = useRef<number>(0) as Box<number>;
const runningRef = useRef<boolean>(false) as Box<boolean>;
const [attract, setAttract] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function homeXY(c: number, r: number): { x: number; y: number } {
const field = fieldRef.current;
const w = field ? field.clientWidth : 424;
const h = field ? field.clientHeight : 176;
return {
x: ((c + 0.5) * w) / COLS - 5 + (r % 2) * 8 - 4,
y: ((r + 0.5) * h) / ROWS - 5 + ((c % 3) - 1) * 6,
};
}
function paintMagnet(): void {
const magnet = magnetRef.current;
if (magnet) magnet.style.transform = "translate(" + magRef.current.x + "px, " + magRef.current.y + "px)";
}
function paintDots(): void {
const dots = dotsRef.current;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
d.el.style.transform = "translate(" + d.x + "px, " + d.y + "px)";
}
}
function wake(): void {
setDemo(false);
if (reduced()) { paintMagnet(); paintDots(); return; }
idleAtRef.current = performance.now();
if (!runningRef.current) {
runningRef.current = true;
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
}
function step(now: number): void {
const dt = Math.min((now - lastRef.current) / 1000, 0.033);
lastRef.current = now;
const dots = dotsRef.current;
const mag = magRef.current;
const lively = now - idleAtRef.current < 1600;
let allCalm = true;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const dx = d.x - mag.x;
const dy = d.y - mag.y;
const r2 = dx * dx + dy * dy;
// 쿨롱 간이: 힘 = 부호 / (r² + 연화) — 거리 제곱에 반비례하고 0 나누기를 막는다
const f = (attractRef.current ? -5200 : 4200) / (r2 + 900);
const len = Math.sqrt(r2) || 1;
d.vx += (dx / len) * f * dt * 60;
d.vy += (dy / len) * f * dt * 60;
// 제자리 스프링 + 감쇠 — 철가루가 흩어지지 않고 돌아온다
d.vx += (d.hx - d.x) * 46 * dt;
d.vy += (d.hy - d.y) * 46 * dt;
d.vx *= 0.86; d.vy *= 0.86;
d.x += d.vx * dt; d.y += d.vy * dt;
if (Math.abs(d.vx) + Math.abs(d.vy) > 3 || Math.abs(d.hx - d.x) + Math.abs(d.hy - d.y) > 0.4) allCalm = false;
d.el.style.transform = "translate(" + d.x + "px, " + d.y + "px)";
}
if (!lively && allCalm) { paintDots(); runningRef.current = false; return; }
rafRef.current = requestAnimationFrame(step);
}
function moveMagnet(x: number, y: number): void {
const field = fieldRef.current;
if (!field) return;
const mag = magRef.current;
mag.x = Math.max(-8, Math.min(field.clientWidth - 24, x));
mag.y = Math.max(-8, Math.min(field.clientHeight - 24, y));
paintMagnet();
wake();
}
function home(): void {
const field = fieldRef.current;
const dots = dotsRef.current;
for (let i = 0; i < dots.length; i++) {
const spot = homeXY(dots[i].c, dots[i].r);
dots[i].hx = spot.x; dots[i].hy = spot.y;
dots[i].x = spot.x; dots[i].y = spot.y;
dots[i].vx = 0; dots[i].vy = 0;
dots[i].el.style.setProperty("--hx", spot.x.toFixed(1) + "px");
dots[i].el.style.setProperty("--hy", spot.y.toFixed(1) + "px");
}
if (field) {
magRef.current.x = field.clientWidth / 2 - 12;
magRef.current.y = field.clientHeight / 2 - 12;
}
paintMagnet();
paintDots();
}
useEffect(() => {
const box = dotsBoxRef.current;
if (box) {
const nodes = box.querySelectorAll("i");
const built: Dot[] = [];
for (let n = 0; n < nodes.length; n++) {
const el = nodes[n];
const c = n % COLS;
const r = Math.floor(n / COLS);
const spot = homeXY(c, r);
el.style.setProperty("--hx", spot.x.toFixed(1) + "px");
el.style.setProperty("--hy", spot.y.toFixed(1) + "px");
built.push({ el: el, c: c, r: r, hx: spot.x, hy: spot.y, x: spot.x, y: spot.y, vx: 0, vy: 0 });
}
dotsRef.current = built;
}
paintMagnet();
paintDots();
function onResize(): void { home(); }
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
moveMagnet(event.clientX - r.left - 12, event.clientY - r.top - 12);
}
function onKeyDown(event: KeyEvt): void {
const mag = magRef.current;
if (event.key === "ArrowLeft") moveMagnet(mag.x - JUMP, mag.y);
else if (event.key === "ArrowRight") moveMagnet(mag.x + JUMP, mag.y);
else if (event.key === "ArrowUp") moveMagnet(mag.x, mag.y - JUMP);
else if (event.key === "ArrowDown") moveMagnet(mag.x, mag.y + JUMP);
else return;
event.preventDefault();
}
function toggleMode(): void {
const next = !attractRef.current;
attractRef.current = next;
setAttract(next);
wake();
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const dotVars = (n: number): DotVars => ({ "--i": String(n) });
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-magnet" viewBox="0 0 256 256" fill="currentColor"><g><path d="m168.36 200.36l-30 29.35a8 8 0 0 1-11.26-.05L98.46 201a8 8 0 0 1 .08-11.4l30-29Zm-142-82.76a8 8 0 0 0 0 11.28L55 157.54a8 8 0 0 0 11.38-.06l29.18-29.92l-39.79-39.79Z" opacity=".2"/><path d="M207 50.25A87.46 87.46 0 0 0 144.6 24h-.33A87.48 87.48 0 0 0 82 49.81l-31.89 32.3L20.61 112a16 16 0 0 0 .06 22.56l28.66 28.66a15.92 15.92 0 0 0 11.32 4.69h.09a16 16 0 0 0 11.36-4.82l60.9-62.4a16.08 16.08 0 0 1 22.41-.21a15.6 15.6 0 0 1 4.73 11.19a16.9 16.9 0 0 1-4.85 12L93 183.88a16 16 0 0 0-.17 22.79l28.66 28.66a16.06 16.06 0 0 0 22.52.12l61.8-60.45c34.45-34.5 34.98-90.44 1.19-124.75M60.7 156.47l-27-27L55.87 108l27 27Zm98.46-71.25a24 24 0 0 0-34.29.44l-30 30.72l-27-27l30.11-30.42A71.5 71.5 0 0 1 144.36 40h.27a71.49 71.49 0 0 1 50.71 21.53c.51.52 1 1.05 1.5 1.59l-37.68 22.1Zm53.76 53.76L151 200l-27-27l61.81-60.46l.24-.24c.4-.4.79-.8 1.17-1.21a24 24 0 0 0-17.3-40.48l25.6-15A71.66 71.66 0 0 1 213 138.87Z"/></g></symbol>
<symbol id="ico-reset" viewBox="0 0 256 256" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-magnet" /></svg>
<span>판 위에서 마우스를 움직이면 철가루가 커서 자석을 따라 밀리고 당겨집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onKeyDown={onKeyDown}
>
<span className={styles.magnet} ref={magnetRef} aria-hidden="true">
<svg className={styles.magnetIco}><use href="#ico-magnet" /></svg>
</span>
<span ref={dotsBoxRef} aria-hidden="true">
{INDEXES.map((n) => (
<i key={n} className={styles.dot} style={dotVars(n)} />
))}
</span>
</div>
<div className={styles.bar}>
<button className={styles.mode} type="button" aria-pressed={attract} onClick={toggleMode}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-magnet" /></svg>
<span>{attract ? "밀어내기" : "당기기"}</span>
</button>
<button className={styles.reset} type="button" onClick={home}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-reset" /></svg><span>처음대로</span>
</button>
</div>
</div>
</main>
);
}04 マグネットパーティクル — 計測 累積面積 18.77% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 物理シミュレーション 作り方 9選 — バネから慣性スクラブまで
このデモが属する記事
物理シミュレーション 作り方 9選 — バネから慣性スクラブまで「物理シミュレーション 作り方」の基本は、requestAnimationFrame が毎フレーム速度と位置を計算し、その結果を transform だけで画面に反映することです。ライブラリを使わない9つのデモは、ばね追従・重力・振り子から この記事のデモをすべて見る


