← Biblioteca componentes/listas/paineis-empilhados/ficha.md CódigoTela cheia
listas/paineis-empilhados

Painéis empilhados com índice

🟢 estavel complexidade média prefixo pnl- sticky empilhamento indice navegacao-interna capitulos servicos frentes aria-current ancoras sem-lib

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

<link rel="stylesheet" href="componentes/listas/paineis-empilhados/estilo.css">
<script src="componentes/listas/paineis-empilhados/script.js"></script>

markup.html · 269 linhas · 15,0 KB

<!-- ==========================================================================
     PAINÉIS EMPILHADOS COM ÍNDICE — capítulos que se cobrem, e um índice que
     deixa pular fora de ordem
     Prefixo: pnl-
     Requer: core (tokens, base, motion.css, motion.js) + estilo.css + script.js
     Compõe: efeitos/selo-circular (só o selo do cabeçalho; o CSS vem de lá)

     A IDEIA. Quatro painéis de largura cheia, irmãos, TODOS position:sticky no
     mesmo top. Conforme a página rola, cada um sobe e cobre o anterior por
     inteiro — não sobra faixa do de baixo, e é o fundo opaco de cada painel
     que faz a troca ler como corte, não como sobreposição. Ao lado, um índice
     também colado marca o painel corrente e leva a qualquer um deles no
     clique. Substituição COM CONTROLE: a pessoa vê um capítulo por vez e
     escolhe qual.

     A PILHA É CSS PURO. Nenhuma linha de JavaScript empilha nada: os painéis
     são irmãos com o mesmo `top`, e quem vem depois no HTML pinta por cima —
     elemento posicionado sem z-index pinta em ordem de árvore. Inverter a
     ordem dos <article> inverte quem cobre quem. O script existe para duas
     coisas e nada mais: marcar o item corrente do índice e rolar no clique.

     SEM JAVASCRIPT a dobra continua inteira e honesta: os quatro painéis são
     conteúdo estático e a pilha (CSS) continua funcionando; o índice é feito
     de <a href="#id"> de verdade, então o clique ainda leva ao painel — ver o
     scroll-margin-top no estilo.css, que é o que faz a âncora nativa parar no
     lugar certo. O que se perde é só a marcação do item corrente, que nasce
     no 01 (correto em repouso) e deixa de acompanhar.

     data-pnl-alvo E href NOMEIAM O MESMO ID, e isso é de propósito: o `href`
     é o que funciona sem script, o `data-pnl-alvo` é o gancho declarado que o
     script lê. Item cujo id não existe é ignorado pelo script — nunca vira
     erro no console. (Na referência o alvo vinha num rel="gestao" inventado,
     que não aponta para nada.)

     ENTRADA. O data-rv NUNCA vai no <article>: o painel é o elemento sticky e
     o que entra é o conteúdo dele — a arte e o bloco de texto, escalonados.
     Painel intocado, sticky intocado.

     ASSETS. As quatro fotos são placeholders de showroom/demo/. A ficha
     especifica o que a foto real precisa ser; <video> é variação legítima
     (muted playsinline loop, preload contido) e está descrita lá.

     COMPLIANCE. Nenhuma linha promete ou insinua retorno, e não há número
     nenhum nas listas — só o que a assessoria entrega. "Taxa previamente
     acordada" é a única menção a remuneração, e é sobre custo, não resultado.
     ========================================================================== -->

<section class="sec-dark sec-pad pnl" data-pnl>
  <div class="wrap">

    <!-- ---------------------------------------------------------- cabeçalho -->
    <!-- Duas colunas: argumento à esquerda, selo à direita. O selo é o
         efeitos/selo-circular COMPOSTO, não recopiado: o desenho e a volta
         vivem no estilo.css dele, declarado em preview-css.txt. O data-rv fica
         no invólucro .pnl-selo-slot e nunca no .selo, porque o .selo gerencia
         o próprio transform (o giro) e [data-rv].in{transform:none} venceria
         por especificidade e congelaria a volta depois da entrada. -->
    <header class="pnl-head">
      <div class="pnl-head-txt">
        <span class="eyebrow" data-rv>Frentes de atuação</span>
        <h2 class="sec-title ttl">Quatro frentes, e o que entra em <em>cada uma</em></h2>
        <p class="sec-lead" data-rv data-d="1">
          Toda relação começa pelo diagnóstico e pela política de investimentos. O que
          vem depois muda conforme o momento da família — e é isso que está aqui
          embaixo, frente por frente.
        </p>
      </div>

      <div class="pnl-selo-slot" data-rv data-d="2">
        <!-- O id do arco tem de ser único na página: dois selos com o mesmo id
             fazem os dois textos seguirem o PRIMEIRO arco. Por isso aqui ele é
             pnl-selo-arco, e não o selo-arco do componente de origem. O texto
             tem de caber na volta: o arco gasta ~4,38 unidades por caractere em
             232,5 disponíveis, ou seja ~53 caracteres contando os separadores.
             A frase abaixo, duplicada para fechar o laço, gasta 50. -->
        <div class="selo">
          <svg class="selo-svg" viewBox="0 0 100 100" aria-hidden="true" focusable="false">
            <defs>
              <path id="pnl-selo-arco" fill="none"
                    d="M 50,50 m -37,0 a 37,37 0 1,1 74,0 a 37,37 0 1,1 -74,0"></path>
            </defs>
            <text class="selo-texto">
              <textPath href="#pnl-selo-arco" startOffset="0">
                Independência · Método · Independência · Método ·
              </textPath>
            </text>
          </svg>
          <span class="selo-miolo" aria-hidden="true">
            <svg viewBox="0 0 24 24" focusable="false">
              <path d="M12 4v14M6 13l6 6 6-6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
          <span class="visually-hidden">Independência e método.</span>
        </div>
      </div>
    </header>

    <div class="pnl-body">

      <!-- ------------------------------------------------------------ índice -->
      <!-- <a> e não <button>: o índice tem de continuar levando ao painel sem
           script, e botão sem script é controle morto. aria-current="true"
           marca o corrente (o CSS lê o atributo, não uma classe: uma verdade
           só). Nasce no 01, que é o painel visível em repouso. -->
      <nav class="pnl-nav" aria-label="Frentes de atuação">
        <ul class="pnl-nav-list">
          <li>
            <a class="pnl-nav-item" href="#pnl-carteira" data-pnl-alvo="pnl-carteira" aria-current="true">
              <span class="pnl-nav-num" aria-hidden="true">01</span>
              <span class="pnl-nav-rot">Carteira e alocação</span>
            </a>
          </li>
          <li>
            <a class="pnl-nav-item" href="#pnl-sucessao" data-pnl-alvo="pnl-sucessao">
              <span class="pnl-nav-num" aria-hidden="true">02</span>
              <span class="pnl-nav-rot">Sucessão e holding</span>
            </a>
          </li>
          <li>
            <a class="pnl-nav-item" href="#pnl-previdencia" data-pnl-alvo="pnl-previdencia">
              <span class="pnl-nav-num" aria-hidden="true">03</span>
              <span class="pnl-nav-rot">Previdência e proteção</span>
            </a>
          </li>
          <li>
            <a class="pnl-nav-item" href="#pnl-relatorio" data-pnl-alvo="pnl-relatorio">
              <span class="pnl-nav-num" aria-hidden="true">04</span>
              <span class="pnl-nav-rot">Relatório e acompanhamento</span>
            </a>
          </li>
        </ul>
      </nav>

      <!-- ------------------------------------------------------------- pilha -->
      <!-- Os quatro <article> são irmãos diretos daqui. Não os embrulhe em um
           invólucro por painel: sticky só se move DENTRO do bloco que o contém,
           e um invólucro da altura do painel tiraria dele todo o percurso. É
           esta .pnl-pilha, alta, que dá o percurso aos quatro. -->
      <div class="pnl-pilha">

        <!-- ============================================================ 01 -->
        <article class="sec-deep pnl-painel" id="pnl-carteira" tabindex="-1" aria-labelledby="pnl-carteira-t">
          <div class="pnl-arte" data-rv>
            <img src="../../../showroom/demo/ph-4.webp" alt="Três pessoas em reunião num escritório com vista para a cidade" loading="lazy" decoding="async">
            <span class="grain" aria-hidden="true"></span>
          </div>
          <div class="pnl-texto" data-rv data-d="1">
            <span class="pill pnl-marca">01 · Carteira e alocação</span>
            <h3 class="pnl-title" id="pnl-carteira-t">A carteira sai de uma política escrita, não de um palpite</h3>
            <ul class="pnl-checks">
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Diagnóstico do patrimônio consolidado, onshore e offshore</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Política de investimentos por escrito, com faixa de alocação por classe</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Comitê próprio revisa a política a cada trimestre</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Rebalanceamento quando a alocação sai da faixa combinada</span>
              </li>
            </ul>
            <a class="btn btn-primary pnl-cta" href="#">Entender o método</a>
          </div>
        </article>

        <!-- ============================================================ 02 -->
        <article class="sec-deep pnl-painel" id="pnl-sucessao" tabindex="-1" aria-labelledby="pnl-sucessao-t">
          <div class="pnl-arte" data-rv>
            <img src="../../../showroom/demo/ph-5.webp" alt="Homem mais velho lendo ao lado de uma criança" loading="lazy" decoding="async">
            <span class="grain" aria-hidden="true"></span>
          </div>
          <div class="pnl-texto" data-rv data-d="1">
            <span class="pill pnl-marca">02 · Sucessão e holding</span>
            <h3 class="pnl-title" id="pnl-sucessao-t">O que passa adiante decidido em vida, com o custo na mesa</h3>
            <ul class="pnl-checks">
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Mapa dos bens, dos titulares e do regime de casamento</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Estimativa do custo de inventário no seu estado, em faixa</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Estrutura de holding avaliada junto do seu advogado</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Revisão a cada mudança de família ou de patrimônio</span>
              </li>
            </ul>
            <a class="btn btn-primary pnl-cta" href="#">Falar sobre sucessão</a>
          </div>
        </article>

        <!-- ============================================================ 03 -->
        <article class="sec-deep pnl-painel" id="pnl-previdencia" tabindex="-1" aria-labelledby="pnl-previdencia-t">
          <div class="pnl-arte" data-rv>
            <img src="../../../showroom/demo/ph-2.webp" alt="Família caminhando por uma alameda arborizada" loading="lazy" decoding="async">
            <span class="grain" aria-hidden="true"></span>
          </div>
          <div class="pnl-texto" data-rv data-d="1">
            <span class="pill pnl-marca">03 · Previdência e proteção</span>
            <h3 class="pnl-title" id="pnl-previdencia-t">A previdência é uma peça da carteira, não um produto solto</h3>
            <ul class="pnl-checks">
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Comparação de PGBL e VGBL diante da sua declaração</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Custos de cada plano abertos linha a linha, antes da escolha</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Cobertura de vida e invalidez dimensionada pela renda da família</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Portabilidade avaliada quando o custo do plano atual não se justifica</span>
              </li>
            </ul>
            <a class="btn btn-primary pnl-cta" href="#">Revisar a previdência</a>
          </div>
        </article>

        <!-- ============================================================ 04 -->
        <article class="sec-deep pnl-painel" id="pnl-relatorio" tabindex="-1" aria-labelledby="pnl-relatorio-t">
          <div class="pnl-arte" data-rv>
            <img src="../../../showroom/demo/ph-7.webp" alt="Reunião de trabalho vista através de uma sala de vidro" loading="lazy" decoding="async">
            <span class="grain" aria-hidden="true"></span>
          </div>
          <div class="pnl-texto" data-rv data-d="1">
            <span class="pill pnl-marca">04 · Relatório e acompanhamento</span>
            <h3 class="pnl-title" id="pnl-relatorio-t">Um relatório consolidado, na mesma data todo mês</h3>
            <ul class="pnl-checks">
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Posição de todas as instituições reunida em um documento</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Custos e taxas do período discriminados, inclusive a nossa</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Reunião de acompanhamento na periodicidade que você escolher</span>
              </li>
              <li>
                <svg class="pnl-check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.6 8.5l3.5 3.6 7.3-8.2"/></svg>
                <span>Assessor dedicado, com remuneração por taxa previamente acordada</span>
              </li>
            </ul>
            <a class="btn btn-primary pnl-cta" href="#">Ver um relatório modelo</a>
          </div>
        </article>

      </div>
    </div>

  </div>
</section>

estilo.css · 362 linhas · 13,6 KB

/* ==========================================================================
   PAINÉIS EMPILHADOS COM ÍNDICE — pnl-
   Quatro painéis irmãos, todos sticky no mesmo top, cobrindo-se por inteiro;
   índice lateral também colado.

   Tokens usados: --bg --fg --fg-2 --fg-muted --rule --rule-2 --accent
                  --display --nav-h --ease --radius-media --pad --max
   Primitivas usadas: .wrap .sec-pad .eyebrow .sec-title .sec-lead .pill
                      .btn/.btn-primary .grain .visually-hidden
   Compõe: efeitos/selo-circular (o .selo do cabeçalho — o CSS dele vem de lá,
           nada aqui redesenha o selo)

   A PILHA É CSS PURO E CABE EM TRÊS LINHAS: os quatro painéis são irmãos com
   o mesmo `top`, têm altura menor que o bloco que os contém (.pnl-pilha) e
   fundo opaco. Sticky faz o resto, e a ordem de árvore decide quem cobre quem.
   Não há uma linha de JavaScript envolvida no empilhamento.

   O QUE QUEBRA A PILHA, e é sempre a mesma coisa: `overflow` diferente de
   `visible` em QUALQUER ancestral entre o painel e a .pnl-pilha mata o sticky
   (medido no irmão conteudo/rolagem-sincronizada, que derruba o painel em
   ~800px). Por isso não existe overflow:hidden em .pnl-pilha, .pnl-body,
   .wrap nem na seção. O overflow:hidden do .pnl-painel é seguro porque ele é
   o PRÓPRIO elemento colado — overflow recorta descendente, não a si mesmo.
   ========================================================================== */

.pnl{
  /* Onde os painéis e o índice encaixam. Lê --nav-h para o componente
     funcionar em projeto com menu fixo alto e baixo sem tocar no CSS. */
  --pnl-top:calc(var(--nav-h) + clamp(14px,2.4vh,28px));
  /* Respiro sob o painel colado. Ele é também o vão entre painéis na pilha,
     e é o que sobra de tela por onde o próximo aparece. Uma variável só
     porque as duas medidas TÊM de andar juntas: se o vão for maior que o
     respiro, o painel seguinte entra antes de haver espaço para ele. */
  --pnl-fim:clamp(24px,5vh,52px);
  /* Altura do painel. O teto de 660px existe porque acima disso o bloco de
     texto boia no meio de meia tela de vazio; o piso de 400px é o que o
     conteúdo (pílula + título + quatro linhas + botão) ocupa sem apertar. */
  --pnl-h:clamp(400px,calc(100vh - var(--pnl-top) - var(--pnl-fim)),660px);
  --pnl-gap:var(--pnl-fim);
  --pnl-nav-w:158px;
  --pnl-col:clamp(24px,3vw,52px);
  /* Proporção arte : texto dentro do painel. */
  --pnl-col-arte:1.22fr;
  /* Altura do trilho horizontal do celular — MEDIDA (49px a 390x844), não
     estimada. Só o scroll-margin-top do caso sem script a consome; com script
     quem mede o trilho é o próprio script. */
  --pnl-trilho-h:50px;
}

/* ---- cabeçalho ---- */
.pnl-head{
  display:grid;
  grid-template-columns:minmax(0,1fr) auto;
  gap:clamp(24px,4vw,64px);
  align-items:center;
}
.pnl-head-txt{max-width:62ch}
.pnl-selo-slot{justify-self:end}

/* ---- corpo: índice + pilha ---- */
.pnl-body{
  display:flex;
  gap:var(--pnl-col);
  margin-top:clamp(44px,7vh,84px);
}

/* O índice é um item flex de altura esticada (o padrão), e é isso que dá ao
   .pnl-nav-list um bloco alto onde grudar. Trocar align-items para start aqui
   encolhe o .pnl-nav e o índice deixa de acompanhar a pilha. */
.pnl-nav{flex:0 0 var(--pnl-nav-w)}

.pnl-nav-list{
  position:sticky;
  top:var(--pnl-top);
  border-inline-start:1px solid var(--rule);
}

.pnl-nav-item{
  position:relative;
  display:block;
  padding:14px 12px 16px 18px;
  color:var(--fg-muted);
  transition:color .3s var(--ease),background-color .3s var(--ease);
}
/* O filete que acende no rail do índice. É ele que faz a leitura de
   "estou aqui" antes de qualquer cor mudar. */
.pnl-nav-item::before{
  content:"";
  position:absolute;
  inset-block:0;
  inset-inline-start:-1px;
  inline-size:2px;
  background:var(--accent);
  transform:scaleY(0);
  transform-origin:top center;
  transition:transform .45s var(--ease);
}
.pnl-nav-item:hover{color:var(--fg-2)}

/* O estado corrente é o ATRIBUTO, não uma classe: aria-current é o que o
   leitor de tela recebe, e ter as duas coisas criaria duas verdades que
   podem divergir. O script só mexe no atributo. */
.pnl-nav-item[aria-current="true"]{
  color:var(--fg);
  background-color:color-mix(in srgb,var(--fg) 6%,transparent);
}
.pnl-nav-item[aria-current="true"]::before{transform:scaleY(1)}

@media(prefers-reduced-motion:reduce){
  /* Colado na regra que desliga. O filete continua acendendo — é a resposta
     ao gesto de quem rola —, só não desliza. */
  .pnl-nav-item::before{transition:none}
}

.pnl-nav-num{
  display:block;
  font-family:var(--display);
  font-weight:700;
  font-size:11px;
  letter-spacing:.16em;
  color:var(--fg-muted);
  transition:color .3s var(--ease);
}
.pnl-nav-item[aria-current="true"] .pnl-nav-num{color:var(--accent)}

.pnl-nav-rot{
  display:block;
  margin-top:7px;
  font-size:13px;
  line-height:1.3;
  letter-spacing:.005em;
}

/* ---- a pilha ---- */
.pnl-pilha{
  flex:1 1 auto;
  min-width:0;
}

/* O PERCURSO DO ÚLTIMO PAINEL, e este bloco vazio não é gambiarra: sem ele o
   painel 04 gruda e desgruda no MESMO pixel — encosta no fim do contêiner no
   instante em que encaixa, e sai de cena sem ter sido lido.

   E tem de ser um ::after, não `padding-bottom` na .pnl-pilha. Elemento
   sticky é preso ao bloco de CONTEÚDO do contêiner, e padding fica FORA dele:
   medido a 1440x900 com padding-bottom de 705px, o painel 04 já se soltava em
   scrollY 2437 (topo 36 em vez de 106, junto com os outros três) — os 705px
   contavam para a altura da página e nada para o percurso. Um bloco em fluxo
   entra no conteúdo, e aí o painel 04 ganha o mesmo passo dos outros. */
.pnl-pilha::after{
  content:"";
  display:block;
  height:calc(var(--pnl-h) + var(--pnl-gap));
}

.pnl-painel{
  position:sticky;
  top:var(--pnl-top);
  /* Sem script, a âncora nativa (#id) para no topo da janela e o painel fica
     debaixo do menu fixo. O scroll-margin-top devolve exatamente a distância
     do encaixe — é o que faz o índice funcionar com o JavaScript desligado. */
  scroll-margin-top:var(--pnl-top);
  height:var(--pnl-h);
  display:grid;
  grid-template-columns:var(--pnl-col-arte) 1fr;
  /* Opaco. É o fundo que faz a troca ler como corte limpo em vez de
     sobreposição — sem ele a pilha vira sopa. O escopo de tom vem do markup
     (.sec-deep dentro de uma seção .sec-dark): o componente não sobrescreve
     --fg, só declara em qual escopo o painel vive. Precedente:
     conteudo/bento-recursos-expansivel. */
  background:var(--bg);
  /* O fio de cima é a borda que se vê subir durante a troca. Sem ele, dois
     escuros vizinhos entrando um sobre o outro não têm aresta legível. */
  border-top:1px solid var(--rule-2);
  border-radius:var(--radius-media);
  /* Conteúdo mais alto que --pnl-h transbordaria POR CIMA do painel de
     baixo — texto sobre texto. Recortar contém o estrago no próprio painel.
     Ver o teto editorial de quatro linhas na ficha. */
  overflow:hidden;
}
.pnl-painel + .pnl-painel{margin-top:var(--pnl-gap)}

/* ---- arte ---- */
.pnl-arte{position:relative;overflow:hidden}
.pnl-arte img{inline-size:100%;block-size:100%;object-fit:cover}

/* ---- texto ---- */
.pnl-texto{
  display:flex;
  flex-direction:column;
  align-items:flex-start;
  justify-content:center;
  gap:clamp(13px,1.9vh,20px);
  /* O respiro de cima e de baixo é vh, o dos lados é vw. Não é simetria
     perdida por descuido: numa janela baixa quem falta é altura, e um padding
     em vw continuaria gastando 46px em cima e embaixo de um painel de 490 —
     medido, era o que fazia o botão cair fora do recorte. */
  padding:clamp(22px,3.4vh,46px) clamp(26px,3.2vw,50px);
  min-width:0;
}
.pnl-marca{flex:0 0 auto}

.pnl-title{
  font-family:var(--display);
  font-weight:700;
  font-size:clamp(20px,2vw,27px);
  line-height:1.22;
  letter-spacing:-.022em;
  color:var(--fg);
  max-width:20ch;
}

.pnl-checks{display:grid;gap:clamp(9px,1.4vh,15px)}
.pnl-checks li{
  display:grid;
  grid-template-columns:16px minmax(0,1fr);
  gap:12px;
  align-items:start;
  font-size:15.5px;
  line-height:1.5;
  color:var(--fg-2);
}
.pnl-check{
  inline-size:16px;
  block-size:16px;
  margin-top:4px;
  fill:none;
  stroke:var(--accent);
  stroke-width:2;
  stroke-linecap:round;
  stroke-linejoin:round;
}

.pnl-cta{margin-top:clamp(4px,1.2vh,12px)}

/* ==========================================================================
   JANELA BAIXA — a pilha sai, o índice fica
   A conta que a pilha precisa fechar é --pnl-top + --pnl-h + --pnl-fim <= 100vh,
   com --pnl-h grande o bastante para o conteúdo. Abaixo de ~700px de altura
   ela não fecha: medido a 1440x620, o painel cabia em 490px e o bloco de texto
   pedia 554 — e como o painel colado RECORTA, o botão sumia sem aviso. Um
   notebook de 1366x768 com barra de menu e abas cai bem aí.

   A saída não é apertar o texto, é desligar a pilha: os painéis viram sequência
   em altura automática e o índice continua colado ao lado, navegável. Perde-se
   a troca; não se perde nem conteúdo nem o acesso fora de ordem, que é o que
   este componente tem de próprio.
   ========================================================================== */
@media(max-height:699px){
  .pnl-pilha::after{content:none}
  .pnl-painel{
    position:static;
    top:auto;                       /* ver o comentário do bloco de celular */
    height:auto;
    min-height:var(--pnl-h);
    overflow:visible;
    scroll-margin-top:var(--pnl-top);
  }
  .pnl-painel + .pnl-painel{margin-top:clamp(36px,6vh,60px)}
  /* Sem altura fixa no painel, a <img> com block-size:100% cai na proporção
     natural dela e passa a MANDAR na altura da linha do grid: medido a
     1440x620, um painel que precisava de 514px saía com 657. Absoluta, a arte
     não pede altura nenhuma e quem decide volta a ser o texto. */
  .pnl-arte img{position:absolute;inset:0}
}

/* ==========================================================================
   MOBILE — a pilha sai de cena, o índice fica
   Empilhar painéis colados numa tela de celular esconderia o conteúdo em vez
   de organizá-lo: um painel de largura cheia com arte e quatro linhas não
   cabe em 844px de altura sem encolher a arte até ela não dizer mais nada.
   Então os painéis viram uma sequência comum, em altura automática.

   O ÍNDICE FICA, e vira um trilho horizontal colado no topo — porque é ele o
   que este componente tem e os irmãos não: acesso fora de ordem. Escondê-lo
   no celular apagaria justamente a razão de o componente existir.

   E É AQUI QUE A BARRA DE ENDEREÇO DEIXA DE IMPORTAR: no celular NENHUMA
   altura depende de vh (o painel é auto, o trilho é px), e a única coisa
   colada é um trilho de altura fixa em `top`. A barra que some e volta muda
   o valor de 100vh; como nada aqui o lê, não há o que saltar.
   ========================================================================== */
@media(max-width:900px){
  .pnl-head{grid-template-columns:1fr;gap:0}
  .pnl-selo-slot{justify-self:start;margin-top:30px}

  .pnl-body{display:block;margin-top:clamp(30px,5vh,44px)}

  /* Quem gruda agora é o <nav>, e não a <ul>: sticky só se move dentro do
     bloco que o contém, e como bloco no fluxo o <nav> tem a .pnl-body inteira
     como containing block — a <ul> teria só a caixa curta do <nav>. */
  .pnl-nav{
    position:sticky;
    top:var(--nav-h);
    z-index:3;
    /* Opaco: o trilho passa POR CIMA dos painéis. */
    background:var(--bg);
    border-block-end:1px solid var(--rule);
    overflow-x:auto;
    scrollbar-width:none;
  }
  .pnl-nav-list{
    position:static;
    display:flex;
    gap:6px;
    border-inline-start:0;
    /* Respiro vertical para o anel de foco (outline-offset:3px do core)
       caber sem gerar rolagem dentro do trilho. */
    padding-block:7px;
    width:max-content;
  }
  .pnl-nav-item{
    display:flex;
    align-items:baseline;
    gap:8px;
    padding:8px 14px;
    white-space:nowrap;
  }
  .pnl-nav-item::before{
    inset-block:auto 0;
    inset-inline:0;
    inline-size:auto;
    block-size:2px;
    transform:scaleX(0);
    transform-origin:left center;
  }
  .pnl-nav-item[aria-current="true"]::before{transform:scaleX(1)}
  .pnl-nav-rot{margin-top:0;font-size:13.5px}

  /* Sem pilha não há último painel para segurar: o percurso extra sairia como
     um buraco no fim da dobra. */
  .pnl-pilha::after{content:none}
  .pnl-painel{
    position:static;
    /* O `top` volta a auto de propósito. Não é higiene: elemento estático
       guarda o valor computado do top que o CSS declarou, e o script confere
       `position` justamente porque essa leitura engana — devolver auto aqui
       mantém as duas pistas dizendo a mesma coisa. */
    top:auto;
    height:auto;
    /* Zera o piso que o bloco de janela baixa põe: ele lê 100vh, e é a única
       coisa aqui que dependeria da barra de endereço que some. Sem esta linha
       a afirmação do comentário acima deixaria de ser verdade num celular de
       tela baixa, onde as duas media queries valem ao mesmo tempo. */
    min-height:0;
    grid-template-columns:1fr;
    overflow:visible;
    /* O trilho colado cobre o topo do painel: a âncora tem de parar abaixo
       dele. Com script quem calcula é o próprio script, medindo o trilho;
       este valor é o caso SEM script, e por isso é aproximado de propósito. */
    scroll-margin-top:calc(var(--nav-h) + var(--pnl-trilho-h) + 10px);
  }
  .pnl-painel + .pnl-painel{margin-top:clamp(40px,7vh,68px)}
  .pnl-arte{aspect-ratio:3/2}
  .pnl-texto{padding:clamp(22px,5vw,30px) 0 0}
  .pnl-title{max-width:none}
}

@media(max-width:520px){
  .pnl-arte{aspect-ratio:4/3}
  .pnl-checks li{font-size:15px}
}

script.js · 237 linhas · 10,8 KB

/* ==========================================================================
   PAINÉIS EMPILHADOS COM ÍNDICE — prefixo pnl-
   Quatro painéis irmãos colados no mesmo topo; índice lateral navegável.

   O QUE ESTE SCRIPT **NÃO** FAZ: empilhar. A pilha é `position:sticky` em CSS
   puro e funciona com o JavaScript desligado. Se algum dia aparecer aqui uma
   conta de posição de painel dentro de um listener de scroll, alguém parou de
   usar o sticky e reimplementou à mão — é o sinal para desfazer.

   O QUE ELE FAZ, e é só isto:
     1. marca o item corrente do índice com aria-current="true";
     2. rola até o painel no clique, respeitando movimento reduzido.

   POR QUE NÃO HÁ NENHUM LISTENER DE SCROLL. Um IntersectionObserver vigia uma
   FAIXA DE 1px na altura exata da troca (o `top` dos painéis). Um painel só
   entra ou sai dessa faixa no instante em que encaixa ou se solta, então o
   observador dispara nesses pixels e em nenhum outro — e é ele quem manda
   recalcular. Zero trabalho por quadro.

   Quem DECIDE não é o observador, é a posição: corrente = o último painel
   cujo topo já passou da linha. A distinção parece sutil e não é — está
   explicada em resolver().

   A LINHA É MEDIDA, NÃO CRAVADA: sai do `top` já resolvido em px de quem
   estiver colado — os painéis, o trilho do celular ou o índice na janela
   baixa. Uma regra só serve os três layouts, e é o CSS que decide qual —
   ver linha().

   POR QUE O CLIQUE NÃO É `scrollIntoView` NEM ÂNCORA NATIVA, e este foi o
   achado que custou a tarde: um painel ENCAIXADO responde que já está no
   lugar — mesmo estando COBERTO por três painéis acima dele. Medido: com os
   quatro encaixados a 1440x900, os quatro devolveram o mesmo topo (2555) em
   getBoundingClientRect() E em offsetTop. Voltar para o 01 depois do 04 rolava
   4px. Por isso a posição do alvo é reconstruída a partir da .pnl-pilha, que
   não é sticky — ver topoDeFluxo() lá embaixo.

   O ÍNDICE NÃO SE MARCA NO CLIQUE. Quem marca é sempre o observador, para não
   existirem duas verdades: durante a rolagem suave o índice acende cada painel
   por onde passa, que é o que de fato está na tela.
   ========================================================================== */

(function () {

  var raizes = document.querySelectorAll('[data-pnl]');
  if (!raizes.length) return;                       // guard silencioso

  function reduzido() {
    if (window.LIB && typeof window.LIB.reduce === 'boolean') return window.LIB.reduce;
    return matchMedia('(prefers-reduced-motion: reduce)').matches &&
           location.search.indexOf('motion=on') < 0;
  }

  function num(v) { var n = parseFloat(v); return isNaN(n) ? 0 : n; }

  function paginaY() {
    return window.pageYOffset || document.documentElement.scrollTop || 0;
  }

  for (var r = 0; r < raizes.length; r++) montar(raizes[r]);

  function montar(raiz) {

    var nav = raiz.querySelector('.pnl-nav');
    var lista = raiz.querySelector('.pnl-nav-list') || nav;
    var links = raiz.querySelectorAll('[data-pnl-alvo]');
    var itens = [], paineis = [];
    var i, id, alvo;

    /* Item cujo alvo não existe é descartado aqui, e não vira erro depois:
       o markup é editado por quem escreve conteúdo, e apagar um painel sem
       apagar o item do índice é o erro natural de fazer. */
    for (i = 0; i < links.length; i++) {
      id = links[i].getAttribute('data-pnl-alvo');
      alvo = id ? raiz.querySelector('#' + id) : null;
      if (!alvo) continue;
      itens.push(links[i]);
      paineis.push(alvo);
    }
    if (paineis.length < 2 || !nav) return;

    var caixa = paineis[0].parentNode;               /* a .pnl-pilha */
    var atual = -1, io = null, linhaIO = -1, alturaIO = -1;
    /* Borda de baixo da faixa vigiada, em px de viewport. É o MESMO número
       que o observador usa e que a decisão compara — ver resolver(). */
    var faixaFim = 0;

    /* ---------- posição de FLUXO do painel k ----------
       Não adianta perguntar ao próprio painel: MEDIDO no Chrome, tanto
       offsetTop quanto getBoundingClientRect() de um elemento sticky já vêm
       COM o deslocamento do encaixe — com os quatro painéis encaixados os
       quatro responderam o mesmo topo (2555 a 1440x900). Foi assim que a
       navegação PARA TRÁS nasceu quebrada: voltar ao 01 depois do 04 rolava
       4px, porque o 01 dizia estar onde o 04 estava.

       Quem não mente é a .pnl-pilha, que não é sticky. Dela se desce somando
       altura e margem de cada painel — e offsetHeight é imune ao encaixe,
       que move o painel sem redimensioná-lo. Somar em vez de cravar
       k * passo é o que mantém a conta certa se um painel tiver altura
       diferente dos outros. */
    function topoDeFluxo(k) {
      var cs = getComputedStyle(caixa);
      var y = caixa.getBoundingClientRect().top + paginaY() +
              num(cs.borderTopWidth) + num(cs.paddingTop);
      for (var j = 0; j < k; j++) {
        y += paineis[j].offsetHeight + num(getComputedStyle(paineis[j + 1]).marginTop);
      }
      return y;
    }

    /* ---------- a linha de troca ----------
       Onde um painel deixa de ser o corrente. São três layouts e uma regra: a
       linha é a base do que estiver COLADO por cima dele.

         celular      -> o trilho horizontal: topo dele + a altura dele
         pilha ligada -> o `top` dos próprios painéis
         janela baixa -> o `top` do índice, que continua colado sem a pilha

       O teste é `position`, e nunca o `top`: um elemento estático GUARDA o
       `top` que o CSS declarou (a propriedade não se aplica, mas o valor
       computado continua lá). A primeira versão disto lia 105.6px no celular
       e mandava o painel parar 33px POR BAIXO do trilho. Medido. */
    function linha() {
      if (getComputedStyle(nav).position === 'sticky') {
        return num(getComputedStyle(nav).top) + nav.offsetHeight + 8;
      }
      var cs = getComputedStyle(paineis[0]);
      if (cs.position === 'sticky') return num(cs.top);
      return num(getComputedStyle(lista).top);
    }

    /* ---------- marcação ---------- */
    function marcar(k) {
      if (k === atual || k < 0 || k >= itens.length) return;
      atual = k;
      for (var j = 0; j < itens.length; j++) {
        if (j === k) itens[j].setAttribute('aria-current', 'true');
        else itens[j].removeAttribute('aria-current');
      }
      seguirTrilho(itens[k]);
    }

    /* No celular o índice é um trilho horizontal que pode ser mais largo que
       a tela: sem isto o item corrente acende fora de vista. Move o
       scrollLeft do próprio trilho — nunca scrollIntoView, que subiria a
       PÁGINA junto e brigaria com a rolagem que a pessoa está fazendo. */
    function seguirTrilho(item) {
      if (nav.scrollWidth <= nav.clientWidth + 1) return;
      var alvoX = item.offsetLeft - (nav.clientWidth - item.offsetWidth) / 2;
      if (alvoX < 0) alvoX = 0;
      nav.scrollTo({ left: alvoX, behavior: reduzido() ? 'instant' : 'smooth' });
    }

    /* ---------- quem está por cima ----------
       Regra única, e ela vale nos dois layouts: o corrente é o ÚLTIMO painel
       cujo topo já passou da linha. No desktop isso é "o último encaixado";
       no celular, "o último que já subiu além do trilho".

       O observador NÃO decide — ele só avisa QUANDO recalcular. A primeira
       versão decidia pelo próprio isIntersecting (corrente = último que cruza
       a faixa), e no celular isso quebrava: entre um painel e o outro há um
       vão de ~59px em que a faixa não cruza painel nenhum, e a marcação
       saltava para a última frente e voltava. Perguntar a posição, aqui
       dentro, custa uma leitura de layout algumas vezes por rolagem — não por
       quadro. É o mesmo espírito do resolverPonta() do
       conteudo/rolagem-sincronizada: o observador dispara, mas quem decide é
       a posição. */
    function resolver() {
      var k = 0;
      for (var j = 0; j < paineis.length; j++) {
        if (paineis[j].getBoundingClientRect().top <= faixaFim) k = j;
      }
      marcar(k);
    }

    /* Faixa de 1px na altura do encaixe. rootMargin é fixo na construção do
       observador, então mudar de layout (ou de altura de janela) pede
       reconstruir — e só quando o número muda de verdade, senão um resize
       arrastado recria o observador dezenas de vezes. */
    function construir() {
      var alt = window.innerHeight || document.documentElement.clientHeight;
      var y = linha();
      if (io && Math.abs(y - linhaIO) < 1 && Math.abs(alt - alturaIO) < 1) return;
      linhaIO = y;
      alturaIO = alt;
      if (io) io.disconnect();

      var b = Math.round(y) + 1;
      if (b > alt - 3) b = Math.max(0, alt - 3);     /* janela muito baixa */
      /* O limiar da decisão TEM de ser a borda de baixo da faixa, e não a
         linha "de verdade": o observador dispara no quadro em que o topo do
         painel entra na faixa, e nesse quadro ele ainda está uns pixels
         abaixo do encaixe. Com os dois números diferentes (faixa em 107,
         decisão em 106,6) a marcação parava sempre um painel atrás — medido:
         clicar no 04 acendia o 03. Um número só, usado nos dois lugares. */
      faixaFim = b + 1;

      io = new IntersectionObserver(resolver, {
        rootMargin: (-b) + 'px 0px ' + (-(alt - b - 1)) + 'px 0px',
        threshold: 0
      });
      for (var j = 0; j < paineis.length; j++) io.observe(paineis[j]);
    }

    /* ---------- clique ---------- */
    function aoClicar(ev) {
      /* Abrir em nova aba, em nova janela ou com o botão do meio é decisão de
         quem clicou: não sequestramos. O href continua sendo um link real. */
      if (ev.defaultPrevented || ev.button !== 0 ||
          ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;

      var k = itens.indexOf(this);
      if (k < 0) return;
      ev.preventDefault();

      var y = topoDeFluxo(k) - linha() + 4;            /* 4px além do encaixe */
      if (y < 0) y = 0;

      /* 'instant' e não 'auto': o core declara html{scroll-behavior:smooth},
         e 'auto' herdaria a rolagem suave justamente de quem pediu para não
         ter movimento. Rolar continua acontecendo — foi pedido —, só sem a
         animação. */
      window.scrollTo({ top: y, behavior: reduzido() ? 'instant' : 'smooth' });

      /* Foco no painel, como faz um link de pular conteúdo: quem chegou de
         teclado continua o Tab DENTRO do capítulo escolhido, no botão dele, e
         não no próximo item do índice. preventScroll para não brigar com a
         rolagem que acabou de começar. */
      paineis[k].focus({ preventScroll: true });
    }

    for (i = 0; i < itens.length; i++) itens[i].addEventListener('click', aoClicar);

    construir();
    addEventListener('resize', construir, { passive: true });
  }

})();

Quatro painéis de largura cheia, irmãos, todos colados no mesmo topo. Conforme a página desce, cada um sobe e cobre o anterior por inteiro — não sobra faixa, não sobra sombra, e o fundo opaco faz a troca ler como corte de plano, não como card empilhando. Ao lado, um índice vertical também colado acende o capítulo corrente e leva a qualquer um dos quatro no clique. O olho vê um capítulo por vez; a mão escolhe qual.

Resolve a dobra em que uma empresa tem três a cinco frentes de atuação e cada uma precisa de espaço de página inteira: uma ilustração grande, uma lista de quatro entregas e um botão próprio. Em grade, quatro dessas viram quatro cartões de 300px em que a imagem morre e a lista não cabe. Em sequência simples, viram quatro telas que ninguém rola até o fim. Aqui cada frente ocupa a tela toda, uma de cada vez — e o índice existe justamente para que ninguém seja obrigado a passar pelas outras três para chegar na que interessa.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho 2 a 3 palavras. É o nome da série ("Frentes de atuação"), não uma frase.
Título da seção 1 linha, uma palavra em <em>. Diz quantas frentes são: o número é o que prepara o índice.
.sec-lead 1 a 2 frases, até 40 palavras. Diz o que as frentes têm em comum e o que muda entre elas.
Selo 2 palavras separadas por ·, repetidas para fechar a volta. Máximo ~53 caracteres na volta inteira, contando os separadores — o excedente não é desenhado e some sem aviso. Ver a ficha do efeitos/selo-circular.
.pnl-nav-rot 2 a 4 palavras, o mesmo texto da .pnl-marca do painel. É o rótulo do capítulo, não o título dele.
.pnl-marca 01 · Nome da frente. Numeração a partir de 01, em sequência, sem pular.
.pnl-title 6 a 10 palavras. É a promessa da frente, não o nome dela — o nome já está na pílula acima. Cabe em 3 linhas de 20ch; acima disso o bloco começa a apertar em janela baixa.
.pnl-checks li Exatamente 4 linhas, de 6 a 12 palavras cada. Cada uma é uma entrega, não um adjetivo: "Política de investimentos por escrito, com faixa de alocação por classe", nunca "Atendimento de excelência".
.pnl-cta 2 a 4 palavras, verbo na frente. Um por painel, e cada um leva a um lugar diferente.

Quatro linhas é estrutural, não estilo. A altura do painel é fixa e ele recorta o que sobra (ver gotchas). Uma quinta linha em qualquer painel some silenciosamente em janela de 900px. Precisa de cinco? Suba --pnl-h e refaça a conta de altura da dobra.

Os quatro títulos têm de ser paralelos. Todos afirmações ("A carteira sai de uma política escrita"), ou todos substantivos. Misturar as duas formas quebra a leitura da série — que aqui é a única coisa que liga quatro telas que se apagam uma à outra.

Especificação dos assets


Como usar

<link rel="stylesheet" href="componentes/listas/paineis-empilhados/estilo.css">
<script src="componentes/listas/paineis-empilhados/script.js"></script>

O selo do cabeçalho é o efeitos/selo-circular composto, não recopiado: cole também o estilo.css dele, ou apague o .pnl-selo-slot (variação "sem selo").

Um capítulo é sempre duas coisas, e as duas nomeiam o mesmo id:

<a class="pnl-nav-item" href="#pnl-carteira" data-pnl-alvo="pnl-carteira">…</a>
…
<article class="sec-deep pnl-painel" id="pnl-carteira" tabindex="-1" aria-labelledby="pnl-carteira-t">…</article>

Acrescentar ou remover um capítulo é acrescentar ou remover os dois juntos. Item de índice cujo id não existe é descartado pelo script em silêncio — nunca vira erro no console.

Ganchos

Gancho Papel
[data-pnl] Raiz. O script parte daqui e nunca do documento — a dobra pode repetir na página.
[data-pnl-alvo] No <a> do índice. O valor é o id do painel, e tem de bater com o href.
aria-current="true" O item corrente. É atributo, não classe: o CSS lê o atributo, o leitor de tela lê a mesma coisa, e não há duas verdades para divergir.
--pnl-top Onde painéis e índice encaixam. calc(var(--nav-h) + clamp(14px,2.4vh,28px)) — lê o menu do projeto.
--pnl-fim Respiro sob o painel colado, e o vão entre painéis na pilha. Uma variável para as duas porque elas têm de andar juntas.
--pnl-h Altura do painel. clamp(400px, 100vh − top − fim, 660px). É o principal parafuso: sobe a altura da dobra na mesma proporção.
--pnl-gap Vão da pilha. Lê --pnl-fim; separe os dois só se souber por quê.
--pnl-nav-w Largura do índice. 158px.
--pnl-col Respiro entre índice e pilha.
--pnl-col-arte Proporção arte : texto dentro do painel. 1.22fr contra 1fr.
--pnl-trilho-h Altura do trilho horizontal do celular, medida (49px a 390×844). Só o scroll-margin-top do caso sem script a consome.

Variações


Compliance

A peça não devolve número nenhum, não compara cenários e não captura dado — mas ela promete entregas, e é isso que o consumidor precisa conferir antes de publicar:


Gotchas


Histórico

Projeto Onde Data Diferenças
referência: contasonline.com.br https://www.contasonline.com.br/ — seção .s-recursos, "Principais recursos" 2026-09-06 Clone reescrito do zero. Nenhuma linha de código veio de lá.

Referência visual, nunca origem de código. A seção foi medida no DOM e reescrita aqui dentro. Da referência vieram três coisas e só três: a ideia da interação (painéis irmãos sticky no mesmo top, com índice lateral também colado), a proporção (painel de largura cheia com arte de um lado e lista de verificação do outro) e o ritmo (~680px de rolagem por painel de 512px, num índice de 104px com itens de 112px). O arquivo é nosso, em token, prefixo, primitiva do core e gancho do motion.js. Registro consolidado em projetos/referencias-externas.md, seção Clones.

O que a medição desmentiu. À primeira vista a seção parece um conjunto de abas clicadas. Não é: são quatro painéis irmãos, todos position:sticky; top:96px, numa seção de 3 127px para 4 painéis de 512px. O .ativo do índice não mudava durante a medição e o clique não trocava nada — não porque o site esteja quebrado, mas porque o painel de navegador embutido congela rAF e observador. Para o clone, o comportamento correto era o óbvio, e é o que está aqui: o índice acende conforme o painel correspondente cobre, e clicar rola até ele.

O que não atravessou. As fontes (Jakarta e Inter da referência → --display/--sans da casa), os ícones das abas (não atravessam — viraram numeração 01…04, que é o idioma da casa), o <video> proprietário (placeholder de showroom/demo/, com a especificação do asset real acima), o verde de marca (--accent do tema, zero cor crua) e o superlativo do título.

Decisões de tradução para o contrato.

Divergências conhecidas vs. a referência: a referência tem quatro painéis de altura fixa (512px) e um índice com ícones; aqui a altura é clamp da janela (400–660px), o índice é numerado, e há dois layouts que a referência não tem — o trilho horizontal colado do celular (a referência simplesmente empilha em coluna, m:flex-col) e o desligamento da pilha em janela abaixo de 700px de altura.