Particle Button
A button that emits a GPU-simulated spark burst on click, with a transparent CPU fallback.
A button that emits a GPU-simulated spark burst on click, with a transparent CPU fallback.
import { Scene, ComputeParticleEntity } from "@vectojs/core";
import { Button } from "@vectojs/ui";
// Cap render resolution at 2x. WebGL point cost scales with backing-store
// pixels (logical size x DPR^2); at 3x a full-screen 1200-particle field
// overruns the 16ms budget on click, while 2x is already retina-crisp. The
// engine reads window.devicePixelRatio live per resize and exposes no cap, so
// clamp it here — safe because each demo owns its own iframe document.
{
const real = window.devicePixelRatio || 1;
if (real > 2) {
Object.defineProperty(window, "devicePixelRatio", {
get: () => 2,
configurable: true,
});
}
}
const app = document.getElementById("app");
const canvas = document.getElementById("canvas");
// navigator.gpu is hidden on Firefox by no-ff-webgpu.js (its WebGPU backend can
// crash the GPU process), so this reliably distinguishes "GPU available" and
// lets us scale the burst down on the CPU fallback to stay smooth.
const canGPU = !!navigator.gpu;
const MAX_PARTICLES = canGPU ? 1200 : 350;
// ComputeParticleEntity draws through the WebGL point batch, so this scene
// needs pointBackend:'webgl'. The Firefox GPU-process crash is handled by
// no-ff-webgpu.js (loaded first in index.html) forcing the CPU sim path; this
// is an animating demo, not a static catalog, so it isn't exposed to the
// idle-60fps WebGL->2D composite bug that hit the gallery.
// disableWindowResize: a ResizeObserver on the host element drives sizing (the
// VectoJS idiom) so the demo tracks its container, not the top window.
const scene = new Scene(canvas, {
renderMode: "always",
pointBackend: "webgl",
maxFPS: 60,
disableWindowResize: true,
});
const sparks = new ComputeParticleEntity({
maxParticles: MAX_PARTICLES,
...(canGPU ? {} : { particleBackend: "cpu" }),
springK: 0.035,
damping: 0.9,
maxVelocity: 700,
size: 3,
color: "#7c5cff",
});
scene.add(sparks);
function burst() {
// Shove the ambient field outward from the button center; each particle's
// spring then reels it back to its own spread-out origin. (Don't re-home
// origins here — that would permanently collapse the whole field onto the
// button.)
sparks.triggerExplosion(
button.x + button.width / 2,
button.y + button.height / 2,
620,
);
}
const button = new Button("\u2726 Launch", {
bg: "#7c5cff",
hoverBg: "#8b6dff",
color: "#ffffff",
font: '700 16px "Inter", sans-serif',
padding: 16,
radius: 12,
onClick: burst,
});
scene.add(button);
let logicalW = 0;
let logicalH = 0;
function layout() {
button.setPosition(
(logicalW - button.width) / 2,
(logicalH - button.height) / 2,
);
}
let seeded = false;
function fit() {
const w = app.clientWidth;
const h = app.clientHeight;
if (w === 0 || h === 0) return;
logicalW = w;
logicalH = h;
scene.resize(w, h);
// Seed the field once, on the first real size — re-seeding every resize
// would reset every particle mid-flight.
if (!seeded) {
sparks.initRandomParticles(w, h);
seeded = true;
}
layout();
}
// Drive sizing from the host element. The first callback fires after layout has
// settled (and the WebGPU device is ready), so it doubles as the deferred
// initial seed — no separate rAF needed.
const observer = new ResizeObserver(fit);
observer.observe(app);
scene.start();
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/no-ff-webgpu.js"></script>
<title>Particle Button — Motif</title>
<style>
* {
margin: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: #06070a;
}
#app {
position: relative;
width: 100%;
height: 100%;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
</style>
<script type="importmap">
{
"imports": {
"@vectojs/core": "https://esm.sh/@vectojs/core@1.9.2",
"@vectojs/ui": "https://esm.sh/@vectojs/ui@1.9.5?bundle"
}
}
</script>
</head>
<body>
<div id="app"><canvas id="canvas"></canvas></div>
<script type="module" src="./demo.js"></script>
</body>
</html>