Cabeçalhos fixos que reconhecem o estado

Os cabeçalhos fixos são um padrão de interface comum, mas geralmente precisam mudar de aparência quando ficam "fixos" para manter a legibilidade ou economizar espaço. As soluções tradicionais exigiam listeners de rolagem do JavaScript, o que pode causar problemas de desempenho. O CSS moderno permite que você lide com isso de forma nativa usando consultas de estado de rolagem.

Etapas de implementação

1. Criar o contêiner fixo

Você precisa de um contêiner que funcione como o elemento fixo e o contêiner para a consulta de estado de rolagem.

<!-- Wrap in a section to define the containment area for the sticky element -->
<div class="section">
  <div class="sticky-container">
    <div class="sticky-header">
      Section Header
    </div>
  </div>
  <div class="content">
    <!-- Content goes here -->
  </div>
</div>

2. Aplicar CSS para comportamento fixo e tipo de contêiner

Defina container-type: scroll-state no elemento position: sticky, não no ancestral rolável. Sempre especifique um container-name, já que essas consultas podem ser aninhadas e os contêineres sem nome vão entrar em conflito. Também é possível combinar com consultas de tamanho, por exemplo, container-type: scroll-state inline-size.

.sticky-container {
  position: sticky;
  top: 0;
  /* MANDATORY: Always specify a container name to avoid collisions if CQs are nested */
  container-type: scroll-state;
  container-name: section-header;
  z-index: 10;
}

.sticky-header {
  /* Base styles for the header */
  background: #f0f0f0;
  padding: 20px;
  transition: background-color 0.3s, box-shadow 0.3s;
}

Importante: o estado de rolagem é consultado por descendentes do contêiner de estado de rolagem. Os estilos na consulta @container não serão aplicados ao .sticky-container em si, mas aos filhos dele (como .sticky-header). Não é possível estilizar o elemento de contêiner com a própria consulta de estado de rolagem.

3. Segmentar o estado travado

Use a consulta @container scroll-state(...) para aplicar estilos quando o cabeçalho estiver fixo.

/* Apply styles when the named container is stuck at the top */
@container section-header scroll-state(stuck: top) {
  .sticky-header {
    background: #0056b3;
    color: white;
    box-shadow: 0 4px 6px rgb(0 0 0 / 0.15);
  }
}

Dica: você também pode usar propriedades lógicas, como stuck: inset-block-start ou stuck: inset-inline-start, para melhorar a internacionalização (i18n) e os layouts da direita para a esquerda (RTL, na sigla em inglês) consultando bordas relativas ao fluxo em vez de bordas físicas.

Observações sobre mudanças de dimensão

Se a ancoragem de rolagem não estiver desativada, mudar as propriedades que afetam o layout (altura, padding, tamanho da fonte) quando o elemento estiver fixo pode causar oscilações visuais. A ancoragem de rolagem no conteúdo em fluxo abaixo do elemento fixo ajusta o deslocamento de rolagem para compensar a mudança de layout, o que empurra o elemento para fora da posição fixa e aciona uma oscilação.

Desative a fixação de rolagem no pai do elemento fixo para evitar isso:

.section {
  overflow-anchor: none;
}

Aplique overflow-anchor: none ao elemento pai direto de position: sticky, não a :root ou body, o que desativaria a fixação de rolagem para toda a página. Com isso, é possível mudar livremente qualquer propriedade no estado fixo, incluindo propriedades e transformações do modelo de caixa.

NÃO: use overflow-anchor: none para elementos fixados na parte de baixo. A ancoragem de rolagem só compensa as mudanças de layout acima da posição de rolagem atual, então não tem efeito em elementos fixados na parte de baixo. Evite mudar as propriedades do modelo de caixa no estado fixo para cabeçalhos fixos na parte de baixo.

Estratégias de fallback

As consultas de estado de rolagem são um aprimoramento progressivo. Em navegadores que não oferecem suporte a container-type: scroll-state, o cabeçalho ainda vai ficar fixo na parte de cima (devido a position: sticky), mas não vai se transformar visualmente. Para a maioria dos casos de uso, essa é a abordagem recomendada.

Se a fixação não for essencial, mas o position: sticky sem um estilo diferente for uma experiência pior do que nenhuma fixação, você também poderá envolver o position: sticky e a declaração relacionada em uma consulta @supports:

.sticky-container {
  @supports (container-type: scroll-state) {
      position: sticky;
      top: 0;
      container-type: scroll-state;
      container-name: section-header;
      z-index: 10;
    }
}

Dica: se o estilo "fixo" exigir uma cor de plano de fundo diferente para facilitar a leitura, configure o layout para que o estilo padrão funcione em todos os lugares (aprimoramento progressivo). Se você precisar usar um substituto, poderá controlar o comportamento de position: sticky em uma consulta @supports (container-type: scroll-state).

Se a transformação visual for absolutamente essencial para o design (por exemplo, se o estado fixo introduzir um plano de fundo ou uma compactação sem os quais o conteúdo fica ilegível), implemente um substituto robusto em JavaScript usando IntersectionObserver. Duplique os estilos CSS em uma classe .is-stuck. O polyfill genérico a seguir verifica getComputedStyle para subir e encontrar o contêiner de rolagem correto (o padrão é a janela de visualização), correspondendo ao comportamento de @container scroll-state(stuck: top):

function getScrollParent(node) {
  if (node == null || node === document.body || node === document.documentElement) {
    return null; // default to viewport
  }
  if (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) {
    const overflow = getComputedStyle(node).overflow;
    if (overflow !== 'visible' && overflow !== 'clip') {
      return node;
    }
  }
  return getScrollParent(node.parentNode);
}

document.querySelectorAll('.sticky-container').forEach(container => {
  const root = getScrollParent(container);
  
  const topOffset = parseFloat(getComputedStyle(container).top) || 0;
  
  const observer = new IntersectionObserver(
    ([e]) => {
      // Toggle the fallback class on the sticky header container
      e.target.classList.toggle('is-stuck', e.intersectionRatio < 1);
    },
    { 
      root: root,
      threshold: [1],
      rootMargin: `-${topOffset + 1}px 0px 0px 0px` 
    }
  );
  
  observer.observe(container);
});

Observação: esse padrão genérico de IntersectionObserver também pode ser usado como um polyfill para a consulta scroll-state(scrollable).