Loading...
Loading...
In this tutorial you'll build a production-quality lower third graphic from scratch — the same kind you see on CBS, BBC, or any news broadcast. It slides in, displays a name and title, updates live, and slides out.
Create a new folder with these four files. That's your entire OGraf package — no build tools, no npm, no framework.
That's it. Four files. No node_modules, no package.json, no build step. OGraf packages are plain web files.
The manifest tells every OGraf system who your graphic is and what it needs. When an operator loads your graphic in SPX or any controller, this file is the first thing it reads. It auto-generates the data form you saw in the demo above.
{
"$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json",
"id": "dev.ograf.tutorial.lower-third",
"version": "1.0.0",
"name": "CBS-Style Lower Third",
"description": "Clean white and blue lower third with slide-in animation. Built as part of the ograf.dev tutorial.",
"author": {
"name": "ograf.dev",
"url": "https://ograf.dev"
},
"main": "graphic.mjs",
"stepCount": 1,
"supportsRealTime": true,
"supportsNonRealTime": false,
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name",
"gddType": "single-line",
"default": "Jane Smith"
},
"title": {
"type": "string",
"title": "Title",
"gddType": "single-line",
"default": "Senior Graphics Engineer"
}
}
}
}Identity
id and name — how controllers identify and display your graphic.
Behavior
stepCount: 1 — one step: it appears, holds, then disappears when stopped.
Entry Point
main — points to your JavaScript file with the Web Component class.
Data Schema
schema — defines the form fields. Controllers auto-generate the input UI from this.
An OGraf package is a small folder with a manifest, a JavaScript module, a stylesheet, and any static assets the graphic needs. There is no HTML entry point — the renderer mounts the default-exported class under its own tag, so the module just has to export a class that extends HTMLElement.
lower-third/
├── lower-third.ograf.json
├── graphic.mjs
├── style.css
└── fonts/
├── Inter-Medium.woff2
├── Inter-Bold.woff2
└── LICENSE.txtThe fonts/ folder ships the Inter weights this graphic uses along with their license (SIL OFL) — playout boxes are often offline, so bundling fonts avoids CDN calls that would silently fail.
This is where the visual design lives. We're building a CBS-inspired clean look: white background, blue accent bar on the left, uppercase blue title. The slide-in uses CSS transitions with cubic-bezier easing for that broadcast-quality feel.
/* style.css -- loaded via <link> injected by graphic.mjs.
URLs below resolve relative to this file, so the fonts in ./fonts/ just work. */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./fonts/Inter-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/Inter-Bold.woff2') format('woff2');
}
.l3rd, .l3rd *, .l3rd *::before, .l3rd *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.l3rd {
position: absolute; /* NOT fixed -- anchor to the renderer's frame */
bottom: 64px;
left: 48px;
font-family: 'Inter', system-ui, sans-serif;
display: flex;
transform: translateX(-120%);
opacity: 0;
filter: blur(4px);
}
.l3rd.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;
}
.l3rd.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;
}
.l3rd-accent {
width: 5px;
background: linear-gradient(180deg, #2563eb, #1d4ed8);
border-radius: 3px 0 0 3px;
}
.l3rd-content {
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(20px);
padding: 16px 32px 16px 20px;
border-radius: 0 6px 6px 0;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
}
.l3rd-name {
font-size: 22px;
font-weight: 700;
color: #0f172a;
}
.l3rd-title {
font-size: 13px;
font-weight: 500;
color: #2563eb;
text-transform: uppercase;
letter-spacing: 0.02em;
margin-top: 3px;
}Design tip
The cubic-bezier(0.16, 1, 0.3, 1) easing is key — it starts fast and decelerates smoothly, giving that snappy broadcast motion feel. The out-animation uses cubic-bezier(0.76, 0, 0.24, 1) for a quick, punchy exit.
This is the heart of your OGraf graphic. It's a standard Web Component that the renderer controls by calling six methods — five linear lifecycle steps plus customAction for graphic-specific extras. Each returns a Promise: the renderer waits for your animation to finish before doing anything else.
load
Get data
play
Animate in
update
Change data
stop
Animate out
dispose
Clean up
// Resolve the stylesheet URL relative to this module so it loads no matter
// where the renderer serves the package from.
const STYLE_URL = new URL('./style.css', import.meta.url).href;
const TEMPLATE = `
<link rel="stylesheet" href="${STYLE_URL}">
<div class="l3rd">
<div class="l3rd-accent"></div>
<div class="l3rd-content">
<div class="l3rd-name"></div>
<div class="l3rd-title"></div>
</div>
</div>
`;
export default class LowerThird extends HTMLElement {
_initDom() {
if (this._initialized) return; // idempotent
this.innerHTML = TEMPLATE;
this._root = this.querySelector('.l3rd');
this._name = this.querySelector('.l3rd-name');
this._title = this.querySelector('.l3rd-title');
this._initialized = true;
}
async load({ data } = {}) {
this._initDom(); // <-- first line of every public method
if (data?.name) this._name.textContent = data.name;
if (data?.title) this._title.textContent = data.title;
return { statusCode: 200 };
}
async playAction({ skipAnimation } = {}) {
this._initDom();
this._root.classList.remove('out');
if (skipAnimation) {
this._root.classList.add('visible');
return { statusCode: 200, currentStep: 0 };
}
void this._root.offsetWidth; // force reflow before transition
this._root.classList.add('visible');
await new Promise(r => setTimeout(r, 700));
return { statusCode: 200, currentStep: 0 };
}
async stopAction({ skipAnimation } = {}) {
this._initDom();
if (skipAnimation) {
this._root.classList.remove('visible');
return { statusCode: 200 };
}
this._root.classList.add('out');
await new Promise(r => setTimeout(r, 500));
this._root.classList.remove('visible', 'out');
return { statusCode: 200 };
}
async updateAction({ data } = {}) {
this._initDom();
if (data?.name) this._name.textContent = data.name;
if (data?.title) this._title.textContent = data.title;
return { statusCode: 200 };
}
// Required on every graphic, even when the manifest declares no customActions.
async customAction({ action } = {}) {
return { statusCode: 404, description: `Unknown custom action: ${action ?? ""}` };
}
async dispose() {
this.innerHTML = '';
this._initialized = false; // reset so a re-load re-inits
return { statusCode: 200 };
}
}
// Note the absence of customElements.define() -- the renderer picks the tag.How it works
_initDom() — A private helper, idempotent. The first public method to run calls it to set innerHTML + grab element refs. This way the graphic works whether the renderer inserts the element before or after calling load().
load() — Receives the operator's data (name + title) and puts it in the DOM. No animation yet.
playAction() — Adds the .visible CSS class, which triggers the slide-in transition. Waits 700ms for it to finish, then tells the renderer "I'm ready."
updateAction() — Swaps the text content. In production you'd add a smooth text-swap animation.
stopAction() — Adds the .out class for the exit animation. Waits 500ms, then cleans up.
customAction() — OGraf requires every graphic to expose this, even without any declared in the manifest. A no-op that returns statusCode: 404 for unknown actions is the correct default.
dispose() — Clears the DOM and resets _initialized so a re-load rebuilds cleanly. Called when the graphic is removed from the renderer entirely.
Your graphic is ready. Here's how to test it:
Option A: Use the live demo above
Scroll up — the interactive preview at the top of this page is running the exact same code. Click Play, change the text, click Update, click Stop.
Option B: Check your package
Zip your folder and drop it on /check. You'll get a structured report against 30+ rules and the live EBU schema.
Open checkerOption C: Load it in an OGraf renderer
Deploy to a compliant renderer: ograf-server (self-hosted reference), SPX-GC (browser controller), or CasparCG (via the HTML producer). Links are on the download card below.
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.
lower-third.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
README.md
Usage notes
This package works with any OGraf-compatible system — SPX, ograf-server, CasparCG (via HTML producer), and more. Same files, everywhere.

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

Weather Forecast
Conditions & 3-day outlook

Social Media Card
Post overlay with avatar