Gallery
Magma Drift
Canvas 2DMedium
A dense Canvas 2D particle field in drift motion. Interactive and GPU-light.
#particles#canvas#drift#magma
Live preview
1 file
// @ts-nocheck
import { useEffect, useRef } from "react";
// Render <Particles /> inside a container with position: relative.
const opts = {"variant":"drift","bg":"#0a0503","colors":["#fbbf24","#f97316","#ef4444"],"light":{"bg":"#fdf6ef","colors":["#b45309","#c2410c","#b91c1c"]},"speed":1,"density":1.6,"shape":"circle"};
export function Particles() {
const ref = useRef(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
const V = opts.variant;
/* Both schemes ship in opts; `light` is absent in the live preview, which
resolves the scheme itself, so nothing is observed there. */
const schemeMq =
opts.light && typeof matchMedia === "function"
? matchMedia("(prefers-color-scheme: light)")
: null;
const schemeNow = () => (schemeMq && schemeMq.matches ? opts.light : opts);
/** The scheme the scene or particle set was first built from. */
const SCHEME_BUILT = schemeNow();
let COLORS = SCHEME_BUILT.colors, BG = SCHEME_BUILT.bg;
const onScheme = () => {
const s = schemeNow();
COLORS = s.colors; BG = s.bg;
if (typeof seed === "function") seed();
if (typeof applyScheme === "function") applyScheme();
};
if (schemeMq) schemeMq.addEventListener("change", onScheme);
const SPEED = opts.speed || 1, DENSITY = opts.density || 1, SHAPE = opts.shape || "circle";
let w = 0, h = 0, dpr = 1, ps = [], raf = 0, t = 0;
const mouse = { x: -9999, y: -9999, on: false };
const rnd = (a, b) => a + Math.random() * (b - a);
const pick = (a) => a[(Math.random() * a.length) | 0];
function count() {
const base = V === "network" ? 9000 : (V === "snow" || V === "confetti") ? 7000 : 6000;
return Math.max(24, Math.min(260, Math.floor((w * h) / base * DENSITY)));
}
function spawn(i, n) {
const p = { c: pick(COLORS), r: rnd(1.2, 3.2), a: rnd(0.4, 1), ang: 0, sp: 0, rad: 0, rot: rnd(0, 6.28), spin: rnd(-0.1, 0.1), ph: rnd(0, 6.28), vx: 0, vy: 0, x: 0, y: 0 };
if (V === "orbit" || V === "spiral") {
p.ang = (i / n) * Math.PI * 2 + (V === "spiral" ? i * 0.28 : 0);
p.rad = V === "spiral" ? (i / n) * Math.min(w, h) * 0.48 : rnd(0.06, 0.5) * Math.min(w, h);
p.sp = rnd(0.2, 0.6) * (V === "spiral" ? 0.4 : 1);
p.x = w / 2 + Math.cos(p.ang) * p.rad; p.y = h / 2 + Math.sin(p.ang) * p.rad;
} else if (V === "gravity" || V === "snow" || V === "confetti") {
p.x = rnd(0, w); p.y = rnd(-h, 0);
p.vy = (V === "gravity" ? rnd(2, 5) : rnd(0.6, 1.8)) * SPEED;
p.vx = V === "snow" ? rnd(-0.4, 0.4) : rnd(-0.2, 0.2);
p.r = V === "confetti" ? rnd(3, 6) : p.r;
} else {
p.x = rnd(0, w); p.y = rnd(0, h);
p.vx = rnd(-0.5, 0.5) * SPEED; p.vy = rnd(-0.5, 0.5) * SPEED;
}
return p;
}
function seed() { const n = count(); ps = Array.from({ length: n }, (_, i) => spawn(i, n)); }
function resize() {
w = canvas.clientWidth; h = canvas.clientHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = w * dpr; canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); seed();
}
function shape(p) {
if (SHAPE === "square" || V === "confetti") { ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillRect(-p.r, -p.r * 0.6, p.r * 2, p.r * 1.2); ctx.restore(); return; }
if (SHAPE === "triangle") { ctx.beginPath(); ctx.moveTo(p.x, p.y - p.r); ctx.lineTo(p.x + p.r, p.y + p.r); ctx.lineTo(p.x - p.r, p.y + p.r); ctx.closePath(); ctx.fill(); return; }
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, 6.283); ctx.fill();
}
function frame() {
t += 0.016 * SPEED;
ctx.fillStyle = BG; ctx.fillRect(0, 0, w, h);
if (V === "network") {
for (let i = 0; i < ps.length; i++) for (let j = i + 1; j < ps.length; j++) {
const a = ps[i], b = ps[j], d = Math.hypot(a.x - b.x, a.y - b.y);
if (d < 130) { ctx.globalAlpha = (1 - d / 130) * 0.4; ctx.strokeStyle = a.c; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); }
}
ctx.globalAlpha = 1;
}
for (const p of ps) {
if (V === "orbit" || V === "spiral") { p.ang += (p.sp / (p.rad + 40)) * SPEED * (V === "spiral" ? 8 : 6); p.x = w / 2 + Math.cos(p.ang + (V === "spiral" ? t : 0)) * p.rad; p.y = h / 2 + Math.sin(p.ang + (V === "spiral" ? t : 0)) * p.rad; }
else if (V === "gravity" || V === "snow" || V === "confetti") { p.y += p.vy; p.x += p.vx + (V === "snow" ? Math.sin(t + p.ph) * 0.4 : 0); p.rot += p.spin; if (p.y > h + 10) { p.y = -10; p.x = rnd(0, w); } }
else {
if (mouse.on) { const dx = mouse.x - p.x, dy = mouse.y - p.y, d2 = dx * dx + dy * dy; if (d2 < 24000) { p.vx += (dx / Math.sqrt(d2 + 1)) * 0.03; p.vy += (dy / Math.sqrt(d2 + 1)) * 0.03; } }
p.vx *= 0.99; p.vy *= 0.99; p.x += p.vx; p.y += p.vy;
if (p.x < 0 || p.x > w) p.vx *= -1; if (p.y < 0 || p.y > h) p.vy *= -1;
p.x = Math.max(0, Math.min(w, p.x)); p.y = Math.max(0, Math.min(h, p.y));
if (V === "fireflies") p.a = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 2 + p.ph));
}
ctx.globalAlpha = p.a;
ctx.fillStyle = p.c;
if (V === "fireflies") { ctx.shadowBlur = 12; ctx.shadowColor = p.c; }
shape(p);
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
raf = requestAnimationFrame(frame);
}
const onMove = (e) => { const r = canvas.getBoundingClientRect(); mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top; mouse.on = true; };
const onLeave = () => { mouse.on = false; };
const ro = new ResizeObserver(resize); ro.observe(canvas);
resize(); frame();
canvas.addEventListener("pointermove", onMove); canvas.addEventListener("pointerleave", onLeave);
return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener("pointermove", onMove); canvas.removeEventListener("pointerleave", onLeave); if (schemeMq) schemeMq.removeEventListener("change", onScheme); };
}, []);
return (
<canvas ref={ref} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", display: "block" }} />
);
}Copy or download any file. The HTML build loads Three.js from a CDN via an import map, so it runs by just opening index.html. The React build is react-three-fiber; setup notes live at the top of each file.
Variants
40 of this pattern · page 1 of 4