Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

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

Imagens em HTML: img, alt, tamanho e carregamento lento

Como colocar imagem na página, escrever um alt que presta, evitar o layout pulando e servir a versão certa da foto para cada tela.

Rodolfo Mori12 min de leitura

Uma imagem entra na página com a tag img e dois atributos: src, o caminho do arquivo, e alt, o texto que ocupa o lugar dela quando a imagem não aparece. Essa parte cabe em uma linha e você aprende em trinta segundos.

O resto desta lição é o que vem depois da linha existir: o layout que pula quando a foto chega, os kB que a página baixa sem precisar e o arquivo certo para cada tamanho de tela. O cenário é a estante online da Livraria Página Sete, com doze capas de livro numa mesma página. Todos os números aqui saíram de rodar essa página num Mac — o peso dos arquivos no Node 24.16.0, e as medições de tela no Chrome 151.

O img é um elemento substituído: o navegador reserva um lugar no documento e desenha ali um recurso externo. Em palavras simples, a tag marca a posição; o arquivo chega por outra requisição e ocupa esse espaço.

Reservar o espaço antes de a foto chegar

Imagine a entrega de uma estante para a livraria. Se a equipe conhece largura e altura, deixa o espaço livre; se não conhece, precisa empurrar mesas quando o móvel chega. Os atributos width e height dão ao navegador a proporção da imagem antes do download, evitando que o conteúdo ao redor salte.

Abra a aba Network com o cache desligado e recarregue o primeiro exemplo. Observe primeiro quando o elemento entra no layout e depois quando o arquivo termina de baixar. Repita removendo apenas as dimensões no DevTools e compare o movimento. Você verifica separadamente markup, requisição e estabilidade visual.

html
<img src="/img/dom-casmurro.jpg" alt="Capa de Dom Casmurro">

img é um elemento vazio: ele não tem conteúdo entre tags de abertura e fechamento, porque o conteúdo dele é o arquivo apontado pelo src. Não existe </img>. Se você escrever mesmo assim, o navegador não reclama — ele descarta o fechamento e deixa o texto solto na página, o que é pior do que um erro.

Dá para ver isso sem abrir o navegador, usando o jsdom, que é o mesmo analisador de HTML por trás de muitos testes de front-end:

js
import { JSDOM } from 'jsdom';

const dom = new JSDOM('<img src="capas/dom-casmurro.jpg" alt="Capa de Dom Casmurro">Capa de Dom Casmurro</img>');
console.log(dom.window.document.body.innerHTML);
<img src="capas/dom-casmurro.jpg" alt="Capa de Dom Casmurro">Capa de Dom Casmurro

O </img> sumiu e a frase virou texto normal, colada na imagem. Se você queria uma legenda, ela tem outro lugar — o figcaption, que aparece mais adiante.

alt: o texto que ocupa o lugar da imagem

O alt é lido em voz alta por leitores de tela, aparece quando a imagem não carrega e é o que o buscador entende da foto. Ele tem três estados, e os dois últimos são diferentes entre si:

html
<img src="capas/grande-sertao.jpg" alt="Capa de Grande Sertão: Veredas, de Guimarães Rosa">
<img src="ornamentos/folha.png" alt="">
<img src="capas/vidas-secas.jpg">
js
import { JSDOM } from 'jsdom';

const dom = new JSDOM(
  `<img src="capas/grande-sertao.jpg" alt="Capa de Grande Sertão: Veredas, de Guimarães Rosa">
   <img src="ornamentos/folha.png" alt="">
   <img src="capas/vidas-secas.jpg">`,
  { url: 'https://livrariapaginasete.com.br/estante/' }
);

for (const img of dom.window.document.images) {
  console.log(img.getAttribute('src'));
  console.log('  URL final   :', img.src);
  console.log('  tem atributo:', img.hasAttribute('alt'));
  console.log('  valor do alt:', JSON.stringify(img.alt));
}
capas/grande-sertao.jpg URL final : https://livrariapaginasete.com.br/estante/capas/grande-sertao.jpg tem atributo: true valor do alt: "Capa de Grande Sertão: Veredas, de Guimarães Rosa" ornamentos/folha.png URL final : https://livrariapaginasete.com.br/estante/ornamentos/folha.png tem atributo: true valor do alt: "" capas/vidas-secas.jpg URL final : https://livrariapaginasete.com.br/estante/capas/vidas-secas.jpg tem atributo: false valor do alt: ""

Repare nas duas últimas: a propriedade alt devolve string vazia nas duas, mas hasAttribute('alt') distingue uma da outra. E essa diferença é justamente o que o leitor de tela usa. alt="" significa “esta imagem é enfeite, pule”; sem atributo nenhum significa “não sei o que é isso”, e aí o leitor de tela costuma anunciar o nome do arquivo — vidas-secas.jpg — para não deixar a pessoa sem informação.

Repare também na URL final: o caminho relativo virou URL absoluta a partir do endereço da página. É o mesmo cálculo do href que você viu em links em HTML.

a imagem é o que escrever por quê
capa do livro no catálogo alt="Capa de Vidas Secas, de Graciliano Ramos" descreve a informação que a foto carrega
ícone de carrinho ao lado da palavra “Carrinho” alt="" o texto ao lado já diz tudo; repetir é ruído
botão só com o ícone de lupa alt="Buscar" descreva a ação, não o desenho
linha decorativa entre seções alt="" não carrega informação nenhuma

O erro clássico é escrever alt="imagem" ou alt="capa.jpg". Não descreve nada e ainda faz o leitor de tela dizer “imagem imagem”. A pergunta certa é: se a foto sumisse, que frase eu colocaria no lugar dela? Escreva essa frase. O assunto tem uma lição inteira em acessibilidade em HTML.

width e height: medindo o pulo do layout

Esse é o atributo que quase todo mundo pula, e ele tem número. Sem width e height no HTML, o navegador não sabe quanto espaço reservar antes de a imagem chegar: ele desenha a página com a imagem valendo zero de altura e, quando o arquivo termina de baixar, empurra tudo que estava embaixo para baixo. É o famoso texto que foge do dedo na hora do clique.

Montei a estante em duas versões. A primeira, sem medida:

html
<figure class="cartao">
  <img src="/img/vitrine-800.jpg?t=Dom-Casmurro" alt="Capa de Dom Casmurro">
  <figcaption>Dom Casmurro</figcaption>
</figure>

A segunda, idêntica, só com os dois atributos — os números são o tamanho real do arquivo em pixels, 800 × 534:

html
<figure class="cartao">
  <img src="/img/vitrine-800.jpg?t=Dom-Casmurro" alt="Capa de Dom Casmurro"
       width="800" height="534">
  <figcaption>Dom Casmurro</figcaption>
</figure>

O ?t=Dom-Casmurro no fim do src não faz parte da lição: é só para cada uma das doze imagens ter uma URL diferente e o navegador não reaproveitar o cache durante a medição.

As duas páginas são servidas por um servidor local que atrasa cada imagem em 400 ms, simulando uma rede ruim. O Chrome roda de verdade, e o próprio navegador informa o quanto a página se mexeu — é a métrica CLS (Cumulative Layout Shift), a mesma que o Google usa para avaliar a experiência da página:

js
import puppeteer from 'puppeteer-core';

const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const navegador = await puppeteer.launch({ executablePath: CHROME, headless: 'shell' });
console.log('Chrome:', await navegador.version());

async function medirCLS(pagina) {
  const aba = await navegador.newPage();
  await aba.setViewport({ width: 1280, height: 800 });
  await aba.evaluateOnNewDocument(() => {
    window.__cls = 0;
    new PerformanceObserver((lista) => {
      for (const e of lista.getEntries()) if (!e.hadRecentInput) window.__cls += e.value;
    }).observe({ type: 'layout-shift', buffered: true });
  });
  await aba.goto(`http://localhost:4599/${pagina}`, { waitUntil: 'load' });
  await new Promise((r) => setTimeout(r, 1500));
  const cls = await aba.evaluate(() => window.__cls);
  await aba.close();
  console.log(pagina.padEnd(16), 'CLS', cls.toFixed(3));
}

await medirCLS('sem-medida.html');
await medirCLS('com-medida.html');
await navegador.close();
Chrome: Chrome/151.0.7922.170 sem-medida.html CLS 0.592 com-medida.html CLS 0.000

Zero contra 0,592. Em três execuções seguidas a página sem medida marcou 0,592, 0,556 e 0,306 — varia com o momento em que cada imagem chega —, e a página com medida marcou 0,000 nas três. Para referência, o Google considera boa uma página com CLS até 0,1. Dois atributos que ninguém digita são a diferença entre passar folgado e reprovar por seis vezes o limite.

O truque é que o navegador usa width e height só para calcular a proporção e reservar a caixa. Quem manda no tamanho final continua sendo o CSS, desde que você deixe a altura livre:

css
.cartao img {
  width: 100%;
  max-width: 600px;
  height: auto; /* sem isto, a imagem estica: 534px fixos */
}

loading="lazy": quantos kB ele deixa de baixar

Por padrão o navegador baixa todas as imagens da página, inclusive as que estão a três rolagens de distância e que talvez ninguém veja. O atributo loading="lazy" adia isso: a imagem só é pedida quando chega perto da janela.

html
<img src="/img/vitrine-800.jpg?t=Dom-Casmurro" alt="Capa de Dom Casmurro"
     width="800" height="534" loading="lazy">

Para medir, o servidor da estante conta quantas imagens foram pedidas e quantos bytes saíram. O teste abre a página, espera, olha o contador, rola até o fim e olha de novo:

js
async function abrir(pagina) {
  await fetch('http://localhost:4599/reset');
  const aba = await navegador.newPage();
  await aba.setViewport({ width: 1280, height: 800 });
  await aba.goto(`http://localhost:4599/${pagina}`, { waitUntil: 'load' });
  await new Promise((r) => setTimeout(r, 1500));
  const antes = await (await fetch('http://localhost:4599/stats')).json();

  await aba.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
  await new Promise((r) => setTimeout(r, 2500));
  const depois = await (await fetch('http://localhost:4599/stats')).json();

  await aba.close();
  return { antes, depois };
}

for (const p of ['eager.html', 'lazy.html']) {
  const { antes, depois } = await abrir(p);
  console.log(p.padEnd(12),
    'ao abrir: ' + String(antes.imagens).padStart(2) + ' imagens / ' + (antes.bytes / 1024).toFixed(0).padStart(3) + ' kB',
    '| depois de rolar: ' + String(depois.imagens).padStart(2) + ' imagens / ' + (depois.bytes / 1024).toFixed(0).padStart(3) + ' kB');
}

await navegador.close();
eager.html ao abrir: 12 imagens / 406 kB | depois de rolar: 12 imagens / 406 kB lazy.html ao abrir: 5 imagens / 169 kB | depois de rolar: 10 imagens / 338 kB

Ao abrir a estante, a versão normal baixou as doze capas e 406 kB. A versão com lazy baixou cinco e 169 kB — 58% a menos para mostrar exatamente a mesma tela. E olhe a segunda metade da linha: mesmo depois de rolar até o fim, a página com lazy parou em dez imagens. Duas capas do meio nunca foram pedidas, porque o salto até o fim passou por cima delas sem parar. O navegador não busca o que você não chegou a olhar.

JPG, PNG, WebP ou SVG: quanto cada formato custa

A escolha do formato costuma custar mais bytes que qualquer otimização de código. Peguei a mesma foto de 1600 × 1067 e gerei as versões com a biblioteca sharp:

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

for (const largura of [400, 800, 1600]) {
  await sharp('vitrine.jpg').resize(largura).jpeg({ quality: 80 }).toFile(`vitrine-${largura}.jpg`);
  await sharp('vitrine.jpg').resize(largura).webp({ quality: 80 }).toFile(`vitrine-${largura}.webp`);
}
await sharp('vitrine.jpg').resize(1600).png().toFile('vitrine-1600.png');

const kb = (arq) => (statSync(arq).size / 1024).toFixed(1).padStart(7) + ' kB';
for (const arq of ['vitrine-400.jpg', 'vitrine-400.webp', 'vitrine-800.jpg',
                   'vitrine-800.webp', 'vitrine-1600.jpg', 'vitrine-1600.webp',
                   'vitrine-1600.png']) {
  console.log(arq.padEnd(18), kb(arq));
}
vitrine-400.jpg 10.5 kB vitrine-400.webp 5.3 kB vitrine-800.jpg 33.8 kB vitrine-800.webp 14.9 kB vitrine-1600.jpg 89.2 kB vitrine-1600.webp 43.0 kB vitrine-1600.png 1279.9 kB

A mesma foto: 1279,9 kB em PNG, 89,2 kB em JPG, 43,0 kB em WebP. O PNG é quatorze vezes o JPG — e é o formato que sai por padrão do print da tela, que é como a maioria das fotos pesadas entra num projeto de iniciante.

A regra que dá para levar para qualquer projeto:

  • foto (capa, produto, pessoa): JPG, ou WebP se você puder gerar;
  • imagem com transparência ou área chapada (logotipo em PNG antigo, print de interface): PNG;
  • desenho vetorial (ícone, logo, gráfico): SVG, que não perde qualidade em nenhum tamanho e costuma pesar poucos kB — tem lição própria em SVG e canvas no HTML;
  • animação curta: vídeo, nunca GIF. Um GIF de dois segundos passa fácil de 1 MB.

srcset e sizes: quem escolhe o arquivo é o navegador

Mandar a foto de 1600 px para um celular de 375 px é desperdiçar 80 kB. Com srcset você oferece as três versões e declara a largura real de cada uma (400w, 800w, 1600w); com sizes você diz quanto espaço a imagem vai ocupar na tela. O navegador faz a conta e escolhe.

html
<img src="/img/vitrine-800.jpg"
     srcset="/img/vitrine-400.jpg 400w,
             /img/vitrine-800.jpg 800w,
             /img/vitrine-1600.jpg 1600w"
     sizes="(max-width: 700px) 100vw, 600px"
     width="800" height="534"
     alt="Capa de Grande Sertão: Veredas">

Traduzindo o sizes: até 700 px de tela a imagem ocupa a largura toda (100vw); acima disso, ocupa 600 px fixos. O src continua ali como reserva para quem não entender srcset. E o width/height continua sendo o do arquivo padrão: como as três versões têm a mesma proporção, um par de números serve para reservar a caixa de qualquer uma delas.

Para descobrir qual arquivo o Chrome realmente pediu em cada situação, basta perguntar ao próprio elemento pela propriedade currentSrc depois que ele carrega:

js
async function escolhida(pagina, width, dpr) {
  const aba = await navegador.newPage();
  await aba.setViewport({ width, height: 800, deviceScaleFactor: dpr });
  await aba.goto(`http://localhost:4599/${pagina}`, { waitUntil: 'networkidle0' });
  const src = await aba.evaluate(() => document.getElementById('capa').currentSrc);
  await aba.close();
  return src.replace('http://localhost:4599', '');
}

console.log('tela  DPR  arquivo que o Chrome baixou');
for (const [w, d] of [[375, 1], [375, 2], [768, 1], [1440, 1], [1440, 2]]) {
  console.log(String(w).padStart(4), String(d).padStart(4), ' ', await escolhida('srcset.html', w, d));
}

await navegador.close();
tela DPR arquivo que o Chrome baixou 375 1 /img/vitrine-400.jpg 375 2 /img/vitrine-800.jpg 768 1 /img/vitrine-800.jpg 1440 1 /img/vitrine-800.jpg 1440 2 /img/vitrine-1600.jpg

O DPR é a densidade da tela: num celular moderno, 1 pixel de CSS vale 2 pixels de verdade. Veja a segunda linha — a mesma tela de 375 px, com DPR 2, precisa de 750 pixels reais e por isso sobe para o arquivo de 800. E na quarta linha, uma tela de 1440 px com DPR 1 fica no arquivo de 800, porque o sizes avisou que a imagem só ocupa 600 px. Sem esse aviso o navegador chutaria a largura da janela inteira e baixaria o arquivo grande à toa.

<picture>: quando você precisa mandar mesmo

O srcset deixa a decisão com o navegador. O picture serve para quando a decisão é sua — trocar o formato do arquivo, ou trocar a arte da imagem no celular (o famoso corte diferente para tela estreita).

html
<picture>
  <source srcset="/img/vitrine-800.webp" type="image/webp">
  <img src="/img/vitrine-800.jpg" width="800" height="534" alt="Capa de Grande Sertão: Veredas">
</picture>

O navegador lê os source de cima para baixo e fica no primeiro que entende. Se nenhum servir, cai no img — que continua obrigatório e é ele que carrega o alt, o width e o height.

js
const aba = await navegador.newPage();
await aba.goto('http://localhost:4599/picture.html', { waitUntil: 'networkidle0' });

console.log('src   :', await aba.evaluate(() => document.getElementById('capa').src));
console.log('baixou:', await aba.evaluate(() => document.getElementById('capa').currentSrc));
await navegador.close();
src : http://localhost:4599/img/vitrine-800.jpg baixou: http://localhost:4599/img/vitrine-800.webp

O src continua apontando para o JPG, mas o arquivo que desceu foi o WebP — 14,9 kB no lugar de 33,8 kB. É a diferença entre src (o que está escrito) e currentSrc (o que o navegador escolheu).

figure e figcaption: imagem com legenda

Quando a imagem tem uma legenda visível, ela e o texto formam uma unidade. É para isso que existe o figure:

html
<figure class="cartao">
  <img src="/img/dom-casmurro.jpg" alt="Capa de Dom Casmurro: um homem de perfil sobre fundo escuro"
       width="800" height="534" loading="lazy">
  <figcaption>Dom Casmurro — Machado de Assis, 1899. R$ 34,90</figcaption>
</figure>

alt e figcaption não são a mesma coisa e não devem repetir um ao outro. O alt descreve o que se vê, para quem não vê. O figcaption é informação adicional, que todo mundo lê. Se os dois estão idênticos, apague o alt deixando alt="" — a legenda já cumpre o papel.

A imagem que não aparece: extensão, maiúscula e caminho

Toda pessoa que aprende HTML passa por isso: a imagem existe, o código parece certo, e a página mostra o ícone quebrado. São quase sempre três causas, e o navegador sabe apontar qual.

html
<img id="ok"     src="/img/vitrine-400.jpg"  alt="Capa de Grande Sertão: Veredas">
<img id="errada" src="/img/vitrine-400.jpeg" alt="Capa de Vidas Secas">
js
const aba = await navegador.newPage();

const respostas = [];
aba.on('response', (r) => {
  if (r.request().resourceType() === 'image') respostas.push(`${r.status()} ${new URL(r.url()).pathname}`);
});
await aba.goto('http://localhost:4599/quebrada.html', { waitUntil: 'networkidle0' });
for (const linha of respostas.sort()) console.log('rede  ', linha);

const diagnostico = await aba.evaluate(() =>
  [...document.images].map((i) => `${i.id.padEnd(7)} naturalWidth=${i.naturalWidth}  carregou=${i.complete && i.naturalWidth > 0}`)
);
for (const linha of diagnostico) console.log('imagem', linha);
await navegador.close();
rede 200 /img/vitrine-400.jpg rede 404 /img/vitrine-400.jpeg imagem ok naturalWidth=400 carregou=true imagem errada naturalWidth=0 carregou=false

.jpg e .jpeg são extensões diferentes para o mesmo formato, e o servidor não adivinha. O 404 na aba Rede do DevTools é a prova — o mesmo Failed to load resource: 404 que aparece no console.

A terceira causa é a mais traiçoeira, porque só aparece depois que você publica: a maiúscula. O arquivo se chama Grande-Sertao.JPG e você escreveu grande-sertao.jpg. No seu Mac isso funciona. No servidor, não.

js
import { existsSync, readdirSync } from 'node:fs';

console.log('sistema      :', process.platform);
console.log('arquivos em capas/:', readdirSync('capas').join(', '));
for (const caminho of ['capas/Grande-Sertao.JPG', 'capas/grande-sertao.jpg', 'capas/grande-sertao.jpeg']) {
  console.log((existsSync(caminho) ? 'encontrou   ' : 'nao achou   ') + caminho);
}
sistema : darwin arquivos em capas/: Grande-Sertao.JPG encontrou capas/Grande-Sertao.JPG encontrou capas/grande-sertao.jpg nao achou capas/grande-sertao.jpeg

O macOS encontrou o arquivo com o nome errado. Agora o mesmo teste dentro de um Linux, que é onde o site vai morar:

bash
docker run --rm -v "$PWD/capas:/entrada" alpine sh -c \
  'mkdir /site && cp /entrada/Grande-Sertao.JPG /site/ && echo "sistema      : linux" \
   && echo "arquivos em capas/: $(ls /site)" \
   && for f in Grande-Sertao.JPG grande-sertao.jpg grande-sertao.jpeg; do \
        [ -f "/site/$f" ] && echo "encontrou   capas/$f" || echo "nao achou   capas/$f"; done'
sistema : linux arquivos em capas/: Grande-Sertao.JPG encontrou capas/Grande-Sertao.JPG nao achou capas/grande-sertao.jpg nao achou capas/grande-sertao.jpeg

O mesmo arquivo, o mesmo caminho, resposta diferente. O Windows se comporta como o Mac, e o Linux do servidor é quem manda no fim. Por isso a regra de nomear arquivo em programação é sempre a mesma: tudo minúsculo, sem acento, sem espaço, separando com hífen. grande-sertao.jpg, nunca Grande Sertão.JPG.

O que vem depois

Você já consegue publicar uma página com imagens que não pulam, não pesam e são lidas por quem não enxerga. O próximo passo natural é organizar o conteúdo em volta delas: primeiro as listas em HTML, e depois as outras mídias, em vídeo e áudio em HTML, onde os atributos width, height e o carregamento adiado voltam com outro nome.

O mapa completo com a ordem de estudo está na trilha de HTML e no guia de HTML do zero.

Prefere aprender em vídeo?

Tem uma aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • html
  • imagens
  • alt
  • lazy loading
  • srcset

Perguntas frequentes

Onde eu guardo as imagens do projeto?
Numa pasta só, geralmente img/ ou assets/img/, na raiz do site. Use caminho começando com barra (/img/capa.jpg): ele funciona igual em qualquer página do site, enquanto o caminho relativo muda de significado quando a página muda de pasta.
Posso apontar o src para uma imagem hospedada em outro site?
Funciona, e chama-se hotlink. Só que o outro servidor pode renomear o arquivo, cair ou bloquear o seu domínio a qualquer momento, e você não controla tamanho nem cache. Fora a questão de direito de uso. Baixe a imagem e sirva do seu próprio site.
Qual o tamanho máximo que uma imagem deveria ter?
Como regra de bolso, nunca mais que o dobro da largura em que ela é exibida — o dobro cobre telas de alta densidade. Uma foto exibida em 600 pixels não precisa passar de 1200 de largura.
Uso `img` ou `background-image` do CSS?
Se a imagem é conteúdo (a capa do livro, a foto do produto), é img, que tem alt e entra na busca por imagem. Se é enfeite atrás do texto (textura, gradiente), é background-image no CSS.
Para que serve o atributo `decoding="async"`?
Ele libera o navegador para decodificar a imagem fora da thread principal, evitando um travamento curto na hora de exibir fotos grandes. É seguro deixar em imagens que não são a primeira coisa visível da página.

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.170, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — img: o elemento de imagem — developer.mozilla.org
  2. MDN — Imagens responsivas — developer.mozilla.org
  3. web.dev — Cumulative Layout Shift (CLS) — web.dev
  4. HTML Standard — The img element — html.spec.whatwg.org

Continue por aqui