Jellyfish
Glowing jellyfish drift upwards through deep water. Each bell squeezes to push itself up, then relaxes and glides while its tentacles trail and ripple behind it. Specks of plankton float by and light from the surface shimmers down.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-jellyfish>where the scene should go. It fills the element with an SVG scene.
Options
Add any of these attributes to the same element.
data-count | Number of jellyfish, 1 to 8. Default 4. |
data-colors | Comma-separated bell colours, used in turn. Default soft pinks, blues and lilacs. |
data-speed | Speed multiplier. Default 1. |
data-rays="false" | No light rays from the surface. |
It is 8:5 with a deep-sea background by default; set your own height or background on the element to change that. The water fills any shape, cropping at the edges. It pauses when off screen. With reduced motion the jellyfish hang still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Jellyfish: glowing jellyfish pulse and drift upwards through deep water.
// Each bell squeezes to push itself up, then relaxes and glides while its
// tentacles trail and ripple behind it. Specks of plankton float by and
// light rays shimmer from the surface. Drawn as SVG that fills the
// element, and paused when off screen.
// Usage: <div data-jellyfish></div>
// It is 8:5 with a deep-sea background by default; give it your own
// height or background to change that.
// Optional: data-count (number of jellyfish, 1 to 8, default 4),
// data-colors (comma-separated bell colours), data-speed (default 1),
// data-rays="false" (no light rays).
var DEFAULTS = {
count: 4,
speed: 1,
colors: ['#f0abfc', '#a5b4fc', '#67e8f9', '#f9a8d4', '#c4b5fd']
};
var NS = 'http://www.w3.org/2000/svg';
var W = 400, H = 250;
var uid = 0;
var css =
'[data-jellyfish]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5;' +
'background:linear-gradient(#123a6b,#0b2149 45%,#050c24)}' +
'.jfs-svg{position:absolute;inset:0;width:100%;height:100%;display:block}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function f(n) { return n.toFixed(1); }
function build(el) {
var d = el.dataset;
var n = ++uid;
var count = Math.max(1, Math.min(8, parseInt(d.count, 10) || DEFAULTS.count));
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }).filter(Boolean) : DEFAULTS.colors;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Jellyfish pulsing and drifting upwards through the sea');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
var id = 'jfs' + n;
var rays = '';
if (d.rays !== 'false') {
for (var r = 0; r < 6; r++) {
var rx = 20 + r * 72 + Math.random() * 30;
rays += '<path class="jfs-ray" d="M' + f(rx) + ' -10L' + f(rx + 26) + ' -10L' + f(rx - 40) + ' 260L' + f(rx - 90) + ' 260Z" fill="url(#' + id + '-ray)" opacity=".5"/>';
}
}
var specks = '';
for (var s = 0; s < 40; s++) {
specks += '<circle class="jfs-speck" cx="' + f(Math.random() * W) + '" cy="' + f(Math.random() * H) + '" r="' + (0.5 + Math.random() * 1.1).toFixed(2) + '" fill="#bae6fd" opacity="' + (0.15 + Math.random() * 0.4).toFixed(2) + '"/>';
}
el.innerHTML =
'<svg class="jfs-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">' +
'<defs>' +
'<linearGradient id="' + id + '-ray" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#bae6fd" stop-opacity=".22"/><stop offset="1" stop-color="#bae6fd" stop-opacity="0"/></linearGradient>' +
'<radialGradient id="' + id + '-halo"><stop offset="0" stop-color="#fff" stop-opacity=".35"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>' +
'</defs>' +
'<g class="jfs-rays">' + rays + '</g>' +
'<g class="jfs-specks">' + specks + '</g>' +
'<g class="jfs-jellies"></g>' +
'</svg>';
var svg = el.firstChild;
var rayEls = svg.querySelectorAll('.jfs-ray');
var speckG = svg.querySelector('.jfs-specks');
var host = svg.querySelector('.jfs-jellies');
var jellies = [];
for (var j = 0; j < count; j++) {
var col = colors[j % colors.length];
var size = 0.6 + Math.random() * 0.55;
if (j === 0) size = 1.1; // one big one for the eye to follow
var g = document.createElementNS(NS, 'g');
var tent = '', arms = '';
for (var k = 0; k < 7; k++) tent += '<path class="jfs-tent" fill="none" stroke="' + col + '" stroke-width="1.1" stroke-linecap="round" opacity=".55"/>';
for (var a = 0; a < 3; a++) arms += '<path class="jfs-arm" fill="none" stroke="' + col + '" stroke-width="3.4" stroke-linecap="round" opacity=".5"/>';
g.innerHTML =
'<circle r="46" fill="url(#' + id + '-halo)"/>' +
'<circle r="34" fill="' + col + '" opacity=".1"/>' +
tent + arms +
'<g class="jfs-bell">' +
'<path d="M-24 0C-24 -18 -13 -28 0 -28C13 -28 24 -18 24 0Q18 3 12 0Q6 4 0 0Q-6 4 -12 0Q-18 3 -24 0Z" fill="' + col + '" fill-opacity=".55" stroke="' + col + '" stroke-width="1.2"/>' +
'<path d="M-15 -4C-15 -15 -8 -21 0 -21C8 -21 15 -15 15 -4" fill="none" stroke="#fff" stroke-opacity=".35" stroke-width="1.2"/>' +
'<path d="M-6 -12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0M2 -9a4 4 0 1 0 8 0a4 4 0 1 0 -8 0M-10 -8a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" fill="#fff" opacity=".22"/>' +
'<path d="M-14 -20Q-8 -26 0 -26" fill="none" stroke="#fff" stroke-opacity=".7" stroke-width="2" stroke-linecap="round"/>' +
'</g>';
host.appendChild(g);
var jl = {
g: g, bell: g.querySelector('.jfs-bell'),
tents: g.querySelectorAll('.jfs-tent'), arms: g.querySelectorAll('.jfs-arm'),
size: size,
x: 40 + ((j + 0.5) / count) * (W - 80) + (Math.random() - 0.5) * 30,
y: 40 + Math.random() * (H + 40),
period: 2.2 + Math.random() * 0.9, // seconds per pulse
ph: Math.random(),
drift: (Math.random() - 0.5) * 6,
tilt: 0, trail: []
};
jellies.push(jl);
}
// Draw the small ones behind the big ones.
jellies.slice().sort(function (p, q) { return p.size - q.size; }).forEach(function (jl) { host.appendChild(jl.g); });
// Pulse shape over one cycle u (0..1): a quick squeeze then a slow relax.
function squeeze(u) {
return u < 0.25 ? Math.sin(u / 0.25 * Math.PI / 2) : Math.cos((u - 0.25) / 0.75 * Math.PI / 2);
}
function draw(jl, sec) {
var u = ((sec / jl.period + jl.ph) % 1 + 1) % 1;
var sq = squeeze(u);
var sx = 1 - 0.2 * sq, sy = 1 + 0.12 * sq;
var s = jl.size;
jl.g.setAttribute('transform', 'translate(' + f(jl.x) + ' ' + f(jl.y) + ') rotate(' + jl.tilt.toFixed(2) + ') scale(' + s.toFixed(3) + ')');
jl.bell.setAttribute('transform', 'scale(' + sx.toFixed(3) + ' ' + sy.toFixed(3) + ')');
// Tentacles hang from the rim and ripple; they stretch out as the bell pushes.
var rim = 24 * sx;
for (var k = 0; k < jl.tents.length; k++) {
var x0 = -rim + (k + 0.5) / jl.tents.length * rim * 2;
var len = 46 + (k % 3) * 9 + 10 * sq;
var dd = 'M' + f(x0 * 0.9) + ' 1';
for (var p = 1; p <= 5; p++) {
var yy = p / 5 * len;
var wob = Math.sin(sec * 2.6 - p * 0.9 + k * 1.3) * (1 + p * 1.1) * (1 - 0.5 * sq);
dd += 'L' + f(x0 * (0.9 - p * 0.1) + wob) + ' ' + f(yy);
}
jl.tents[k].setAttribute('d', dd);
}
for (var m = 0; m < jl.arms.length; m++) {
var ax = (m - 1) * 5;
var al = 30 + m * 4 + 6 * sq;
var w1 = Math.sin(sec * 1.8 + m * 2) * 5, w2 = Math.sin(sec * 1.8 + m * 2 - 1.4) * 7;
jl.arms[m].setAttribute('d', 'M' + f(ax) + ' 0C' + f(ax + w1) + ' ' + f(al * 0.35) + ' ' + f(ax - w2) + ' ' + f(al * 0.7) + ' ' + f(ax + w2 * 0.6) + ' ' + f(al));
}
return sq;
}
function step(jl, sec, dt) {
var u = ((sec / jl.period + jl.ph) % 1 + 1) % 1;
// Thrust while squeezing, a gentle glide the rest of the time.
var thrust = u < 0.3 ? Math.sin(u / 0.3 * Math.PI) : 0;
var v = (5 + 38 * thrust) * (0.75 + jl.size * 0.3);
var wander = Math.sin(sec * 0.35 + jl.ph * 9) * 8 + jl.drift;
jl.tilt = wander * 0.9;
jl.y -= v * dt;
jl.x += Math.sin(jl.tilt * Math.PI / 180) * v * dt;
if (jl.x < -20) jl.x = W + 20;
if (jl.x > W + 20) jl.x = -20;
// Off the top: come back in from below.
if (jl.y < -50) { jl.y = H + 70 + Math.random() * 40; jl.x = 30 + Math.random() * (W - 60); }
}
function scene(sec) {
for (var i = 0; i < rayEls.length; i++) rayEls[i].setAttribute('opacity', (0.3 + 0.25 * Math.sin(sec * 0.7 + i * 1.7)).toFixed(3));
speckG.setAttribute('transform', 'translate(' + f(Math.sin(sec * 0.2) * 6) + ' ' + f(-((sec * 4) % H)) + ')');
}
// Repeat the specks below so their slow climb loops seamlessly.
var copy = speckG.cloneNode(true);
copy.removeAttribute('class');
copy.setAttribute('transform', 'translate(0 ' + H + ')');
speckG.appendChild(copy);
if (reduced()) {
jellies.forEach(function (jl, i) { jl.y = 70 + (i * 67) % 150; draw(jl, 0); });
scene(0);
return;
}
var sec = 0, last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = (last ? Math.min(50, now - last) : 16) / 1000 * speed;
last = now;
sec += dt;
jellies.forEach(function (jl) { step(jl, sec, dt); draw(jl, sec); });
scene(sec);
}
});
jellies.forEach(function (jl) { draw(jl, 0); });
scene(0);
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-jellyfish]').forEach(function (el) {
if (el._jfs) return; // idempotent
el._jfs = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-jellyfish></div>
<!-- a calmer tank of two: -->
<div data-jellyfish data-count="2" data-speed="0.7"></div>