← Numbers

Timecode Counter

A film-style timecode, hours, minutes, seconds and frames, ticking on a dark slate beside a blinking REC light. The frames race past in amber and each time a second rolls over the new digits drop in. Start it just before the hour to watch everything roll over.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-timecode-counter to an empty element and size it with font-size.

Options

Add any of these attributes to the same element.

data-fpsFrames per second. Default 24; try 25 or 30.
data-startWhere it starts, e.g. 01:00:00:00. Default zero.
data-rateSpeed. Default 1, real time; 2 runs twice as fast.
data-loopJump back to the start every this many seconds.
data-recText beside the red light. Default REC; an empty value hides the bar.
data-colorColour of the frame digits. Default #fbbf24.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Timecode Counter: a film-style timecode, HH:MM:SS:FF, ticking through
    // hours, minutes, seconds and frames on a dark slate with a blinking REC
    // light. The frames race; each time a second or more rolls over, the new
    // digits drop in.
    // Usage: <div data-timecode-counter></div>   (size it with font-size)
    // Optional: data-fps (frames per second, default 24), data-start (where it
    // starts, e.g. "00:59:52:00"), data-rate (speed, default 1 = real time),
    // data-loop (jump back to the start every this many seconds),
    // data-rec (label beside the light, default "REC"; "" hides the bar),
    // data-color (frame digits, default #fbbf24).
    var DEFAULTS = { fps: 24, rate: 1, rec: 'REC', color: '#fbbf24' };
    var GROUPS = ['HR', 'MIN', 'SEC', 'FR'];

    var css =
      '.tmc{--tmc-color:#fbbf24;display:inline-flex;flex-direction:column;gap:0.45em;padding:0.55em 0.75em 0.6em;border-radius:0.28em;' +
      'background:linear-gradient(#17171c,#0b0b0e);color:#f4f4f5;box-shadow:0 0.3em 1em rgba(0,0,0,.25),inset 0 1px 0 rgba(255,255,255,.06);' +
      'font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;line-height:1;font-variant-numeric:tabular-nums}' +
      '.tmc-bar{display:flex;justify-content:space-between;align-items:center;gap:1em;font-size:0.24em;font-weight:700;letter-spacing:0.14em;opacity:.8}' +
      '.tmc-rec{display:inline-flex;align-items:center;gap:0.6em;color:#f87171}' +
      '.tmc-rec::before{content:"";width:0.9em;height:0.9em;border-radius:50%;background:#ef4444;box-shadow:0 0 0.8em #ef4444;animation:tmc-blink 1s steps(1) infinite}' +
      '@keyframes tmc-blink{50%{opacity:.15;box-shadow:none}}' +
      '@media (prefers-reduced-motion:reduce){.tmc-rec::before{animation:none}}' +
      '.tmc-row{display:flex;align-items:flex-start}' +
      '.tmc-group{display:flex;flex-direction:column;align-items:center;gap:0.4em}' +
      '.tmc-digits{display:flex;font-weight:600;letter-spacing:0}' +
      '.tmc-d{display:inline-block;width:0.62em;text-align:center;overflow:hidden;height:1em}' +
      '.tmc-d>span{display:block}' +
      '.tmc-frames .tmc-d{color:var(--tmc-color);text-shadow:0 0 0.35em color-mix(in srgb,var(--tmc-color) 45%,transparent)}' +
      '.tmc-frames .tmc-d{overflow:visible}' +
      '.tmc-sep{width:0.42em;text-align:center;opacity:.4}' +
      '.tmc-label{font-size:0.2em;font-weight:700;letter-spacing:0.16em;opacity:.45}' +
      '.tmc-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function make(tag, cls, parent, text) {
      var n = document.createElement(tag);
      if (cls) n.className = cls;
      if (text !== undefined) n.textContent = text;
      parent.appendChild(n);
      return n;
    }

    function pad(n) { return (n < 10 ? '0' : '') + n; }

    // "01:02:03:04" -> a frame count.
    function parse(s, fps) {
      var p = (s || '').split(/[:;.]/).map(function (x) { return parseInt(x, 10) || 0; });
      while (p.length < 4) p.unshift(0);
      return ((p[0] * 60 + p[1]) * 60 + p[2]) * fps + p[3];
    }

    function setup(el) {
      var d = el.dataset;
      var fps = parseFloat(d.fps) || DEFAULTS.fps;
      el.classList.add('tmc');
      if (d.color) el.style.setProperty('--tmc-color', d.color);
      el.textContent = '';
      var t = {
        el: el, fps: fps, start: parse(d.start, fps), rate: parseFloat(d.rate) || DEFAULTS.rate,
        loop: parseFloat(d.loop) || 0, time: 0, last: 0, visible: false, raf: 0, digits: [], shown: '',
        still: reduced()
      };
      var rec = d.rec !== undefined ? d.rec : DEFAULTS.rec;
      if (rec) {
        var bar = make('div', 'tmc-bar', el);
        bar.setAttribute('aria-hidden', 'true');
        make('span', 'tmc-rec', bar, rec);
        make('span', '', bar, (fps % 1 ? fps.toFixed(2) : fps) + ' FPS');
      }
      var row = make('div', 'tmc-row', el);
      row.setAttribute('aria-hidden', 'true');
      GROUPS.forEach(function (g, i) {
        if (i) make('span', 'tmc-sep', row, ':');
        var group = make('div', 'tmc-group' + (i === 3 ? ' tmc-frames' : ''), row);
        var digits = make('div', 'tmc-digits', group);
        for (var k = 0; k < 2; k++) {
          var cell = make('span', 'tmc-d', digits);
          t.digits.push({ cell: cell, inner: make('span', '', cell, '0'), v: '0', group: i });
        }
        make('span', 'tmc-label', group, g);
      });
      t.sr = make('span', 'tmc-sr', el);
      t.sr.setAttribute('role', 'timer');
      render(t, false);
      return t;
    }

    function code(t) {
      var f = t.start + Math.floor(t.time * t.fps);
      var fr = f % Math.round(t.fps);
      var s = Math.floor(f / t.fps);
      return pad(Math.floor(s / 3600) % 100) + pad(Math.floor(s / 60) % 60) + pad(s % 60) + pad(fr);
    }

    function render(t, animate) {
      var c = code(t);
      if (c === t.shown) return;
      t.shown = c;
      t.digits.forEach(function (dg, i) {
        if (c[i] === dg.v) return;
        dg.v = c[i];
        dg.inner.textContent = c[i];
        // Frames change too fast to animate; the slower digits drop in.
        if (animate && dg.group < 3) anime.animate(dg.inner, { y: ['-0.8em', '0em'], opacity: [0.2, 1], duration: 260, ease: 'outBack(1.6)' });
      });
      var sec = c.slice(0, 6);
      if (sec !== t.said) { t.said = sec; t.sr.textContent = c.replace(/(\d\d)(?=\d)/g, '$1:'); }
    }

    function frame(t, ts) {
      if (!t.visible) { t.raf = 0; t.last = 0; return; }
      if (t.last) t.time += (ts - t.last) / 1000 * t.rate;
      t.last = ts;
      if (t.loop && t.time >= t.loop * t.rate) t.time = 0;
      render(t, true);
      t.raf = requestAnimationFrame(function (n) { frame(t, n); });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var t = e.target._tmc;
          t.visible = e.isIntersecting;
          if (t.visible && !t.raf) t.raf = requestAnimationFrame(function (n) { frame(t, n); });
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-timecode-counter]').forEach(function (el) {
        if (el._tmc) return;
        var t = el._tmc = setup(el);
        // Reduced motion: the code still counts, but only once a second.
        if (t.still) {
          setInterval(function () {
            t.time += t.rate;
            if (t.loop && t.time >= t.loop * t.rate) t.time = 0;
            render(t, false);
          }, 1000);
        } else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-timecode-counter style="font-size: 48px"></div>

<!-- 25 fps, starting just before the hour: -->
<div data-timecode-counter data-fps="25" data-start="00:59:50:00"></div>