Cabeçalho fixo na tabela com position: sticky, e por que ele às vezes não gruda
Mantenha o cabeçalho de uma tabela longa visível com CSS puro: sticky nas células, o contêiner certo rolando, fundo, borda que não some e a primeira coluna fixa.
O relatório de estoque tem 400 linhas e nove colunas: código, descrição, depósito, saldo, reservado, disponível, custo, preço e giro. Na linha 60, ninguém lembra mais se a quinta coluna é "reservado" ou "disponível", e a pessoa rola para cima e para baixo até errar o pedido de compra. A resposta curta: aplique position: sticky e top: 0 nas células th do cabeçalho, dê a elas um fundo opaco e garanta que exista só um elemento rolando, de preferência um contêiner com altura máxima e overflow: auto. Se não grudar, quase sempre há um ancestral com overflow que você não sabia que existia.
Não é preciso biblioteca nem duas tabelas empilhadas. Os navegadores atuais suportam sticky em células de tabela, e uma tabela só mantém as colunas alinhadas sem cálculo de largura em JavaScript.
Como o sticky decide onde grudar
Um elemento com position: sticky se comporta como normal até que, durante a rolagem, ele alcança a distância definida em top. A partir daí, fica preso naquela posição enquanto o seu bloco pai ainda estiver visível. Para a célula de cabeçalho, o bloco pai é a própria tabela: o cabeçalho acompanha a rolagem até a tabela acabar.
A pergunta importante é "preso em relação a quê". A resposta é: ao ancestral mais próximo que rola, ou seja, que tem overflow diferente de visible. Se nenhum ancestral rola, a referência é a janela. Isso explica os dois cenários que confundem.
- Você quer que grude no topo da janela, mas algum ancestral tem
overflow-x: auto(para rolagem horizontal no celular) ouoverflow: hidden(para conter um float). O sticky passa a grudar dentro desse ancestral, que não rola na vertical, e na prática não gruda nunca. - Você quer que grude no topo de uma caixa com rolagem própria, mas a caixa não tem altura máxima. Ela cresce até caber a tabela inteira, quem rola é a página, e o cabeçalho rola junto.
O componente de tabela responsiva de vários frameworks CSS envolve a tabela num contêiner com overflow-x: auto. Quando o overflow de um eixo não é visible, o do outro eixo também deixa de ser, e esse contêiner vira a referência do sticky. É a causa mais comum de "funcionou no exemplo e não no meu projeto".
Como fazer o cabeçalho fixo passo a passo
- Use uma tabela de verdade, com
thead,thcomscope="col"e uma legenda. - Envolva a tabela num contêiner com
max-heighteoverflow: auto. Esse é o único elemento que rola. - Aplique sticky nas células
thdothead, comtop: 0, fundo opaco ez-index. - Troque
border-collapse: collapseporseparatecom espaçamento zero, para a borda do cabeçalho não ficar para trás. - Se precisar, fixe a primeira coluna com
left: 0, e dê à célula do canto o maiorz-index. - Torne o contêiner acessível por teclado, com
tabindex="0", papel de região e nome. - Teste no celular: rolagem horizontal e vertical no mesmo contêiner.
O exemplo do relatório de estoque
O HTML
<div class="tabela-rolavel" tabindex="0" role="region" aria-labelledby="titulo-estoque">
<table>
<caption id="titulo-estoque">Saldo de estoque por depósito</caption>
<thead>
<tr>
<th scope="col">Código</th>
<th scope="col">Descrição</th>
<th scope="col">Depósito</th>
<th scope="col">Saldo</th>
<th scope="col">Reservado</th>
<th scope="col">Disponível</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">00012345</th>
<td>Filtro de óleo FO-220</td>
<td>Central</td>
<td>148</td>
<td>20</td>
<td>128</td>
</tr>
<!-- demais linhas -->
</tbody>
</table>
</div>
A primeira célula de cada linha é um th scope="row", porque o código identifica a linha. Isso ajuda o leitor de tela a anunciar "00012345, Disponível, 128" e permite fixar essa coluna com o mesmo seletor.
O CSS
.tabela-rolavel {
max-height: 70vh;
overflow: auto;
}
.tabela-rolavel table {
border-collapse: separate;
border-spacing: 0;
width: 100%;
}
.tabela-rolavel th,
.tabela-rolavel td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #d0d5dd;
text-align: left;
white-space: nowrap;
}
.tabela-rolavel thead th {
position: sticky;
top: 0;
z-index: 2;
background: #f2f4f7;
border-bottom: 2px solid #98a2b3;
}
.tabela-rolavel tbody th {
position: sticky;
left: 0;
z-index: 1;
background: #ffffff;
}
.tabela-rolavel thead th:first-child {
left: 0;
z-index: 3;
}
.tabela-rolavel:focus-visible {
outline: 3px solid #1d4ed8;
outline-offset: 2px;
}
O que cada regra resolve
O max-height: 70vh limita o contêiner a 70% da altura da janela; sem limite, ele cresceria e quem rolaria seria a página. O fundo do cabeçalho é obrigatório: sem ele, o cabeçalho fica transparente e as linhas passam por baixo, visíveis através do texto.
O border-collapse: separate com border-spacing: 0 parece igual ao collapse, mas com uma diferença decisiva. No modo collapse, as bordas pertencem à tabela, não à célula, e por isso não acompanham o cabeçalho grudado: a linha divisória rola embora e o cabeçalho fica sem borda. No modo separate, cada célula carrega a própria borda.
Os valores de z-index estabelecem quem fica por cima. A célula do canto, que é cabeçalho e primeira coluna ao mesmo tempo, gruda nos dois eixos e precisa ficar acima de todas. O cabeçalho fica acima da primeira coluna, que fica acima das células comuns.
Por fim, o tabindex="0" no contêiner permite que quem usa teclado chegue até ele com Tab e role com as setas. Sem isso, uma tabela que rola dentro de uma caixa fica inalcançável para quem não usa mouse.
Quando o site tem um menu fixo no topo
Se você prefere que a página inteira role e o cabeçalho grude na janela, remova o max-height e o overflow do contêiner e garanta que nenhum ancestral tenha overflow. Se o site tem uma barra fixa de 64 pixels no topo, use top: 64px nas células do cabeçalho, para elas grudarem logo abaixo da barra, e não por baixo dela. O mesmo cuidado aparece no guia sobre menu fixo que não cobre o título.
Como achar o ancestral que quebra o sticky
Quando o cabeçalho se recusa a grudar, cole isto no console do DevTools, na página do relatório:
let el = document.querySelector('thead th')
while ((el = el.parentElement)) {
const s = getComputedStyle(el)
if (s.overflowX !== 'visible' || s.overflowY !== 'visible') {
console.log(el, s.overflowX, s.overflowY)
}
}
O script sobe da primeira célula do cabeçalho até a raiz e lista todo ancestral cujo overflow não é visible. O primeiro da lista é a referência do sticky. Se ele não é o contêiner que você planejou, é ali que está o problema. No relatório de estoque, apareceu uma div de layout com overflow: hidden, colocada anos atrás para conter um float que nem existia mais.
No celular
Em tela estreita, as nove colunas não cabem. O mesmo contêiner rola nas duas direções: o cabeçalho gruda no topo, o código gruda à esquerda, e a pessoa nunca perde a referência de linha e coluna. Se a tabela é usada principalmente no celular, avalie as alternativas do guia sobre tabela larga no celular, como mostrar cada linha como uma ficha.
Erros comuns
- Esquecer o fundo do cabeçalho. As linhas aparecem por trás do texto e o cabeçalho fica ilegível.
- Manter
border-collapse: collapse. A borda inferior do cabeçalho rola embora e ele parece flutuar sem separação. - Ancestral com
overflow: hiddenouoverflow-x: auto. O cabeçalho gruda numa caixa que não rola e, na prática, não gruda. - Contêiner sem altura máxima. Quem rola é a página e o cabeçalho rola junto.
- Duas tabelas, uma só com o cabeçalho. As colunas desalinham com qualquer texto mais longo, e o leitor de tela perde a associação entre célula e cabeçalho.
- Contêiner rolável sem
tabindex. Quem usa teclado não consegue ver as linhas de baixo.
Checklist
- Existe uma única tabela, com
thead,th scope="col"e legenda. - Só um elemento rola: o contêiner com altura máxima, ou a página.
- As células do cabeçalho têm
position: sticky,tope fundo opaco. - A tabela usa
border-collapse: separatecom espaçamento zero. - A célula do canto tem o maior
z-indexquando a primeira coluna também é fixa. - O contêiner rolável recebe foco por teclado e tem nome.
- Nenhum ancestral inesperado tem
overflowdiferente devisible.
Abra o maior relatório do seu sistema e role até a linha 100. Você ainda sabe, sem subir, qual é a quinta coluna?
Referência: https://pt.stackoverflow.com/questions/271288/fixar-thead-ao-rolar-a-tabela