Cargando...
Cargando...
El rótulo de texto que se desplaza por la parte inferior de la pantalla — CNN, BBC News, Bloomberg. Los titulares avanzan sin parar de derecha a izquierda. Este tutorial trata las animaciones CSS infinitas, los arrays de datos dinámicos y los bucles sin cortes.
Desplazamiento infinito
Usa @keyframes de CSS con repetición infinite en .ticker-content (20s por ciclo). El contenido se duplica para que el bucle no tenga cortes.
Datos en array
En lugar de campos sueltos, el schema acepta un array de titulares. Cada elemento pasa uno tras otro.
Barra a todo lo ancho
Ocupa todo el borde inferior en una barra de 48px. La etiqueta azul (el campo badge) va a la izquierda y el texto se desplaza a su derecha.
El ticker duplica todos los titulares para que el desplazamiento parezca infinito. Cuando el primer bloque sale por completo de la pantalla, el segundo ya ha ocupado su lugar — la animación se reinicia sin que se note. Cada titular pasa por escapeHtml() antes de llegar a innerHTML, así que un titular que contenga < o & se muestra como texto. _applyData() solo vuelve a renderizar cuando items es un array, y un loop: false opcional (lo usa el selector de modo de reproducción de la demo; no está en el schema del manifiesto) hace que el rótulo pase una sola vez y se quede quieto en lugar de repetirse.
_renderItems(items) {
// Render the headlines twice, each followed by its separator, so the two
// halves are identical. Moving -50% then lands the second copy exactly
// where the first started, and the loop restarts without a jump.
const oneSet = items.map((item) =>
`<span class="ticker-item"><span class="ticker-dot"></span>${escapeHtml(item)}</span>` +
'<span class="ticker-separator"></span>'
).join('');
this._content.innerHTML = oneSet + oneSet;
}
_applyPlayMode(loop) {
if (!this._content) return;
this._content.style.animationIterationCount = loop === false ? '1' : 'infinite';
this._content.style.animationFillMode = loop === false ? 'forwards' : '';
}
_applyData(data) {
if (!data) return;
if (data.badge !== undefined) this._badge.textContent = data.badge;
if (Array.isArray(data.items)) this._renderItems(data.items);
if ('loop' in data) this._applyPlayMode(data.loop);
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}.ticker-track {
flex: 1;
overflow: hidden;
display: flex;
align-items: center;
position: relative;
}
.ticker-content {
display: flex;
align-items: center;
white-space: nowrap;
animation: scroll 20s linear infinite;
}
@keyframes scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}¿Por qué -50%?
_renderItems escribe los titulares dos veces, cada uno seguido de su separador, y todos los hijos de .ticker-content llevan el mismo margen final de 40px. Por eso las dos mitades miden exactamente lo mismo, y desplazar un -50% lleva la copia justo al punto donde empezaba el original — cuando la animación se reinicia, no hay ningún salto. También por eso el espaciado es un margen y no gap ni padding-left: cualquiera de los dos haría que las mitades fueran distintas. .ticker-track lo recorta todo con overflow: hidden.
El rótulo no termina nunca, pero playAction() se resuelve tras los 500ms en que sube la barra. La especificación dice que las animaciones largas o infinitas no deben retrasar la promise — el renderizador necesita saber que el grafismo está en antena, no cuándo termina el rótulo. El modelo de pasos es el de la especificación: el primer play cae en el paso 0 y un segundo play saca de antena el ticker de un solo paso y devuelve currentStep: undefined. stopAction() baja la barra en 400ms y comprueba this._rev antes de ocultarla, así que un play enviado durante ese stop tiene prioridad.
/**
* 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(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 };
}Fíjate en que el schema usa un array de strings para los titulares, no un único campo de texto. Los controladores lo muestran como una lista en la que el operador puede añadir, quitar y reordenar elementos.
{
"$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json",
"id": "dev.ograf.tutorial.ticker",
"version": "1.0.0",
"name": "News Ticker",
"description": "Scrolling headline crawl with an infinite CSS loop. 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": {
"badge": {
"type": "string",
"title": "Badge Text",
"gddType": "single-line",
"default": "Breaking"
},
"items": {
"type": "array",
"title": "Headlines",
"items": {
"type": "string",
"gddType": "single-line"
},
"default": [
"Heavy rain expected across the region from Thursday evening",
"Local team books its place in the regional cup final",
"City library extends weekend opening hours from next month",
"Morning commute: delays on the northern line after signal fault"
]
}
}
}
}Un paquete OGraf Graphics Definition v1 real. Un renderizador compatible lee el manifiesto y gestiona el ciclo de vida. Con licencia MIT; úsalo en cualquier sistema compatible con OGraf.
ticker.ograf.json
Manifiesto — lo que lee el renderizador (id, schema, flags del ciclo de vida)
graphic.mjs
Web Component con load / play / update / stop / customAction / dispose
style.css
Hoja de estilos, cargada por graphic.mjs mediante una etiqueta <link>
thumbnail.webp
Vista previa de 1920×1080, declarada en el manifiesto
README.md
Notas de uso
LICENSE
MIT
Despliégalo en un renderizador OGraf compatible
Has aprendido animaciones CSS infinitas, schemas con arrays de datos, el truco de duplicar el contenido para un bucle sin cortes y por qué un rótulo interminable resuelve igualmente playAction al momento.

Rótulo inferior
Nombre y cargo sobreimpresos

Mosca / EN DIRECTO
Indicador de esquina con pulso

Cita a pantalla completa
Tipografía cinematográfica a pantalla completa

Barras electorales
Gráfico de porcentajes animado

Alineación deportiva
Cuadrícula con la plantilla del equipo

Marcador
Marcador de partido en directo

Cuenta atrás
Reloj que avanza solo

Última hora
Alerta urgente a pantalla completa

Previsión meteorológica
Condiciones actuales y previsión a 3 días

Tarjeta de redes sociales
Publicación sobreimpresa con avatar