Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

SVG e canvas no HTML: qual usar para desenhar na página

SVG inline, SVG em img e o canvas: o que fica no DOM, o que escala sem borrar, o que pesa menos e o que o leitor de tela consegue ler.

Rodolfo Mori15 min de leitura

SVG é desenho feito de markup: cada barra de um gráfico é um elemento que entra na mesma árvore da sua página, e que você seleciona, pinta pelo CSS e escuta com um addEventListener. Canvas é um retângulo de pixels: você pinta com JavaScript e, terminado o traço, não sobra elemento nenhum para segurar. A escolha entre os dois cabe numa pergunta — você precisa mexer em cada parte do desenho depois de desenhado?

Todos os exemplos são da Biblioteca Municipal Vila Nova, que quer publicar no site o número de empréstimos de cada mês e um campo de busca no acervo. Rodei tudo em Chrome 151 dirigido pelo Playwright, num MacBook com Apple M4 Pro; as saídas coladas aqui são as do terminal.

Peças de montar ou tinta numa tela

Pense em dois desenhos iguais. O primeiro é montado com peças: depois de pronto, você ainda consegue pegar a barra de março, trocar sua cor e ouvir um clique nela. Esse é o SVG, cuja sigla significa Scalable Vector Graphics: cada forma continua sendo um elemento. O segundo é pintado com tinta. Você vê os pixels, mas não consegue pegar “a barra de março” porque ela deixou de existir como peça. Esse é o canvas.

A comparação serve para identidade, não para qualidade visual: os dois podem ficar nítidos e os dois podem ser redesenhados. A diferença é o que sobra no DOM. Antes de continuar, imagine que a biblioteca quer mostrar uma dica ao passar o mouse em uma única barra. Em qual desenho você já tem uma peça para receber o evento? Essa resposta é a regra de decisão do artigo inteiro.

O mesmo gráfico, desenhado das duas formas

Seis meses, seis barras. Em SVG, o gráfico é markup que você escreve como escreveria uma lista — só que com rect e text no lugar de li:

html
<svg id="grafico-svg" viewBox="0 0 320 180" width="320" height="180" role="img"
     aria-label="Empréstimos por mês na Biblioteca Vila Nova: 320 em janeiro, 410 em fevereiro, 380 em março, 520 em abril, 610 em maio e 470 em junho.">
  <rect class="barra" data-mes="jan" data-total="320" x="28"  y="87" width="34" height="63"  fill="#2f7d4f"></rect>
  <rect class="barra" data-mes="fev" data-total="410" x="74"  y="69" width="34" height="81"  fill="#2f7d4f"></rect>
  <rect class="barra" data-mes="mar" data-total="380" x="120" y="75" width="34" height="75"  fill="#2f7d4f"></rect>
  <rect class="barra" data-mes="abr" data-total="520" x="166" y="48" width="34" height="102" fill="#2f7d4f"></rect>
  <rect class="barra" data-mes="mai" data-total="610" x="212" y="30" width="34" height="120" fill="#2f7d4f"></rect>
  <rect class="barra" data-mes="jun" data-total="470" x="258" y="58" width="34" height="92"  fill="#2f7d4f"></rect>
  <text x="45"  y="168" text-anchor="middle" font-size="12">jan</text>
  <text x="91"  y="168" text-anchor="middle" font-size="12">fev</text>
  <text x="137" y="168" text-anchor="middle" font-size="12">mar</text>
  <text x="183" y="168" text-anchor="middle" font-size="12">abr</text>
  <text x="229" y="168" text-anchor="middle" font-size="12">mai</text>
  <text x="275" y="168" text-anchor="middle" font-size="12">jun</text>
</svg>

O viewBox é o sistema de coordenadas interno: “esta imagem tem 320 por 180 unidades”. O width e o height dizem o tamanho na tela. Quando os dois diferem, o navegador escala tudo — e como escalar um rect é recalcular retângulo, não esticar pixel, o desenho continua nítido em qualquer tamanho.

O mesmo gráfico no canvas é o contrário: o HTML tem uma linha só, e o desenho inteiro vive no JavaScript.

html
<canvas id="grafico-canvas" width="320" height="180"></canvas>
js
const emprestimos = [
  { mes: 'jan', total: 320 }, { mes: 'fev', total: 410 }, { mes: 'mar', total: 380 },
  { mes: 'abr', total: 520 }, { mes: 'mai', total: 610 }, { mes: 'jun', total: 470 },
];

const ctx = document.querySelector('#grafico-canvas').getContext('2d');
const escala = 120 / 610;

ctx.font = '12px sans-serif';
ctx.textAlign = 'center';

emprestimos.forEach((item, i) => {
  const altura = Math.round(item.total * escala);
  const x = 28 + i * 46;
  ctx.fillStyle = '#2f7d4f';
  ctx.fillRect(x, 150 - altura, 34, altura);
  ctx.fillStyle = '#000';
  ctx.fillText(item.mes, x + 17, 168);
});

Na tela, os dois gráficos são indistinguíveis. Por dentro, não têm nada a ver um com o outro.

Cada barra do SVG é um nó; no canvas não sobrou nenhum

Este é o script que abre a página no Chrome e pergunta ao DOM o que existe dentro de cada gráfico:

js
import { chromium } from 'playwright-core';
import { pathToFileURL } from 'node:url';

const navegador = await chromium.launch();
const pagina = await navegador.newPage();
await pagina.goto(pathToFileURL('grafico.html').href);

const contagem = await pagina.evaluate(() => ({
  nosNoSvg: document.querySelectorAll('#grafico-svg *').length,
  nosNoCanvas: document.querySelectorAll('#grafico-canvas *').length,
  textoDoSvg: document.querySelector('#grafico-svg').textContent.trim().split(/\s+/).join(' '),
  textoDoCanvas: document.querySelector('#grafico-canvas').textContent.trim(),
  barraDeMaio: document.querySelectorAll('#grafico-svg .barra')[4].getAttribute('height'),
}));

console.log(contagem);
await navegador.close();
{ nosNoSvg: 12, nosNoCanvas: 0, textoDoSvg: 'jan fev mar abr mai jun', textoDoCanvas: '', barraDeMaio: '120' }

Doze nós no SVG: seis rect e seis text. Zero no canvas. E repare no textoDoSvg: os rótulos dos meses são texto de verdade, que o Ctrl+F acha, que o Google indexa e que o tradutor do navegador traduz. No canvas, jan é um borrão de pixels com forma de letra.

O barraDeMaio: '120' é a quinta barra respondendo qual é a própria altura. E uma linha de CSS — .barra:hover { fill: #b03030; } — já pinta a barra sob o mouse, sem JavaScript nenhum. No canvas, o mesmo efeito custa um mousemove, uma tabela de coordenadas e um redesenho do gráfico inteiro a cada movimento do cursor.

A consequência prática aparece no clique. O mesmo ponto da tela, nos dois gráficos:

js
await pagina.evaluate(() => {
  globalThis.alvos = [];
  const anotar = (e) =>
    globalThis.alvos.push({
      onde: e.currentTarget.id,
      alvo: e.target.tagName,
      mes: e.target.dataset?.mes ?? '(nenhum)',
      total: e.target.dataset?.total ?? '(nenhum)',
    });
  document.querySelector('#grafico-svg').addEventListener('click', anotar);
  document.querySelector('#grafico-canvas').addEventListener('click', anotar);
});

// clica no centro da barra de maio nos dois gráficos — mesmo ponto, mesmo desenho
for (const seletor of ['#grafico-svg', '#grafico-canvas']) {
  const caixa = await pagina.locator(seletor).boundingBox();
  await pagina.mouse.click(caixa.x + 229, caixa.y + 90);
}

console.log(await pagina.evaluate(() => globalThis.alvos));
[ { onde: 'grafico-svg', alvo: 'rect', mes: 'mai', total: '610' }, { onde: 'grafico-canvas', alvo: 'CANVAS', mes: '(nenhum)', total: '(nenhum)' } ]

No SVG, o event.target é a barra de maio, e ela chega trazendo o próprio dado junto — os data attributes que eu pendurei em cada rect. Montar um tooltip com isso é uma linha. No canvas, o alvo é o elemento inteiro: para saber que o clique caiu em maio, você teria que guardar as coordenadas de cada barra num array e comparar offsetX com todas elas na mão. Isso tem nome — hit testing —, e é o trabalho que o navegador já faz de graça para você no SVG.

Quatro formas de colocar um SVG na página

O mesmo arquivo lupa.svg — o ícone do campo de busca do acervo — pode entrar de quatro jeitos, e eles não são intercambiáveis:

html
<!-- o sprite: fica escondido e guarda o desenho uma única vez -->
<svg width="0" height="0" style="position:absolute">
  <symbol id="sprite-lupa" viewBox="0 0 24 24" fill="none"
          stroke-width="2" stroke-linecap="round">
    <circle cx="11" cy="11" r="7"></circle>
    <path d="M16.2 16.2 21 21"></path>
  </symbol>
</svg>

<label class="campo-busca">
  <!-- 1. SVG inline: o markup é da própria página -->
  <svg id="icone-inline" width="24" height="24" viewBox="0 0 24 24" fill="none"
       stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
    <circle cx="11" cy="11" r="7"></circle>
    <path d="M16.2 16.2 21 21"></path>
  </svg>

  <!-- 2. o mesmo arquivo dentro de uma img -->
  <img id="icone-img" src="lupa.svg" width="24" height="24" alt="">

  <!-- 3. o mesmo arquivo como imagem de fundo no CSS -->
  <span id="icone-fundo" class="icone-fundo"></span>

  <!-- 4. use apontando para o sprite -->
  <svg id="icone-use" width="24" height="24" stroke="currentColor" aria-hidden="true"><use href="#sprite-lupa"></use></svg>

  <input type="search" placeholder="Buscar no acervo">
</label>

Em img e em background-image, o SVG é tratado como qualquer outra imagem: tem cache próprio, não engorda o HTML e é ótimo para ilustração grande que se repete pouco — o mesmo raciocínio que vale para imagens em HTML. O preço é que o conteúdo dele fica fechado: o CSS da página não atravessa a fronteira.

Inline, o desenho é markup seu, com todos os poderes e todo o peso disso. E o use é o meio-termo: o desenho aparece uma vez no documento e cada uso é uma referência de uma linha.

currentColor: o ícone que muda de cor junto com o texto

currentColor é a palavra-chave que significa “o valor de color que este elemento herdou”. Num ícone inline, ela liga o traço do desenho à cor do texto ao lado — e o ícone passa a acompanhar hover, tema escuro e estado desabilitado sem uma linha de JavaScript.

css
.campo-busca { color: #2f7d4f; }
.campo-busca:hover { color: #b03030; }
.icone-fundo { display: inline-block; width: 24px; height: 24px; background: url(lupa.svg) no-repeat; }

Para provar que funciona (e onde não funciona), li o stroke calculado dos dois ícones que são markup e contei quantas partes cada um dos quatro tem, antes e depois de passar o mouse:

js
const ler = () =>
  pagina.evaluate(() => ({
    corDoCampo: getComputedStyle(document.querySelector('.campo-busca')).color,
    strokeDoInline: getComputedStyle(document.querySelector('#icone-inline circle')).stroke,
    strokeDoUse: getComputedStyle(document.querySelector('#icone-use')).stroke,
    nosDentroDoInline: document.querySelectorAll('#icone-inline *').length,
    nosDentroDaImg: document.querySelectorAll('#icone-img *').length,
    nosDentroDoFundo: document.querySelectorAll('#icone-fundo *').length,
  }));

console.log('--- estado normal (color: #2f7d4f) ---');
console.log(await ler());

await pagina.hover('.campo-busca');
console.log('--- com o mouse em cima (color: #b03030) ---');
console.log(await ler());
--- estado normal (color: #2f7d4f) --- { corDoCampo: 'rgb(47, 125, 79)', strokeDoInline: 'rgb(47, 125, 79)', strokeDoUse: 'rgb(47, 125, 79)', nosDentroDoInline: 2, nosDentroDaImg: 0, nosDentroDoFundo: 0 } --- com o mouse em cima (color: #b03030) --- { corDoCampo: 'rgb(176, 48, 48)', strokeDoInline: 'rgb(176, 48, 48)', strokeDoUse: 'rgb(176, 48, 48)', nosDentroDoInline: 2, nosDentroDaImg: 0, nosDentroDoFundo: 0 }

O ícone inline foi de verde para vermelho junto com o campo, e o <svg> que consome o sprite também — o conteúdo clonado pelo use herda o stroke de quem o chamou. A img e o background não têm como seguir: nosDentroDaImg e nosDentroDoFundo são 0, ou seja, do ponto de vista da página aquele ícone não tem partes. É um retângulo opaco, do mesmo jeito que um iframe é uma página opaca.

Para o ícone de img ou de fundo mudar de cor você precisaria de outro arquivo para cada cor — ou de truques de filter, que funcionam mal e envelhecem pior. É por isso que biblioteca de ícones séria entrega SVG inline.

canvas: um retângulo de pixels e nada por dentro

O elemento canvas não é o desenho: é a superfície. Tudo acontece no contexto, o objeto que getContext('2d') devolve — e que carrega estado. fillStyle, font, textAlign valem daí em diante, para todas as chamadas seguintes, até você trocar. Foi por isso que, no gráfico da biblioteca, eu tive que voltar a #2f7d4f dentro do laço: o fillText preto da volta anterior tinha deixado a tinta preta carregada.

Esse estado é a maior diferença de mentalidade em relação ao SVG. No SVG você descreve o que existe; no canvas você dá ordens em sequência, e a ordem importa. Um retângulo pintado por cima apaga o que estava embaixo, e não existe “desfazer” — para mudar uma barra, você limpa com clearRect e redesenha o gráfico inteiro.

Em troca, o canvas não paga o preço do DOM. Cinquenta mil círculos em SVG são cinquenta mil elementos que o navegador precisa criar, posicionar e manter; em canvas são cinquenta mil chamadas que terminam em pixel e somem.

Onde o canvas ganha: o mapa de consultas do acervo

A biblioteca quer plotar um ponto por consulta ao acervo. Medi as duas formas de desenhar essa nuvem, do começo da montagem até o navegador entregar o quadro seguinte — doisFrames() é uma promessa que espera dois requestAnimationFrame seguidos, e pontos é o array de coordenadas, o mesmo para os dois lados. Média de cinco execuções, descartando a primeira:

js
const NS = 'http://www.w3.org/2000/svg';

async function medirSvg() {
  const svg = document.querySelector('#mapa-svg');
  svg.replaceChildren();
  const t0 = performance.now();
  for (const p of pontos) {
    const c = document.createElementNS(NS, 'circle');
    c.setAttribute('cx', p.x);
    c.setAttribute('cy', p.y);
    c.setAttribute('r', 2);
    c.setAttribute('fill', '#2f7d4f');
    svg.append(c);
  }
  await doisFrames();
  return performance.now() - t0;
}

async function medirCanvas() {
  const ctx = document.querySelector('#mapa-canvas').getContext('2d');
  ctx.clearRect(0, 0, 640, 400);
  const t0 = performance.now();
  ctx.fillStyle = '#2f7d4f';
  for (const p of pontos) {
    ctx.beginPath();
    ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
    ctx.fill();
  }
  await doisFrames();
  return performance.now() - t0;
}
500 marcas -> SVG 16.6 ms canvas 16.7 ms 5000 marcas -> SVG 15.5 ms canvas 17.1 ms 50000 marcas -> SVG 87.6 ms canvas 36.2 ms

Este é o número que muda a conversa. Com 500 e com 5.000 marcas, os dois empatam em torno de 16 ms — que é a duração de um quadro a 60 fps. Os dois terminam dentro do mesmo quadro; a diferença não existe para quem está olhando.

Só em 50.000 marcas o SVG estoura o orçamento: 87,6 ms contra 36,2 ms — mais de cinco quadros contra pouco mais de dois. “Canvas é mais rápido” é verdade, mas com um limiar embaixo, e o gráfico de seis barras da biblioteca está a quatro ordens de grandeza desse limiar. Trocar SVG por canvas num gráfico pequeno é pagar em acessibilidade e em código de hit testing por um ganho que o cronômetro não mede.

Acessibilidade: o que o leitor de tela recebe de cada um

Um SVG inline com role="img" e aria-label vira uma imagem com legenda. Um canvas, por padrão, é uma caixa sem nome. Pedi ao próprio Chrome a árvore de acessibilidade da página do gráfico, que é literalmente o que o leitor de tela consome:

js
const cdp = await pagina.context().newCDPSession(pagina);
await cdp.send('Accessibility.enable');
const { nodes } = await cdp.send('Accessibility.getFullAXTree');

for (const no of nodes) {
  if (no.ignored) continue;
  const papel = no.role?.value ?? '?';
  const nome = no.name?.value ?? '';
  if (['RootWebArea', 'none', 'generic', 'InlineTextBox'].includes(papel)) continue;
  console.log(papel.padEnd(10), JSON.stringify(nome));
}
image "Empréstimos por mês na Biblioteca Vila Nova: 320 em janeiro, 410 em fevereiro, 380 em março, 520 em abril, 610 em maio e 470 em junho." Canvas "" StaticText "jan" StaticText "fev" StaticText "mar" StaticText "abr" StaticText "mai" StaticText "jun"

A primeira linha é o gráfico em SVG: papel image, e o aria-label inteiro virou o nome. A segunda é o canvas: papel Canvas, nome vazio — quem navega por áudio recebe “gráfico” e nada mais. As seis últimas linhas são os rótulos dos meses, que continuam no documento como texto porque são <text> do SVG. Do canvas não veio nenhuma.

A correção não é abandonar o canvas. São duas opções, e a escolha entre elas tem consequência:

html
<!-- A. o resumo cabe numa frase -->
<canvas width="320" height="180" role="img"
        aria-label="Empréstimos por mês: 320 em janeiro, 410 em fevereiro."></canvas>

<!-- B. o dado vira tabela, escrita dentro do próprio canvas -->
<canvas width="320" height="180">
  <table>
    <caption>Empréstimos por mês</caption>
    <tr><th>jan</th><td>320</td></tr>
    <tr><th>fev</th><td>410</td></tr>
  </table>
</canvas>

O que você escreve entre as tags <canvas> e </canvas> não aparece na tela — só existe para quem não vê o desenho. Rodando o mesmo dump da árvore num arquivo com os três canvas — um vazio, um da opção A e um da opção B — e escondendo também os nós de texto solto:

Canvas "" image "Empréstimos por mês: 320 em janeiro, 410 em fevereiro." Canvas "" table "Empréstimos por mês" caption "" rowgroup "" row "" row "" rowheader "jan" cell "320" rowheader "fev" cell "410"

O canvas cru continua sem nome. O da opção A virou image com legenda. O da opção B continua sendo Canvas "", mas a tabela inteira apareceu na árvore, com cabeçalho e células — quem navega por áudio percorre o dado linha a linha.

E se você quiser as duas coisas no mesmo canvas?

html
<!-- as duas juntas: aria-label por fora, tabela por dentro -->
<canvas width="320" height="180" role="img"
        aria-label="Empréstimos por mês: 320 em janeiro, 410 em fevereiro.">
  <table>
    <caption>Empréstimos por mês</caption>
    <tr><th>jan</th><td>320</td></tr>
    <tr><th>fev</th><td>410</td></tr>
  </table>
</canvas>
image "Empréstimos por mês: 320 em janeiro, 410 em fevereiro." table "Empréstimos por mês" caption "" rowgroup "" row "" row "" rowheader "jan" cell "320" rowheader "fev" cell "410"

O Chrome 151 entrega as duas: o nome de imagem e a tabela inteira embaixo dele. Só que isso é implementação, não contrato. A especificação da ARIA classifica os descendentes de um role="img" como apresentacionais — o navegador está autorizado a apagar aquela tabela da árvore, e um leitor de tela que siga a regra ao pé da letra lê só o aria-label. É a mesma frouxidão que você já viu no primeiro dump, quando os seis StaticText dos meses apareceram apesar de estarem dentro de um SVG com role="img".

Vale para o SVG também: quando o gráfico é complexo demais para caber num aria-label, em vez de descrever, publique a tabela ao lado. O resto do assunto está em acessibilidade em HTML.

Peso real: o mesmo ícone em SVG, em PNG e em fonte de ícones

Rasterizei o ícone da lupa no Chrome em três tamanhos e baixei uma fonte de ícones inteira para comparar:

js
import { statSync } from 'node:fs';

for (const arquivo of ['lupa.svg', 'lupa-24.png', 'lupa-48.png', 'lupa-72.png', 'material-icons.woff2']) {
  console.log(arquivo.padEnd(22), String(statSync(arquivo).size).padStart(6) + ' B');
}
lupa.svg 200 B lupa-24.png 674 B lupa-48.png 1472 B lupa-72.png 2131 B material-icons.woff2 128616 B

Duzentos bytes. Um SVG de ícone é literalmente menor que o PNG de 24 pixels do mesmo desenho — e serve para 24, 48, 72 e para o cartaz impresso, porque é a mesma descrição geométrica em qualquer escala. A fonte de ícones do Google, para comparação, custa 128 kB para entregar o primeiro ícone. Se a biblioteca usa oito ícones no site inteiro, oito SVGs somam menos de 2 kB.

O gráfico de barras conta a mesma história. Este é o arquivo .svg do gráfico contra o PNG exportado do canvas em três densidades de tela:

arquivo bytes com gzip serve para
grafico.svg 1387 468 qualquer tamanho
grafico-1x.png 3614 3510 só tela comum
grafico-2x.png 9766 7882 só telas 2x
grafico-3x.png 16853 12190 só telas 3x

O SVG comprime para 468 bytes porque é texto repetitivo, e o gzip adora texto repetitivo. O PNG cede pouco — de 3% no 1x a 28% no 3x — porque já é um formato comprimido, e o gzip só arranha o que sobrou. E, para cobrir as três densidades de tela, o PNG te obriga a três arquivos; o SVG é um só, e serve inclusive a densidade que ainda não existe.

Os dois erros que derrubam quem está começando

O primeiro é o mais comum de todos, e nem é sobre desenho: é sobre ordem. Se o script roda antes de o canvas existir na página, querySelector devolve null:

html
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Mapa de calor das estantes</title>
<script>
  const ctx = document.querySelector('#mapa-estantes').getContext('2d');
  ctx.fillRect(0, 0, 40, 40);
</script>
</head>
<body>
  <canvas id="mapa-estantes" width="320" height="180"></canvas>
</body>
</html>
TypeError: Cannot read properties of null (reading 'getContext') at file:///private/tmp/biblioteca/erro-canvas.html:7:55

Leia a mensagem inteira: o problema não é getContext, é o null antes dele. O querySelector não achou nada porque, naquele instante, o body ainda não foi lido.

E aqui mora a armadilha da correção: defer não conserta script inline. O atributo só vale para script com src; num bloco escrito dentro do HTML o navegador ignora o atributo em silêncio. Rodei as três versões da mesma página — a original, a mesma com <script defer>, e uma terceira com as duas linhas movidas para um mapa.js carregado por <script defer src="mapa.js"> (com um console.log no fim, para provar que desenhou):

js
for (const arquivo of ['erro-canvas.html', 'erro-defer.html', 'erro-defer-src.html']) {
  const pagina = await navegador.newPage();
  const linhas = [];
  pagina.on('pageerror', (e) => linhas.push(e.message));
  pagina.on('console', (m) => linhas.push('console: ' + m.text()));
  await pagina.goto(pathToFileURL(arquivo).href);
  console.log(arquivo.padEnd(20), linhas.length ? linhas.join(' | ') : '(sem erro)');
  await pagina.close();
}
erro-canvas.html Cannot read properties of null (reading 'getContext') erro-defer.html Cannot read properties of null (reading 'getContext') erro-defer-src.html console: desenhou no mapa-estantes

A do meio quebra igualzinho à primeira. Só a terceira desenha. Se você não quer criar um arquivo separado, os dois caminhos honestos são mover o <script> para o fim do body ou embrulhar o desenho num DOMContentLoaded. O assunto tem lição própria em script defer e async.

O segundo é específico de SVG e cruel, porque não dá erro nenhum: você cria a forma com document.createElement e ela simplesmente não aparece.

js
const svg = document.querySelector('#grafico-svg');

const errado = document.createElement('rect');
errado.setAttribute('x', '28');
errado.setAttribute('y', '10');
errado.setAttribute('width', '34');
errado.setAttribute('height', '20');
svg.append(errado);

const NS = 'http://www.w3.org/2000/svg';
const certo = document.createElementNS(NS, 'rect');
certo.setAttribute('x', '74');
certo.setAttribute('y', '10');
certo.setAttribute('width', '34');
certo.setAttribute('height', '20');
svg.append(certo);

const medir = (el) => `${el.getBoundingClientRect().width} x ${el.getBoundingClientRect().height}`;
console.log({
  errado: { namespace: errado.namespaceURI, ehSVGElement: errado instanceof SVGElement, caixa: medir(errado) },
  certo: { namespace: certo.namespaceURI, ehSVGElement: certo instanceof SVGElement, caixa: medir(certo) },
});
{ errado: { namespace: 'http://www.w3.org/1999/xhtml', ehSVGElement: false, caixa: '0 x 0' }, certo: { namespace: 'http://www.w3.org/2000/svg', ehSVGElement: true, caixa: '34 x 20' } }

Os dois elementos entraram no documento. Só que o primeiro nasceu no namespace do HTML — é um <rect> HTML, uma tag desconhecida qualquer, do mesmo naipe de um <xyz>. Ele não é um SVGElement, o navegador não sabe desenhá-lo, e a caixa dele mede zero por zero. O segundo nasceu no namespace do SVG e mede exatamente os 34 por 20 que você pediu.

A tabela de decisão

o que você vai desenhar escolha por quê
ícone, logo, ilustração SVG inline ou use herda a cor do texto e pesa centenas de bytes
gráfico com até alguns milhares de formas SVG cada forma é clicável e descrita sem código extra
ilustração grande que se repete em várias páginas SVG em img ganha cache do navegador e não engorda o HTML
mapa de calor, nuvem de pontos, partículas canvas acima de dezenas de milhares de formas o DOM cobra caro
jogo, editor de imagem, filtro de foto canvas você precisa mesmo é do pixel
assinatura, desenho a dedo na tela canvas traço livre não vira elemento
animação de layout (menu, card, botão) nem um nem outro CSS resolve, e roda na GPU

Quando a dúvida persistir, volte à pergunta do começo: cada parte do desenho precisa de nome, de clique ou de descrição? Se sim, SVG. Se o desenho é uma massa de pixels que ninguém vai selecionar, canvas.

O que vem depois

O próximo passo natural é fazer o gráfico da biblioteca sair de um array de dados em vez de estar escrito na mão — o mesmo for que você já usa para montar lista, só que gerando rect com createElementNS. Depois disso, vale ver como o resto da mídia entra na página em vídeo e áudio em HTML, e conferir onde este assunto se encaixa no guia completo de HTML.

  • html
  • svg
  • canvas
  • icones
  • graficos

Perguntas frequentes

Dá para animar um SVG sem JavaScript?
Dá. Como cada forma é um elemento, transition e @keyframes do CSS funcionam nos atributos apresentados como propriedade — fill, stroke-dashoffset, opacity, transform. É assim que se faz aquele traço que se desenha sozinho, sem uma linha de script.
Como eu salvo o que foi desenhado no canvas como imagem?
Com canvas.toBlob(blob => ...), que devolve o PNG pronto para virar download ou upload. Existe também toDataURL(), que devolve a imagem como texto base64 — mais fácil de inspecionar, e cerca de 33% maior que o arquivo binário equivalente.
Por que o meu canvas fica borrado no celular?
Porque width e height do elemento são pixels reais, e o CSS estica esses pixels. Numa tela com devicePixelRatio 3, defina o canvas com o triplo do tamanho, aplique ctx.scale(3, 3) e deixe o CSS controlar só o tamanho visível. Um SVG não tem esse problema porque não tem pixel.
SVG baixado da internet pode conter script?
Pode: SVG aceita a tag script dentro dele. Dentro de img o navegador não executa esse script, mas se você colar o arquivo inline na sua página ele passa a ser código seu. Abra o arquivo num editor de texto antes de colar — SVG é texto, dá para ler.
Preciso de uma biblioteca de gráficos para desenhar isso?
Para um gráfico de barras estático, não: é um for gerando rect. A biblioteca compensa quando entram eixos com escala automática, tooltip, zoom e séries que mudam com o tempo — aí você está reescrevendo a biblioteca sem querer.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0 e Chrome 151.0.7922.34 (headless), MacBook Apple M4 Pro, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — SVG: Scalable Vector Graphics — developer.mozilla.org
  2. MDN — Canvas API — developer.mozilla.org
  3. HTML Standard — The canvas element — html.spec.whatwg.org

Continue por aqui