Bobbing Boat
A little sailing boat rides the swell, rising, falling and rocking as the waves roll under it. Its flag flutters at the masthead, the bands of sea slide past at different speeds and a couple of gulls wheel overhead.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-bobbing-boat>where the scene should go. It fills the element with an SVG scene.
Options
Add any of these attributes to the same element.
data-color | Colour of the hull. Default #7c3aed. |
data-sail | Colour of the sails. Default #ffffff. |
data-swell | Wave height multiplier: 0.5 for a calm day, 2 for a rough one. Default 1. |
data-speed | Speed multiplier. Default 1. |
It is 8:5 with a sky background by default; set your own height or background on the element to change that. The sea stretches to fill any width. It pauses when off screen. With reduced motion it’s a still picture.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Bobbing Boat: a little sailing boat rides the swell, rising, falling and
// rocking with the waves passing under it, its flag fluttering, while
// gulls wheel overhead. Drawn as SVG that fills the element, and paused
// when off screen.
// Usage: <div data-bobbing-boat></div>
// It is 8:5 by default with a sky background; give it your own height or
// background to change that. The sea stretches to fill any width.
// Optional: data-color (colour of the hull, default #7c3aed),
// data-sail (colour of the sails, default #ffffff), data-swell (wave
// height multiplier, default 1), data-speed (default 1).
var DEFAULTS = { color: '#7c3aed', sail: '#ffffff', swell: 1, speed: 1 };
var css =
'[data-bobbing-boat]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5;' +
'background:linear-gradient(#8ec5f7,#d6eafc 60%,#fdf2d0)}' +
'.bbt-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// Three bands of waves, back to front: base height, amplitude, wavelength,
// speed (units per second, negative runs left), fill and crest colour.
var WAVES = [
{ base: 148, a: 3.5, l: 90, v: -14, fill: '#8fcdec', crest: '#d4f0fc' },
{ base: 166, a: 6, l: 150, v: 26, fill: '#55acdb', crest: '#bfe6fa' },
{ base: 176, a: 3.5, l: 120, v: 34, fill: '#2f86c0', crest: '#8cd0f2' }
];
function build(el) {
var d = el.dataset;
var hull = d.color || DEFAULTS.color;
var sail = d.sail || DEFAULTS.sail;
var swell = d.swell !== undefined ? +d.swell : DEFAULTS.swell;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A little boat bobbing on the waves');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
el.innerHTML =
'<svg class="bbt-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<circle cx="300" cy="78" r="36" fill="#fff7cc" opacity=".5"/><circle cx="300" cy="78" r="19" fill="#fde68a"/>' +
'<g fill="none" stroke="#475569" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
'<path class="bbt-gull" d="M-7 0Q-3.5 -5 0 0Q3.5 -5 7 0"/><path class="bbt-gull" d="M-5 0Q-2.5 -4 0 0Q2.5 -4 5 0"/></g>' +
'<path class="bbt-wave" stroke-width="1.6"/>' +
'<path class="bbt-wave" stroke-width="2"/>' +
'<g class="bbt-boat">' +
'<path d="M-3 -14L-3 -84" stroke="#7c5434" stroke-width="3" stroke-linecap="round"/>' +
'<path class="bbt-flag" fill="#ef4444"/>' +
'<path d="M0 -80Q20 -52 40 -20L0 -20Z" fill="' + sail + '"/>' +
'<path d="M0 -80Q20 -52 40 -20L30 -20Q16 -48 0 -72Z" fill="#000" opacity=".06"/>' +
'<path d="M-6 -76Q-24 -46 -42 -20L-6 -20Z" fill="' + sail + '" opacity=".92"/>' +
'<path d="M-6 -76L-6 -20" stroke="#000" stroke-opacity=".08" stroke-width="2"/>' +
'<path d="M-52 -16L52 -16L40 6Q37 11 31 11L-33 11Q-39 11 -42 6Z" fill="' + hull + '"/>' +
'<path d="M-52 -16L52 -16L50 -11L-50 -11Z" fill="#fff" opacity=".9"/>' +
'<path d="M-40 5L40 5Q37 11 31 11L-33 11Q-39 11 -42 6Z" fill="#000" opacity=".18"/>' +
'<circle cx="-22" cy="-3" r="3.4" fill="#e0f2fe" stroke="#fff" stroke-width="1.2"/>' +
'<circle cx="-6" cy="-3" r="3.4" fill="#e0f2fe" stroke="#fff" stroke-width="1.2"/>' +
'<circle cx="10" cy="-3" r="3.4" fill="#e0f2fe" stroke="#fff" stroke-width="1.2"/>' +
'</g>' +
'<path class="bbt-wave" stroke-width="2.2"/>' +
'</svg>';
var svg = el.firstChild;
var paths = svg.querySelectorAll('.bbt-wave');
WAVES.forEach(function (w, i) {
paths[i].setAttribute('fill', w.fill);
paths[i].setAttribute('stroke', w.crest);
w.el = paths[i];
});
var boat = svg.querySelector('.bbt-boat');
var flag = svg.querySelector('.bbt-flag');
var gulls = svg.querySelectorAll('.bbt-gull');
function waveY(w, x, s) {
var k = 2 * Math.PI / w.l;
return w.base + swell * (w.a * Math.sin(k * (x - w.v * s)) + w.a * 0.45 * Math.sin(k * 1.7 * (x + w.v * 0.6 * s) + 1.3));
}
function frame(t) {
var s = t / 1000;
WAVES.forEach(function (w) {
var p = 'M-900 600';
for (var x = -900; x <= 1300; x += 8) p += 'L' + x + ' ' + waveY(w, x, s).toFixed(1);
w.el.setAttribute('d', p + 'L1300 600Z');
});
// The boat floats on the middle band: height from the wave under it,
// tilt from the slope between bow and stern, both eased a little.
var mid = WAVES[1];
var yb = (waveY(mid, 170, s) + waveY(mid, 230, s)) / 2;
var tilt = Math.atan2(waveY(mid, 230, s) - waveY(mid, 170, s), 60) * 180 / Math.PI;
boat.setAttribute('transform', 'translate(200 ' + (yb + 1).toFixed(2) + ') rotate(' + (tilt * 0.85 + 1.2 * Math.sin(s * 1.3)).toFixed(2) + ')');
// The flag ripples at the masthead.
var f = '';
for (var i = 0; i <= 6; i++) {
var fx = -3 - i * 3, fy = Math.sin(s * 8 + i * 0.9) * i * 0.35;
f += (i ? 'L' : 'M') + fx.toFixed(1) + ' ' + (-84 + fy).toFixed(2);
}
for (var j = 6; j >= 0; j--) {
var bx = -3 - j * 3, by = Math.sin(s * 8 + j * 0.9) * j * 0.35;
f += 'L' + bx.toFixed(1) + ' ' + (-77 + by).toFixed(2);
}
flag.setAttribute('d', f + 'Z');
// Gulls glide in slow loops, flapping now and then.
for (var g = 0; g < gulls.length; g++) {
var gx = 110 + g * 50 + 34 * Math.sin(s * 0.25 + g * 2.1);
var gy = 52 + g * 16 + 8 * Math.sin(s * 0.5 + g);
var flap = Math.sin(s * 0.9 + g * 1.7) > 0.2 ? 0.45 + 0.55 * Math.abs(Math.sin(s * 9 + g)) : 1;
gulls[g].setAttribute('transform', 'translate(' + gx.toFixed(1) + ' ' + gy.toFixed(1) + ') scale(1 ' + flap.toFixed(2) + ')');
}
}
if (reduced()) { frame(1500); return; }
var t = 0, last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += (last ? Math.min(50, now - last) : 16) * speed;
last = now;
frame(t);
}
});
frame(0);
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-bobbing-boat]').forEach(function (el) {
if (el._bbt) return; // idempotent
el._bbt = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-bobbing-boat></div>
<!-- a rougher sea: -->
<div data-bobbing-boat data-swell="1.8" data-color="#0f766e"></div>