Sleigh Flyover
A big full moon over snowy hills. A sleigh pulled by a team of galloping reindeer sweeps up across it in silhouette, leaving a trail of golden sparkles behind.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-sleigh-flyover>where the scene should go. The sleigh sets off when it scrolls into view.
Options
Add any of these attributes to the same element.
data-loop | Fly past again and again. |
data-duration | How long one pass takes. Default 6500 ms. |
data-pause | Gap between passes when looping. Default 1500 ms. |
data-reindeer | How many reindeer pull the sleigh, 1 to 6. Default 4. |
data-trail="false" | No sparkle trail. |
It is 8:5 with a night-sky background by default; set your own height or background on the element to change that. The scene fills the element and the flight path stretches to fit. It pauses when off screen. With reduced motion it shows the sleigh still, in front of the moon.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Sleigh Flyover: a night sky with a big full moon. A sleigh pulled by a
// team of galloping reindeer sweeps up across it in silhouette, leaving a
// trail of golden sparkles. Drawn as SVG that fills the element, and
// paused when off screen.
// Usage: <div data-sleigh-flyover></div>
// It is 8:5 by default with a night-sky background; give it your own
// height or background to change that.
// Optional: data-loop (fly past again and again), data-duration (ms for one
// pass, default 6500), data-pause (ms between passes, default 1500),
// data-reindeer (team size, 1 to 6, default 4), data-trail="false" (no
// sparkles).
var DEFAULTS = { duration: 6500, pause: 1500, reindeer: 4 };
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
var INK = '#0c1226';
var css =
'[data-sleigh-flyover]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5;' +
'background:linear-gradient(#070b1d 0%,#13204a 60%,#25386e 100%)}' +
'.slf-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; }
// One reindeer facing right, centred on its body. Legs pivot at the hip.
function reindeer() {
var leg = function (x, cls) {
return '<g class="' + cls + '" transform="translate(' + x + ' 3)"><path d="M0 0 L0 9 L1.6 12" fill="none" stroke="' + INK + '" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></g>';
};
return '<g class="slf-deer">' +
leg(8, 'slf-fl') + leg(-8, 'slf-bl') +
'<ellipse cx="0" cy="0" rx="12" ry="5.4" fill="' + INK + '"/>' +
'<path d="M7 -3 Q12 -6 14 -12 L18.5 -10 Q16 -4 10 1 Z" fill="' + INK + '"/>' +
'<ellipse cx="18" cy="-12" rx="5" ry="2.8" transform="rotate(12 18 -12)" fill="' + INK + '"/>' +
'<path d="M15 -14 L13 -20 L10 -23 M13 -20 L16 -24 M13 -20 L9.5 -19" fill="none" stroke="' + INK + '" stroke-width="1.3" stroke-linecap="round"/>' +
'<path d="M17 -14 L17 -21 L15 -24 M17 -21 L20 -24" fill="none" stroke="' + INK + '" stroke-width="1.3" stroke-linecap="round"/>' +
'<path d="M-11 -2 L-15 -5 L-12 0 Z" fill="' + INK + '"/>' +
leg(10, 'slf-fl2') + leg(-6, 'slf-bl2') +
'</g>';
}
function sleigh() {
return '<g class="slf-sleigh">' +
// Sack, Santa and the sleigh body with its curled front.
'<ellipse cx="-14" cy="-14" rx="9" ry="10" fill="' + INK + '"/>' +
'<path d="M-2 -6 Q-4 -18 2 -22 Q8 -24 10 -18 Q12 -12 10 -6 Z" fill="' + INK + '"/>' +
'<circle cx="5" cy="-25" r="4.6" fill="' + INK + '"/>' +
'<path d="M1 -27 Q6 -36 12 -33 L9 -27 Z" fill="' + INK + '"/>' +
'<path d="M-26 -10 L16 -10 Q22 -10 24 -16 Q27 -20 30 -17 Q30 -8 22 0 L-22 0 Q-26 -2 -26 -10 Z" fill="' + INK + '"/>' +
// Runners.
'<path d="M-24 0 L-22 6 M18 0 L16 6 M-30 6 L22 6 Q30 6 32 0" fill="none" stroke="' + INK + '" stroke-width="2" stroke-linecap="round"/>' +
'</g>';
}
function build(el) {
var d = el.dataset;
var n = ++uid;
var loop = d.loop !== undefined && d.loop !== 'false';
var dur = +d.duration || DEFAULTS.duration;
var pause = d.pause !== undefined ? +d.pause : DEFAULTS.pause;
var team = Math.max(1, Math.min(6, +d.reindeer || DEFAULTS.reindeer));
var trail = d.trail !== 'false';
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A sleigh and reindeer flying across the moon');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
var stars = '';
for (var i = 0; i < 110; i++) {
stars += '<circle class="slf-star" cx="' + (Math.random() * 800 - 200).toFixed(1) + '" cy="' + (Math.random() * 250 - 60).toFixed(1) +
'" r="' + (Math.random() * 0.9 + 0.35).toFixed(2) + '" fill="#fff" opacity="' + (Math.random() * 0.6 + 0.25).toFixed(2) + '"/>';
}
// The team: reindeer in a line ahead of the sleigh, joined by reins.
var teamSvg = '<path class="slf-reins" d="M28 -12 L' + (40 + team * 30) + ' -6" stroke="' + INK + '" stroke-width="0.9" fill="none"/>' + sleigh();
for (var r = 0; r < team; r++) {
teamSvg += '<g transform="translate(' + (52 + r * 30) + ' ' + (-4 - r * 1.5) + ')"><g class="slf-bob">' + reindeer() + '</g></g>';
}
var g = 'slf' + n;
el.innerHTML =
'<svg class="slf-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<defs>' +
'<radialGradient id="' + g + '-halo"><stop offset=".45" stop-color="#fef3c7" stop-opacity=".35"/><stop offset="1" stop-color="#fef3c7" stop-opacity="0"/></radialGradient>' +
'<radialGradient id="' + g + '-moon" cx=".42" cy=".4" r=".7"><stop offset="0" stop-color="#fffbeb"/><stop offset=".8" stop-color="#fde68a"/><stop offset="1" stop-color="#f6cf6b"/></radialGradient>' +
'</defs>' +
stars +
'<circle cx="200" cy="112" r="120" fill="url(#' + g + '-halo)"/>' +
'<circle cx="200" cy="112" r="68" fill="url(#' + g + '-moon)"/>' +
'<g fill="#e9c46a" opacity=".45"><circle cx="176" cy="92" r="11"/><circle cx="222" cy="136" r="8"/><circle cx="216" cy="88" r="5"/><circle cx="180" cy="138" r="5"/></g>' +
// Snowy hills and pines along the bottom, wide enough for any width.
'<path d="M-300 214 Q-120 178 60 206 Q200 228 330 196 Q520 160 700 204 L700 400 L-300 400Z" fill="#1b2a52"/>' +
'<g fill="#0f1a38">' +
'<path d="M70 214 l9 -26 l9 26z M86 216 l7 -20 l7 20z M300 204 l10 -30 l10 30z M318 206 l7 -20 l7 20z"/>' +
'</g>' +
'<path d="M-300 228 Q0 210 200 232 Q420 252 700 222 L700 400 L-300 400Z" fill="#e6edf8"/>' +
'<g class="slf-sparks"></g>' +
'<g class="slf-team">' + teamSvg + '</g>' +
'</svg>';
var svg = el.firstChild;
var teamG = svg.querySelector('.slf-team');
var sparksG = svg.querySelector('.slf-sparks');
var deer = [].slice.call(svg.querySelectorAll('.slf-bob'));
var legs = ['.slf-fl', '.slf-fl2', '.slf-bl', '.slf-bl2'].map(function (sel) {
return [].slice.call(svg.querySelectorAll(sel));
});
// Flight path: a gentle rising curve across the moon (quadratic Bezier),
// starting and ending just outside whatever part of the scene is visible.
var P0, P1, P2;
function fit() {
var aspect = (el.clientWidth || 400) / (el.clientHeight || 250);
var half = Math.max(200, 125 * aspect);
var sx = 200 - half - 175, ex = 200 + half + 45;
P0 = [sx, 222]; P2 = [ex, 8]; P1 = [(sx + ex) / 2 - 40, 140];
}
fit();
if (window.ResizeObserver) new ResizeObserver(fit).observe(el);
function at(p) {
var q = 1 - p;
return [
q * q * P0[0] + 2 * q * p * P1[0] + p * p * P2[0],
q * q * P0[1] + 2 * q * p * P1[1] + p * p * P2[1],
Math.atan2(2 * q * (P1[1] - P0[1]) + 2 * p * (P2[1] - P1[1]), 2 * q * (P1[0] - P0[0]) + 2 * p * (P2[0] - P1[0]))
];
}
var sparks = [];
for (var k = 0; trail && k < 46; k++) {
var c = document.createElementNS(NS, 'circle');
c.setAttribute('fill', k % 3 ? '#fde68a' : '#fff');
c.setAttribute('opacity', '0');
sparksG.appendChild(c);
sparks.push({ el: c, life: 1, max: 1 });
}
var nextSpark = 0, sparkClock = 0;
var t = 0;
function frame(dt) {
t += dt;
var cycle = dur + pause;
var local = loop ? t % cycle : Math.min(t, dur);
var p = Math.min(1, local / dur);
var flying = local < dur;
var pos = at(p);
var ang = pos[2] * 180 / Math.PI;
teamG.setAttribute('transform', 'translate(' + pos[0].toFixed(1) + ' ' + pos[1].toFixed(1) + ') rotate(' + ang.toFixed(2) + ') scale(.95)');
teamG.style.visibility = flying ? '' : 'hidden';
// Gallop: front legs reach forward as back legs kick back, each deer a
// little out of step with the one before.
var s = t / 1000;
deer.forEach(function (dEl, i) {
dEl.setAttribute('transform', 'translate(0 ' + (Math.sin(s * 9 + i * 1.3) * 1.6).toFixed(2) + ')');
});
for (var j = 0; j < legs[0].length; j++) {
var ph = s * 9 + j * 1.3;
var a = Math.sin(ph), b = Math.sin(ph + 0.6);
legs[0][j].setAttribute('transform', 'translate(8 3) rotate(' + (-50 + a * 30).toFixed(1) + ')');
legs[1][j].setAttribute('transform', 'translate(10 3) rotate(' + (-45 + b * 32).toFixed(1) + ')');
legs[2][j].setAttribute('transform', 'translate(-8 3) rotate(' + (45 - a * 28).toFixed(1) + ')');
legs[3][j].setAttribute('transform', 'translate(-6 3) rotate(' + (40 - b * 30).toFixed(1) + ')');
}
// Sparkles fall away from behind the sleigh and fade.
if (sparks.length) {
sparkClock += dt;
while (flying && sparkClock > 55) {
sparkClock -= 55;
var sp = sparks[nextSpark++ % sparks.length];
var back = at(Math.max(0, p - 0.012));
sp.x = back[0] - Math.cos(pos[2]) * 26 + (Math.random() - 0.5) * 6;
sp.y = back[1] - Math.sin(pos[2]) * 26 + 4 + (Math.random() - 0.5) * 6;
sp.vy = 0.004 + Math.random() * 0.01;
sp.vx = (Math.random() - 0.5) * 0.01;
sp.r = 0.6 + Math.random() * 1.3;
sp.life = 0; sp.max = 900 + Math.random() * 900;
}
if (!flying) sparkClock = 0;
for (var m = 0; m < sparks.length; m++) {
var q = sparks[m];
if (q.life >= q.max) { q.el.setAttribute('opacity', '0'); continue; }
q.life += dt;
q.x += q.vx * dt; q.y += q.vy * dt;
var f = q.life / q.max;
q.el.setAttribute('cx', q.x.toFixed(1));
q.el.setAttribute('cy', q.y.toFixed(1));
q.el.setAttribute('r', (q.r * (1 - f * 0.7)).toFixed(2));
q.el.setAttribute('opacity', ((1 - f) * (0.6 + 0.4 * Math.sin(q.life / 60))).toFixed(2));
}
}
}
if (reduced()) {
// A still frame: the team silhouetted against the moon.
t = dur * 0.47;
frame(0);
return;
}
// Start with the team just off screen.
frame(0);
var started = false, 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;
last = now;
frame(dt);
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { started = true; last = 0; timer.play(); } else if (started) timer.pause();
}, { threshold: 0.2 }).observe(el);
// Stars twinkle.
var tw = anime.animate(svg.querySelectorAll('.slf-star'), {
opacity: function () { return anime.utils.random(0.1, 0.9, 2); },
duration: function () { return anime.utils.random(1200, 3000); },
delay: function () { return anime.utils.random(0, 2000); },
ease: 'inOutSine',
loop: true,
alternate: true,
autoplay: false
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) tw.play(); else tw.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-sleigh-flyover]').forEach(function (el) {
if (el._slf) return; // idempotent
el._slf = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-sleigh-flyover data-loop></div>
<!-- a longer team, a slower pass, a fixed height: -->
<div data-sleigh-flyover data-loop data-reindeer="6" data-duration="9000" style="height: 320px"></div>