← Biblioteca componentes/efeitos/destaque-texto-scroll/ficha.md CódigoTela cheia
efeitos/destaque-texto-scroll

Destaque de texto pelo scroll

🟢 estavel complexidade média prefixo dts- scroll scrub manifesto frase-impacto tipografia opacidade reversivel custom-property efeito primitiva display-xl acessibilidade sem-lib

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

<link rel="stylesheet" href="componentes/efeitos/destaque-texto-scroll/estilo.css">
<script src="componentes/efeitos/destaque-texto-scroll/script.js"></script>

markup.html · 100 linhas · 5,6 KB

<!-- ==========================================================================
     DESTAQUE DE TEXTO PELO SCROLL — a frase que acende enquanto a dobra passa
     Prefixo: dts-
     Requer: core (tokens.css, base.css, motion.css, motion.js) + este
             estilo.css e este script.js

     O QUE SE CATALOGA AQUI NÃO É A SEÇÃO. É a frase e o motor que a acende:
     o bloco `.dts` com `[data-dts]` dentro, e nada mais. A <section> em volta
     é PALCO — no site real ela é a dobra que você já tem, e você não a copia
     daqui. Por isso o bloco está delimitado abaixo pelos comentários de
     COMPONENTE — INÍCIO e COMPONENTE — FIM, como fazem efeitos/moldura-secao
     e efeitos/cursor-anotacao.

     E ESTE PARÁGRAFO É A PROVA DE QUE VALE A PENA NÃO ESCREVER A MARCA DE
     FECHAMENTO DE COMENTÁRIO DENTRO DE UM COMENTÁRIO. A primeira versão
     deste cabeçalho citava o delimitador com a marca inteira, entre crases,
     e o navegador fechou o comentário ali: o resto do cabeçalho virou
     markup, o `<em>` mencionado três parágrafos abaixo virou um ELEMENTO de
     verdade e engoliu a página inteira. Medido em 390px de largura: a página
     passou a 885px de rolagem horizontal, com um único `<em>` de 885px e 40
     retângulos de linha. É primo do caso 6 do MANUAL §6 — lá o exemplo em
     span inline vazava caminho na ficha; aqui a citação vaza o fim do
     comentário.

     A IDEIA, e o que a separa do data-split do core. O `data-split` revela
     palavra a palavra NA ENTRADA: a dobra cruza o observador, as palavras
     acendem em cascata por setTimeout, e acabou — rolar de volta não desfaz
     nada, porque não há nada para desfazer. Aqui é o contrário: a frase
     nasce apagada (opacidade de repouso, ainda legível), e cada unidade
     acende em função de ONDE o bloco está na janela. É escovado (scrubbed),
     preso ao scroll e REVERSÍVEL — subir apaga de volta, exatamente na mesma
     curva. O gesto não é "apareceu", é "está sendo lido".

     DUAS GRANULARIDADES, e o script escolhe sozinho. Se houver `.dts-w`
     (palavra) dentro da raiz, as palavras são as unidades; se não houver,
     as unidades são as `.dts-ln` (linhas). Nunca as duas ao mesmo tempo —
     ver o comentário do script.js. A linha é o padrão para frase de
     manifesto de três a cinco linhas; a palavra é para frase curta, de uma
     ou duas linhas, onde acender linha a linha seria acender a frase toda
     de uma vez.

     QUEBRA NA MÃO. As linhas são `<span class="dts-ln">` em bloco — sem
     `<br>`, porque cada linha já é uma caixa. Quebra automática desmancharia
     o desenho: a graça é que cada linha termine na palavra escolhida, e é
     isso que faz a leitura andar no ritmo do scroll. A mesma decisão do
     conteudo/manifesto-com-imagens, com uma diferença: lá o `<br>` era
     preciso porque as linhas eram inline-block com imagem dentro; aqui não.

     O REALCE lê `--accent-ink`, nunca `--accent` (contrato §3). O `<em>` da
     segunda linha é o único ponto de cor da peça, e num `.sec-paper` o
     accent puro daria 1.36:1 com o sage da Liberta. Ver o estilo.css, que
     traz a conta de contraste do estado apagado — que é o estado mais
     delicado desta peça e o que decidiu o valor de `--dts-off`.

     UM data-rv, NO INVÓLUCRO DA FRASE, e não nas unidades. O §5 do contrato
     pede que nada entre seco, e quem entrega o blur de entrada é o
     `[data-rv]` no `.dts-txt`. As unidades por dentro não levam data-rv
     nenhum, e não é economia: `[data-rv].in` termina em `opacity:1` com
     especificidade (0,2,0), que venceria em silêncio o `opacity:clamp(…)`
     de `.dts-ln` (0,1,0) assim que a dobra entrasse — e o escovado inteiro
     morreria sem nada acusar. É a mesma armadilha do efeitos/inclinacao-3d,
     trocando `transform` por `opacity`.

     O `[data-dts]` FICA NUM NÓ SEM data-rv de propósito: é dele que o script
     lê `getBoundingClientRect()` a cada quadro, e `[data-rv]` em repouso
     carrega `translateY(22px)` — medir num nó deslocado faria o progresso
     nascer 22px atrasado e se corrigir sozinho durante a entrada, que é um
     salto pequeno mas gratuito.

     COMPLIANCE. O manifesto fala de método e de estrutura, nunca de
     resultado: não há número, não há promessa de rentabilidade e não há
     marca de terceiro. Frase de manifesto é o lugar mais fácil de escorregar
     para "o seu dinheiro rendendo mais" — ver a ficha.
     ========================================================================== -->

<!-- CONTEXTO — a dobra que hospeda a frase. Não faz parte do componente. -->
<section class="sec-dark sec-pad" id="manifesto">
  <div class="wrap">

    <span class="eyebrow" data-rv>Manifesto</span>

    <!-- ===================== COMPONENTE — INÍCIO ===================== -->

    <!-- A raiz mede a si mesma. Ela vive DENTRO do .wrap da dobra que a
         hospeda — a peça não controla coluna nenhuma (contrato §4).
         Os parafusos de janela e de repouso entram aqui por style quando o
         padrão não servir: style="--dts-off:.52;--dts-end:28". -->
    <div class="dts" data-dts>
      <p class="dts-txt display-xl" data-rv>
        <span class="dts-ln">Patrimônio não se defende no susto.</span>
        <span class="dts-ln">Ele se organiza <em>antes</em>:</span>
        <span class="dts-ln">em política escrita, em relatório que a família lê</span>
        <span class="dts-ln">e em gente que atende no dia difícil.</span>
      </p>
    </div>

    <!-- ====================== COMPONENTE — FIM ====================== -->

  </div>
</section>

estilo.css · 203 linhas · 9,7 KB

/* ==========================================================================
   DESTAQUE DE TEXTO PELO SCROLL — prefixo dts-
   A frase de manifesto que nasce apagada e acende unidade por unidade
   conforme a dobra passa pela janela. Escovado, reversível, preso ao scroll.

   Tokens usados:
     --fg                tinta da frase (a primitiva .display-xl já a lê)
     --accent-ink        tinta do realce, com --accent de fallback
     --display           a face do manifesto, via .display-xl
     --sec-gap           respiro da faixa, por multiplicação

   O QUE O JAVASCRIPT ESCREVE, e é só isto:
     --dts-p    na raiz, uma vez por quadro    progresso da dobra, 0 a 1
     --dts-n    na raiz, uma vez na carga      quantas unidades existem
     --dts-i    em cada unidade, na carga      o índice dela, a partir de 0

   Todo o resto é conta de CSS. A escolha está explicada no bloco da
   opacidade, e ela é o coração da peça.
   ========================================================================== */

.dts{
  /* --------------------------------------------------------------- repouso
     OPACIDADE DA FRASE APAGADA. O original da referência usa .38, e .38 NÃO
     PASSA aqui — este é o ajuste de acessibilidade que a tradução trouxe.

     Medido no tema liberta-wealth, escopo .sec-dark (--fg #f2f0ec e
     --accent #c6ceaa sobre --dark #0a0a0a), compondo a tinta contra o fundo
     na opacidade de repouso:

       opacidade | texto (--fg) | realce (--accent-ink)
       ----------|--------------|----------------------
       .38       |    3,22:1    |    2,57:1   <- o valor do original
       .42       |    3,70:1    |    2,90:1
       .46       |    4,23:1    |    3,26:1   <- o daqui
       .50       |    4,81:1    |    3,66:1
       1 (aceso) |   17,39:1    |   12,08:1

     O mínimo da WCAG para TEXTO GRANDE (medido no palco: 48,96px em peso
     700; o piso do clamp é 25px, e o limite de "texto grande" é 18,66px em
     negrito) é 3:1. O texto do original já passava raspando; o REALCE em
     accent não passava, e é justamente ele que a peça quer que se leia. Como
     a pessoa pode simplesmente parar de rolar, o estado apagado não é um
     quadro de transição: é um estado que fica na tela, e tem de fechar
     contraste sozinho.

     Quem baixar este parafuso abaixo de .42 quebra o AA da frase, e abaixo
     de .46 quebra o do realce. Ver a ficha. */
  --dts-off:.46;

  /* --------------------------------------------------- sobreposição do laço
     Quantas "fatias" de progresso cada unidade leva para ir de apagada a
     acesa. 1 = estritamente sequencial (uma acaba, a outra começa), e lê
     como interruptor. 2 = cada unidade leva o dobro da fatia e a seguinte
     começa na metade do caminho da anterior — duas acendendo ao mesmo tempo,
     que é o que faz a leitura parecer uma onda e não um contador.

     Acima de 3 as unidades acendem quase juntas e a peça vira um fade só. */
  --dts-k:2;

  /* ------------------------------------------------------ janela de cálculo
     Em % da altura da janela, medido do topo dela.
       --dts-start  onde o TOPO do bloco está quando o progresso é 0
       --dts-end    onde a BASE do bloco está quando o progresso é 1

     Por que assim, e não "a seção passando pelo meio da tela": esta conta
     tem de servir a um bloco BAIXO (uma frase de quatro linhas, ~280px) e a
     uma seção PINADA ALTA (300vh de palco com a frase grudada dentro). O
     percurso do progresso é (start - end) da janela MAIS a altura do próprio
     bloco, então ele cresce junto com a peça: a frase de quatro linhas deste
     markup acende ao longo de 648px de rolagem (medido em 1440x900, com o
     bloco em 432px de altura), e uma seção pinada de 300vh acende ao longo
     dela inteira, sem nenhum ajuste. Ver a ficha e o script.js.

     82 E 58 SAIRAM DE UMA MEDICAO, e o primeiro par tentado (76 e 34) estava
     errado por um motivo que so' o numero mostra: com a base do bloco indo
     ate' 34% da janela, a ultima linha da frase de quatro linhas (432px de
     altura, medida em 1440x900) so' fechava com o topo do bloco em -126px --
     a primeira linha ja' tinha saido pela borda de cima quando a ultima
     acendeu, e a frase inteira acesa nunca chegava a existir na tela.

     Com 82 e 58 o progresso fecha com o bloco em 90..522 da janela: inteiro
     dentro, na metade de cima. Medido no mesmo palco: com o bloco centrado
     verticalmente o progresso esta' em 0,78 e a ultima linha em 70% do
     caminho -- ainda ha o que ver acendendo quando a frase ja' esta' toda na
     tela, que e' exatamente o efeito que se quer. */
  --dts-start:82;
  --dts-end:58;

  /* Parafusos da primitiva .display-xl do core (fallback dentro do var(),
     por isso entram aqui no ancestral e não na regra do .dts-txt).
     A entrelinha é MUITO maior que o .95 padrão dela: lá o título tem uma ou
     duas palavras; aqui são quatro frases inteiras, e apertar a entrelinha
     cola uma linha na outra justamente onde a peça pede que o olho ande
     linha a linha. */
  --display-xl:clamp(25px,3.4vw,50px);
  --display-xl-lh:1.26;

  /* FAIXA, NÃO DOBRA — quando a peça for colada sozinha numa seção só dela.
     Dentro de uma dobra que já tem .sec-pad isto não faz nada, porque é
     margem e não padding: o respiro do palco continua sendo o do palco. */
  margin-block:calc(var(--sec-gap) * .12);
}

/* O desenho da frase é a primitiva .display-xl do core. Sobra aqui só o que
   é desta peça: a medida de leitura. Manifesto em linha longa demais deixa
   de ser peça gráfica e vira parágrafo grande. */
.dts-txt{max-width:22ch}

/* ---------------------------------------------------------------- unidades */

/* A linha é uma CAIXA, não um trecho inline com <br> depois: assim a quebra
   está na estrutura, o reveal tem em que pegar e a opacidade se aplica a uma
   caixa inteira sem herdar espaço de linha do vizinho. */
.dts-ln{display:block}

/* A palavra é inline-block pela mesma razão que a .w do core é: para não se
   partir no meio quando a linha vira. Não é a .w — a .w é do core e
   componente não a estiliza (contrato §5). */
.dts-w{display:inline-block}

/* ---------------------------------------------------------------- a conta
   O CORAÇÃO DA PEÇA, e o motivo de o script escrever UMA propriedade por
   quadro em vez de uma opacidade por unidade.

   Cada unidade lê o progresso global (--dts-p) e o próprio índice (--dts-i)
   e resolve sozinha quanto já acendeu:

     total = n - 1 + k          fatias de progresso que o laço inteiro ocupa
     local = (p * total - i) / k    o quanto ESTA unidade já andou, 0 a 1
     opacidade = off + (1 - off) * local,  presa entre off e 1

   O clamp() faz as duas pontas: abaixo de zero fica em `off`, acima de um
   fica em 1. Nenhum Math.min no JavaScript, nenhuma escrita por unidade.

   SE O SCRIPT NÃO RODAR, --dts-i e --dts-n não existem, o var() sem fallback
   torna a declaração inválida no tempo de valor computado, e `opacity` — que
   não é herdada — volta ao valor inicial, 1. Ou seja: sem script a frase
   aparece ACESA E INTEIRA, que é exatamente o que se quer. Isto foi medido,
   não deduzido: apagando --dts-i de uma unidade com a página carregada, o
   computado dela vai para 1. As duas media queries abaixo declaram o mesmo
   caso de propósito, para não depender de um efeito colateral da cascata.

   SEM transition, e é decisão: o valor É a posição do scroll. Qualquer
   amortecimento transformaria "escovado" em "atrasado", e numa rolagem de
   volta o rastro ficaria para trás da mão. A suavidade vem do --dts-k, que
   sobrepõe as unidades, não de uma curva no tempo.

   SEM will-change também: a propriedade muda por recálculo de estilo, não
   por animação de compositor, e reservar uma camada por palavra custaria
   memória de vídeo para não acelerar nada. */
.dts-ln,
.dts-w{
  opacity:clamp(
    var(--dts-off),
    calc(
      var(--dts-off) +
      (1 - var(--dts-off)) *
      (var(--dts-p) * (var(--dts-n) - 1 + var(--dts-k)) - var(--dts-i)) / var(--dts-k)
    ),
    1
  );
}

/* Colada à regra que ela desliga, como manda o §6.6. Com movimento reduzido
   o script nem assina o laço de rolagem (ver script.js) e --dts-p nunca
   nasce; esta linha é a que garante que a frase fique acesa por escrito, e
   não por acidente de cascata. */
@media(prefers-reduced-motion:reduce){
  .dts-ln,
  .dts-w{opacity:1}
}

/* Mesma garantia para quem está sem JavaScript — bloqueador, erro de rede,
   script desligado. O conteúdo inteiro está no HTML e tem de ser legível.
   É a mesma consulta que o core/motion.css usa para devolver [data-rv]. */
@media (scripting: none){
  .dts-ln,
  .dts-w{opacity:1}
}

/* ---------------------------------------------------------------- realce */
/* Elemento descendente do próprio prefixo, em conteúdo de prosa — o caso
   permitido pelo §6.3. O autor do texto escreve <em> onde quiser dentro da
   frase, sem pedir uma classe a ninguém.

   A TINTA É --accent-ink, NUNCA --accent (§3): em escopo escuro os dois são
   o mesmo valor, mas numa .sec-paper o accent puro dá 1.36:1 com o sage da
   Liberta sobre o creme. O fallback mantém de pé quem colar a frase fora de
   um escopo de tom. */
.dts-txt em{
  font-style:italic;
  color:var(--accent-ink,var(--accent));
}

/* ---------------------------------------------------------------- mobile */
/* A medida de leitura sobe em tela estreita: 22ch num corpo de 25px já é
   mais largo que a coluna, e o teto acabaria cortando a frase em mais linhas
   do que o autor escreveu — o que desmancharia a contagem de unidades que ele
   calibrou. Soltar o teto devolve a quebra ao <span> de cada linha. */
@media(max-width:640px){
  .dts-txt{max-width:none}
  .dts{--display-xl-lh:1.3}
}

script.js · 184 linhas · 8,5 KB

/* ==========================================================================
   DESTAQUE DE TEXTO PELO SCROLL — o progresso da dobra vira UMA propriedade

   O QUE ESTE ARQUIVO FAZ, e é só isto:

     1. na carga, numera as unidades (--dts-i em cada uma) e escreve quantas
        são (--dts-n na raiz). São N + 1 escritas, uma vez, para sempre;
     2. a cada quadro de rolagem, escreve UMA custom property na raiz
        (--dts-p, de 0 a 1). Quem decide a opacidade de cada unidade é o CSS,
        com a conta explicada no estilo.css.

   POR QUE UMA SÓ PROPRIEDADE, e não uma opacidade por unidade — e o número
   NÃO favorece esta escolha, então ele fica escrito aqui. Medido em
   1440x900, Chrome headless, 400 iterações de (escrever + forçar recálculo),
   em milissegundos por quadro:

     unidades | --dts-p na raiz (esta versão) | style.opacity por unidade
     ---------|------------------------------|--------------------------
      4 linhas|        0,12 – 0,14           |       0,011 – 0,014
     27 palavras|      0,44 – 0,45           |       0,018 – 0,021

   A versão em CSS custa cerca de DEZ VEZES mais por quadro, e a razão é
   conhecida: escrever uma custom property na raiz invalida o estilo de toda
   a subárvore que a herda, enquanto escrever `style.opacity` invalida só o
   nó escrito. Ela fica assim mesmo, por três motivos, nesta ordem:

     1. o pior caso medido — 0,45 ms — é 2,7% de um quadro de 16,7 ms, e esse
        pior caso é um manifesto inteiro partido em 27 palavras, que é
        justamente o markup que a ficha desaconselha (palavra é para frase
        curta, de 4 a 12). No markup padrão, quatro linhas, são 0,8%;
     2. a curva vive junto da regra que ela limita. O clamp, o piso de
        opacidade e a sobreposição estão todos no estilo.css, onde quem for
        ajustá-los vai procurar — e mudar a curva não pede tocar em
        JavaScript nenhum;
     3. sem script a conta cai inteira e a opacidade volta a 1 (ver
        estilo.css). Na versão em JS, sem script não há nada escrito e a
        peça dependeria de uma segunda regra para não ficar apagada.

   Se algum dia a peça for parar numa página com dezenas de unidades, o
   número acima é o que decide a troca — não o gosto. Não há duas versões no
   arquivo, como pede o contrato; há esta.

   NÃO EXISTE LISTENER DE SCROLL AQUI. A assinatura é no LIB.onScroll do
   core, que é um único requestAnimationFrame para a página inteira (§7.4 e
   MANUAL §5). O plano B só existe para o caso de a peça ser colada sem o
   motion.js — mesmo contrato: um rAF, {passive:true}, e a primeira chamada
   logo de saída.

   MOVIMENTO REDUZIDO: sai antes de escrever qualquer coisa. Sem --dts-i e
   sem --dts-n a conta do CSS fica inválida no tempo de valor computado e a
   opacidade volta ao inicial (1) — e o @media(prefers-reduced-motion) do
   estilo.css ainda declara o mesmo por escrito. A frase fica inteira, acesa
   e parada. Não há versão "acende sem rolar": o acendimento É a rolagem.

   DUAS GRANULARIDADES, UMA ESCOLHA. Se houver .dts-w na raiz, as palavras
   são as unidades; se não houver, são as .dts-ln. Nunca as duas: num markup
   com palavras dentro de linhas, contar os dois conjuntos daria índices
   duplicados e a frase acenderia em duas ondas desencontradas. A linha que
   sobra sem --dts-i não atrapalha — a declaração dela fica inválida e a
   opacidade computa 1, que é o neutro certo para um invólucro.
   ========================================================================== */

(function () {

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

  /* A flag do core é a fonte; o matchMedia é o plano B de quem colou a peça
     sem o motion.js. `?motion=on` continua valendo pelos dois caminhos. */
  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;
  }
  if (reduzido()) return;

  function assinarRolagem(fn) {
    if (window.LIB && typeof window.LIB.onScroll === 'function') {
      window.LIB.onScroll(fn);
      return;
    }
    var pedido = 0;
    function agendar() {
      if (pedido) return;
      pedido = requestAnimationFrame(function () { pedido = 0; fn(); });
    }
    addEventListener('scroll', agendar, { passive: true });
    addEventListener('resize', agendar, { passive: true });
    fn();
  }

  function iniciar(raiz) {
    /* Palavra vence linha — ver o cabeçalho. */
    var unidades = raiz.querySelectorAll('.dts-w');
    if (!unidades.length) unidades = raiz.querySelectorAll('.dts-ln');

    /* Uma unidade só não escova nada: o laço inteiro seria um fade. Sai sem
       erro, sem escrever --dts-i, e a frase fica acesa — que é o estado certo
       para uma linha sozinha. */
    var n = unidades.length;
    if (n < 2) return;

    for (var i = 0; i < n; i++) unidades[i].style.setProperty('--dts-i', i);
    raiz.style.setProperty('--dts-n', n);

    var dentro = false;      /* a dobra está na tela */
    var ultimo = '';         /* último --dts-p escrito, como string */

    /* ------------------------------------------------------------ a janela

       --dts-start e --dts-end são % da altura da JANELA, lidos do CSS para a
       decisão de layout morar num lugar só (mesma razão do --sync-band do
       conteudo/rolagem-sincronizada). Lidos a cada medida porque uma media
       query pode trocá-los na virada de layout, e getComputedStyle sobre uma
       propriedade herdada é barato perto do getBoundingClientRect que vem
       logo abaixo — as duas leituras acontecem no mesmo quadro, então não há
       um segundo forced reflow a pagar.

       A CONTA. Com a = janela * start/100 e b = janela * end/100:

         p = 0  quando o TOPO do bloco está em a
         p = 1  quando a BASE do bloco está em b, ou seja, topo em (b - altura)

       logo  p = (a - topo) / (a - b + altura).

       O denominador é o percurso, e é ele que faz a mesma conta servir aos
       dois casos que o contrato deste componente exige: num bloco baixo o
       percurso é quase só (a - b), pouco menos de meia tela de rolagem; numa
       seção pinada de 300vh o percurso vira (a - b) + 3 telas, e o
       acendimento se espalha pela seção inteira sem nenhum parafuso novo.
       Nunca é zero nem negativo, porque a > b por construção e a altura não
       é negativa — não há divisão por zero para proteger.

       É uma FUNÇÃO PURA da posição do bloco: nada de acumulador, nada de
       "último sentido de rolagem". Por isso a volta apaga exatamente na mesma
       curva em que acendeu, e um salto de âncora cai no valor certo de
       primeira. */
    function medir() {
      var estilo = getComputedStyle(raiz);
      var a = parseFloat(estilo.getPropertyValue('--dts-start'));
      var b = parseFloat(estilo.getPropertyValue('--dts-end'));
      if (!(a >= 0) || !(b >= 0) || a <= b) { a = 82; b = 58; }

      var r = raiz.getBoundingClientRect();
      var alto = innerHeight * a / 100;
      var baixo = innerHeight * b / 100;

      var p = (alto - r.top) / (alto - baixo + r.height);
      p = p < 0 ? 0 : (p > 1 ? 1 : p);

      /* Três casas dão mil degraus no percurso inteiro — mais fino que o
         pixel em qualquer tela — e a comparação impede reescrever a mesma
         string enquanto a rolagem está parada. */
      var s = p.toFixed(3);
      if (s === ultimo) return;
      ultimo = s;
      raiz.style.setProperty('--dts-p', s);
    }

    function quadro() {
      if (!dentro) return;
      medir();
    }

    /* Fora da tela não há o que medir, e o portão é o que faz a assinatura no
       laço do core custar uma comparação por quadro em vez de duas leituras
       de layout.

       A medida na VIRADA é obrigatória, e não sobra: sem ela, uma rolagem
       rápida que atravesse a dobra inteira num quadro deixaria --dts-p
       congelado no último valor calculado (0,87, por exemplo) e a frase
       ficaria meio acesa lá em cima, para sempre. Como a saída só acontece
       por uma das duas pontas, a conta devolve exatamente 0 ou 1. */
    var portao = new IntersectionObserver(function (entradas) {
      for (var e = 0; e < entradas.length; e++) dentro = entradas[e].isIntersecting;
      medir();
    }, { threshold: 0 });

    portao.observe(raiz);
    assinarRolagem(quadro);
  }

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

Uma frase de manifesto em corpo grande, quebrada na mão em três a cinco linhas, que não entra e acaba: ela nasce apagada — opacidade de repouso baixa, mas ainda legível — e vai acendendo unidade por unidade conforme a dobra atravessa a janela. Rolar de volta apaga, exatamente na mesma curva. É escovado (scrubbed), reversível e preso ao scroll: o gesto não é "apareceu", é "está sendo lido".

Resolve um problema de ritmo, não de informação. Uma frase de manifesto colada numa página é uma parede de tipografia que o olho lê de uma vez e abandona; o mesmo texto acendendo no compasso da rolagem obriga o olho a andar linha a linha, e é isso que faz a frase parecer dita e não impressa. É uma primitiva de efeito, como efeitos/inclinacao-3d: o que se cataloga é a frase e o motor que a acende, não a dobra em volta.


Compliance

Manifesto é o lugar mais fácil de escorregar para promessa, porque o registro do gênero convida a isso: frase curta, corpo grande, tom de afirmação. Fale de método, estrutura e postura — nunca de resultado. "Patrimônio não se defende no susto" e "em política escrita" são afirmações sobre como a casa trabalha; "seu dinheiro rendendo mais" e "acima do mercado" são promessa de rentabilidade, e não entram aqui de nenhuma forma.

Esta peça não recebe número. Não é limitação de layout: número apurado pede fonte e data, número ilustrativo pede a ressalva .nota do core, e a peça não tem onde pôr uma sem deixar de ser um manifesto — a ressalva embaixo de uma frase de 50px vira uma segunda peça. Copy com cifra pertence a prova/numero-gigante ou a dados/grade-indicadores, que já trazem o aparato.

Sem marca real de terceiro, pelo mesmo motivo de sempre: o manifesto fala da casa, e citar banco ou corretora no corpo dele é comparação implícita.


Quando usar

Quando não usar


Padrão editorial

A tabela descreve a frase, não o palco: a <section> do markup.html é contexto e não faz parte do que se cola.

Slot Regra
Linha (.dts-ln) 3 a 5 linhas, 18 a 52 caracteres cada. A quebra é decidida aqui, não pelo navegador — cada linha é uma caixa e termina exatamente na palavra escrita.
Realce (em) Opcional, um por frase, dentro de uma linha. 1 a 3 palavras. Realce o verbo ou o advérbio que carrega a tese ("antes", "escrita"), não o substantivo óbvio.
Palavra (.dts-w) Só na variação por palavra, e aí ela substitui a linha como unidade. 4 a 12 palavras.

O que mantém a peça coesa é o comprimento parecido entre as linhas: a variação de largura entre elas é o desenho do bloco, e uma linha com o dobro da vizinha o desmancha. E o paralelismo de construção — as quatro linhas do exemplo são uma negação seguida de três complementos da mesma oração, o que é o que permite ler a frase inteira depois de ela ter sido lida em pedaços.

O realce nunca atravessa duas linhas: cada linha é uma unidade de opacidade, e um <em> partido acenderia pela metade.

Especificação dos assets

Nenhum. Sem imagem, sem ícone, sem fonte própria: a peça é a .display-xl do core na face do tema, e o efeito é opacidade calculada em CSS.


Como usar

<link rel="stylesheet" href="componentes/efeitos/destaque-texto-scroll/estilo.css">
<script src="componentes/efeitos/destaque-texto-scroll/script.js"></script>

O bloco vai dentro do .wrap da dobra que você já tem — a peça não controla coluna nenhuma (contrato §4):

<div class="dts" data-dts>
  <p class="dts-txt display-xl" data-rv>
    <span class="dts-ln">Primeira linha da frase.</span>
    <span class="dts-ln">Segunda linha, com o realce <em>aqui</em>.</span>
    <span class="dts-ln">Terceira linha.</span>
  </p>
</div>

Não há nada a inicializar, nenhuma opção a passar e nenhum índice a escrever à mão: o script numera as unidades sozinho, na carga.

Ganchos

Gancho Papel
[data-dts] A raiz da peça. É dela que o script lê a posição, e nela que escreve --dts-p e --dts-n.
.dts-ln A unidade linha. O padrão.
.dts-w A unidade palavra. Existindo .dts-w na raiz, ela vence: as linhas viram invólucro.
--dts-p Progresso da dobra, de 0 a 1. Escrito pelo script uma vez por quadro. Estado interno — não se ajusta à mão.
--dts-n / --dts-i Quantas unidades existem, e o índice de cada uma. Escritos na carga. Estado interno.
--dts-off Opacidade da frase apagada. Padrão .46. Tem piso medido — ver Parafusos.
--dts-k Quantas fatias de progresso cada unidade leva para acender. Padrão 2.
--dts-start / --dts-end A janela de cálculo, em % da altura da janela. Padrão 82 e 58.

Parafusos

Parafuso Padrão O que muda
--dts-off .46 A opacidade do repouso. Abaixo de .42 a frase perde o AA; abaixo de .46 o realce perde. Ver a tabela de contraste em Gotchas. Subir é sempre seguro, e a partir de .6 o efeito começa a desaparecer.
--dts-k 2 1 = estritamente sequencial, lê como contador. 2 = a unidade seguinte começa na metade do caminho da anterior, e é isso que faz a leitura parecer uma onda. Acima de 3 vira um fade só.
--dts-start / --dts-end 82 / 58 Onde o topo do bloco está quando o progresso é 0, e onde a base está quando é 1. 92/20 estica o percurso para quase uma tela inteira. start tem de ser maior que end — se não for, o script cai no par padrão em vez de dividir por um percurso errado.
--display-xl / --display-xl-lh clamp(25px,3.4vw,50px) / 1.26 Os dois parafusos da primitiva do core. O corpo é calibrado pela fração da coluna que a linha ocupa, não pelo número: trocar a display do tema muda a conta.

Variações


Gotchas

opacidade texto (--fg) realce (--accent-ink)
.38 (o valor da referência) 3,22:1 2,57:1
.42 3,70:1 2,90:1
.46 (o daqui) 4,23:1 3,26:1
.50 4,81:1 3,66:1
1 (aceso) 17,39:1 12,08:1

O mínimo para texto grande é 3:1 — e esta frase é sempre texto grande (medido no palco: 48,96px em peso 700; o piso do clamp é 25px, e o limite da regra é 18,66px em negrito). A referência repousa em .38, onde o texto passa raspando e o realce não passa — e o realce é justamente o que a peça quer que se leia. .46 é o menor valor que fecha os dois.


Movimento reduzido / toque

prefers-reduced-motion: reduce: o script sai no guard, antes de escrever qualquer coisa — não numera as unidades, não assina o laço de rolagem do core e não observa nada. Medido com --reduce em 1440×900, com a dobra na tela: --dts-p e --dts-n saem vazios, o --dts-i de cada linha sai vazio, e a opacidade computada das quatro linhas é 1. O @media(prefers-reduced-motion:reduce) do estilo.css declara o mesmo por escrito, para a garantia não depender de um efeito colateral da cascata. Não existe versão "acende sem rolar": o acendimento é a rolagem, e simular um relógio no lugar dela seria exatamente o movimento automático que a preferência pediu para desligar.

Toque: nada muda, e não há nada a mudar — o gesto é a rolagem, que existe igual em toque e em mouse. Não há hover em lugar nenhum desta peça. Medido em 390×844 com toque: as quatro unidades numeradas, --dts-p acompanhando, overflow-x ok.

?motion=on funciona, porque o guard passa pelo LIB.reduce do core — mas não há o que ele mostre a mais: com a preferência ligada a peça não anima sozinha, ela só deixa de reagir ao scroll.


Histórico

Projeto Onde Data Diferenças
referência: skinboost-design-review.vercel.app a dobra #manifesto — as linhas saindo de opacity .38 para 1 conforme a seção passa 2026-09-21 reescrito do zero: sem GSAP/ScrollTrigger, sem three.js, sem fonte de terceiro; repouso subiu de .38 para .46 por contraste medido; realce passou a ler --accent-ink

Origem. Clone de referência (contrato §11): o que veio do original foi a ideia da interação — a frase de manifesto que acende conforme a seção passa, e volta a apagar —, a proporção e o timing. Nenhuma linha de código foi consultada ou copiada. Registro consolidado em projetos/referencias-externas.md, seção Clones.

Decisões de tradução.

O original escova a opacidade com GSAP ScrollTrigger em scrub, que é literalmente o caso em que o §1 do contrato autorizaria GSAP. Ele não atravessou mesmo assim, e a razão não é purismo: a peça não precisa de timeline encadeada, precisa de uma fração — e o core já serve o laço de scroll da página inteira em LIB.onScroll, um requestAnimationFrame para todos os componentes juntos. Uma lib de 70kB para produzir um número entre 0 e 1 seria o pior negócio da biblioteca.

A divisão do trabalho mudou de forma. No original o script escreve a opacidade de cada linha a cada quadro. Aqui ele escreve uma custom property na raiz (--dts-p) e cada unidade resolve a própria opacidade em CSS, a partir do índice que recebeu na carga. O número não favorece a escolha (ver Gotchas), e é por isso que ele está escrito na ficha e no cabeçalho do script: quem for trocar um dia tem o custo medido na mão, não uma preferência.

A correção que mais importa é de acessibilidade, e ela só apareceu porque foi medida: o repouso em .38 do original dá 2,57:1 no realce em cor de marca sobre o escuro da casa, contra um mínimo de 3:1 para texto grande. O padrão daqui é .46, e a ficha registra o piso abaixo do qual o parafuso volta a quebrar. Entraram também três coisas que o original não declara: @media (scripting: none) e @media(prefers-reduced-motion:reduce) deixando a frase inteira acesa, e o guard no script que nem assina o laço quando a preferência está ligada.

A janela de cálculo foi reescrita para servir aos dois casos que o contrato deste componente exige — bloco baixo e seção pinada alta. O ScrollTrigger resolve isso com start/end em string ("top 80%", "bottom 20%"); aqui é uma divisão em que o denominador inclui a altura do próprio bloco, o que faz o percurso crescer junto com a peça sem nenhum parafuso novo. O primeiro par de valores tentado (76/34) estava errado, e só o número mostrou — ver Gotchas.

Normalizações aplicadas na extração: prefixo dts- em toda classe; .display-xl do core no lugar da tipografia própria; --accent-ink no lugar de --accent no realce; data-rv do core como entrada, no invólucro; LIB.onScroll no lugar de listener próprio; copy de assessoria na voz da Liberta no lugar da copy de skincare.

Divergências conhecidas vs. a referência: o repouso é mais claro (.46 contra .38), e a granularidade por palavra não existe no original — ela entrou porque a frase curta é um caso real da casa e a cascata por linha a resolveria mal.