04Magnet particles
The cursor becomes a magnet that pushes and pulls iron filings. Force falls off with the square of the distance, and every frame moves through translate so the swarm stays crisp.
Best forHero section backdrop, a nav highlight that crowds around the cursor
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>Move the mouse over the board and the iron filings are pushed and pulled by the cursor magnet</span></p>
<div class="mg__field" id="field" role="application" aria-label="Magnet test board" 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">Attract</span>
</button>
<button class="mg__reset" id="reset" type="button">
<svg class="mg__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>Reset</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;
// 제자리 스프링 + Damping — 철가루가 흩어지지 않고 돌아온다
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 ? 'Repel' : '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 Magnet particles — Measured area 18.77% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · JavaScript Physics Simulation: 9 Demos, No Library
The post this demo lives in
JavaScript Physics Simulation: 9 Demos, No LibraryA JavaScript physics simulation is arithmetic repeated once per frame: force adds to velocity, velocity adds to position See all demos in this post


