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.
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.
<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:
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);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:
<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">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));
}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:
<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:
<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:
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();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:
.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.
<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:
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();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:
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));
}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.
<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:
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();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).
<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.
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();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:
<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.
<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">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();.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.
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);
}O macOS encontrou o arquivo com o nome errado. Agora o mesmo teste dentro de um Linux, que é onde o site vai morar:
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'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.
Perguntas frequentes
Onde eu guardo as imagens do projeto?
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?
Qual o tamanho máximo que uma imagem deveria ter?
Uso `img` ou `background-image` do CSS?
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"`?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
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
- MDN — img: o elemento de imagem — developer.mozilla.org
- MDN — Imagens responsivas — developer.mozilla.org
- web.dev — Cumulative Layout Shift (CLS) — web.dev
- HTML Standard — The img element — html.spec.whatwg.org



