← Biblioteca componentes/navegacao/indice-ancoras/ficha.md CódigoTela cheia
navegacao/indice-ancoras

Índice de âncoras

🟢 estavel complexidade média prefixo anc- ancora sticky scrollspy sumario subnav pagina-longa navegacao

Incluir · as linhas que se colam num site novo, depois do core (CLAUDE.md §10)

<link rel="stylesheet" href="componentes/navegacao/indice-ancoras/estilo.css">
<script src="componentes/navegacao/indice-ancoras/script.js"></script>

markup.html · 42 linhas · 2,1 KB

<!-- ==========================================================================
     ÍNDICE DE ÂNCORAS — "nesta página", colado no topo, marcando onde você está
     Prefixo: anc-
     Requer: core (tokens, base, motion.css, motion.js) + este estilo.css e
             este script.js

     A barra fina que as páginas longas de produto usam: o nome da página à
     esquerda, as seções no meio, um botão à direita. Ela rola com a página
     até encostar no topo e ali gruda; conforme a leitura desce, a seção em
     que você está ganha a marca embaixo. Clicar leva até a seção — descontada
     a altura da própria barra, para o título não nascer escondido atrás dela.

     ONDE COLAR: ENTRE duas seções, como irmão delas — nunca DENTRO de uma.
     `position:sticky` gruda dentro do bloco que o contém: dentro de uma
     <section>, a barra soltaria no fim daquela seção. Não é um <section>,
     então carrega o próprio escopo de tom (.sec-deep aqui), como a
     navegacao/nav-fixa-vidro.

     SEM JAVASCRIPT os links continuam sendo âncoras de verdade e a barra
     continua grudando — o que some é só a marca de "você está aqui".

     Cada href="#x" tem de apontar para um id que EXISTE na página. Link sem
     alvo é descartado pelo script da conta de posição, mas continua lá como
     link morto: confira.
     ========================================================================== -->

<nav class="anc sec-deep" data-anc aria-label="Nesta página">
  <div class="wrap anc-in">
    <span class="anc-titulo">Gestão patrimonial</span>
    <div class="anc-trilho">
      <ul class="anc-lista">
        <li><a class="anc-link" href="#visao-geral">Visão geral</a></li>
        <li><a class="anc-link" href="#como-funciona">Como funciona</a></li>
        <li><a class="anc-link" href="#para-quem">Para quem</a></li>
        <li><a class="anc-link" href="#honorarios">Honorários</a></li>
        <li><a class="anc-link" href="#perguntas">Perguntas</a></li>
      </ul>
      <span class="anc-marca" aria-hidden="true"></span>
    </div>
    <a class="anc-cta" href="#contato">Agendar conversa</a>
  </div>
</nav>

estilo.css · 96 linhas · 3,7 KB

/* ==========================================================================
   ÍNDICE DE ÂNCORAS — anc-
   Barra "nesta página" que gruda no topo e marca a seção em leitura.
   Tokens usados: --bg --fg --fg-2 --fg-muted --rule --accent --dark
                  --display --radius-pill --ease --nav-h
   Parafusos locais:
     --anc-top   onde a barra gruda. 0 por padrão; var(--nav-h) sob menu fixo
     --anc-z     camada. 90: abaixo do menu fixo (100), acima do conteúdo
     --anc-x/w   posição e largura da marca, ESCRITAS pelo script
   ========================================================================== */

.anc{
  position:sticky;
  top:var(--anc-top,0px);
  z-index:var(--anc-z,90);
  background:var(--bg);
  border-block:1px solid var(--rule);
  transition:background .35s var(--ease);
}
/* Grudada, ela passa por cima do conteúdo, e o fundo sólido cortaria a foto
   de baixo com uma régua. Vidro no lugar — a mesma receita do menu fixo, com
   a mistura feita sobre o --bg do escopo, para servir a .sec-deep e a
   .sec-paper sem uma variação de código. */
.anc.is-preso{
  background:color-mix(in srgb,var(--bg) 86%,transparent);
  backdrop-filter:blur(16px) saturate(140%);
  -webkit-backdrop-filter:blur(16px) saturate(140%);
}

.anc-in{display:flex;align-items:center;gap:clamp(18px,3vw,40px);min-height:58px}

.anc-titulo{
  flex:0 0 auto;
  font-family:var(--display);font-weight:700;font-size:14px;letter-spacing:-.01em;
  color:var(--fg);white-space:nowrap;
}

/* O trilho rola de lado quando os links não cabem; a marca mora DENTRO dele,
   para rolar junto com os links em vez de ficar parada sobre o lugar errado. */
.anc-trilho{
  position:relative;flex:1 1 auto;min-width:0;
  overflow-x:auto;overscroll-behavior-x:contain;
  scrollbar-width:none;-ms-overflow-style:none;
}
.anc-trilho::-webkit-scrollbar{display:none}

.anc-lista{display:flex;gap:clamp(18px,2.4vw,32px);white-space:nowrap}
.anc-link{
  display:block;padding:19px 0 18px;
  font-size:13px;letter-spacing:.02em;color:var(--fg-muted);
  transition:color .25s;
}
.anc-link:hover,
.anc-link[aria-current]{color:var(--fg)}
.anc-link:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}

/* A marca de "você está aqui": um filete de 2px na cor de marca, colado ao
   pé do trilho. Anda de um link para o outro por transform — nunca por left
   ou width animados, que refariam o layout da barra a cada quadro. A largura
   entra por scaleX sobre uma base de 1px. Sem script ela nem aparece. */
.anc-marca{
  position:absolute;left:0;bottom:0;
  width:1px;height:2px;background:var(--accent);
  transform-origin:0 50%;
  transform:translateX(var(--anc-x,0px)) scaleX(var(--anc-w,0));
  opacity:0;
  transition:transform .5s var(--ease),opacity .3s;
}
.anc.is-ativo .anc-marca{opacity:1}
@media(prefers-reduced-motion:reduce){
  .anc-marca{transition:opacity .3s}
}

.anc-cta{
  flex:0 0 auto;
  font-family:var(--display);font-weight:700;font-size:10.5px;
  letter-spacing:.1em;text-transform:uppercase;white-space:nowrap;
  background:var(--accent);color:var(--dark);
  border-radius:var(--radius-pill);padding:10px 18px;
  transition:background .3s;
}
.anc-cta:hover{background:var(--fg)}

@media(max-width:860px){
  .anc-titulo{display:none}
  /* As bordas do trilho dissolvem, para o link cortado na ponta ler como
     "tem mais para o lado" e não como erro de layout. */
  .anc-trilho{
    -webkit-mask-image:linear-gradient(to right,transparent 0,#000 14px,#000 calc(100% - 22px),transparent 100%);
    mask-image:linear-gradient(to right,transparent 0,#000 14px,#000 calc(100% - 22px),transparent 100%);
  }
  .anc-lista{padding-inline:14px 22px}
}
@media(max-width:480px){
  .anc-cta{padding:9px 14px;font-size:10px}
}

script.js · 98 linhas · 4,6 KB

/* ==========================================================================
   ÍNDICE DE ÂNCORAS — anc-
   Três trabalhos, todos pequenos:
     1. marcar a seção em leitura (aria-current + a marca que anda)
     2. levar à seção no clique descontando a altura da barra
     3. dizer ao CSS quando a barra está grudada (.is-preso)
   Assina o laço único de scroll do core. Pode haver mais de um índice na
   página: parte sempre da própria raiz.
   ========================================================================== */
(function () {
  var raizes = document.querySelectorAll('[data-anc]');
  if (!raizes.length) return;                  // guard: sem o componente, saída silenciosa
  if (!window.LIB) return;                     // sem o core, os links seguem como âncoras nativas

  var doc = document.documentElement;

  [].forEach.call(raizes, function (nav) {
    var trilho = nav.querySelector('.anc-trilho');
    var marca = nav.querySelector('.anc-marca');
    var links = [].slice.call(nav.querySelectorAll('.anc-link'));
    if (!trilho || !links.length) return;

    /* Cada link com o seu alvo. Link sem alvo na página fica fora da conta de
       posição — marcá-lo mentiria —, mas continua clicável como estava. */
    var pares = [];
    links.forEach(function (a) {
      var id = (a.getAttribute('href') || '').replace(/^#/, '');
      var alvo = id && document.getElementById(id);
      if (alvo) pares.push({ link: a, alvo: alvo });
    });
    if (!pares.length) return;

    var atual = -1;

    /* A marca vai para baixo do link ativo. translateX + scaleX sobre uma
       base de 1px: só transform, nada de layout (ver o estilo.css). No
       celular o trilho rola de lado, e o link ativo é trazido para o meio
       dele — pelo scrollLeft do TRILHO, nunca por scrollIntoView, que
       rolaria a página junto. */
    function marcar() {
      links.forEach(function (a) { a.removeAttribute('aria-current'); });
      nav.classList.toggle('is-ativo', atual >= 0);
      if (atual < 0) return;
      var a = pares[atual].link;
      a.setAttribute('aria-current', 'location');
      if (marca) {
        marca.style.setProperty('--anc-x', a.offsetLeft + 'px');
        marca.style.setProperty('--anc-w', a.offsetWidth);
      }
      if (trilho.scrollWidth > trilho.clientWidth + 1) {
        var alvoX = a.offsetLeft - (trilho.clientWidth - a.offsetWidth) / 2;
        trilho.scrollTo({ left: alvoX, behavior: LIB.reduce ? 'auto' : 'smooth' });
      }
    }

    /* A linha de corte fica um quarto de tela abaixo do pé da barra: a seção
       vira "a atual" quando o título dela já está na zona de leitura, e não
       no instante em que a primeira linha encosta na barra. No fim da página
       a última seção vence mesmo sem chegar à linha — senão uma seção final
       curta nunca seria marcada. */
    function pintar() {
      var pe = nav.getBoundingClientRect();
      var topo = parseFloat(getComputedStyle(nav).top) || 0;
      nav.classList.toggle('is-preso', pe.top <= topo + .5);

      var corte = pe.bottom + innerHeight * .25, melhor = -1;
      for (var i = 0; i < pares.length; i++) {
        if (pares[i].alvo.getBoundingClientRect().top <= corte) melhor = i;
      }
      var noFim = innerHeight + (window.pageYOffset || doc.scrollTop) >= doc.scrollHeight - 2;
      if (noFim) melhor = pares.length - 1;
      if (melhor === atual) return;
      atual = melhor;
      marcar();
    }

    /* O clique leva ao alvo descontando a barra, e escreve o hash sem saltar.
       O salto nativo pararia com o título da seção escondido atrás da barra
       grudada. Movimento reduzido chega direto, sem atravessar a página. */
    pares.forEach(function (par) {
      par.link.addEventListener('click', function (e) {
        e.preventDefault();
        var altura = nav.offsetHeight + (parseFloat(getComputedStyle(nav).top) || 0);
        var y = par.alvo.getBoundingClientRect().top + (window.pageYOffset || doc.scrollTop) - altura + 1;
        window.scrollTo({ top: y, behavior: LIB.reduce ? 'auto' : 'smooth' });
        if (history.replaceState) history.replaceState(null, '', par.link.getAttribute('href'));
        par.alvo.setAttribute('tabindex', '-1');
        par.alvo.focus({ preventScroll: true });
      });
    });

    LIB.onScroll(pintar);
    /* A largura dos links muda com a fonte carregando e com a janela: a marca
       é reposicionada sem esperar a próxima troca de seção. */
    addEventListener('resize', marcar, { passive: true });
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(marcar);
  });
})();

A barra "nesta página" das páginas longas: o nome da página à esquerda, as seções no meio, um botão à direita. Ela rola junto com a página até encostar no topo e ali gruda, ganhando vidro. Conforme a leitura desce, a seção em que você está ganha um filete na cor de marca, que anda de um link para o outro. Clicar leva até a seção — descontada a altura da barra, para o título não nascer escondido atrás dela.

O problema que ela resolve: página de serviço de assessoria tem seis, oito dobras, e quem chega de um anúncio quer uma delas — os honorários, as perguntas. Sem índice, a pessoa rola procurando, e quem rola procurando desiste. O nav-fixa-vidro diz para onde se pode ir no site; este diz para onde se pode ir nesta página, e onde você está nela.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Título da página 1 a 4 palavras, igual ao h1 ou mais curto. Some abaixo de 860px. Opcional
Seção 1 a 3 palavras por link, substantivo, o nome que a dobra já tem na página. 3 a 7 links
Botão 1 a 3 palavras, verbo mais objeto, o mesmo destino do CTA principal. Opcional

O que mantém a peça: o link repete o nome da seção, não o parafraseia. Quem clica em "Honorários" tem de cair num título que diz "Honorários", e não em "Quanto custa ter a gente por perto".

Especificação dos assets

Nenhum.


Como usar

<link rel="stylesheet" href="componentes/navegacao/indice-ancoras/estilo.css">
<script src="componentes/navegacao/indice-ancoras/script.js"></script>

Cole a <nav> entre duas seções, como irmã delas — nunca dentro de uma. E dê a cada seção o id que o link aponta:

<section class="sec-dark sec-pad">…hero…</section>
<nav class="anc sec-deep" data-anc aria-label="Nesta página">…</nav>
<section class="sec-dark sec-pad" id="visao-geral">…</section>
<section class="sec-deep sec-pad" id="como-funciona">…</section>

Ganchos

Gancho Papel
[data-anc] a raiz que o script procura. Pode repetir, cada uma com os próprios links
.anc.is-preso escrito pelo script enquanto a barra está grudada. O CSS troca o fundo sólido por vidro
.anc.is-ativo há uma seção marcada. Antes da primeira seção, nenhuma está, e a marca some
.anc-link[aria-current="location"] o link da seção em leitura
--anc-top onde a barra gruda. 0px por padrão
--anc-z camada. 90: abaixo do menu fixo (100)

Variações


Gotchas


Movimento reduzido / toque

Com prefers-reduced-motion: reduce o clique chega direto, sem atravessar a página rolando, e a marca troca de link sem deslizar. A barra continua grudando e marcando — posição é informação, não enfeite.

No toque o trilho rola de lado com o dedo, com as pontas dissolvidas em máscara para o link cortado ler como "tem mais", e o link ativo é trazido para o meio sozinho.


Histórico

Origem Quando O quê
autoral 2026-09-29 Construído do zero. Padrão de domínio comum, sem referência única

Origem. É a barra das páginas de produto da Apple, dos sumários de documentação e das landings longas de SaaS — um padrão tão difundido que não tem dono. Nada foi copiado. O registro consolidado vai em projetos/autorais.md.

Por que agora. A descrição da categoria navegacao promete "âncoras" desde o primeiro dia, e nenhum componente entregava. É o mesmo tipo de dívida que a leva de 2026-09-06 pagou: descrição que promete e não entrega é descoberta pelo consumidor no meio de um projeto.

Decisões da tradução para o contrato. Escopo de tom próprio, como o menu fixo, porque não é <section>. A marca por transform, o trilho alinhado por scrollLeft, o clique descontando a barra, e nenhum listener de scroll próprio — assina LIB.onScroll.