Particle Fountain
A jet of glowing droplets sprays up from a nozzle at the bottom, arcs over under gravity and splashes down, with a few droplets skipping where they land. Move the pointer or drag a finger to tilt the jet.
Point to tilt
How to use it
- Paste the snippet just before
</head>. - Add
data-particle-fountainto a section with a dark background. Adddata-autoplayto have the jet sway by itself whenever nobody is pointing.
Options
Add any of these attributes to the same element.
data-autoplay | The jet sways gently by itself when idle. Without it, it points straight up. |
data-rate | Particles per second. Default 260. |
data-height | How high it shoots, as a share of the section’s height. Default 0.75. |
data-spread | Width of the spray, in degrees. Default 14. |
data-colors | Comma-separated colours. |
It’s drawn on a transparent canvas behind your content and paused when off screen. On a phone the jet follows your finger and goes back to swaying a moment after you let go. With reduced motion you see a still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Particle Fountain: a jet of glowing particles sprays up from a nozzle at
// the bottom of a section, arcs over under gravity and splashes down, with
// little droplets bouncing where it lands. Point (or drag a finger) to tilt
// the jet; with data-autoplay it sways gently by itself when idle. Drawn on
// a canvas behind your content, and paused when off screen.
// Usage: <section data-particle-fountain>…</section>
// Optional: data-autoplay (sway by itself when nobody is pointing),
// data-rate (particles per second, default 260), data-height (how high it
// shoots, as a share of the section's height, default 0.75), data-spread
// (width of the spray in degrees, default 14), data-colors (comma-separated).
var DEFAULTS = { rate: 260, height: 0.75, spread: 14, colors: ['#67e8f9', '#38bdf8', '#a78bfa', '#f0abfc', '#ffffff'] };
var css =
'[data-particle-fountain]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent;touch-action:pan-y}' +
'.pfn-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'pfn-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var rate = +d.rate || DEFAULTS.rate;
var heightShare = +d.height || DEFAULTS.height;
var spread = (d.spread !== undefined ? +d.spread : DEFAULTS.spread) * Math.PI / 180;
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
var w = 0, h = 0, dpr = 1, t = 0, owed = 0;
var parts = [];
var tilt = 0, aim = 0, pointerUntil = -1;
var G = 0.0012; // px per ms², scaled to the height below
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
function g() { return G * Math.max(0.35, h / 600); }
function emit() {
var gg = g();
// Speed needed to reach the chosen height, with a little variety.
var v = Math.sqrt(2 * gg * h * heightShare) * (0.82 + Math.random() * 0.2);
var a = -Math.PI / 2 + tilt + (Math.random() - 0.5) * spread;
parts.push({
x: w / 2 + (Math.random() - 0.5) * 6, y: h - 4,
vx: Math.cos(a) * v, vy: Math.sin(a) * v,
r: 1 + Math.random() * 1.8, c: colors[(Math.random() * colors.length) | 0],
age: 0, bounces: 0
});
}
function step(dt) {
t += dt;
if (t > pointerUntil) {
aim = autoplay ? Math.sin(t * 0.0007) * 0.38 + Math.sin(t * 0.0019) * 0.1 : 0;
}
tilt += (aim - tilt) * Math.min(1, dt * 0.004);
owed += rate * dt / 1000;
var cap = Math.min(1600, rate * 4);
while (owed >= 1) { owed--; if (parts.length < cap) emit(); }
var gg = g();
for (var i = parts.length - 1; i >= 0; i--) {
var p = parts[i];
p.age += dt;
p.vy += gg * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > h - 2 && p.vy > 0) {
// Splash: bounce a little, lose most of the energy, then go.
if (p.bounces < 2 && Math.random() < 0.6) {
p.y = h - 2;
p.vy *= -(0.25 + Math.random() * 0.2);
p.vx = p.vx * 0.6 + (Math.random() - 0.5) * 0.15;
p.bounces++;
} else { parts.splice(i, 1); continue; }
}
if (p.x < -20 || p.x > w + 20) parts.splice(i, 1);
}
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// The pool glow where it lands.
var gl = ctx.createRadialGradient(w / 2, h, 0, w / 2, h, Math.min(w, 420) * 0.6);
gl.addColorStop(0, 'rgba(103,232,249,.22)');
gl.addColorStop(1, 'rgba(103,232,249,0)');
ctx.fillStyle = gl;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
ctx.globalAlpha = p.bounces ? 0.55 : 0.9;
ctx.strokeStyle = p.c;
ctx.lineWidth = p.r * 1.6;
ctx.beginPath();
ctx.moveTo(p.x - p.vx * 18, p.y - p.vy * 18);
ctx.lineTo(p.x, p.y);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
// The nozzle.
ctx.save();
ctx.translate(w / 2, h);
ctx.rotate(tilt);
ctx.fillStyle = 'rgba(255,255,255,.85)';
ctx.beginPath();
ctx.moveTo(-7, 0); ctx.lineTo(-3.5, -12); ctx.lineTo(3.5, -12); ctx.lineTo(7, 0);
ctx.fill();
ctx.restore();
}
function point(e, hold) {
var r = el.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
aim = Math.max(-0.7, Math.min(0.7, Math.atan2(x - w / 2, Math.max(40, h - y))));
pointerUntil = t + hold;
}
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// A still frame mid-spray.
for (var k = 0; k < 160; k++) step(16);
draw();
return;
}
for (var warm = 0; warm < 60; warm++) step(16);
el.addEventListener('pointermove', function (e) { point(e, e.pointerType === 'mouse' ? 1500 : 2500); });
el.addEventListener('pointerdown', function (e) { point(e, 2500); });
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(40, now - last) : 16;
last = now;
step(dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-particle-fountain]').forEach(function (el) {
if (el._pfn) return;
el._pfn = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-particle-fountain data-autoplay style="background: #08152a; color: #fff">
<h1>Make a splash</h1>
</section>