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
- Paste the snippet just before
</head>. - Add
data-timecode-counterto an empty element and size it withfont-size.
Options
Add any of these attributes to the same element.
data-fps | Frames per second. Default 24; try 25 or 30. |
data-start | Where it starts, e.g. 01:00:00:00. Default zero. |
data-rate | Speed. Default 1, real time; 2 runs twice as fast. |
data-loop | Jump back to the start every this many seconds. |
data-rec | Text beside the red light. Default REC; an empty value hides the bar. |
data-color | Colour of the frame digits. Default #fbbf24. |
<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><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>