Loading...
Loading...
Countdown timers are used everywhere in broadcast — pre-show countdowns, segment timers, auction clocks, and event countdowns. This graphic is unique because it ticks itself using setInterval — no external update calls needed once it starts.
Self-ticking
Uses setInterval internally. Once it has played in, it counts down on its own. No updateAction calls from the renderer are needed.
Urgency state
When 10 seconds or fewer remain, the digits turn red and pulse, signalling urgency to the viewer without any operator intervention.
Clean teardown
The interval must be cleared in both stopAction() and dispose(). Forgetting either leaves a ghost timer ticking in the background.
_startTicking clears any previous interval, then starts a 1-second one. Each tick decrements _remaining, repaints the clock and toggles the urgent class at 10 seconds. When it reaches zero it stops itself, leaving 00:00 on screen. _paintTime only touches the minutes or seconds span whose text actually changed, and _swap restarts that span's tick animation with a forced reflow.
_startTicking() {
this._stopTicking();
this._interval = setInterval(() => {
if (this._remaining <= 0) {
this._stopTicking();
return;
}
this._remaining--;
this._paintTime(this._remaining, { animate: true });
if (this._remaining <= 10) this._root.classList.add('urgent');
else this._root.classList.remove('urgent');
}, 1000);
}
_stopTicking() {
if (this._interval) {
clearInterval(this._interval);
this._interval = null;
}
}
_paintTime(totalSeconds, { animate } = {}) {
const mins = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
const secs = String(totalSeconds % 60).padStart(2, '0');
if (this._mins.textContent !== mins) this._swap(this._mins, mins, animate);
if (this._secs.textContent !== secs) this._swap(this._secs, secs, animate);
}
_swap(el, next, animate) {
if (!animate) {
el.textContent = next;
return;
}
el.classList.remove('tick');
void el.offsetWidth;
el.textContent = next;
el.classList.add('tick');
}load paints the starting time from the seconds field; the clock only starts moving on play. playAction follows the spec's step model (goto, else current step plus delta). With stepCount: 1, a second play goes past the last step, so it stops the graphic and returns currentStep: undefined. The interval starts only after the 800 ms entrance, and only if this._rev hasn't moved on: a stop sent during the entrance must not be followed by a clock that starts ticking anyway. updateAction takes partial data; if the clock was running it restarts from the new value.
// Each action takes the next revision number. Anything that finishes after a
// newer action has started checks it and backs off, so play → stop → play
// sent without waiting ends on air instead of hidden by the stale stop.
async playAction({ goto, delta, skipAnimation } = {}) {
this._initDom();
const target = resolveTargetStep(this._step, { goto, delta });
if (target === undefined) {
await this.stopAction({ skipAnimation });
return { statusCode: 200, currentStep: undefined };
}
const rev = ++this._rev;
this._step = target;
this._root.classList.remove('out');
if (skipAnimation) {
this._root.classList.add('instant', 'visible');
this._startTicking();
return { statusCode: 200, currentStep: this._step };
}
this._root.classList.remove('instant');
// Force a reflow so the browser registers the starting state.
void this._root.offsetWidth;
this._root.classList.add('visible');
await sleep(800);
if (rev === this._rev) this._startTicking();
return { statusCode: 200, currentStep: this._step };
}
async updateAction({ data } = {}) {
this._initDom();
const wasTicking = this._interval !== null;
this._applyData(data);
if (data?.seconds !== undefined) this._root.classList.remove('urgent');
if (wasTicking) this._startTicking();
return { statusCode: 200 };
}stopAction clears the interval before its 500 ms exit, and dispose clears it again and bumps the revision so nothing pending touches the emptied element.
async stopAction({ skipAnimation } = {}) {
this._initDom();
const rev = ++this._rev;
this._step = undefined;
this._stopTicking();
if (skipAnimation) {
this._root.classList.add('instant');
this._root.classList.remove('visible', 'out', 'urgent');
return { statusCode: 200 };
}
this._root.classList.remove('instant');
this._root.classList.add('out');
await sleep(500);
if (rev === this._rev) this._root.classList.remove('visible', 'out', 'urgent');
return { statusCode: 200 };
}
async dispose() {
this._rev = (this._rev ?? 0) + 1;
this._stopTicking();
this.innerHTML = '';
this._initialized = false;
return { statusCode: 200 };
}Design tip
Always clear intervals in stopAction() and dispose(). In a broadcast environment, graphics are loaded and unloaded frequently. A forgotten interval means a timer ticking in the background, consuming CPU and potentially causing unexpected behaviour when the graphic is reloaded.
Each digit pair that changes slides up into place over 0.36 s with the tick class. When urgency kicks in, the time turns red and pulses gently in scale once a second.
/* Pulse animation for last 10 seconds */
.countdown.urgent .countdown-time {
animation: urgentPulse 1s ease infinite;
color: #dc2626;
}
@keyframes urgentPulse {
0% { transform: scale(1); }
50% { transform: scale(1.04); }
100% { transform: scale(1); }
}
/* Digit swap: the new digit rises into place from below as it fades in */
.countdown-mins.tick,
.countdown-secs.tick {
animation: digitSwap 0.36s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes digitSwap {
0% { transform: translateY(0.55em); opacity: 0; }
40% { opacity: 1; }
100% { transform: translateY(0); opacity: 1; }
}{
"$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json",
"id": "dev.ograf.tutorial.countdown",
"version": "1.0.0",
"name": "Countdown Timer",
"description": "Self-ticking countdown clock that goes red in the last 10 seconds. Tutorial from ograf.dev.",
"author": {
"name": "ograf.dev",
"url": "https://ograf.dev"
},
"main": "graphic.mjs",
"stepCount": 1,
"supportsRealTime": true,
"supportsNonRealTime": false,
"thumbnails": [
{
"file": "thumbnail.webp",
"resolution": {
"width": 1920,
"height": 1080
}
}
],
"schema": {
"type": "object",
"properties": {
"label": {
"type": "string",
"title": "Label",
"gddType": "single-line",
"default": "STARTING IN"
},
"seconds": {
"type": "integer",
"title": "Seconds",
"default": 120,
"minimum": 1
}
}
}
}A real OGraf Graphics Definition v1 package. A compliant renderer reads the manifest and drives the lifecycle. MIT-licensed; drop it into any OGraf-compatible system.
countdown.ograf.json
Manifest — what a renderer reads (id, schema, lifecycle flags)
graphic.mjs
Web Component with load / play / update / stop / customAction / dispose
style.css
Stylesheet, loaded by graphic.mjs via a <link> tag
thumbnail.webp
1920×1080 preview, declared in the manifest
README.md
Usage notes
LICENSE
MIT
Self-ticking with setInterval, an urgency state, and proper cleanup in stopAction() and dispose(): a self-contained timer graphic.

Lower Third
Name & title overlay

Bug / LIVE
Corner indicator with pulse

News Ticker
Scrolling headline crawl

Full Page Quote
Cinematic full-screen typography

Election Bars
Animated percentage chart

Sport Lineup
Team roster grid

Score Bug
Live match scoreboard

Breaking News
Full-screen urgent alert

Weather Forecast
Conditions & 3-day outlook

Social Media Card
Post overlay with avatar