← Numbers

Sand Timer

An hourglass whose sand pours from the top bulb into a growing heap below over the time you set. When the top runs dry the glass turns over, the sand slumps down to the neck, and it starts pouring again.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-sand-timer="30" (the time in seconds) to an empty element and give it a width.

Options

Add any of these attributes to the same element.

data-onceStop when the sand runs out instead of turning over.
data-show-timeShow the time left under the glass.
data-sandSand colour. Default #f2b347.
data-frameColour of the wooden caps and posts. Default #7c4a2d.

Each time the sand runs out it fires a sand-timer:done event on the element.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sand Timer: an hourglass whose sand pours from the top bulb into a
    // growing heap in the bottom one over the set time. When the top runs dry
    // the glass turns over, the sand slumps down to the neck and it pours again.
    // Usage: <div data-sand-timer="30"></div>   (seconds; size it with width)
    // Optional: data-once (stop when it runs out instead of turning over),
    // data-show-time (show the time left under the glass), data-sand (sand
    // colour, default #f2b347), data-frame (wood colour, default #7c4a2d).
    // It fires "sand-timer:done" on the element each time the sand runs out.
    var DEFAULTS = { seconds: 10, sand: '#f2b347', frame: '#7c4a2d', pause: 500, flip: 1000 };
    var NS = 'http://www.w3.org/2000/svg';
    var GLASS = 'M21 15C21 52 46 66 47.2 80C46 94 21 108 21 145L79 145C79 108 54 94 52.8 80C54 66 79 52 79 15Z';
    var uid = 0;

    var css =
      ':where(.sdt){width:7em}' +
      '.sdt{display:inline-flex;flex-direction:column;align-items:center;gap:0.5em;font-variant-numeric:tabular-nums}' +
      '.sdt-glass{width:100%;aspect-ratio:100/160;transform-origin:50% 50%}' +
      '.sdt-glass>svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.sdt-time{font-weight:700;font-size:1.1em;letter-spacing:-0.01em}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function svg(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      parent.appendChild(n);
      return n;
    }

    function f(n) { return n.toFixed(2); }

    function setup(el) {
      var d = el.dataset;
      var id = 'sdt-clip-' + (++uid);
      var sand = d.sand || DEFAULTS.sand;
      var wood = d.frame || DEFAULTS.frame;
      el.classList.add('sdt');
      el.textContent = '';
      el.setAttribute('role', 'timer');
      var t = {
        el: el, total: parseFloat(el.getAttribute('data-sand-timer')) || DEFAULTS.seconds,
        once: d.once !== undefined, still: reduced(),
        p: 0, settle: 1, elapsed: 0, last: 0, state: 'run', visible: false, raf: 0, dash: 0
      };
      t.glass = document.createElement('div');
      t.glass.className = 'sdt-glass';
      t.glass.setAttribute('aria-hidden', 'true');
      el.appendChild(t.glass);
      var s = svg('svg', { viewBox: '0 0 100 160' }, t.glass);
      var defs = svg('defs', {}, s);
      svg('path', { d: GLASS }, svg('clipPath', { id: id }, defs));
      // Posts behind the glass.
      svg('rect', { x: 12, y: 10, width: 4, height: 140, rx: 2, fill: wood, opacity: 0.85 }, s);
      svg('rect', { x: 84, y: 10, width: 4, height: 140, rx: 2, fill: wood, opacity: 0.85 }, s);
      svg('path', { d: GLASS, fill: 'rgba(214,232,255,.35)' }, s);
      var g = svg('g', { 'clip-path': 'url(#' + id + ')' }, s);
      t.top = svg('path', { fill: sand }, g);
      t.heap = svg('path', { fill: sand }, g);
      t.stream = svg('line', { x1: 50, x2: 50, y1: 79, stroke: sand, 'stroke-width': 1.7, 'stroke-linecap': 'round', 'stroke-dasharray': '3 2.2' }, g);
      // Glass edge and highlights.
      svg('path', { d: GLASS, fill: 'none', stroke: 'rgba(90,110,140,.45)', 'stroke-width': 1.4 }, s);
      svg('path', { d: 'M27 22C27.5 44 38 58 43 68', fill: 'none', stroke: '#fff', 'stroke-opacity': 0.7, 'stroke-width': 2.2, 'stroke-linecap': 'round' }, s);
      svg('path', { d: 'M27 138C27.5 118 36 104 41 96', fill: 'none', stroke: '#fff', 'stroke-opacity': 0.5, 'stroke-width': 2, 'stroke-linecap': 'round' }, s);
      // Caps.
      svg('rect', { x: 6, y: 3, width: 88, height: 12, rx: 3.5, fill: wood }, s);
      svg('rect', { x: 6, y: 145, width: 88, height: 12, rx: 3.5, fill: wood }, s);
      svg('rect', { x: 9, y: 5, width: 82, height: 3, rx: 1.5, fill: '#fff', opacity: 0.18 }, s);
      svg('rect', { x: 9, y: 147, width: 82, height: 3, rx: 1.5, fill: '#fff', opacity: 0.18 }, s);
      if (d.showTime !== undefined) {
        t.time = document.createElement('div');
        t.time.className = 'sdt-time';
        el.appendChild(t.time);
      }
      draw(t);
      return t;
    }

    function draw(t) {
      var p = t.p;
      // The top surface sinks slowly at first and faster as the bulb narrows.
      var off = -30 * (1 - t.settle);
      var level = 80 - 33 * Math.sqrt(1 - p) + off;
      var bottom = 81 + off;
      var dip = p > 0 && p < 1 ? Math.min(1, p * 6) * 7 * (1 - p) + 1 : 0;
      t.top.setAttribute('d', p >= 1 ? '' :
        'M0 ' + f(level) + 'L36 ' + f(level) + 'Q50 ' + f(level + dip * 2) + ' 64 ' + f(level) + 'L100 ' + f(level) + 'L100 ' + f(bottom) + 'L0 ' + f(bottom) + 'Z');
      // The heap below grows as a mound.
      var h = 38 * Math.pow(p, 0.85);
      t.heap.setAttribute('d', h < 0.2 ? '' :
        'M0 146L0 ' + f(146 - h * 0.55) + 'Q50 ' + f(146 - h * 1.45) + ' 100 ' + f(146 - h * 0.55) + 'L100 146Z');
      var pouring = t.state === 'run' && p > 0 && p < 1 && t.settle >= 1;
      t.stream.setAttribute('y2', f(146 - h));
      t.stream.setAttribute('stroke-dashoffset', f(-t.dash));
      t.stream.setAttribute('opacity', pouring ? 1 : 0);
      t.stream.setAttribute('stroke-width', p > 0.96 ? 1.7 * (1 - p) / 0.04 + 0.3 : 1.7);
      var left = Math.max(0, Math.ceil(t.total * (1 - p) - 1e-6));
      var label = Math.floor(left / 60) + ':' + (left % 60 < 10 ? '0' : '') + (left % 60);
      if (label !== t.said) {
        t.said = label;
        if (t.time) t.time.textContent = label;
        t.el.setAttribute('aria-label', label + ' left');
      }
    }

    function frame(t, ts) {
      if (!t.visible) { t.raf = 0; t.last = 0; return; }
      var dt = t.last ? (ts - t.last) / 1000 : 0;
      t.last = ts;
      if (t.state === 'run') {
        t.elapsed += dt;
        t.dash += dt * 40;
        t.p = Math.min(1, t.elapsed / t.total);
        draw(t);
        if (t.p >= 1) done(t);
      }
      t.raf = requestAnimationFrame(function (n) { frame(t, n); });
    }

    function done(t) {
      t.state = 'wait';
      t.el.dispatchEvent(new CustomEvent('sand-timer:done'));
      if (t.once) return;
      setTimeout(function () { turn(t); }, DEFAULTS.pause);
    }

    // Turn the glass over, then swap the drawing back upright with the sand up
    // at the cap, and let it slump down to the neck before it pours.
    function turn(t) {
      var restart = function () {
        t.p = 0;
        t.elapsed = 0;
        if (t.still) { t.settle = 1; t.state = 'run'; draw(t); return; }
        anime.utils.set(t.glass, { rotate: 0 });
        t.settle = 0;
        draw(t);
        anime.animate(t, {
          settle: 1, duration: 420, ease: 'in(2)', onUpdate: function () { draw(t); },
          onComplete: function () { t.state = 'run'; }
        });
      };
      if (t.still) { restart(); return; }
      anime.animate(t.glass, { rotate: [0, 180], duration: DEFAULTS.flip, ease: 'inOut(3)', onComplete: restart });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var t = e.target._sdt;
          t.visible = e.isIntersecting;
          if (t.visible && !t.raf) t.raf = requestAnimationFrame(function (n) { frame(t, n); });
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-sand-timer]').forEach(function (el) {
        if (el._sdt) return;
        el._sdt = setup(el);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-sand-timer="30" style="width: 100px"></div>

<!-- a one-minute timer that stops at the end: -->
<div data-sand-timer="60" data-once data-show-time></div>