Pular para o conteúdo
Gustavo Tutorial

Montar o site

A trilha HTML puro ensina as peças — tag, seletor, flexbox, evento. Esta ensina a juntar as peças num site inteiro: em que ordem construir, onde cada arquivo mora, o esqueleto do HTML, o CSS em camadas, os padrões de layout que se repetem, os nomes que mantêm tudo legível, os ícones em SVG, os vídeos e mapas que não pesam, e o que o CSS já resolve sozinho. No fim, o projeto-base pronto para copiar, com os mesmos tokens que as ferramentas geram.

Revisão já conversamos sobre isso Novo ainda não vimos 9 partes · 38 tópicos

Nenhum tópico com esse filtro.

PARTE 1

Antes do primeiro arquivo

Todo site começa igual, e é por isso que vale ter um jeito fixo de começar. A ordem em que as coisas são construídas, onde cada arquivo mora e quando separar em vários.

A ordem em que um site se constrói

Novo

O erro mais caro é começar pelo que dá mais vontade: a animação da hero, a cor do botão. Cada camada depende da de baixo, e mexer em baixo depois obriga a refazer o que está em cima.

OrdemCamadaPronta quando
1Conteúdoo texto de cada seção existe — de verdade ou marcado como [PREENCHER]
2HTMLa página lê bem sem CSS nenhum: títulos na ordem, listas como lista, botões como botão
3Tokenscor, fonte, tamanhos e espaços decididos e em variáveis
4Layoutas seções se arrumam do celular à tela grande
5Componentesbotão, card, formulário, com todos os estados
6Movimentosó depois que o resto está certo — e só onde ajuda

A camada 2 é a que mais se pula e a que mais protege: HTML que faz sentido sem estilo é HTML que o Google, o leitor de tela e a IA que resume o site entendem. O teste é desligar o CSS no DevTools e ler a página de cima a baixo.

As camadas 3 e 5 já têm ferramenta: Paleta, Tipografia e Escala decidem os tokens, e o Design system junta tudo num tokens.css. O conteúdo da camada 1 sai da estratégia da trilha Landing pages.

Onde cada arquivo mora

Novo

Não existe estrutura "certa", mas existe uma que não atrapalha: previsível, com nomes que dizem o que o arquivo é, e rasa — ninguém precisa abrir cinco pastas para achar a foto da fachada.

projeto de site de serviço
nome-do-cliente/
├── index.html
├── obrigado.html
├── politica-de-privacidade.html
├── 404.html
├── css/
│   ├── tokens.css      ← sai da ferramenta Design system
│   └── estilo.css      ← reset, base, layout, componentes
├── js/
│   └── app.js
├── img/
│   ├── hero.webp
│   └── servicos/
├── favicon.svg
├── robots.txt
└── sitemap.xml
Regra de nomePor quê
tudo minúsculoo servidor diferencia Foto.jpg de foto.jpg; o Windows, não — funciona no seu computador e quebra no ar
sem acento, sem espaço, sem çvira %C3%A7 na URL e quebra em algum lugar
palavras separadas por hífenreforma-cozinha.webp se lê; reformacozinha não
o nome diz o que éimg_0231.jpg obriga a abrir para saber
Os arquivos que ficam na raiz — robots, sitemap, favicon, 404 — e para que cada um serve estão em Os arquivos da raiz, na trilha Depois do ar.

Um arquivo só ou vários?

Novo

As duas formas estão certas; o que decide é quantas páginas o site tem.

Um HTML com tudo dentroHTML + css/ + js/
Bom paralanding page de uma página só, entrega por arquivo, protótipoduas páginas ou mais, site que vai crescer
Vantagemum arquivo abre em qualquer lugar, sem servidoro CSS é baixado uma vez e reaproveitado em todas as páginas
Custocada página nova copia o estilo inteiromais arquivos para organizar

A regra prática: a partir da segunda página, separe. Duas páginas com o mesmo CSS colado dentro viram duas versões diferentes do site na primeira correção que for feita só numa delas.

Por que o formato de arquivo único funciona tão bem para landing page está em HTML único autocontido. E quando o que se repete é o HTML — o mesmo cabeçalho em dez páginas —, as saídas sem build estão em Reuso sem build.
PARTE 2

O esqueleto do HTML

O <head> que toda página precisa, o <body> com as regiões certas, e um molde de seção que se repete igual do começo ao fim.

O head completo, em ordem

NovoHTML

O <head> não aparece na tela, mas decide como o site aparece em todo o resto: na aba, no Google, no WhatsApp, na tela do celular. A ordem abaixo não é enfeite — o charset e o viewport vêm primeiro porque o navegador precisa deles antes de ler qualquer outra coisa.

head
<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <title>Nome do negócio — o que faz, onde</title>
  <meta name="description" content="Uma frase que responde a busca e convida a clicar.">
  <link rel="canonical" href="https://seusite.com.br/">

  <meta property="og:title" content="Nome do negócio — o que faz">
  <meta property="og:description" content="A mesma ideia da description, para quem recebe o link.">
  <meta property="og:image" content="https://seusite.com.br/og.jpg">
  <meta property="og:url" content="https://seusite.com.br/">
  <meta property="og:type" content="website">
  <meta name="twitter:card" content="summary_large_image">

  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
  <link rel="icon" href="/favicon.ico" sizes="32x32">
  <link rel="apple-touch-icon" href="/apple-touch-icon.png">
  <meta name="theme-color" content="#0D6B62">

  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=...&display=swap">

  <link rel="stylesheet" href="css/tokens.css">
  <link rel="stylesheet" href="css/estilo.css">
  <script src="js/app.js" defer></script>
</head>
  • fontes antes do CSS: o preconnect abre a conexão com o Google Fonts enquanto o resto ainda está sendo lido
  • defer no script: ele baixa em paralelo e só roda depois que o HTML inteiro foi lido — pode ficar no <head> sem travar nada
  • tokens.css antes do estilo.css: as variáveis precisam existir antes de alguém usá-las
O bloco de título, descrição, canonical, Open Graph e JSON-LD sai pronto da ferramenta Cabeçalho da página. O que cada meta faz está em Anatomia de um documento HTML e SEO e Open Graph.

O body: as regiões da página

NovoHTML

Quatro regiões resolvem quase todo site: <header>, <main>, <footer> e, dentro do header, um <nav>. Leitor de tela lista essas regiões e deixa pular direto para elas — por isso cada uma aparece uma vez só e o <main> tem o conteúdo, não o menu.

body
<body>
  <a class="pular" href="#conteudo">Pular para o conteúdo</a>

  <header class="topo">
    <div class="container topo__linha">
      <a class="marca" href="/">Nome do negócio</a>
      <nav aria-label="Principal">
        <ul class="fila">
          <li><a href="#servicos">Serviços</a></li>
          <li><a href="#sobre">Sobre</a></li>
          <li><a href="#contato">Contato</a></li>
        </ul>
      </nav>
    </div>
  </header>

  <main id="conteudo">
    <section class="secao hero" aria-labelledby="titulo">
      <div class="container pilha">
        <h1 id="titulo">A promessa em uma linha</h1>
        <p>O que você faz, para quem, e por que confiar.</p>
        <a class="botao" href="https://wa.me/55DDDNUMERO">Pedir orçamento</a>
      </div>
    </section>

    <section class="secao" id="servicos" aria-labelledby="servicos-titulo">
      <div class="container pilha">
        <h2 id="servicos-titulo">Serviços</h2>
        <ul class="grade">
          <li class="card">...</li>
        </ul>
      </div>
    </section>
  </main>

  <footer class="rodape">
    <div class="container">
      <p>© <span data-ano>2026</span> Nome do negócio</p>
    </div>
  </footer>
</body>
</html>
  • o link "Pular para o conteúdo" é o primeiro da página: quem navega pelo teclado não precisa passar pelo menu em toda página
  • um <h1> só, com a promessa principal; cada seção começa num <h2>
  • aria-labelledby liga a seção ao próprio título: o leitor de tela anuncia "Serviços, região" em vez de só "região"
  • o botão que leva ao WhatsApp é um <a> — ele leva para outro lugar; <button> é para o que acontece na própria página
Por que o HTML certo já é metade da acessibilidade está em HTML semântico e Semântica: o HTML certo já é metade do caminho.

O molde de seção

NovoHTMLCSS

Uma landing page é uma sequência de seções, e o que faz ela parecer feita com cuidado é todas seguirem o mesmo molde: a mesma margem nas laterais, o mesmo respiro em cima e embaixo, o título sempre no mesmo lugar. Quando o molde é um só, seção nova custa minutos.

o molde
<section class="secao" id="depoimentos" aria-labelledby="depoimentos-titulo">
  <div class="container pilha">
    <h2 id="depoimentos-titulo">O que dizem os clientes</h2>
    <!-- o conteúdo da seção -->
  </div>
</section>
PeçaO que resolve
.secaoo respiro vertical, igual em todas
ido menu e os botões levam direto para ela: href="#depoimentos"
.containera largura máxima e a margem lateral, iguais em todas
.pilhao espaço entre título, texto e conteúdo

Seção com fundo de cor diferente continua no molde: a cor vai na <section>, que ocupa a largura toda, e o .container dentro dela mantém o texto alinhado com o resto da página.

Onze seções já prontas neste molde — topo com menu no celular, hero, serviços, quem faz, como funciona, depoimentos, planos, dúvidas, chamada final, contato e rodapé — estão na categoria Seções dos Modelos, para copiar direto no projeto-base. Que seções uma landing page precisa, e em que ordem, é estratégia: está em O catálogo de seções e A sequência lógica. Aqui é só o molde onde elas entram.
PARTE 3

O CSS em camadas

Um arquivo de estilo que cresce sem virar briga: em que ordem as coisas entram, o reset curto que todo projeto usa, onde moram os tokens e o que é estilo de base.

A ordem do arquivo de estilo

NovoCSS

CSS sem ordem vira uma sequência de correções: a regra de baixo vence a de cima, alguém põe !important, e seis meses depois ninguém sabe por que o botão está azul. O @layer resolve isso declarando, logo na primeira linha, quem vence quem.

a primeira linha do estilo.css
@layer reset, base, layout, componentes, utilitarios;
CamadaO que entraExemplo
resetzerar as diferenças entre navegadoresbox-sizing, margens
baseas tags sem classebody, h1, a, :focus-visible
layouto que arruma, não o que enfeita.container, .grade
componentesas peças com cara própria.botao, .card, .topo
utilitariosexceções de uma propriedade só.so-leitor

Com as camadas declaradas, a de baixo na lista sempre vence a de cima, não importa a especificidade: um .botao em componentes vence um a em base, mesmo que a regra do a esteja escrita depois.

O detalhe que pega: CSS escrito fora de qualquer camada vence todas elas. Isso é útil para uma correção de emergência, e é uma armadilha se um arquivo de terceiro for incluído sem camada — ele passa por cima de tudo. O resto da sintaxe está em Aninhamento nativo e @layer.

Um reset curto

NovoCSS

Cada navegador vem com estilos próprios: margens nos títulos, imagens que estouram a coluna, botões com outra fonte. O reset zera o que atrapalha e mais nada — não precisa de biblioteca, cabe em quinze linhas.

reset
@layer reset {
  *, *::before, *::after { box-sizing: border-box; }
  * { margin: 0; }
  html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
  body { min-height: 100svh; }
  img, picture, video, svg { display: block; max-width: 100%; height: auto; }
  input, button, textarea, select { font: inherit; color: inherit; }
  h1, h2, h3, h4, p, li { overflow-wrap: break-word; }
  ul[class], ol[class] { list-style: none; padding: 0; }
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
      animation-duration: .01ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: .01ms !important;
      scroll-behavior: auto !important;
    }
  }
}
LinhaO problema que resolve
box-sizing: border-boxlargura de 300px com padding continua 300px, não 340px
margin: 0as margens do navegador somem; o espaço vem da escala, não de um padrão invisível
img { max-width: 100% }foto grande não estoura a coluna no celular
font: inherit nos camposbotão e campo usam a fonte do site, não a do sistema
ul[class]lista usada como layout perde os marcadores; lista de texto corrido mantém
prefers-reduced-motionquem pediu menos movimento no sistema recebe menos movimento
O que é o box model, e por que o border-box muda a conta, está em Box model. O porquê do movimento reduzido, em Movimento.

Os tokens no :root

NovoCSS

Token é uma decisão de design com nome: --cor-destaque em vez de #0D6B62 espalhado em vinte lugares. Mudar a cor do site vira mudar uma linha — e o modo escuro vira redefinir as mesmas variáveis com outros valores.

css/tokens.css
/* tokens.css — troque este arquivo inteiro pelo que sai da
   ferramenta Design system: os nomes são os mesmos. */
:root {
  --cor-fundo:          #FFFFFF;
  --cor-superficie:     #F6F7F7;
  --cor-linha:          #DDE2E1;
  --cor-texto:          #1C2524;
  --cor-texto-fraco:    #5F6E6B;
  --cor-destaque:       #0D6B62;
  --cor-destaque-texto: #0D6B62;
  --cor-destaque-hover: #0A554E;
  --cor-sobre-destaque: #FFFFFF;
  --cor-foco:           #0D6B62;

  --fonte-titulo: system-ui, sans-serif;
  --fonte-corpo:  system-ui, sans-serif;
  --peso-titulo:  700;
  --peso-corpo:   400;

  --t-peq:   14px;
  --t-corpo: 17px;
  --t-h3:    clamp(20px, 2.5vw, 27px);
  --t-h2:    clamp(29px, 3.5vw, 41px);
  --t-h1:    clamp(32px, 6vw, 65px);
  --alt-corpo:  1.6;
  --alt-titulo: 1.1;
  --medida:     66ch;

  --e1: 4px;  --e2: 8px;  --e3: 12px; --e4: 16px; --e5: 24px;
  --e6: 32px; --e7: 48px; --e8: 64px; --e9: 96px; --e10: 128px;

  --raio: 8px;
  --dur:  200ms;
  --curva: cubic-bezier(.23, 1, .32, 1);
}

Os nomes acima são exatamente os que a ferramenta Design system exporta. O fluxo é: decidir cor, fonte e escala nas ferramentas da fase 2, baixar o tokens.css e colocar no lugar deste. O estilo.css não muda — ele só usa os nomes.

Nenhum valor solto no estilo.css. Se aparecer #333 ou margin: 13px no meio de um componente, ou é um token que faltou criar, ou é um erro. É essa regra que faz o site inteiro parecer da mesma mão. O porquê está em Variáveis CSS e as três camadas de token.

O estilo de base

NovoCSS

A camada de base estiliza as tags sozinhas, sem classe: o texto corrido, os títulos, os links, o foco. Feita direito, um parágrafo novo já nasce certo — sem ninguém lembrar de pôr classe nele.

base
@layer base {
  html { scroll-behavior: smooth; }
  body {
    background: var(--cor-fundo); color: var(--cor-texto);
    font-family: var(--fonte-corpo); font-weight: var(--peso-corpo);
    font-size: var(--t-corpo); line-height: var(--alt-corpo);
  }
  h1, h2, h3 {
    font-family: var(--fonte-titulo); font-weight: var(--peso-titulo);
    line-height: var(--alt-titulo); text-wrap: balance;
  }
  h1 { font-size: var(--t-h1); }
  h2 { font-size: var(--t-h2); }
  h3 { font-size: var(--t-h3); }
  p  { max-width: var(--medida); text-wrap: pretty; }
  a  { color: var(--cor-destaque-texto); text-underline-offset: .15em; }
  :root { accent-color: var(--cor-destaque); }   /* marcar, opção e deslizar na cor da marca (parte 8) */
  :focus-visible { outline: 2px solid var(--cor-foco); outline-offset: 3px; }
  [id] { scroll-margin-top: var(--e9); }   /* a âncora não some atrás do topo fixo */
}
  • text-wrap: balance nos títulos evita a última linha com uma palavra só; pretty nos parágrafos evita a palavra órfã no fim. Navegador que não entende simplesmente ignora
  • max-width: var(--medida) no parágrafo segura a linha numa largura confortável de ler, mesmo em tela grande
  • :focus-visible mostra o foco para quem usa teclado, sem aparecer no clique do mouse
  • scroll-margin-top faz o link #servicos parar abaixo do topo fixo, e não com o título escondido atrás dele
A medida da linha e o acabamento tipográfico estão em Acabamento tipográfico. O foco visível e por que ele não pode sumir, em Teclado e foco visível.
PARTE 4

Layout que se repete

Quase todo layout de site de serviço é a combinação de poucos padrões. Ter cada um como uma classe pronta é o que faz a página se montar rápido e continuar alinhada.

O container

NovoCSS

A largura máxima do conteúdo e a margem lateral no celular, numa linha só:

container
.container { width: min(100% - 2 * var(--e5), 72rem); margin-inline: auto; }

Leia assim: "a largura é o menor entre a tela menos 24px de cada lado e 72rem". No celular, vale a primeira — sobra margem dos dois lados. Na tela grande, vale a segunda — o conteúdo para de crescer e fica centralizado pelo margin-inline: auto. Nenhuma media query.

Uma largura para o site todo, não uma por seção. Texto corrido fica ainda mais estreito pela --medida do parágrafo; o que é mais largo — uma galeria, um mapa — sai do container de propósito, e isso é uma decisão, não um acidente.

Pilha, fila e grade

NovoCSS

Três classes cobrem quase todo arranjo de uma página de serviço. Cada uma resolve uma pergunta só.

os três padrões
@layer layout {
  .container { width: min(100% - 2 * var(--e5), 72rem); margin-inline: auto; }
  .secao     { padding-block: clamp(var(--e7), 8vw, var(--e9)); }

  /* pilha: coisas uma embaixo da outra, com o mesmo respiro */
  .pilha { display: flex; flex-direction: column; gap: var(--e5); }

  /* fila: coisas lado a lado que quebram linha quando não cabem */
  .fila  { display: flex; flex-wrap: wrap; align-items: center; gap: var(--e3); }

  /* grade: quantas colunas couberem, nenhuma mais estreita que 16rem */
  .grade {
    display: grid; gap: var(--e5);
    grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
  }
}
PadrãoA perguntaOnde aparece
.pilhacoisas uma embaixo da outra, com o mesmo espaço entre elastítulo + texto + botão; o conteúdo de um card
.filacoisas lado a lado que quebram linha quando não cabemos links do menu, os botões, as etiquetas
.gradequantas colunas couberem, todas iguaiscards de serviço, depoimentos, fotos

A .grade é a que mais economiza: auto-fit com minmax(min(16rem, 100%), 1fr) dá uma coluna no celular, duas no tablet e três ou quatro na tela grande — sem uma media query sequer. O min(16rem, 100%) é o que impede a coluna de estourar numa tela menor que 16rem.

A ideia de montar layout com poucos padrões pequenos e combináveis é o centro do livro Every Layout, de Heydon Pickering e Andy Bell. O funcionamento de cada propriedade está em Flexbox e Grid.

Do celular para cima

NovoCSS

A maior parte das visitas de site de serviço chega pelo celular, e o layout de celular é o mais simples: uma coluna. Por isso o CSS começa por ele, e a tela grande é que ganha regras a mais — com min-width.

celular primeiro
/* o padrão já é o celular: uma coluna */
.hero { display: grid; gap: var(--e6); }

/* a partir de onde o conteúdo pede, duas colunas */
@media (min-width: 52rem) {
  .hero { grid-template-columns: 1.2fr 1fr; align-items: center; }
}
  • o ponto de quebra vem do conteúdo, não de um aparelho: abra a página, alargue a janela devagar e ponha a media query onde o layout começa a ficar ruim
  • poucos pontos de quebra: com .grade e clamp() fazendo o trabalho, um ou dois costumam bastar
  • em rem, não em px: quem aumentou a fonte no celular recebe o layout de uma coluna mais cedo, que é o que ele precisa
Media query, clamp() e as unidades estão em Responsividade e media queries e Unidades de medida. Quando o componente precisa reagir ao espaço que ele tem, não à tela, entram as container queries.
PARTE 5

Nomes e organização

O que mantém um projeto legível daqui a seis meses: nomes de classe que dizem o que a coisa é, componentes pensados como peças, e um JavaScript que não derruba a página quando uma parte falha.

Nomes de classe que não viram bagunça

NovoCSS

O nome de classe descreve o que a coisa é, não como ela parece: .card-servico continua certo quando o card deixar de ser azul; .caixa-azul, não.

Uma convenção leve, a mesma usada neste site, resolve quase tudo: o bloco, o elemento de dentro com dois sublinhados, a variação com dois hífens.

bloco, elemento, variação
<article class="card card--destaque">
  <h3 class="card__titulo">Reforma completa</h3>
  <p class="card__texto">Do projeto à entrega da chave.</p>
</article>
ParteFormatoQuer dizer
bloco.carduma peça que faz sentido sozinha
elemento.card__titulouma parte que só existe dentro do bloco
variação.card--destaqueo mesmo bloco, um pouco diferente
O que ganha com isso: olhando só o HTML, dá para saber onde está o estilo de cada coisa; e nenhuma regra de um componente vaza para outro, porque ninguém escreve .card h3 — escreve .card__titulo. A convenção se chama BEM; aqui ela é usada sem cerimônia.

Pensar em componentes sem framework

NovoCSS

Componente não é coisa de React. É uma peça com nome, que funciona em qualquer lugar da página e que tem todos os estados previstos: o botão normal, com o mouse em cima, com foco, pressionado e desabilitado.

Um componente está pronto quandoTeste
funciona em qualquer seçãocopiar para outra seção e não precisar mexer em nada
não depende de onde estánenhuma regra do tipo .hero .botao
tem todos os estadosTab até ele, passar o mouse, apertar, desabilitar
só usa tokensnenhum valor solto no bloco dele

A variação vem pela classe --, nunca pelo lugar: o botão da hero é um .botao--grande que qualquer outra seção pode usar, não um .hero .botao que só existe ali.

A mesma ideia nos frameworks está em Pensar em componentes. Os componentes com todos os estados, já nos tokens do projeto, saem da ferramenta Design system; efeitos prontos para copiar estão em Modelos.

O JavaScript de um site de serviço

NovoJS

Site de serviço precisa de pouco JavaScript: o menu no celular, um acordeão, talvez um formulário. O risco não é o tamanho — é um erro num pedaço derrubar todos os outros.

js/app.js
/* app.js — cada bloco se guarda sozinho: se o elemento não existe, sai */

/* ano do rodapé sempre atual */
(function () {
  var ano = document.querySelector('[data-ano]');
  if (!ano) return;
  ano.textContent = new Date().getFullYear();
})();

/* o próximo recurso entra aqui, no mesmo formato */
  • cada recurso num bloco próprio, que começa verificando se o elemento existe — a página sem aquele elemento simplesmente pula o bloco
  • um arquivo, com defer: roda depois que o HTML está pronto, sem esperar DOMContentLoaded
  • o conteúdo não depende dele: com o JavaScript desligado, o texto continua lá e os links continuam funcionando
  • localStorage dentro de try: aba anônima e navegador bloqueado não podem quebrar a página
São as mesmas regras que o JavaScript deste site segue. O básico de selecionar elemento e ouvir evento está em Selecionar e manipular elementos e Eventos.
PARTE 6

Ícones e SVG

Quase todo site tem ícone: no botão do WhatsApp, na lista de serviços, no menu do celular, no logo. SVG resolve todos — nítido em qualquer tela, leve, e com a cor controlada pelo CSS. O que muda é como ele entra na página.

Três jeitos de pôr um SVG na página

NovoHTML

O mesmo arquivo .svg pode entrar de três formas, e cada uma decide o que o CSS consegue fazer com ele.

FormaComo ficaO CSS muda a cor?Use para
Inlineo código <svg> direto no HTMLsim, e animaícones, que precisam seguir a cor do texto
<img><img src="logo.svg" alt="...">nãologo e ilustração que têm cor própria
Fundo no CSSbackground-image: url(...)nãotextura e enfeite, nunca conteúdo
ícone inline
<svg class="icone" viewBox="0 0 24 24" aria-hidden="true">
  <path d="M5 12l5 5L20 7"/>
</svg>
  • viewBox é a folha de desenho — "0 0 24 24" quer dizer um quadrado de 24 unidades. Com ele, o ícone escala para qualquer tamanho sem perder nitidez
  • o tamanho vem do CSS, não de width e height no código: o mesmo ícone serve no botão pequeno e no card grande
  • fonte de ícones (um arquivo de fonte cheio de símbolos) era o jeito antigo: baixa o conjunto inteiro para usar cinco ícones, e aparece como quadradinho quando a fonte falha. SVG inline substituiu com vantagem

currentColor: o ícone segue o texto

NovoCSS

currentColor é uma palavra do CSS que quer dizer "a cor do texto aqui". Um ícone desenhado com ela muda de cor junto com o link ou o botão onde está — no hover, no foco, no modo escuro — sem nenhuma regra a mais.

a classe de todo ícone
.icone {
  display: inline-block;           /* o reset põe todo svg em bloco */
  vertical-align: -.25em;          /* alinha com a linha do texto */
  width: 1.25em; height: 1.25em;   /* em: acompanha o tamanho do texto ao lado */
  flex: none;                      /* não encolhe dentro de um flex */
  fill: none;
  stroke: currentColor;            /* a cor do texto em volta */
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
no botão
<a class="botao" href="https://wa.me/55DDDNUMERO">
  <svg class="icone" viewBox="0 0 24 24" aria-hidden="true">
    <path d="M4 20l1.5-4A8 8 0 1 1 9 19.5z"/>
  </svg>
  Chamar no WhatsApp
</a>

A classe .icone já está no estilo.css do projeto-base. O botão já tem a cor do texto definida; o ícone herda. Troque o color do botão no hover e o ícone troca junto. O 1.25em faz o ícone crescer quando o texto cresce, e o .botao do projeto-base já tem gap para separar os dois.

Ícone de traço (linha) usa stroke; ícone cheio usa fill: currentColor e stroke: none. Não misture os dois estilos no mesmo site — é o tipo de detalhe que faz a página parecer montada com pedaços de lugares diferentes.

Sprite: desenhar uma vez, usar em todo lugar

NovoHTML

Colar o mesmo SVG em doze cards repete o desenho doze vezes no HTML. O sprite guarda cada ícone uma vez, com um nome, e cada uso só aponta para ele.

o sprite, logo depois do <body>
<svg width="0" height="0" style="position: absolute" aria-hidden="true">
  <symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12l5 5L20 7"/></symbol>
  <symbol id="i-seta" viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></symbol>
  <symbol id="i-fone" viewBox="0 0 24 24"><path d="M5 4h4l2 5-3 2a11 11 0 0 0 5 5l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/></symbol>
</svg>
cada uso
<svg class="icone" aria-hidden="true"><use href="#i-check"/></svg>

O <use> copia o desenho do <symbol>, e o viewBox vem junto. A classe .icone continua valendo: cor, tamanho e espessura do traço passam para dentro da cópia.

Sprite em arquivo separado (<use href="img/icones.svg#i-check"/>) economiza ainda mais, porque o navegador guarda o arquivo em cache — mas só funciona servido por um servidor, do mesmo domínio. Abrindo o HTML direto do computador, com dois cliques, os ícones somem no Chrome. Para landing page de uma página, o sprite dentro do HTML é o mais seguro.

Ícone que é botão precisa de nome

NovoHTML

Quando o ícone acompanha um texto — "✓ Entrega no prazo" —, ele é enfeite, e o aria-hidden="true" o esconde do leitor de tela para não ser lido duas vezes. Quando o ícone é o botão inteiro, é o contrário: sem texto, o leitor de tela anuncia só "botão", e ninguém sabe o que ele faz.

botão só com ícone
<button class="botao-icone" type="button" aria-label="Fechar">
  <svg class="icone" aria-hidden="true"><use href="#i-fechar"/></svg>
</button>

<!-- ou, com texto que só o leitor de tela lê -->
<a class="botao-icone" href="https://instagram.com/perfil">
  <svg class="icone" aria-hidden="true"><use href="#i-instagram"/></svg>
  <span class="so-leitor">Instagram</span>
</a>
CasoO que fazer
ícone ao lado de textoaria-hidden="true" no <svg>
botão ou link só com íconenome no aria-label ou num .so-leitor, e o SVG com aria-hidden
ícone que é informação sozinho (um selo, um gráfico)role="img" e aria-label no <svg>
E o tamanho do toque: um ícone de 20px é pequeno demais para o dedo. O botão em volta dele precisa ter pelo menos 44 por 44 pixels, com o ícone no centro. A classe .so-leitor é a do projeto-base; o porquê de tudo isso está em ARIA — e quando não usar.

Limpar o SVG antes de usar

NovoHTML

SVG exportado do Figma, do Illustrator ou baixado de um site vem com coisa que não serve para nada na página: comentários do programa, metadados, ids gerados, números com oito casas decimais. Um ícone de 400 bytes chega com 3 KB.

antes
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 27.0.0 -->
<svg version="1.1" id="Camada_1" xmlns="http://www.w3.org/2000/svg"
     xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
     width="24px" height="24px" viewBox="0 0 24 24" xml:space="preserve">
  <g id="Grupo_12"><path fill="#1D1D1B" d="M5.000001,12.000000 L10.000000,17.000000..."/></g>
</svg>
depois
<svg viewBox="0 0 24 24"><path d="M5 12l5 5L20 7"/></svg>
  • passe pelo SVGOMG, a versão no navegador do otimizador SVGO: arraste o arquivo e baixe o limpo
  • mantenha o viewBox — é ele que deixa o SVG escalar
  • tire o fill fixo dos ícones (#1D1D1B) para o currentColor do CSS funcionar
  • confira o resultado a olho: a otimização agressiva às vezes arredonda demais e entorta uma curva
SVG é código, não só imagem. Um SVG pode ter <script> dentro, e colado inline ele roda na sua página. Só cole SVG de fonte em que você confia, e passe pelo otimizador — ele remove o que não é desenho. O risco está explicado em XSS — quando texto vira código.

Onde achar ícones — e o que olhar antes

Novo

Desenhar ícone não é o seu trabalho. Existem conjuntos gratuitos, bem desenhados e com licença que permite uso em site de cliente.

ConjuntoEstiloLicença
Lucidetraço, simplesISC
Heroiconstraço e cheioMIT
Phosphorvários pesos do mesmo íconeMIT
Tabler Iconstraço, milhares de íconesMIT
Material Symbolso do Google, ajustávelApache 2.0
  • um conjunto por site: ícones de conjuntos diferentes têm espessura e cantos diferentes, e isso aparece
  • copie o SVG do site do conjunto, só dos ícones que vai usar — não instale um pacote de mil ícones para usar oito
  • confira a licença antes: todas acima permitem uso comercial, mas a licença está sempre no site do conjunto e é ela que vale
  • logo de rede social e do WhatsApp é marca registrada: use o arquivo oficial da central de marca de cada empresa, sem mudar proporção nem cor além do que as regras deles permitem
Para o ícone que não existe em lugar nenhum — o símbolo do serviço específico do cliente —, a saída é uma ilustração encomendada ou uma foto, não um ícone improvisado. Onde procurar referência visual está em Onde procurar, na trilha Vibecoding.
PARTE 7

Vídeo, mapa e outros embeds

O vídeo do YouTube, o mapa do Google, o feed do Instagram e a agenda de horários são o que o cliente mais pede — e o que mais pesa na página. O padrão que resolve quase todos é o mesmo: mostrar uma imagem ou um link e só carregar o de verdade quando a pessoa pede.

O que um embed custa

Novo

Colar o código de incorporação do YouTube parece colar uma imagem. Não é: o <iframe> abre uma segunda página dentro da sua, com o player inteiro, os scripts de medição e as fontes dele — tudo baixado no carregamento, antes de qualquer pessoa pensar em apertar o play.

EmbedO que chega juntoO que fazer
Vídeo do YouTube ou Vimeoo player e os scripts dele, centenas de KBfachada: capa com botão, o player só no clique
Mapa do Googleo mapa interativo inteirolink de como chegar; ou o mapa com loading="lazy"
Post ou feed do Instagramo script de incorporação da redeum print com link para o post; ou o feed pela API, sem o script
Avaliações do Google em widgetscript de terceiro, às vezes pagoas avaliações escritas na página, com link para o perfil

Para ver o custo real, abra o DevTools na aba Network, recarregue com e sem o embed e compare o total transferido. A diferença costuma ser maior que a página inteira sem ele.

Embed pesado no topo da página derruba o LCP, a métrica de carregamento que o Google mede — está em Consertar o LCP. O aviso curto já aparecia em Performance; aqui está o como.

Vídeo do YouTube que carrega no clique

NovoHTMLJS

A fachada é uma imagem de capa com um botão de play por cima. Parece o player, pesa só a imagem, e no clique o botão é trocado pelo <iframe> de verdade, já tocando. Quem nunca aperta o play nunca baixa o player.

a fachada
<div class="video" data-video="ID_DO_VIDEO">
  <img src="img/capa-video.webp" alt="" width="1280" height="720" loading="lazy">
  <button class="video__play" type="button">
    <span class="so-leitor">Assistir ao vídeo: </span>Como funciona a entrega
  </button>
</div>
css
.video { position: relative; aspect-ratio: 16 / 9; border-radius: var(--raio); overflow: hidden; background: #000; }
.video img, .video iframe { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border: 0; }
.video__play {
  position: absolute; inset: 0; display: grid; place-content: center; gap: var(--e2);
  background: rgb(0 0 0 / .25); color: #fff; border: 0; cursor: pointer; font: inherit; font-weight: 600;
}
.video__play::before {   /* o triângulo do play */
  content: ""; justify-self: center; width: 0; height: 0;
  border-style: solid; border-width: 18px 0 18px 30px; border-color: transparent transparent transparent #fff;
}
js
/* troca a capa pelo player só quando a pessoa pede */
document.querySelectorAll('.video[data-video]').forEach(function (caixa) {
  var botao = caixa.querySelector('.video__play');
  if (!botao) return;
  botao.addEventListener('click', function () {
    var f = document.createElement('iframe');
    f.src = 'https://www.youtube-nocookie.com/embed/' + caixa.dataset.video + '?autoplay=1';
    f.title = botao.textContent.trim();
    f.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
    caixa.innerHTML = '';
    caixa.appendChild(f);
    f.focus();
  });
});
  • a capa é sua, salva em img/: um quadro bonito do vídeo, em WebP. Dá para usar a miniatura que o YouTube gera, mas aí a página já fala com o servidor dele antes do clique
  • youtube-nocookie.com é o "modo de privacidade aprimorado" do próprio YouTube: segundo o Google, ele não guarda informação de quem visita até a pessoa dar play
  • o title do iframe é o que o leitor de tela anuncia; e o foco vai para o player, para quem usa teclado não se perder
  • o aspect-ratio reserva o espaço do vídeo antes de tudo carregar — a página não pula
No celular, alguns navegadores só tocam de verdade no segundo toque, dentro do próprio player. É uma regra do navegador, não um erro da fachada.

Vídeo próprio no site

NovoHTML

Quando o vídeo é curto e é do cliente — a vitrine girando, o antes e depois, um fundo discreto na hero —, ele pode morar no próprio site com a tag <video>, sem player de ninguém.

vídeo com controles
<video controls preload="none" poster="img/capa-obra.webp" width="1280" height="720">
  <source src="video/obra.mp4" type="video/mp4">
  Seu navegador não mostra vídeo. <a href="video/obra.mp4">Baixe o arquivo</a>.
</video>
vídeo de fundo, sem som
<video autoplay muted loop playsinline preload="metadata" poster="img/hero.webp" aria-hidden="true">
  <source src="video/hero.mp4" type="video/mp4">
</video>
AtributoPor quê
preload="none"não baixa nada até a pessoa apertar play
postera imagem que aparece antes — sem ela, fica um retângulo preto
muted + playsinlinesem os dois, o iPhone não toca sozinho e abre em tela cheia
aria-hidden no fundovídeo decorativo não é conteúdo; o leitor de tela pula
Vídeo longo não mora no site. Arquivo de vídeo pesa dezenas de megabytes, e cada visita consome banda da hospedagem — no plano gratuito, isso acaba. Até uns 30 segundos, comprimido e sem som, tudo bem; acima disso, YouTube ou Vimeo com a fachada. E vídeo que se mexe sozinho precisa parar para quem pediu menos movimento no sistema: pause-o com prefers-reduced-motion ou mostre só o poster.

Mapa: link, embed ou imagem

NovoHTML

O mapa interativo raramente é o que a pessoa precisa. No celular, o que ela quer é tocar e abrir o aplicativo de mapas já com a rota — e isso é um link.

OpçãoPesoMelhor para
Link "Como chegar"nenhumquase sempre: abre o app de mapas no celular
Imagem do mapa + linkuma imagemquando a localização é argumento (perto do centro, fácil de estacionar)
Mapa incorporado com loading="lazy"o mapa inteiro, mas só ao chegar pertoquando o cliente faz questão do mapa interativo
o link que abre o app
<a href="https://www.google.com/maps/search/?api=1&query=Rua+Exemplo+123+Cidade" target="_blank" rel="noopener">
  Como chegar ↗
</a>
o mapa incorporado, sem pesar no carregamento
<iframe
  src="https://www.google.com/maps/embed?pb=..."
  title="Mapa: Nome do negócio, Rua Exemplo, 123"
  width="600" height="400" style="border: 0; width: 100%; height: auto; aspect-ratio: 3 / 2"
  loading="lazy" referrerpolicy="no-referrer-when-downgrade"
  allowfullscreen></iframe>

O endereço do src sai do próprio Google Maps: Compartilhar, depois Incorporar um mapa. O loading="lazy" faz o navegador só baixar o mapa quando a pessoa rola até perto dele — quem não chega ao rodapé nunca paga o custo.

A seção Contato e horário da galeria de Modelos já usa o link, não o mapa. O endereço escrito precisa ser o mesmo do Perfil da Empresa: Consistência de nome, endereço e telefone.

Feed do Instagram que se atualiza sozinho

NovoJS

"Quero o Instagram no site, atualizando sozinho" é pedido comum — e é o item que mais quebra depois da entrega. Antes de prometer, escolha o caminho de propósito:

CaminhoAtualiza sozinhoO que custa
Print dos posts com link para o perfilnãonada; alguém troca as imagens de vez em quando
Widget pronto de um serviço de terceirosimo script dele na página, o que ele coleta de quem visita e o plano dele — leia os três antes
Integração própria pela API da Metasimuma função no servidor e um token que vence a cada 60 dias

A API mudou em 4 de dezembro de 2024: a Meta desligou a antiga Basic Display API, a única que aceitava conta pessoal. Hoje o caminho oficial é a Instagram API com login do Instagram, que só funciona com conta profissional (Comercial ou Criador de conteúdo). Se o cliente usa conta pessoal, a primeira conversa é essa. A preparação, em resumo: um app do tipo Business no painel de desenvolvedor da Meta, a conta do cliente ligada a ele com a permissão instagram_business_basic e um token de acesso de longa duração gerado ali. O painel da Meta muda de lugar com frequência — siga o guia oficial no dia, não um print antigo.

O token nunca vai para o JavaScript da página: quem o tiver lê a conta. Ele fica numa variável de ambiente da Vercel, e uma função no servidor busca os posts e devolve só o que a página precisa:

api/instagram.mjs — a função na Vercel
/* INSTAGRAM_TOKEN fica em Settings → Environment Variables, nunca no código */
export async function GET() {
  const campos = 'caption,media_type,media_url,thumbnail_url,permalink';
  const url = 'https://graph.instagram.com/me/media?fields=' + campos +
    '&limit=9&access_token=' + process.env.INSTAGRAM_TOKEN;

  const r = await fetch(url);
  if (!r.ok) return Response.json([], { status: 502 });

  const { data = [] } = await r.json();
  const posts = data
    .map((p) => ({
      link: p.permalink,
      /* vídeo usa a miniatura; post com música protegida vem sem media_url */
      imagem: p.media_type === 'VIDEO' ? p.thumbnail_url : p.media_url,
      texto: (p.caption || '').slice(0, 120),
    }))
    .filter((p) => p.imagem);

  /* a CDN da Vercel guarda a resposta por uma hora: a Meta não é chamada a cada visita */
  return Response.json(posts, {
    headers: { 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400' },
  });
}
na página: o link já funciona sem JavaScript
<div class="insta" data-insta>
  <a href="https://instagram.com/perfil_do_cliente" target="_blank" rel="noopener">Ver no Instagram</a>
</div>
css da grade
.insta__grade {
  list-style: none; padding: 0; margin: 0 0 var(--e3);
  display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--e1);
}
.insta__grade img { display: block; width: 100%; height: auto; aspect-ratio: 1; object-fit: cover; border-radius: var(--raio); }
troca o link pela grade quando os posts chegam
var caixa = document.querySelector('[data-insta]');
if (caixa) {
  fetch('/api/instagram')
    .then(function (r) { return r.ok ? r.json() : []; })
    .then(function (posts) {
      if (!posts.length) return;            /* deu errado: fica o link */
      var grade = document.createElement('ul');
      grade.className = 'insta__grade';
      posts.forEach(function (p) {
        var li = document.createElement('li');
        var a = document.createElement('a');
        a.href = p.link; a.target = '_blank'; a.rel = 'noopener';
        var img = document.createElement('img');
        img.src = p.imagem; img.alt = p.texto; img.loading = 'lazy';
        img.width = 320; img.height = 320;
        a.appendChild(img); li.appendChild(a); grade.appendChild(li);
      });
      caixa.prepend(grade);
    })
    .catch(function () {});
}
  • o texto do post vira o alt — é o que existe; se a legenda for só emoji, a foto fica sem descrição útil, e vale saber disso antes
  • o link para o perfil continua embaixo: se a Meta cair ou o token vencer, a seção vira um link, não um buraco
  • as fotos vêm do servidor da Meta, então o navegador de quem visita fala com ela — entra na política de privacidade, como todo embed
renovar o token — no terminal, antes dos 60 dias
curl "https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=TOKEN_ATUAL"
O token vence em 60 dias. Ele pode ser renovado a partir de 24 horas de vida, antes de vencer, e o novo vale mais 60 dias a partir da renovação — é a chamada acima. Ela devolve um token novo: troque a variável na Vercel e publique de novo. Esqueceu, o feed some e volta o link. Por isso esse item é manutenção de verdade, com data: ponha no calendário da Depois de publicar e cobre dentro do contrato de manutenção — é exatamente o que justifica a mensalidade.

Agendamento pelo site

NovoHTML

Consulta, sessão, aula experimental, visita com hora marcada: quando o negócio vive de horário, a pessoa quer escolher o dela sem esperar resposta. Mas agendamento só funciona se a agenda do cliente for a verdade — se ele marca no caderno e esquece de bloquear no sistema, o site oferece um horário que não existe, e o WhatsApp era melhor. Pergunte como ele marca hoje antes de propor.

OpçãoComo entra no siteQuando
WhatsApp com mensagem prontaum link — Link de WhatsAppa agenda é de cabeça, ou cada caso pede conversa
Página de agendamento do Google Agendalink, botão com pop-up ou página incorporadao cliente já usa o Google Agenda; conta pessoal gratuita cria, e alguns recursos pedem assinatura do Google
Calendly ou serviço parecidolink, pop-up ou incorporado — o Calendly oferece os três em todos os planosmais de um tipo de atendimento, lembretes, pagamento na marcação
Sistema próprio, com bancoback-endregra que nenhum pronto cobre — e aí é outro projeto: Você precisa mesmo de banco?

No Google Agenda, a página de agendamento fica em Páginas de agendamento; o código sai de Opções → Opções de compartilhamento → Incorporar no site, em duas formas: a página inteira dentro do site, ou um botão que abre um pop-up. O jeito mais leve é nenhum dos dois — um link:

o botão que só abre a agenda: não pesa nada
<a class="botao" href="LINK_DA_PAGINA_DE_AGENDAMENTO" target="_blank" rel="noopener">
  Escolher um horário
</a>
a agenda dentro da página, só quando a pessoa chega nela
<iframe
  src="ENDERECO_DO_CODIGO_DE_INCORPORACAO"
  title="Agendar horário com Nome do negócio"
  loading="lazy"
  style="border: 0; width: 100%; height: 640px"></iframe>
  • a conta da agenda é do cliente, como o domínio: é nela que ficam nome, telefone e horário de cada pessoa que marcou
  • o pop-up e a página incorporada carregam o script do serviço; o link não carrega nada. Incorporado, ponha numa página própria (agendar.html) ou perto do fim, com loading="lazy", para não pesar a abertura
  • o iframe precisa de title, que é o que o leitor de tela anuncia; e teste a marcação inteira no celular, porque é lá que ela acontece
  • quem marca deixa dado pessoal no serviço — o nome dele entra na política de privacidade
Agendamento integrado é item de escopo, com preço próprio — está na lista do que sai da entrega em O que está e o que não está incluído. E o clique em "Escolher um horário" se mede como o do WhatsApp: Medir o clique no WhatsApp.

Embed também é coleta de dado

Novo

Todo embed de terceiro — YouTube, Maps, Instagram, widget de chat — faz o navegador de quem visita conversar com o servidor daquela empresa, e muitos gravam cookies. Para a lei, é coleta de dado pessoal, mesmo que você não tenha instalado nenhum "rastreamento".

  • a fachada resolve o peso e a privacidade de uma vez: nada é chamado até a pessoa clicar
  • o link no lugar do mapa não coleta nada
  • o que continuar incorporado entra na política de privacidade, com o nome da empresa
Quais dados cada ferramenta coleta e o que isso pede do site estão em O que você coleta sem perceber, na trilha Negócio.
PARTE 8

O que o CSS já resolve

Coisas que até pouco tempo pediam JavaScript, biblioteca ou gambiarra e hoje são uma ou duas linhas de CSS — e as duas situações que quase todo site esquece: a tabela no celular e a página impressa.

Carrossel sem biblioteca

NovoCSS

A maior parte do que se chama carrossel é uma fila de cards que se arrasta para o lado. O navegador já faz isso — com a inércia do dedo, o encaixe em cada card e a barra de rolagem — sem uma linha de JavaScript.

css
.carrossel {
  display: flex; gap: var(--e4);
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;     /* para sempre num card, nunca no meio */
  padding-bottom: var(--e3);         /* espaço para a barra de rolagem */
}
.carrossel > * {
  flex: 0 0 min(80%, 20rem);         /* o pedaço do próximo aparece: mostra que dá para arrastar */
  scroll-snap-align: start;
}
html
<div class="carrossel" role="region" aria-label="Trabalhos recentes" tabindex="0">
  <figure>...</figure>
  <figure>...</figure>
  <figure>...</figure>
</div>
  • o pedaço do próximo card aparecendo na borda é o que avisa que existe mais — seta escondida não avisa ninguém
  • tabindex="0" deixa rolar pelo teclado, com as setas; o aria-label diz ao leitor de tela o que é a região
  • carrossel que passa sozinho é outra coisa, e quase sempre piora: ninguém consegue ler o que sai da tela enquanto lê
A versão com os pontinhos que acompanham e levam a cada card está pronta em Carrossel sem biblioteca, na galeria de Modelos.

Cor nos controles do formulário

NovoCSS

Caixa de marcar, botão de opção, barra deslizante e barra de progresso vêm com a cor do sistema — azul, quase sempre. Estilizar cada um na mão era um trabalho inteiro. Hoje é uma linha.

css
:root {
  accent-color: var(--cor-destaque);   /* marcar, opção, deslizar e progresso na cor da marca */
  color-scheme: light dark;            /* os controles nativos acompanham o modo escuro */
}

O accent-color já está no projeto-base. O color-scheme entra quando o site tiver modo escuro — ele faz a barra de rolagem, os campos e o calendário do <input type="date"> ficarem escuros quando o site está no modo escuro. Sem ele, aparece um campo branco brilhando no meio da página escura.

O navegador ajusta o tom da marca dentro do controle para manter contraste. Se a cor de destaque for muito clara, o controle fica com um visto escuro — é proposital. Os formulários em si estão em O que o navegador já faz de graça.

Cards alinhados entre si

NovoCSS

Três cards lado a lado, cada um com um título de tamanho diferente: os textos e os botões ficam desalinhados, cada card na sua altura. O subgrid faz as linhas de dentro de cada card seguirem as mesmas linhas da grade — título com título, texto com texto, botão com botão.

css
.cards {
  display: grid; gap: var(--e5);
  grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
}
.cards > .card {
  display: grid;
  grid-row: span 3;               /* cada card ocupa três linhas: título, texto, botão */
  grid-template-rows: subgrid;    /* e usa as linhas da grade de fora */
  gap: var(--e3);                 /* o espaço entre as partes de dentro */
}

O número do span é o número de partes do card. Card com imagem, título, texto e botão é span 4. Com o subgrid, o botão de todos os cards para na mesma altura sem ninguém medir nada.

Funciona nos navegadores atuais desde 2023. Onde não funcionar, o card cai para uma grade comum — continua certo, só não fica alinhado. Grid em geral está em Grid.

Bloquear o fundo com inert

NovoHTML

Com o menu do celular aberto por cima da página, o Tab continua passando pelos links que estão atrás dele — invisíveis para quem vê, mas não para quem navega pelo teclado. O atributo inert desliga um pedaço inteiro da página: não recebe clique, nem foco, nem é lido.

js
/* ao abrir o menu, o resto da página sai de alcance; ao fechar, volta */
function menuAberto(aberto) {
  document.querySelector('main').inert = aberto;
  document.querySelector('footer').inert = aberto;
}

A janela feita com <dialog> e showModal() já faz isso sozinha — é um dos motivos para usá-la. O inert é para o que não é <dialog>: um menu lateral, um painel que desliza.

O <dialog> e o que ele resolve estão em <dialog>. O topo com menu da galeria esconde o painel fechado com visibility, que também o tira do Tab: Topo com menu no celular.

Tabela no celular

NovoCSS

Tabela de preços, de horários, de especificações: no celular, ela ou espreme o texto até ficar ilegível, ou estoura a largura da página e faz tudo rolar para o lado. A saída mais simples e mais segura é deixar a tabela rolar sozinha, dentro de uma caixa.

html
<div class="tabela-rolagem" role="region" aria-label="Tabela de preços" tabindex="0">
  <table>...</table>
</div>
css
.tabela-rolagem { overflow-x: auto; }
.tabela-rolagem table { min-width: 36rem; border-collapse: collapse; }
  • só a tabela rola; a página continua parada — nada de rolagem lateral na página inteira
  • tabindex="0" para rolar pelo teclado, e o aria-label para o leitor de tela saber o que é
  • se a tabela tem duas colunas — dia e horário, item e preço —, ela provavelmente cabe no celular sem rolar: teste antes de pôr a caixa
O truque de virar cada linha num card no celular (trocando o display das células) funciona na tela, mas em alguns navegadores faz a tabela deixar de ser tabela para o leitor de tela — as relações entre cabeçalho e dado se perdem. Se usar, teste com leitor de tela; a caixa com rolagem não tem esse risco.

A página impressa

NovoCSS

Cardápio, tabela de preços, política, orçamento: alguém vai imprimir, ou salvar em PDF pelo "Imprimir" do navegador. Sem estilo de impressão, sai o menu, o botão flutuante do WhatsApp, o fundo escuro gastando tinta e os links sem endereço.

no fim do estilo.css
@media print {
  .topo, .botao, .zap-flutuante, video, iframe { display: none; }
  body { background: #fff; color: #000; }
  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: .85em; }
  .card, figure, tr { break-inside: avoid; }   /* não corta um card no meio da folha */
  h2, h3 { break-after: avoid; }               /* título não fica sozinho no pé da página */
}

Para testar sem gastar papel: Ctrl+P (Cmd+P no Mac) e olhar a prévia, ou o DevTools, que simula a impressão na tela (Rendering → Emulate CSS media type → print).

Três linhas de impressão resolvem a maior parte, e ninguém lembra delas até o cliente mandar o PDF torto. Por isso esse bloco já está no estilo.css do projeto-base, na camada de utilitários.
PARTE 9

O projeto-base

Tudo desta trilha junto, pronto para copiar: é o ponto de partida de todo site novo. Troque o tokens.css pelo do cliente e comece pela primeira seção.

O projeto-base inteiro

NovoHTMLCSS

Três arquivos e as pastas. O index.html é o esqueleto das partes 2; o tokens.css e o estilo.css são as partes 3 e 4 montadas; o app.js é o formato da parte 5.

index.html
<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <title>Nome do negócio — o que faz, onde</title>
  <meta name="description" content="Uma frase que responde a busca e convida a clicar.">
  <link rel="canonical" href="https://seusite.com.br/">

  <meta property="og:title" content="Nome do negócio — o que faz">
  <meta property="og:description" content="A mesma ideia da description, para quem recebe o link.">
  <meta property="og:image" content="https://seusite.com.br/og.jpg">
  <meta property="og:url" content="https://seusite.com.br/">
  <meta property="og:type" content="website">
  <meta name="twitter:card" content="summary_large_image">

  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
  <link rel="icon" href="/favicon.ico" sizes="32x32">
  <link rel="apple-touch-icon" href="/apple-touch-icon.png">
  <meta name="theme-color" content="#0D6B62">

  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=...&display=swap">

  <link rel="stylesheet" href="css/tokens.css">
  <link rel="stylesheet" href="css/estilo.css">
  <script src="js/app.js" defer></script>
</head>
<body>
  <a class="pular" href="#conteudo">Pular para o conteúdo</a>

  <header class="topo">
    <div class="container topo__linha">
      <a class="marca" href="/">Nome do negócio</a>
      <nav aria-label="Principal">
        <ul class="fila">
          <li><a href="#servicos">Serviços</a></li>
          <li><a href="#sobre">Sobre</a></li>
          <li><a href="#contato">Contato</a></li>
        </ul>
      </nav>
    </div>
  </header>

  <main id="conteudo">
    <section class="secao hero" aria-labelledby="titulo">
      <div class="container pilha">
        <h1 id="titulo">A promessa em uma linha</h1>
        <p>O que você faz, para quem, e por que confiar.</p>
        <a class="botao" href="https://wa.me/55DDDNUMERO">Pedir orçamento</a>
      </div>
    </section>

    <section class="secao" id="servicos" aria-labelledby="servicos-titulo">
      <div class="container pilha">
        <h2 id="servicos-titulo">Serviços</h2>
        <ul class="grade">
          <li class="card">...</li>
        </ul>
      </div>
    </section>
  </main>

  <footer class="rodape">
    <div class="container">
      <p>© <span data-ano>2026</span> Nome do negócio</p>
    </div>
  </footer>
</body>
</html>
css/tokens.css
/* tokens.css — troque este arquivo inteiro pelo que sai da
   ferramenta Design system: os nomes são os mesmos. */
:root {
  --cor-fundo:          #FFFFFF;
  --cor-superficie:     #F6F7F7;
  --cor-linha:          #DDE2E1;
  --cor-texto:          #1C2524;
  --cor-texto-fraco:    #5F6E6B;
  --cor-destaque:       #0D6B62;
  --cor-destaque-texto: #0D6B62;
  --cor-destaque-hover: #0A554E;
  --cor-sobre-destaque: #FFFFFF;
  --cor-foco:           #0D6B62;

  --fonte-titulo: system-ui, sans-serif;
  --fonte-corpo:  system-ui, sans-serif;
  --peso-titulo:  700;
  --peso-corpo:   400;

  --t-peq:   14px;
  --t-corpo: 17px;
  --t-h3:    clamp(20px, 2.5vw, 27px);
  --t-h2:    clamp(29px, 3.5vw, 41px);
  --t-h1:    clamp(32px, 6vw, 65px);
  --alt-corpo:  1.6;
  --alt-titulo: 1.1;
  --medida:     66ch;

  --e1: 4px;  --e2: 8px;  --e3: 12px; --e4: 16px; --e5: 24px;
  --e6: 32px; --e7: 48px; --e8: 64px; --e9: 96px; --e10: 128px;

  --raio: 8px;
  --dur:  200ms;
  --curva: cubic-bezier(.23, 1, .32, 1);
}
css/estilo.css
/* estilo.css — a ordem das camadas decide quem vence, não a ordem do arquivo */
@layer reset, base, layout, componentes, utilitarios;

@layer reset {
  *, *::before, *::after { box-sizing: border-box; }
  * { margin: 0; }
  html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
  body { min-height: 100svh; }
  img, picture, video, svg { display: block; max-width: 100%; height: auto; }
  input, button, textarea, select { font: inherit; color: inherit; }
  h1, h2, h3, h4, p, li { overflow-wrap: break-word; }
  ul[class], ol[class] { list-style: none; padding: 0; }
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
      animation-duration: .01ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: .01ms !important;
      scroll-behavior: auto !important;
    }
  }
}

@layer base {
  html { scroll-behavior: smooth; }
  body {
    background: var(--cor-fundo); color: var(--cor-texto);
    font-family: var(--fonte-corpo); font-weight: var(--peso-corpo);
    font-size: var(--t-corpo); line-height: var(--alt-corpo);
  }
  h1, h2, h3 {
    font-family: var(--fonte-titulo); font-weight: var(--peso-titulo);
    line-height: var(--alt-titulo); text-wrap: balance;
  }
  h1 { font-size: var(--t-h1); }
  h2 { font-size: var(--t-h2); }
  h3 { font-size: var(--t-h3); }
  p  { max-width: var(--medida); text-wrap: pretty; }
  a  { color: var(--cor-destaque-texto); text-underline-offset: .15em; }
  :root { accent-color: var(--cor-destaque); }   /* marcar, opção e deslizar na cor da marca (parte 8) */
  :focus-visible { outline: 2px solid var(--cor-foco); outline-offset: 3px; }
  [id] { scroll-margin-top: var(--e9); }   /* a âncora não some atrás do topo fixo */
}

@layer layout {
  .container { width: min(100% - 2 * var(--e5), 72rem); margin-inline: auto; }
  .secao     { padding-block: clamp(var(--e7), 8vw, var(--e9)); }

  /* pilha: coisas uma embaixo da outra, com o mesmo respiro */
  .pilha { display: flex; flex-direction: column; gap: var(--e5); }

  /* fila: coisas lado a lado que quebram linha quando não cabem */
  .fila  { display: flex; flex-wrap: wrap; align-items: center; gap: var(--e3); }

  /* grade: quantas colunas couberem, nenhuma mais estreita que 16rem */
  .grade {
    display: grid; gap: var(--e5);
    grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
  }
}

@layer componentes {
  .pular { position: absolute; left: var(--e3); top: -100px; }
  .pular:focus { top: var(--e3); }

  .topo { position: sticky; top: 0; z-index: 10; background: var(--cor-fundo);
          border-bottom: 1px solid var(--cor-linha); }
  .topo__linha { display: flex; align-items: center; justify-content: space-between;
                 gap: var(--e4); min-height: 64px; }
  .topo a { color: inherit; text-decoration: none; }
  .marca   { font-weight: 700; }

  .botao {
    display: inline-flex; align-items: center; justify-content: center; gap: var(--e2);
    min-height: 48px; padding: var(--e3) var(--e5); border-radius: var(--raio);
    background: var(--cor-destaque); color: var(--cor-sobre-destaque);
    font-weight: 600; text-decoration: none; align-self: start;
    transition: background var(--dur) var(--curva), transform 120ms var(--curva);
  }
  .botao:active { transform: scale(.97); }
  @media (hover: hover) { .botao:hover { background: var(--cor-destaque-hover); } }

  .card { padding: var(--e5); background: var(--cor-superficie);
          border: 1px solid var(--cor-linha); border-radius: var(--raio); }

  /* ícone: o tamanho do texto ao lado, a cor dele (parte 6) */
  .icone { display: inline-block; vertical-align: -.25em;   /* o reset põe svg em bloco */
           width: 1.25em; height: 1.25em; flex: none; fill: none; stroke: currentColor;
           stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }

  .rodape { padding-block: var(--e7); border-top: 1px solid var(--cor-linha);
            color: var(--cor-texto-fraco); font-size: var(--t-peq); }
}

@layer utilitarios {
  .so-leitor { position: absolute; width: 1px; height: 1px; overflow: hidden;
               clip-path: inset(50%); white-space: nowrap; }

  /* a página impressa ou salva em PDF (parte 8) */
  @media print {
    .topo, .botao, video, iframe { display: none; }
    body { background: #fff; color: #000; }
    a[href^="http"]::after { content: " (" attr(href) ")"; font-size: .85em; }
    .card, figure, tr { break-inside: avoid; }
    h2, h3 { break-after: avoid; }
  }
}
js/app.js
/* app.js — cada bloco se guarda sozinho: se o elemento não existe, sai */

/* ano do rodapé sempre atual */
(function () {
  var ano = document.querySelector('[data-ano]');
  if (!ano) return;
  ano.textContent = new Date().getFullYear();
})();

/* o próximo recurso entra aqui, no mesmo formato */
No rodapé, <span data-ano></span> recebe o ano atual pelo app.js — o "© 2024" em 2026 é um dos sinais de site abandonado de O conteúdo que envelhece.

Antes da primeira seção

Novo

Com o projeto-base copiado, a sequência de um site novo fica sempre a mesma:

  1. criar a pasta com o nome do cliente e iniciar o Git (Começar um projeto)
  2. gerar os tokens nas ferramentas da fase 2 e trocar o tokens.css
  3. gerar o <head> na ferramenta Cabeçalho da página e colar no lugar do de exemplo
  4. escrever o conteúdo de cada seção, mesmo que com [PREENCHER]
  5. montar as seções no molde da parte 2, uma por vez, testando no celular a cada uma — as mais comuns já estão prontas, no formato do projeto-base, na categoria Seções dos Modelos
  6. só então os efeitos — da trilha Movimento ou dos Modelos
Depois da construção vêm a Qualidade — imagem, acessibilidade, velocidade e teste — e a trilha Depois do ar, com o que fazer quando o site é publicado.