Ficha 01 · HTML e CSS
- HTML
- CSS
- Layout
- Responsive
Parte I · HTML
Exercício 1 · Estrutura semântica (15 pts)
Escreve estrutura HTML5 semântica para uma página "Sobre nós" com: - Cabeçalho com logo e navegação. - Conteúdo principal com título e parágrafos. - Sidebar com links relacionados. - Rodapé com informação de contacto.
<!DOCTYPE html>
<html lang="pt-PT">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sobre Nós | Empresa XYZ</title>
<meta name="description" content="Conhece a nossa empresa, missão e equipa.">
</head>
<body>
<header>
<div class="logo">
<img src="logo.svg" alt="Logo Empresa XYZ">
</div>
<nav aria-label="Principal">
<ul>
<li><a href="/">Início</a></li>
<li><a href="/sobre" aria-current="page">Sobre</a></li>
<li><a href="/servicos">Serviços</a></li>
<li><a href="/contacto">Contacto</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>Sobre a Empresa XYZ</h1>
<section>
<h2>A nossa história</h2>
<p>A Empresa XYZ foi fundada em 2010 com o objectivo de...</p>
<p>Desde então, ajudámos centenas de clientes a...</p>
</section>
<section>
<h2>Missão e valores</h2>
<p>A nossa missão é...</p>
<ul>
<li>Qualidade em primeiro lugar</li>
<li>Inovação constante</li>
<li>Foco no cliente</li>
</ul>
</section>
<section>
<h2>A nossa equipa</h2>
<p>Somos uma equipa de profissionais apaixonados...</p>
</section>
</article>
<aside>
<h3>Ver também</h3>
<ul>
<li><a href="/servicos">Os nossos serviços</a></li>
<li><a href="/portfolio">Portfolio</a></li>
<li><a href="/blog">Blog</a></li>
</ul>
</aside>
</main>
<footer>
<div class="contacto">
<h2>Contacto</h2>
<address>
Empresa XYZ<br>
Rua das Flores, 12<br>
1000-000 Lisboa<br>
<a href="tel:+351200000000">+351 200 000 000</a><br>
<a href="mailto:info@empresa.pt">info@empresa.pt</a>
</address>
</div>
<nav aria-label="Footer">
<ul>
<li><a href="/privacidade">Política de Privacidade</a></li>
<li><a href="/termos">Termos de Uso</a></li>
</ul>
</nav>
<p>© 2026 Empresa XYZ. Todos os direitos reservados.</p>
</footer>
</body>
</html>
Características:
<header>contém logo + nav.<main>é o conteúdo único e principal.<article>para conteúdo independente (página inteira sobre nós).<section>para subdivisões temáticas.<aside>para conteúdo relacionado mas independente.<footer>para rodapé.<nav>comaria-labelpara distinguir nav principal vs footer.<address>para info de contacto.altem imagem.- Hierarquia de headings: h1 → h2 → h3.
Exercício 2 · Formulário (15 pts)
Cria formulário de contacto com validação HTML5: - Nome (obrigatório, mín 3 chars). - Email (obrigatório). - Telefone (opcional, formato português). - Assunto (dropdown). - Mensagem (textarea, mín 10 chars). - Aceitar política privacidade (checkbox). - Botão submit.
<form action="/api/contact" method="POST" class="form-contact">
<h2>Contacto</h2>
<!-- Nome -->
<div class="form-group">
<label for="nome">Nome *</label>
<input
type="text"
id="nome"
name="nome"
required
minlength="3"
maxlength="100"
placeholder="Joana Silva"
autocomplete="name">
</div>
<!-- Email -->
<div class="form-group">
<label for="email">Email *</label>
<input
type="email"
id="email"
name="email"
required
placeholder="joana@email.com"
autocomplete="email">
</div>
<!-- Telefone (opcional) -->
<div class="form-group">
<label for="telefone">Telefone</label>
<input
type="tel"
id="telefone"
name="telefone"
pattern="(\+351)?[0-9]{9}"
placeholder="912 345 678"
autocomplete="tel">
<small>Opcional. Formato: 912345678 ou +351912345678</small>
</div>
<!-- Assunto -->
<div class="form-group">
<label for="assunto">Assunto *</label>
<select id="assunto" name="assunto" required>
<option value="">-- Selecciona --</option>
<option value="info">Pedido de informação</option>
<option value="orcamento">Pedido de orçamento</option>
<option value="suporte">Suporte técnico</option>
<option value="outro">Outro</option>
</select>
</div>
<!-- Mensagem -->
<div class="form-group">
<label for="mensagem">Mensagem *</label>
<textarea
id="mensagem"
name="mensagem"
required
minlength="10"
maxlength="1000"
rows="6"
placeholder="Escreve aqui a tua mensagem..."></textarea>
<small>Mínimo 10 caracteres.</small>
</div>
<!-- Newsletter (opcional) -->
<div class="form-group">
<input
type="checkbox"
id="newsletter"
name="newsletter">
<label for="newsletter">
Quero receber a newsletter mensal
</label>
</div>
<!-- Aceitar política (obrigatório) -->
<div class="form-group">
<input
type="checkbox"
id="privacidade"
name="privacidade"
required>
<label for="privacidade">
Aceito a <a href="/privacidade" target="_blank">política de privacidade</a> *
</label>
</div>
<!-- Submit -->
<div class="form-actions">
<button type="submit">Enviar mensagem</button>
<button type="reset">Limpar</button>
</div>
<p class="form-note">* Campos obrigatórios</p>
</form>
Validação HTML5 usada:
required: campo obrigatório.type="email": validação de email.type="tel": teclado numérico em mobile.pattern: regex para telefone português.minlength/maxlength: limites de texto.autocomplete: para password managers e auto-fill.
Acessibilidade:
label for+idcorrectamente ligados.for=privacidadepermite click no texto.placeholder+<small>dão pistas.*para indicar obrigatório.
CSS sugerido:
.form-contact {
max-width: 500px;
margin: 2rem auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 600;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: 2px solid #6366f1;
border-color: #6366f1;
}
/* Checkbox alinhado */
.form-group input[type="checkbox"] {
width: auto;
display: inline-block;
margin-right: 0.5rem;
}
.form-group input[type="checkbox"] + label {
display: inline;
font-weight: normal;
}
/* Erro visual (HTML5 validation) */
.form-group input:invalid:not(:focus):not(:placeholder-shown) {
border-color: #ef4444;
}
button[type="submit"] {
background: #6366f1;
color: white;
padding: 0.75rem 2rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
button[type="submit"]:hover {
background: #4f46e5;
}
JavaScript para tratamento (opcional):
const form = document.querySelector('.form-contact');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const data = Object.fromEntries(formData);
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
alert('Mensagem enviada! Vamos contactá-lo em breve.');
form.reset();
} else {
alert('Erro ao enviar. Tenta novamente.');
}
} catch (error) {
alert('Erro de rede.');
}
});
Parte II · CSS
Exercício 3 · Box model (10 pts)
Para este HTML:
<div class="caixa">Hello</div>
Aplica CSS: - Width: 200px, height: 100px. - Padding: 20px. - Border: 5px solid black. - Margin: 10px. - Background azul.
a) Qual é a largura total visível? b) Como mudar para que width=200 seja a largura total?
a) Largura total visível:
Por defeito (box-sizing: content-box):
- content width: 200px.
- + padding (esquerda + direita): 20 + 20 = 40px.
- + border (esquerda + direita): 5 + 5 = 10px.
- Total visível: 200 + 40 + 10 = 250px.
Margem não conta para largura visível (é espaço externo).
Espaço total ocupado: - 250 + margem (10 + 10) = 270px de espaço horizontal.
b) Para que width=200 seja largura total:
Usar box-sizing: border-box:
.caixa {
box-sizing: border-box; /* ← isto */
width: 200px;
height: 100px;
padding: 20px;
border: 5px solid black;
margin: 10px;
background: blue;
}
Com border-box:
- width 200px inclui content + padding + border.
- Content area = 200 - 40 (padding) - 10 (border) = 150px.
- Total visível: 200px (como pedido).
Recomendação universal:
*, *::before, *::after {
box-sizing: border-box;
}
Aplicar globalmente para comportamento mais intuitivo.
Comparação visual:
content-box (default):
┌────────────────────────────┐ ← total: 250px
│ border (5px) │
│ ┌────────────────────────┐ │
│ │ padding (20px) │ │
│ │ ┌────────────────────┐ │ │
│ │ │ content (200px) │ │ │
│ │ └────────────────────┘ │ │
│ └────────────────────────┘ │
└────────────────────────────┘
border-box:
┌────────────────────────────┐ ← total: 200px (= width)
│ border (5px) │
│ ┌────────────────────────┐ │
│ │ padding (20px) │ │
│ │ ┌────────────────────┐ │ │
│ │ │ content (150px) │ │ │
│ │ └────────────────────┘ │ │
│ └────────────────────────┘ │
└────────────────────────────┘
Exercício 4 · Flexbox (15 pts)
Cria layout com Flexbox: - Container com 3 cards. - Cada card cresce igualmente. - Espaço de 20px entre cards. - Centrados verticalmente. - Em mobile (< 768px), cards empilham verticalmente.
<div class="cards-container">
<article class="card">
<h2>Card 1</h2>
<p>Conteúdo do primeiro card.</p>
</article>
<article class="card">
<h2>Card 2</h2>
<p>Conteúdo do segundo card.</p>
</article>
<article class="card">
<h2>Card 3</h2>
<p>Conteúdo do terceiro card.</p>
</article>
</div>
/* Mobile first (default) */
.cards-container {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
}
.card {
background: white;
padding: 1.5rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.card h2 {
margin: 0 0 0.5rem 0;
color: #333;
}
.card p {
margin: 0;
color: #666;
}
/* Tablet+ */
@media (min-width: 768px) {
.cards-container {
flex-direction: row;
align-items: stretch; /* mesma altura */
max-width: 1200px;
margin: 0 auto;
}
.card {
flex: 1; /* cada card cresce igualmente */
}
}
Resultado:
Mobile (< 768px):
┌────────────────┐
│ Card 1 │
└────────────────┘
↓ 20px gap
┌────────────────┐
│ Card 2 │
└────────────────┘
↓ 20px gap
┌────────────────┐
│ Card 3 │
└────────────────┘
Tablet+ (>= 768px):
┌─────────┐ ←20px→ ┌─────────┐ ←20px→ ┌─────────┐
│ Card 1 │ │ Card 2 │ │ Card 3 │
└─────────┘ └─────────┘ └─────────┘
Explicação:
Mobile first (regras fora de media query):
- flex-direction: column: empilha verticalmente.
- gap: 20px: espaço entre items.
- Cada card ocupa largura total (default).
Tablet+ (media query):
- flex-direction: row: horizontal.
- flex: 1 em cada card: crescem igualmente.
- Container limitado a 1200px e centrado.
Variações úteis:
Cards com tamanhos diferentes mas iguais:
.card {
flex: 1 1 0; /* grow shrink basis */
}
Card com largura mínima (não compactar demais):
.card {
flex: 1 1 250px; /* min 250px */
}
Centrar tudo:
.cards-container {
display: flex;
justify-content: center; /* eixo principal */
align-items: center; /* eixo cruzado */
}
Justify-content options:
- flex-start: ao início.
- flex-end: ao fim.
- center: ao centro.
- space-between: espaço entre items.
- space-around: espaço à volta.
- space-evenly: espaço igual.
Wrap (várias linhas):
.cards-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.card {
flex: 1 1 250px; /* min 250px, wrap se não cabe */
}
Parte III · Layout completo
Exercício 5 · Grid layout (15 pts)
Cria layout "Holy Grail" com CSS Grid: - Header (topo). - Sidebar (esquerda). - Main content (centro). - Aside (direita). - Footer (baixo).
Mobile: tudo empilhado.
<div class="layout">
<header>Header</header>
<nav class="sidebar">Sidebar</nav>
<main>Main Content</main>
<aside>Aside</aside>
<footer>Footer</footer>
</div>
/* Mobile first */
.layout {
display: grid;
grid-template-areas:
"header"
"sidebar"
"main"
"aside"
"footer";
min-height: 100vh;
gap: 0;
}
header { grid-area: header; }
.sidebar { grid-area: sidebar; }
main { grid-area: main; }
aside { grid-area: aside; }
footer { grid-area: footer; }
/* Estilos */
header {
background: #1f2937;
color: white;
padding: 1rem;
}
.sidebar {
background: #f3f4f6;
padding: 1rem;
}
main {
background: white;
padding: 1rem;
}
aside {
background: #e5e7eb;
padding: 1rem;
}
footer {
background: #1f2937;
color: white;
padding: 1rem;
text-align: center;
}
/* Desktop */
@media (min-width: 1024px) {
.layout {
grid-template-columns: 200px 1fr 200px;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"header header header"
"sidebar main aside"
"footer footer footer";
}
}
Mobile:
┌─────────────┐
│ Header │
├─────────────┤
│ Sidebar │
├─────────────┤
│ Main │
├─────────────┤
│ Aside │
├─────────────┤
│ Footer │
└─────────────┘
Desktop:
┌──────────────────────────────────┐
│ Header │
├─────────┬───────────────┬────────┤
│ Sidebar │ Main │ Aside │
│ │ │ │
│ │ │ │
├─────────┴───────────────┴────────┤
│ Footer │
└──────────────────────────────────┘
Vantagens de Grid Areas:
- Visual: vê-se imediatamente o layout no CSS.
- Manutenção: alterar layout = mudar áreas (não mover HTML).
- Responsive: redefine areas em media queries.
Variações:
Sidebar à direita:
grid-template-areas:
"header header header"
"main main sidebar"
"footer footer footer";
Sem aside:
grid-template-columns: 250px 1fr;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
Magazine layout (várias colunas, áreas complexas):
grid-template-columns: repeat(4, 1fr);
grid-template-areas:
"h h h h"
"f f f s"
"a b c s"
"g g s s";
Combinação Grid + Flexbox: - Grid: layout 2D (página inteira). - Flexbox: layout 1D (dentro de componentes).
/* Grid para página */
.page { display: grid; ... }
/* Flexbox para cards dentro de main */
main {
display: flex;
flex-direction: column;
gap: 1rem;
}
Exercício 6 · Responsividade (15 pts)
Página com cards de produtos. Comportamento responsivo: - Mobile (< 600px): 1 coluna. - Tablet (600-1023px): 2 colunas. - Desktop (>= 1024px): 4 colunas.
Sem media queries explícitas, usar Grid auto-fit.
<section class="produtos">
<article class="produto">...</article>
<article class="produto">...</article>
<article class="produto">...</article>
<article class="produto">...</article>
<!-- mais cards -->
</section>
Solução com Grid auto-fit (elegante, sem media queries):
.produtos {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.produto {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
}
Como funciona:
auto-fit: cria quantas colunas couberem.minmax(250px, 1fr):- mínimo 250px por coluna.
- máximo 1fr (espaço disponível dividido igualmente).
Comportamento automático:
- Mobile (< 500px): 1 coluna (250px mín não cabe 2×).
- 500-740px: 2 colunas.
- 740-980px: 3 colunas.
- 980+px: 4 colunas.
Sem media queries necessárias. Funciona em qualquer largura.
Solução com media queries explícitas (se prefere mais controlo):
.produtos {
display: grid;
grid-template-columns: 1fr; /* mobile: 1 col */
gap: 20px;
padding: 20px;
}
@media (min-width: 600px) {
.produtos {
grid-template-columns: repeat(2, 1fr); /* tablet: 2 cols */
}
}
@media (min-width: 1024px) {
.produtos {
grid-template-columns: repeat(4, 1fr); /* desktop: 4 cols */
max-width: 1200px;
margin: 0 auto;
}
}
Comparação:
| Approach | Vantagens | Desvantagens |
|---|---|---|
auto-fit |
Funciona em qualquer largura, código mínimo | Menos controlo exacto |
| Media queries | Breakpoints precisos | Mais código, manutenção |
Híbrido (recomendado para sites grandes):
.produtos {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
@media (max-width: 768px) {
.produtos {
padding: 10px;
gap: 10px;
}
}
auto-fit para colunas + media queries para padding/gap ajustes finos.
Bonus — Card de produto completo:
<article class="produto">
<img src="produto.jpg" alt="Nome do produto" loading="lazy">
<div class="produto-info">
<h3>Nome do Produto</h3>
<p class="produto-desc">Descrição curta do produto.</p>
<div class="produto-footer">
<span class="preco">19,99 €</span>
<button class="btn-comprar">Comprar</button>
</div>
</div>
</article>
.produto {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
}
.produto:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.produto img {
width: 100%;
height: 200px;
object-fit: cover;
}
.produto-info {
padding: 1rem;
display: flex;
flex-direction: column;
flex: 1;
}
.produto-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
}
.produto-desc {
color: #666;
flex: 1;
margin-bottom: 1rem;
}
.produto-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.preco {
font-size: 1.25rem;
font-weight: 700;
color: #6366f1;
}
.btn-comprar {
background: #6366f1;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.btn-comprar:hover {
background: #4f46e5;
}
Resultado: cards de produto profissionais, responsivos, com hover effects.
Parte IV · Casos práticos
Exercício 7 · Sticky header (10 pts)
Como fazer header que fica fixo no topo ao scroll?
Sticky Header:
<header class="sticky-header">
<nav>
<a href="/">Logo</a>
<ul>
<li><a href="#home">Home</a></li>
<li><a href="#sobre">Sobre</a></li>
<li><a href="#contacto">Contacto</a></li>
</ul>
</nav>
</header>
Solução 1 — position: sticky (moderno, recomendado):
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 1rem;
}
Vantagens:
- Simples: 1 linha (position: sticky).
- Performance: nativo no browser.
- Conteúdo abaixo ajusta-se automaticamente.
Solução 2 — position: fixed (mais antigo):
.sticky-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 1rem;
}
/* Padding no body para compensar */
body {
padding-top: 70px; /* altura do header */
}
Diferenças:
- Sticky: comporta-se como static até atingir o topo, depois fica fixo.
- Fixed: sempre fixo desde o início.
Sticky geralmente é melhor (mais flexível).
Adicionar efeito visual no scroll:
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: white;
padding: 1rem;
transition: all 0.3s;
}
/* Quando faz scroll, adicionar shadow + reduzir altura */
.sticky-header.scrolled {
padding: 0.5rem 1rem;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
window.addEventListener('scroll', () => {
const header = document.querySelector('.sticky-header');
if (window.scrollY > 50) {
header.classList.add('scrolled');
} else {
header.classList.remove('scrolled');
}
});
Esconder ao fazer scroll para baixo, mostrar ao subir:
let lastScroll = 0;
const header = document.querySelector('.sticky-header');
window.addEventListener('scroll', () => {
const current = window.scrollY;
if (current > lastScroll && current > 100) {
// Scroll para baixo
header.style.transform = 'translateY(-100%)';
} else {
// Scroll para cima
header.style.transform = 'translateY(0)';
}
lastScroll = current;
});
.sticky-header {
transition: transform 0.3s;
}
Pattern comum em sites modernos (Medium, etc.).
Exercício 8 · Mobile menu (15 pts)
Cria menu hamburger para mobile que abre/fecha com JavaScript.
<header>
<nav class="navbar">
<a href="/" class="logo">Logo</a>
<button
class="menu-toggle"
aria-label="Abrir menu"
aria-expanded="false"
aria-controls="menu">
<span class="hamburger"></span>
<span class="hamburger"></span>
<span class="hamburger"></span>
</button>
<ul class="menu" id="menu">
<li><a href="/">Home</a></li>
<li><a href="/sobre">Sobre</a></li>
<li><a href="/servicos">Serviços</a></li>
<li><a href="/contacto">Contacto</a></li>
</ul>
</nav>
</header>
/* Reset básico */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: sans-serif;
}
/* Navbar */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
position: relative;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
color: #6366f1;
text-decoration: none;
}
/* Mobile menu (default: hidden) */
.menu {
display: none;
list-style: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
flex-direction: column;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
padding: 1rem 0;
}
.menu.open {
display: flex;
}
.menu li {
padding: 0;
}
.menu a {
display: block;
padding: 0.75rem 1.5rem;
color: #333;
text-decoration: none;
transition: background 0.2s;
}
.menu a:hover {
background: #f3f4f6;
}
/* Hamburger button */
.menu-toggle {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.hamburger {
display: block;
width: 24px;
height: 3px;
background: #333;
border-radius: 2px;
transition: all 0.3s;
}
/* Quando aberto, transforma em X */
.menu-toggle[aria-expanded="true"] .hamburger:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.menu-toggle[aria-expanded="true"] .hamburger:nth-child(2) {
opacity: 0;
}
.menu-toggle[aria-expanded="true"] .hamburger:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
/* Desktop */
@media (min-width: 768px) {
.menu-toggle {
display: none;
}
.menu {
display: flex !important; /* override mobile */
position: static;
flex-direction: row;
box-shadow: none;
padding: 0;
gap: 2rem;
}
.menu a {
padding: 0;
}
}
const toggle = document.querySelector('.menu-toggle');
const menu = document.querySelector('.menu');
toggle.addEventListener('click', () => {
const isOpen = menu.classList.toggle('open');
// Acessibilidade: actualizar aria-expanded
toggle.setAttribute('aria-expanded', isOpen);
toggle.setAttribute('aria-label', isOpen ? 'Fechar menu' : 'Abrir menu');
});
// Fechar ao clicar fora
document.addEventListener('click', (e) => {
if (!e.target.closest('.navbar')) {
menu.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-label', 'Abrir menu');
}
});
// Fechar com Esc
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && menu.classList.contains('open')) {
menu.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
toggle.focus(); // voltar foco ao toggle
}
});
Características demonstradas:
Mobile (< 768px): - Menu hamburger visível. - Menu escondido por defeito. - Click abre menu (vertical, full width). - Hamburger transforma-se em X. - Fechar: click fora, Esc, ou click no toggle.
Desktop (>= 768px): - Hamburger escondido. - Menu horizontal sempre visível. - Espaçamento entre items.
Acessibilidade:
✅ aria-label no botão.
✅ aria-expanded indica estado.
✅ aria-controls liga ao menu.
✅ Esc para fechar (keyboard).
✅ Click fora para fechar.
✅ Foco retorna ao toggle ao fechar.
✅ Texto descritivo no aria-label muda.
Animação CSS do hamburger: - 3 linhas → X com rotação CSS pura. - Smooth transition. - Sem JavaScript para animação visual.
Extensões:
Slide-in animation:
.menu {
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
}
.menu.open {
transform: translateX(0);
}
Overlay backdrop:
.menu.open::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: -1;
}
Submenus expansíveis:
<li class="has-submenu">
<a href="/servicos">Serviços ▾</a>
<ul class="submenu">
<li><a href="/servico-1">Serviço 1</a></li>
<li><a href="/servico-2">Serviço 2</a></li>
</ul>
</li>
document.querySelectorAll('.has-submenu > a').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
link.parentElement.classList.toggle('open');
});
});
Trap focus (acessibilidade avançada): manter Tab dentro do menu aberto.