← Seasonal

New Year Ball Drop

A glittering ball sits at the top of a pole above the city. The countdown ticks down and the ball slides down with it, its facets twinkling. On zero it lands and flashes, “Happy New Year” lights up with the year, and fireworks burst in the sky.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-new-year-ball-drop> where the scene should go. The countdown starts when it scrolls into view.

Options

Add any of these attributes to the same element.

data-loopRaise the ball again and count down again.
data-fromWhere the countdown starts. Default 10.
data-tickHow long each number lasts. Default 1000 ms.
data-yearThe year to show. Default the coming new year.
data-messageThe greeting. Default “Happy New Year”.
data-fireworks="false"No fireworks.

It is 8:5 with a transparent background by default; give it a night sky. The city skyline stretches to its full width. It pauses when off screen. With reduced motion the ball is shown landed, with the greeting lit.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // New Year Ball Drop: a glittering ball sits at the top of a pole on a
    // tower above the city. A countdown ticks down and the ball slides down
    // the pole with it, its facets twinkling. On zero it lands, flashes, and
    // "Happy New Year" lights up with the year while fireworks burst in the
    // sky. Loop it and the ball rises again for another countdown. Drawn as
    // SVG that fills the element.
    // Usage: <div data-new-year-ball-drop></div>
    // It is 8:5 by default with a transparent background; it looks best on a
    // night sky.
    // Optional: data-loop (count down again), data-from (where the countdown
    // starts, default 10), data-tick (ms per number, default 1000),
    // data-year (the year to show, default the coming new year),
    // data-message (default "Happy New Year"), data-fireworks="false" (no
    // fireworks).
    var DEFAULTS = { from: 10, tick: 1000, message: 'Happy New Year' };
    var COLORS = ['#facc15', '#f472b6', '#38bdf8', '#a3e635', '#fb923c', '#c084fc', '#ffffff'];
    var uid = 0;
    var POLE_X = 118, TOP = 54, BOTTOM = 132, ROOF = 150, R = 17;

    var css =
      '[data-new-year-ball-drop]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.nyb-svg{position:absolute;inset:0;width:100%;height:100%;display:block}' +
      '.nyb-txt{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-weight:800}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var NS = 'http://www.w3.org/2000/svg';
    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function f(n) { return (+n).toFixed(2); }
    function seeded(seed) { return function () { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }; }
    function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }

    function build(el) {
      var d = el.dataset;
      var g = 'nyb' + (++uid);
      var loop = d.loop !== undefined && d.loop !== 'false';
      var from = Math.max(1, Math.min(60, d.from !== undefined ? Math.round(+d.from) : DEFAULTS.from));
      var tick = Math.max(200, d.tick !== undefined ? +d.tick : DEFAULTS.tick);
      var now = new Date();
      var year = d.year || String(now.getMonth() >= 6 ? now.getFullYear() + 1 : now.getFullYear());
      var message = d.message || DEFAULTS.message;
      var fireworks = d.fireworks !== 'false';
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A glittering ball drops down its pole as the countdown reaches midnight: ' + message + ' ' + year);
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      // The ball's facets: a grid of little tiles clipped to a circle.
      var tiles = '';
      for (var ty = -R; ty < R; ty += 5) {
        for (var tx = -R; tx < R; tx += 5) {
          if (Math.hypot(tx + 2.5, ty + 2.5) > R + 2) continue;
          tiles += '<rect class="nyb-tile" x="' + tx + '" y="' + ty + '" width="4.6" height="4.6" rx=".8"/>';
        }
      }
      var sparks = '';
      for (var i = 0; i < 6; i++) sparks += '<path class="nyb-spk" d="M0 -6 C.8 -1.5 1.5 -.8 6 0 C1.5 .8 .8 1.5 0 6 C-.8 1.5 -1.5 .8 -6 0 C-1.5 -.8 -.8 -1.5 0 -6 Z" fill="#fff"/>';

      el.innerHTML =
        '<svg class="nyb-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs>' +
        '<radialGradient id="' + g + '-halo"><stop offset="0" stop-color="#fff7d6" stop-opacity=".9"/><stop offset=".3" stop-color="#93c5fd" stop-opacity=".35"/><stop offset="1" stop-color="#93c5fd" stop-opacity="0"/></radialGradient>' +
        '<radialGradient id="' + g + '-ball" cx=".38" cy=".32" r=".8"><stop offset="0" stop-color="#ffffff"/><stop offset=".35" stop-color="#bfdbfe"/><stop offset=".8" stop-color="#3b82f6"/><stop offset="1" stop-color="#1e3a8a"/></radialGradient>' +
        '<linearGradient id="' + g + '-pole" x1="0" x2="1"><stop offset="0" stop-color="#64748b"/><stop offset=".45" stop-color="#e2e8f0"/><stop offset="1" stop-color="#475569"/></linearGradient>' +
        '<linearGradient id="' + g + '-bld" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1e1b4b"/><stop offset="1" stop-color="#0b0a1f"/></linearGradient>' +
        '<clipPath id="' + g + '-clip"><circle r="' + R + '"/></clipPath>' +
        '</defs>' +
        '<g class="nyb-stars" fill="#fff"></g>' +
        '<g class="nyb-fx"></g>' +
        '<g class="nyb-city"></g>' +
        // The tower and its pole.
        '<path d="M' + (POLE_X - 26) + ' 400 L' + (POLE_X - 26) + ' ' + (ROOF + 14) + ' L' + (POLE_X - 18) + ' ' + ROOF + ' L' + (POLE_X + 18) + ' ' + ROOF + ' L' + (POLE_X + 26) + ' ' + (ROOF + 14) + ' L' + (POLE_X + 26) + ' 400 Z" fill="url(#' + g + '-bld)" stroke="#312e81" stroke-width="1"/>' +
        '<rect class="nyb-sign" x="' + (POLE_X - 20) + '" y="' + (ROOF + 20) + '" width="40" height="14" rx="2" fill="#0f172a" stroke="#475569" stroke-width=".8"/>' +
        '<text class="nyb-txt nyb-signtxt" x="' + POLE_X + '" y="' + (ROOF + 30.5) + '" text-anchor="middle" font-size="10.5" fill="#facc15" opacity=".25">' + esc(year) + '</text>' +
        '<rect x="' + (POLE_X - 2) + '" y="' + (TOP - 14) + '" width="4" height="' + (ROOF - TOP + 14) + '" fill="url(#' + g + '-pole)"/>' +
        '<circle cx="' + POLE_X + '" cy="' + (TOP - 16) + '" r="3" fill="#facc15"/>' +
        '<g class="nyb-ballg">' +
        '<circle class="nyb-halo" r="46" fill="url(#' + g + '-halo)"/>' +
        '<circle r="' + R + '" fill="url(#' + g + '-ball)"/>' +
        '<g clip-path="url(#' + g + '-clip)">' + tiles + '</g>' +
        '<ellipse cx="-5" cy="-6" rx="5" ry="3.2" fill="#fff" opacity=".7" transform="rotate(-30 -5 -6)"/>' +
        '<g class="nyb-spks">' + sparks + '</g>' +
        '</g>' +
        // The countdown, then the greeting.
        '<text class="nyb-txt nyb-num" x="0" y="0" text-anchor="middle" font-size="96" fill="#fff"></text>' +
        '<g class="nyb-cel" opacity="0">' +
        '<text class="nyb-txt" x="0" y="-22" text-anchor="middle" font-size="25" fill="#fde68a">' + esc(message) + '</text>' +
        '<text class="nyb-txt" x="0" y="44" text-anchor="middle" font-size="62" fill="#fff">' + esc(year) + '</text>' +
        '</g>' +
        '</svg>';

      var svg = el.firstChild;
      var ballG = svg.querySelector('.nyb-ballg');
      var halo = svg.querySelector('.nyb-halo');
      var tilesEl = svg.querySelectorAll('.nyb-tile');
      var spks = svg.querySelectorAll('.nyb-spk');
      var numEl = svg.querySelector('.nyb-num');
      var celEl = svg.querySelector('.nyb-cel');
      var signTxt = svg.querySelector('.nyb-signtxt');
      var fx = svg.querySelector('.nyb-fx');
      var cityG = svg.querySelector('.nyb-city');
      var starsG = svg.querySelector('.nyb-stars');
      var TX = 278;   // centre of the text
      var T = [];
      tilesEl.forEach(function (t, i) { T.push({ el: t, ph: Math.random() * 6.3, rate: 2 + Math.random() * 4, c: i % COLORS.length }); });
      var SP = [];
      spks.forEach(function () { SP.push({ a: Math.random() * 6.3, r: 18 + Math.random() * 12, ph: Math.random() * 6.3, rate: 2 + Math.random() * 2 }); });

      var S = { prog: 0, flash: 0, party: 0 };
      var NUM = { s: 1, o: 0 };
      var CEL = { s: 0.8, o: 0 };
      var VW = 400, stars = [];

      function layout() {
        var w = el.clientWidth, h = el.clientHeight;
        if (!w || !h) return;
        VW = Math.max(400, 250 * w / h);
        var x0 = 200 - VW / 2;
        svg.setAttribute('viewBox', f(x0) + ' 0 ' + f(VW) + ' 250');
        // A city skyline the full width, with a few lit windows.
        var rand = seeded(97), city = '', win = '';
        for (var x = x0 - 10; x < x0 + VW + 10;) {
          var bw = 16 + rand() * 30, bh = 26 + rand() * 50;
          if (Math.abs(x + bw / 2 - POLE_X) < 34) bh = Math.min(bh, 30);
          city += '<rect x="' + f(x) + '" y="' + f(250 - bh) + '" width="' + f(bw + 1) + '" height="' + f(bh + 160) + '"/>';
          for (var wy = 250 - bh + 5; wy < 245; wy += 7) for (var wx = x + 4; wx < x + bw - 4; wx += 6) if (rand() < 0.22) win += '<rect x="' + f(wx) + '" y="' + f(wy) + '" width="2.4" height="3"/>';
          x += bw + 1;
        }
        cityG.innerHTML = '<g fill="#14123a">' + city + '</g><g fill="#fde68a" opacity=".55">' + win + '</g>';
        var n = Math.round(VW / 12), html = '';
        stars = [];
        for (var i = 0; i < n; i++) {
          stars.push({ ph: rand() * 6.3, rate: 0.6 + rand() * 1.6 });
          html += '<circle cx="' + f(x0 + rand() * VW) + '" cy="' + f(rand() * 160) + '" r="' + f(0.4 + rand() * 0.9) + '"/>';
        }
        starsG.innerHTML = html;
        var cs = starsG.childNodes;
        stars.forEach(function (s, i) { s.el = cs[i]; });
      }
      layout();
      if (window.ResizeObserver) new ResizeObserver(layout).observe(el);

      var parts = [];
      function burst(x, y) {
        var c1 = COLORS[Math.floor(Math.random() * COLORS.length)], c2 = COLORS[Math.floor(Math.random() * COLORS.length)];
        var n = 36;
        for (var i = 0; i < n; i++) {
          var a = (i / n) * Math.PI * 2 + Math.random() * 0.1, v = 55 + Math.random() * 35;
          var c = document.createElementNS(NS, 'circle');
          c.setAttribute('r', (1.2 + Math.random() * 0.7).toFixed(2));
          c.setAttribute('fill', i % 3 ? c1 : c2);
          fx.appendChild(c);
          parts.push({ el: c, x: x, y: y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0, max: 1.1 + Math.random() * 0.5 });
        }
        var flashC = document.createElementNS(NS, 'circle');
        flashC.setAttribute('cx', f(x)); flashC.setAttribute('cy', f(y)); flashC.setAttribute('fill', c1);
        fx.appendChild(flashC);
        parts.push({ el: flashC, flash: true, life: 0, max: 0.35 });
      }
      function randomBurst() {
        var x0 = 200 - VW / 2;
        var x = x0 + VW * 0.08 + Math.random() * VW * 0.84;
        if (Math.abs(x - POLE_X) < 30) x += 60;
        burst(x, 30 + Math.random() * 80);
      }

      var t = 0;
      function render(dt) {
        t += dt || 0;
        var s = t / 1000, k = (dt || 0) / 1000;
        var by = TOP + (BOTTOM - TOP) * S.prog;
        ballG.setAttribute('transform', 'translate(' + POLE_X + ' ' + f(by) + ')');
        var lit = 0.35 + 0.65 * S.party;
        T.forEach(function (tile) {
          var tw = 0.5 + 0.5 * Math.sin(s * tile.rate * (1 + S.party) + tile.ph);
          var ci = S.party > 0.5 ? (tile.c + Math.floor(s * 4)) % COLORS.length : tile.c;
          tile.el.setAttribute('fill', COLORS[ci]);
          tile.el.setAttribute('opacity', f((0.15 + 0.6 * tw) * lit + 0.1));
        });
        halo.setAttribute('opacity', f(0.45 + 0.15 * Math.sin(s * 3) + S.flash * 0.55));
        halo.setAttribute('r', f(42 + S.flash * 40));
        spks.forEach(function (sp, i) {
          var p = SP[i], v = Math.pow(Math.max(0, Math.sin(s * p.rate + p.ph)), 4);
          sp.setAttribute('transform', 'translate(' + f(Math.cos(p.a + s * 0.4) * p.r) + ' ' + f(Math.sin(p.a + s * 0.4) * p.r) + ') scale(' + f(v * (0.6 + S.party * 0.6)) + ')');
        });
        numEl.setAttribute('transform', 'translate(' + TX + ' 158) scale(' + f(NUM.s) + ')');
        numEl.setAttribute('opacity', f(NUM.o));
        celEl.setAttribute('transform', 'translate(' + TX + ' 120) scale(' + f(CEL.s) + ')');
        celEl.setAttribute('opacity', f(CEL.o));
        signTxt.setAttribute('opacity', f(0.25 + 0.75 * S.party));
        stars.forEach(function (st) { st.el.setAttribute('opacity', f(0.35 + 0.5 * (0.5 + 0.5 * Math.sin(s * st.rate + st.ph)))); });
        for (var i = parts.length - 1; i >= 0; i--) {
          var p = parts[i];
          p.life += k;
          if (p.life >= p.max) { p.el.remove(); parts.splice(i, 1); continue; }
          var u = p.life / p.max;
          if (p.flash) { p.el.setAttribute('r', f(4 + u * 14)); p.el.setAttribute('opacity', f(0.7 * (1 - u))); continue; }
          p.vx *= 1 - 1.6 * k; p.vy *= 1 - 1.6 * k; p.vy += 30 * k;
          p.x += p.vx * k; p.y += p.vy * k;
          p.el.setAttribute('cx', f(p.x)); p.el.setAttribute('cy', f(p.y));
          p.el.setAttribute('opacity', f(u < 0.7 ? 1 : (1 - u) / 0.3));
        }
      }

      if (reduced()) {
        S.prog = 1; S.party = 1; CEL.s = 1; CEL.o = 1;
        render(0);
        return;
      }

      var tl = null, visible = false, pending = false, fwTimer = 0;
      function setNum(n) {
        numEl.textContent = n;
        anime.animate(NUM, { s: [1.45, 1], o: [0.2, 1], duration: Math.min(500, tick * 0.6), ease: 'outBack(2)' });
      }
      function play() {
        if (tl) tl.pause();
        S.prog = 0; S.flash = 0; S.party = 0; NUM.o = 0; CEL.o = 0; CEL.s = 0.8;
        tl = anime.createTimeline({
          onComplete: function () {
            tl = null;
            if (!loop) return;
            if (visible) play(); else pending = true;
          }
        });
        var start = 500, i;
        for (i = 0; i < from; i++) (function (n) { tl.call(function () { setNum(n); }, start + (from - n) * tick); })(from - i);
        var end = start + from * tick;
        tl.add(S, { prog: [0, 1], duration: from * tick, ease: 'linear' }, start)
          .add(NUM, { o: 0, s: 1.6, duration: 260, ease: 'in(2)' }, end - 60)
          .add(S, { flash: [{ to: 1, duration: 120, ease: 'out(2)' }, { to: 0.3, duration: 1400, ease: 'out(2)' }] }, end)
          .add(S, { party: [0, 1], duration: 500, ease: 'out(2)' }, end)
          .add(CEL, { o: [0, 1], s: [0.6, 1], duration: 900, ease: 'outElastic(1, .6)' }, end + 100)
          .call(function () { if (fireworks) { randomBurst(); setTimeout(randomBurst, 220); } }, end + 50);
        if (!loop) tl.add(S, { party: 0.75, duration: 800, ease: 'inOut(2)' }, end + 7000);
        if (loop) {
          var hold = end + 5200;
          tl.add(CEL, { o: 0, s: 0.9, duration: 500, ease: 'in(2)' }, hold)
            .add(S, { party: 0, flash: 0, duration: 600, ease: 'inOut(2)' }, hold)
            .add(S, { prog: 0, duration: 1100, ease: 'inOut(3)' }, hold + 400)
            .add({ duration: 300 });
        }
      }

      render(0);
      var last = 0;
      var timer = anime.createTimer({
        loop: true, duration: 1000, autoplay: false,
        onUpdate: function () {
          var nowT = performance.now();
          var dt = last ? Math.min(50, nowT - last) : 16;
          last = nowT;
          if (fireworks && S.party > 0.8) {
            fwTimer -= dt;
            if (fwTimer <= 0) { randomBurst(); fwTimer = 450 + Math.random() * 450; }
          }
          render(dt);
        }
      });
      var started = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) { last = 0; timer.play(); if (tl) tl.play(); }
        else { timer.pause(); if (tl) tl.pause(); }
        if (visible && !started) { started = true; play(); }
        else if (visible && pending) { pending = false; play(); }
      }, { threshold: 0.25 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-new-year-ball-drop]').forEach(function (el) {
        if (el._nyb) return;   // idempotent
        el._nyb = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-new-year-ball-drop style="background: #0b1026"></div>

<!-- a quick five-second countdown, again and again: -->
<div data-new-year-ball-drop data-loop data-from="5" data-message="Cheers!"></div>