Loading...
Loading...
The pre-match lineup graphic is a staple of sports broadcasting — from Premier League coverage to the World Cup. A grid of player cards reveals one by one, showing number, name, and position, with the formation and coach in the footer.
Grid layout
Uses CSS Grid with auto-fill columns at least 120px wide. Cards reflow naturally whether you have 11 players or 5 subs.
Staggered cards
Each player card gets an inline transition-delay of 300 ms + 60 ms per card, creating a wave of cards appearing across the grid.
Three-part structure
Dark gradient header with team name and match info, then the player grid, then a footer with formation and coach — three distinct visual zones.
_renderPlayers builds a .lineup-card for each player: the number in a dark circle, the name, and the position beneath it. Every value goes through escapeHtml before it lands in the markup, and each card's transition-delay is calculated from its index. load and updateAction both call _applyData, which only touches fields that are not undefined — so a partial update changes just what it sends, an empty string blanks a field (for formation and coach, the "Formation:" / "Coach:" label goes too), and a players array re-renders the grid.
_renderPlayers(players) {
this._grid.innerHTML = players.map((p, i) => `
<div class="lineup-card" style="transition-delay: ${300 + i * 60}ms">
<div class="lineup-number">${escapeHtml(p.number)}</div>
<div class="lineup-player-name">${escapeHtml(p.name)}</div>
<div class="lineup-position">${escapeHtml(p.position)}</div>
</div>
`).join('');
}
_applyData(data) {
if (!data) return;
if (data.team !== undefined) this._team.textContent = data.team;
if (data.meta !== undefined) this._meta.textContent = data.meta;
// An emptied field clears the whole line, label included.
if (data.formation !== undefined) this._formation.textContent = data.formation ? 'Formation: ' + data.formation : '';
if (data.coach !== undefined) this._coach.textContent = data.coach ? 'Coach: ' + data.coach : '';
if (Array.isArray(data.players)) this._renderPlayers(data.players);
}resolveTargetStep implements 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 lands on step 0 and a second play goes past the end, so the graphic stops and returns currentStep: undefined. On step 0, playAction adds visible and waits until the last card has finished: 300 ms + 60 ms per card + 500 ms for the card's own transition. stopAction fades out over 400 ms and only clears the classes if no newer action (a higher this._rev) has started meanwhile.
/**
* 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(300 + this._grid.querySelectorAll('.lineup-card').length * 60 + 500);
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(400);
if (rev === this._rev) this._root.classList.remove('visible', 'out');
return { statusCode: 200 };
}The player grid uses grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)) so it adapts to the container width; a 2px gap over a light grey background draws the dividers between cards. Each card fades in and rises 12px when the parent gets the visible class. The reset is scoped with :where(.sport-lineup-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(.sport-lineup-root, .sport-lineup-root *),
:where(.sport-lineup-root, .sport-lineup-root *)::before,
:where(.sport-lineup-root, .sport-lineup-root *)::after { margin: 0; padding: 0; box-sizing: border-box; }
.lineup.visible {
opacity: 1;
transition: opacity 0.6s ease;
}
.lineup.visible .lineup-card {
opacity: 1;
transform: translateY(0);
}
.lineup.out {
opacity: 0;
transition: opacity 0.4s ease;
}
.lineup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 28px;
background: linear-gradient(135deg, #0f172a, #1e293b);
}
.lineup-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 2px;
padding: 2px;
background: #e2e8f0;
}
.lineup-card {
background: white;
padding: 16px 12px;
text-align: center;
opacity: 0;
transform: translateY(12px);
transition: opacity 0.4s ease, transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.lineup-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: #0f172a;
color: white;
font-size: 14px;
font-weight: 800;
margin-bottom: 8px;
}Design tip
Use auto-fill instead of a fixed column count. This way the grid gracefully handles 11 starters, 5 substitutes, or any other roster size without layout changes in the template code.
The players field is a typed array — items.type is object with required number, name, and position. A controller can add, remove, and reorder rows automatically.
{
"$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json",
"id": "dev.ograf.tutorial.sport-lineup",
"version": "1.0.0",
"name": "Sport Lineup",
"description": "Team roster grid with numbered player cards and staggered reveal. 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": {
"team": {
"type": "string",
"title": "Team",
"gddType": "single-line",
"default": "FC Barcelona"
},
"meta": {
"type": "string",
"title": "Match Info",
"gddType": "single-line",
"default": "La Liga — Matchday 32"
},
"formation": {
"type": "string",
"title": "Formation",
"gddType": "single-line",
"default": "4-3-3"
},
"coach": {
"type": "string",
"title": "Coach",
"gddType": "single-line",
"default": "H. Flick"
},
"players": {
"type": "array",
"title": "Players",
"items": {
"type": "object",
"properties": {
"number": {
"type": "integer",
"title": "Number",
"minimum": 0
},
"name": {
"type": "string",
"title": "Name",
"gddType": "single-line"
},
"position": {
"type": "string",
"title": "Position",
"gddType": "single-line"
}
},
"required": [
"number",
"name",
"position"
]
},
"default": [
{
"number": 1,
"name": "ter Stegen",
"position": "GK"
},
{
"number": 23,
"name": "Koundé",
"position": "RB"
},
{
"number": 4,
"name": "Araújo",
"position": "CB"
},
{
"number": 15,
"name": "Christensen",
"position": "CB"
},
{
"number": 28,
"name": "Baldé",
"position": "LB"
},
{
"number": 8,
"name": "Pedri",
"position": "CM"
},
{
"number": 16,
"name": "Fermín",
"position": "CM"
},
{
"number": 6,
"name": "Gavi",
"position": "CM"
},
{
"number": 11,
"name": "Yamal",
"position": "RW"
},
{
"number": 9,
"name": "Lewandowski",
"position": "ST"
},
{
"number": 19,
"name": "Raphinha",
"position": "LW"
}
]
}
}
}
}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.
sport-lineup.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
CSS Grid, staggered card reveals, and a clean header/footer structure — ready for matchday broadcasts.

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

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