Carregando...
Carregando...
A faixa de texto rolando na parte de baixo da tela — CNN, BBC News, Bloomberg. As manchetes passam sem parar da direita para a esquerda. Este tutorial cobre animações CSS infinitas, arrays de dados dinâmicos e loop contínuo sem emendas.
Rolagem infinita
Usa @keyframes de CSS com repetição infinite em .ticker-content (20s por ciclo). O conteúdo é duplicado para o loop não ter emendas.
Dados em array
Em vez de campos únicos, o schema aceita um array de manchetes. Cada item passa em sequência.
Barra de largura total
Ocupa toda a borda inferior numa barra de 48px. O selo azul (o campo badge) fica à esquerda, e o texto rola à direita dele.
O ticker duplica todas as manchetes para que a rolagem pareça infinita. Quando o primeiro conjunto sai totalmente da tela, o segundo já tomou o lugar dele — a animação reinicia sem que se perceba. Cada manchete passa por escapeHtml() antes de chegar ao innerHTML, então uma manchete com < ou & aparece como texto. _applyData() só renderiza de novo quando items é um array, e um loop: false opcional (usado pela chave de modo de reprodução da demo; fora do schema do manifesto) faz a faixa rodar uma vez e parar em vez de repetir.
_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 que -50%?
_renderItems escreve as manchetes duas vezes, cada uma seguida do seu separador, e todo filho de .ticker-content tem a mesma margem final de 40px. Por isso as duas metades têm exatamente a mesma largura, e deslocar em -50% leva a cópia precisamente ao ponto onde o original começou — quando a animação reinicia, nada pula. É também por isso que o espaçamento é uma margem, e não gap ou padding-left: qualquer um dos dois deixaria as metades diferentes. .ticker-track recorta tudo com overflow: hidden.
A faixa nunca termina, mas playAction() resolve depois dos 500ms em que a barra sobe. A especificação diz que animações longas ou infinitas não devem atrasar a promise — o renderizador precisa saber que o grafismo está no ar, não quando a faixa termina. O modelo de passos é o da especificação: o primeiro play cai no passo 0, e um segundo play tira do ar o ticker de um passo só e retorna currentStep: undefined. stopAction() desce a barra em 400ms e confere this._rev antes de escondê-la, então um play enviado durante esse stop prevalece.
/**
* 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 };
}Repare que o schema usa um array de strings para as manchetes, e não um único campo de texto. Os controladores mostram isso como uma lista em que o operador pode adicionar, remover e reordenar itens.
{
"$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"
]
}
}
}
}Um pacote OGraf Graphics Definition v1 de verdade. Um renderizador compatível lê o manifesto e conduz o ciclo de vida. Licença MIT; coloque em qualquer sistema compatível com OGraf.
ticker.ograf.json
Manifesto — o que o renderizador lê (id, schema, flags de ciclo de vida)
graphic.mjs
Web Component com load / play / update / stop / customAction / dispose
style.css
Folha de estilo, carregada pelo graphic.mjs com uma tag <link>
thumbnail.webp
Prévia em 1920×1080, declarada no manifesto
README.md
Instruções de uso
LICENSE
MIT
Você aprendeu animações CSS infinitas, schemas com arrays de dados, o truque de duplicar o conteúdo para um loop sem emendas e por que uma faixa sem fim ainda resolve o playAction na hora.

Lower third
Nome e cargo sobre a imagem

Bug / AO VIVO
Indicador de canto com pulso

Citação em tela cheia
Tipografia cinematográfica em tela cheia

Barras de eleição
Gráfico de porcentagens animado

Escalação esportiva
Grade com o elenco do time

Placar
Placar de partida ao vivo

Contagem regressiva
Relógio que avança sozinho

Plantão
Alerta urgente em tela cheia

Previsão do tempo
Condições atuais e previsão de 3 dias

Card de rede social
Post sobreposto com avatar