Spider Drop
A little spider drops from the top of a section on its silk thread, bounces on the stretchy line and dangles there, swaying and wiggling its legs. Tap it and it bounces again.
Trick or treat
How to use it
- Paste the snippet just before
</head>. - Add
data-spider-dropto any section. The spider drops in when it scrolls into view.
Options
Add any of these attributes to the same element.
data-loop | Climb back up after a while and drop again. |
data-x | Where the thread hangs, as a percentage across. Default 75. |
data-drop | How far it drops, as a percentage of the section’s height. Default 55. |
data-size | Spider size in px. Default 46. |
data-color | Spider colour. Default #1f1b2e. |
data-hang | How long it dangles before climbing, when looping. Default 3200 ms. |
The spider is clipped to the section and pauses when it is off screen. Only the spider itself catches clicks. With reduced motion it simply hangs there.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Spider Drop: a little spider drops from the top of a section on its
// silk thread, bounces on the stretchy line and dangles there, swaying and
// wiggling its legs. Tap or click it and it bounces again. With data-loop
// it climbs back up after a while and drops again.
// Usage: <section data-spider-drop>…</section>
// Optional: data-loop (climb up and drop again), data-x (where the thread
// hangs, % across, default 75), data-drop (how far it drops, % of the
// height, default 55), data-size (px, default 46), data-color (default
// #1f1b2e), data-hang (ms it hangs before climbing when looping, default
// 3200).
var DEFAULTS = { x: 75, drop: 55, size: 46, color: '#1f1b2e', hang: 3200 };
var G = 1400, K = 140, C = 5.5; // gravity, thread stiffness, damping
var css =
'[data-spider-drop]{position:relative}' +
'.spd-layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:2}' +
'.spd-hang{position:absolute;top:0;width:0;height:0;transform-origin:0 0}' +
'.spd-thread{position:absolute;left:-.5px;top:0;width:1px;background:linear-gradient(rgba(140,140,160,.35),rgba(140,140,160,.85))}' +
'.spd-spider{position:absolute;left:0;top:0;display:block;max-width:none;overflow:visible;pointer-events:auto;cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// A spider 60 units across, hanging by its back from the top. Legs are
// separate groups so they can wiggle.
function spiderSvg(color) {
var legs = '';
var sets = [[-6, -34, 'M0 0 Q-10 -12 -20 -6 Q-24 -3 -26 4'], [-2, -12, 'M0 0 Q-12 -6 -22 2 Q-25 6 -26 12'],
[3, 10, 'M0 0 Q-12 2 -20 12 Q-22 16 -22 22'], [7, 30, 'M0 0 Q-9 8 -14 18 Q-15 22 -14 28']];
for (var s = -1; s <= 1; s += 2) {
sets.forEach(function (l, i) {
legs += '<g class="spd-leg" data-side="' + s + '" data-i="' + i + '" transform="translate(' + (s * 5) + ' ' + l[0] + ')">' +
'<path d="' + l[2] + '" transform="scale(' + (-s) + ' 1)" fill="none" stroke="' + color + '" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></g>';
});
}
return legs +
'<ellipse cx="0" cy="10" rx="11" ry="13" fill="' + color + '"/>' +
'<path d="M-3.4 5 L3.4 5 L0 10 L3.4 15 L-3.4 15 L0 10 Z" fill="#ef4444" opacity=".9"/>' +
'<circle cx="0" cy="-6" r="7.5" fill="' + color + '"/>' +
'<circle cx="-3" cy="-5" r="2.8" fill="#fff"/><circle cx="3" cy="-5" r="2.8" fill="#fff"/>' +
'<circle class="spd-pupil" cx="-2.6" cy="-4.4" r="1.4" fill="#111"/><circle class="spd-pupil" cx="3.4" cy="-4.4" r="1.4" fill="#111"/>' +
'<path d="M-2.4 -1 L-1.4 1.6 M2.4 -1 L1.4 1.6" stroke="#fff" stroke-width="1" stroke-linecap="round"/>';
}
function build(el) {
var d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var xPct = d.x !== undefined ? +d.x : DEFAULTS.x;
var dropPct = d.drop !== undefined ? +d.drop : DEFAULTS.drop;
var size = +d.size || DEFAULTS.size;
var color = d.color || DEFAULTS.color;
var hangMs = d.hang !== undefined ? +d.hang : DEFAULTS.hang;
var inThumb = !!el.closest('[aria-hidden="true"]');
var layer = document.createElement('div');
layer.className = 'spd-layer';
layer.setAttribute('aria-hidden', 'true');
layer.innerHTML = '<div class="spd-hang"><div class="spd-thread"></div>' +
'<svg class="spd-spider" viewBox="-30 -18 60 60" width="' + size + '" height="' + size + '" focusable="false">' + spiderSvg(color) + '</svg></div>';
el.appendChild(layer);
var hang = layer.firstChild, thread = hang.firstChild, spider = hang.lastChild;
if (inThumb) spider.style.pointerEvents = 'none';
var legs = [].slice.call(spider.querySelectorAll('.spd-leg')).map(function (g) {
return { el: g, side: +g.getAttribute('data-side'), i: +g.getAttribute('data-i'), base: g.getAttribute('transform') };
});
var h = 0, rest = 0;
function resize() {
h = el.clientHeight;
// The spider (whose top is 12/60 of its size above the thread end) must
// stay inside the section.
rest = Math.max(size * 0.6, Math.min(h * dropPct / 100, h - size * 0.9));
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
hang.style.left = xPct + '%';
var y = -size, v = 0, state = 'wait', clock = 0, t = 0, swayAmp = 0;
function draw() {
var len = Math.max(0, y);
thread.style.height = len + 'px';
spider.style.transform = 'translate(' + (-size / 2) + 'px,' + (y - size * 0.075) + 'px)';
var s = t / 1000;
hang.style.transform = 'rotate(' + (Math.sin(s * 1.9) * swayAmp).toFixed(2) + 'deg)';
var climbing = state === 'climb';
legs.forEach(function (l) {
var w = climbing ? Math.sin(s * 16 + l.i * 1.6 + (l.side > 0 ? Math.PI : 0)) * 14 : Math.sin(s * 3 + l.i * 0.9 + l.side) * 5;
l.el.setAttribute('transform', l.base + ' rotate(' + (w * l.side).toFixed(1) + ')');
});
}
function step(dt) {
var sec = dt / 1000;
t += dt;
clock += dt;
if (state === 'wait') {
y = -size; v = 0;
if (clock > 500) { state = 'drop'; clock = 0; swayAmp = 0; }
} else if (state === 'drop' || state === 'dangle') {
// Free fall while the thread is slack, a spring once it is taut.
var target = rest - G / K;
var a = y < target ? G : G - K * (y - target) - C * v;
v += a * sec; y += v * sec;
if (state === 'drop' && y > target) { state = 'dangle'; swayAmp = 7; }
swayAmp += (2 - swayAmp) * Math.min(1, sec * 0.8);
if (loop && state === 'dangle' && clock > hangMs + 1200) { state = 'climb'; clock = 0; }
} else if (state === 'climb') {
v = 0;
y -= sec * Math.max(90, rest * 0.55);
swayAmp += (1 - swayAmp) * Math.min(1, sec);
if (y < -size) { state = 'wait'; clock = -900; }
}
draw();
}
// Tap the spider for another bounce.
spider.addEventListener('pointerdown', function () {
if (state === 'dangle' || state === 'drop') { v += 520; swayAmp = 8; if (loop) clock = Math.min(clock, 600); }
});
if (reduced()) {
// Hanging still at the end of its thread.
y = rest; state = 'still';
draw();
if (window.ResizeObserver) new ResizeObserver(function () { y = rest; draw(); }).observe(el);
return;
}
draw();
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(32, now - last) : 16;
last = now;
step(dt);
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}, { threshold: 0.25 }).observe(el);
}
function init() {
document.querySelectorAll('[data-spider-drop]').forEach(function (el) {
if (el._spd) return; // idempotent
el._spd = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-spider-drop>
<h2>Trick or treat</h2>
</section>
<!-- on the left, dropping further, again and again: -->
<section data-spider-drop data-loop data-x="20" data-drop="70">…</section>