Loading...
Loading...
An elegant full-screen quote card — the kind used for interview soundbites, motivational segments, or editorial intros. The text, divider, and attribution reveal in a staggered sequence for cinematic impact.
The magic here is CSS transition delays. The text and attribution start transparent and slightly below their final position; the divider starts at zero width. When the .visible class is added, they animate in sequence:
delay: 0.3sQuote text
Fades up from below
delay: 0.4sDivider line
Scales from center
delay: 0.5sAttribution
Fades up last
Everything lives inside .quote-root. The reset is scoped with :where(.quote-root, …), so it never restyles the renderer's page, and the root fills whatever box the renderer provides with position: absolute; inset: 0. Each child's hidden state (like .quote-text below) sits 20px low at zero opacity; the .visible rules carry the delays.
/* 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(.quote-root, .quote-root *),
:where(.quote-root, .quote-root *)::before,
:where(.quote-root, .quote-root *)::after { margin: 0; padding: 0; box-sizing: border-box; }
/* The graphic's own root. `position: absolute; inset: 0` fills whatever box the
* renderer hands us: in an iframe mount that is the frame, and inside a shadow
* root or a positioned container it is that container. `position: fixed` would
* escape to the browser viewport in the second case. The package never styles
* `body` — the renderer owns the document, not the graphic. */
.quote-root {
position: absolute;
inset: 0;
overflow: hidden;
}
.quote {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.quote.visible {
opacity: 1;
transition: opacity 0.8s ease;
}
.quote.visible .quote-text {
opacity: 1;
transform: translateY(0);
transition: opacity 0.6s ease 0.3s, transform 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.3s;
}
.quote.visible .quote-line {
transform: scaleX(1);
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.4s;
}
.quote.visible .quote-attr {
opacity: 1;
transform: translateY(0);
transition: opacity 0.6s ease 0.5s, transform 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.5s;
}
.quote-text {
font-family: 'Instrument Serif', serif;
font-size: 48px;
line-height: 1.3;
color: white;
font-style: italic;
opacity: 0;
transform: translateY(20px);
}Design tip
The background uses scale(1.1) as its starting state, then transitions to scale(1). This creates a subtle "camera settling" effect — the background gently zooms in as the quote appears. Cinematic feel with one line of CSS.
This template uses two fonts for contrast:
"The quote"
Instrument Serif — italic, large (48px). The editorial, elegant voice.
The Attribution
ROLE / TITLE
Inter — clean, modern sans-serif. The factual voice.
Since the staggered animation takes longer than a simple slide, the playAction() promise waits 1300ms before it resolves — the length of the slowest reveal. By then the background (1s), the quote text (0.3s delay + 0.8s), the divider (0.4s + 0.6s) and the attribution (0.5s + 0.8s) have all landed. stopAction() fades the whole card out over 500ms.
The rest follows the OGraf step model. resolveTargetStep() takes goto if given, otherwise the current step plus delta (default 1). The first play puts the quote on air at step 0; a second play goes past the single step, so it runs the stop and returns currentStep: undefined. Each action bumps this._rev, and a stop only removes .visible if nothing newer has started — play → stop → play 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(1300);
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 };
}{
"$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json",
"id": "dev.ograf.tutorial.quote",
"version": "1.0.0",
"name": "Full Page Quote",
"description": "Cinematic full-screen quote card with staggered reveal of text, divider, and attribution. 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": {
"text": {
"type": "string",
"title": "Quote",
"gddType": "multi-line",
"default": "Open graphics, open broadcast, open standards. That's the future."
},
"author": {
"type": "string",
"title": "Author",
"gddType": "single-line",
"default": "Demo Quote"
},
"role": {
"type": "string",
"title": "Role",
"gddType": "single-line",
"default": "Sample Credit"
}
}
}
}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.
quote.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
You've learned staggered CSS transitions, full-screen layouts, typographic contrast, and timing coordination between CSS and JavaScript.

Lower Third
Name & title overlay

Bug / LIVE
Corner indicator with pulse

News Ticker
Scrolling headline crawl

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