Loading...
Loading...
Weather graphics are a broadcast staple — from local news segments to national forecasts. This card shows current conditions with a large temperature display, plus a multi-day forecast row. It demonstrates how to handle nested data schemas — scalar fields plus an array of objects — in a single graphic.
Nested data schema
Four scalar fields (location, temp, condition, icon) sit alongside an array of forecast objects, each with day, temp, and icon. Temperatures are plain strings, so the unit travels with the value.
Emoji as icons
Icons are Unicode emoji typed straight into the data and written out as text. No SVGs or icon fonts — zero dependencies, and viewers instantly recognize them.
Multi-section layout
Two visual zones: the current conditions (large icon + temp) and a compact forecast row below, whose days fade in after the card has slid into place.
_renderForecast maps the forecast array to one .weather-forecast-day per entry, showing the day, the emoji, and the temperature. Every value passes through escapeHtml, and each day's transition-delay is 500 ms + 60 ms per day, creating a left-to-right reveal. load and updateAction share _applyData: scalar fields are applied when they are not undefined (so an empty string clears one), and a forecast array re-renders the row.
_renderForecast(forecast) {
if (!Array.isArray(forecast)) return;
this._forecast.innerHTML = forecast.map((day, i) => `
<div class="weather-forecast-day" style="transition-delay: ${500 + i * 60}ms">
<div class="weather-forecast-label">${escapeHtml(day.day)}</div>
<div class="weather-forecast-icon">${escapeHtml(day.icon)}</div>
<div class="weather-forecast-temp">${escapeHtml(day.temp)}</div>
</div>
`).join('');
}
_applyData(data) {
if (!data) return;
if (data.location !== undefined) this._location.textContent = data.location;
if (data.temp !== undefined) this._temp.textContent = data.temp;
if (data.condition !== undefined) this._condition.textContent = data.condition;
if (data.icon !== undefined) this._icon.textContent = data.icon;
if (data.forecast) this._renderForecast(data.forecast);
}resolveTargetStep follows the OGraf step model: goto if given, otherwise the current step (-1 before the first play) plus delta, default 1. With one step, the first play puts the card on air at step 0; a second play goes past the end, so the graphic stops and returns currentStep: undefined. playAction adds visible and resolves after 1000 ms; stopAction adds out, waits 500 ms, and clears the classes only if no newer action has bumped this._rev — so play → stop → play sent without waiting ends on air.
/**
* The step a playAction() lands on, exactly as the spec defines it: `goto`
* wins; otherwise the current step (-1 before the first play) plus `delta`,
* which defaults to 1. A target at or past stepCount means "go to the end",
* returned as undefined — so a second play on a one-step graphic takes it off
* air instead of replaying it.
*/
function resolveTargetStep(currentStep, { goto, delta } = {}, stepCount = 1) {
const target = Number.isInteger(goto) && goto >= 0
? goto
: (currentStep ?? -1) + (Number.isInteger(delta) ? delta : 1);
return target >= stepCount ? undefined : Math.max(target, 0);
}
// 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 };
}
++this._rev;
this._step = target;
this._root.classList.remove('out');
if (skipAnimation) {
this._root.classList.add('instant', 'visible');
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(1000);
return { statusCode: 200, currentStep: this._step };
}
async stopAction({ skipAnimation } = {}) {
this._initDom();
const rev = ++this._rev;
this._step = undefined;
if (skipAnimation) {
this._root.classList.add('instant');
this._root.classList.remove('visible', 'out');
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');
return { statusCode: 200 };
}The .weather wrapper starts off-screen to the left, transparent and blurred. Adding visible slides it in over 0.7 s while it sharpens; out slides it back over 0.5 s. The forecast days rise 8px and fade in once the wrapper is visible, each with its own delay. The reset is scoped with :where(.weather-root, …), so it never restyles the renderer's page.
/* Reset scoped to the graphic. A bare `*` rule in light DOM would also restyle
* the renderer's page and every other graphic on it; :where() keeps the
* specificity at zero, same as the bare selector it replaces. */
:where(.weather-root, .weather-root *),
:where(.weather-root, .weather-root *)::before,
:where(.weather-root, .weather-root *)::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.weather {
position: absolute;
bottom: 60px;
left: 48px;
font-family: 'Inter', system-ui, sans-serif;
transform: translateX(-120%);
opacity: 0;
filter: blur(4px);
}
.weather.visible {
transform: translateX(0);
opacity: 1;
filter: blur(0);
transition:
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.5s ease,
filter 0.5s ease;
}
.weather.out {
transform: translateX(-120%);
opacity: 0;
filter: blur(4px);
transition:
transform 0.5s cubic-bezier(0.76, 0, 0.24, 1),
opacity 0.4s ease 0.1s,
filter 0.4s ease 0.1s;
}
.weather-forecast-day {
flex: 1;
text-align: center;
position: relative;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.4s ease, transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.weather.visible .weather-forecast-day {
opacity: 1;
transform: translateY(0);
}Design tip
If temperatures update while the card is on air, consider adding font-variant-numeric: tabular-nums to .weather-temp (the template doesn't set it). Tabular digits all take the same width, so a change from "8°C" to "9°C" won't nudge the layout.
The forecast field mixes scalar props with a typed array — that's how OGraf handles multi-section data in a single schema.
{
"$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json",
"id": "dev.ograf.tutorial.weather",
"version": "1.0.0",
"name": "Weather Forecast",
"description": "Weather card with current conditions and a 3-day forecast row. 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": {
"location": {
"type": "string",
"title": "Location",
"gddType": "single-line",
"default": "London"
},
"temp": {
"type": "string",
"title": "Temperature",
"gddType": "single-line",
"default": "18°C"
},
"condition": {
"type": "string",
"title": "Condition",
"gddType": "single-line",
"default": "Partly Cloudy"
},
"icon": {
"type": "string",
"title": "Icon (emoji)",
"gddType": "single-line",
"default": "⛅"
},
"forecast": {
"type": "array",
"title": "Forecast",
"items": {
"type": "object",
"properties": {
"day": {
"type": "string",
"title": "Day",
"gddType": "single-line"
},
"temp": {
"type": "string",
"title": "Temp",
"gddType": "single-line"
},
"icon": {
"type": "string",
"title": "Icon",
"gddType": "single-line"
}
},
"required": [
"day",
"temp",
"icon"
]
},
"default": [
{
"day": "Tue",
"temp": "20°C",
"icon": "☀️"
},
{
"day": "Wed",
"temp": "16°C",
"icon": "🌧️"
},
{
"day": "Thu",
"temp": "19°C",
"icon": "⛅"
}
]
}
}
}
}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.
weather.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
Nested data, emoji icons, a slide-in card and a staggered forecast reveal — ready for any weather segment.

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

Countdown Timer
Self-ticking clock

Breaking News
Full-screen urgent alert

Social Media Card
Post overlay with avatar