← Biblioteca componentes/midia/comparador-antes-depois/ficha.md CódigoTela cheia
midia/comparador-antes-depois

Comparador antes e depois

🟢 estavel complexidade média prefixo cmp- comparador antes-depois imagem arrasto drag divisor range acessibilidade teclado miniaturas galeria compliance

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

<link rel="stylesheet" href="componentes/midia/comparador-antes-depois/estilo.css">
<script src="componentes/midia/comparador-antes-depois/script.js"></script>

markup.html · 164 linhas · 9,3 KB

<!-- ==========================================================================
     COMPARADOR ANTES / DEPOIS — divisor arrastável entre duas imagens,
     com seletor de até quatro pares
     Prefixo: cmp-
     Requer: core (tokens, base, motion.css, motion.js) + este estilo.css e
             este script.js

     Duas fotos do MESMO enquadramento, uma por cima da outra, e um divisor
     vertical que a pessoa arrasta para revelar uma ou outra. A de cima é
     recortada por clip-path; a de baixo está sempre inteira ali atrás.

     O CONTROLE É UM <input type="range"> DE VERDADE, invisível por cima da
     imagem. Quase toda implementação deste padrão constrói o arraste na
     mão, com pointerdown/pointermove/pointerup, e aí precisa reimplementar
     teclado, toque, captura de ponteiro e anúncio para leitor de tela —
     quatro coisas que o range já traz prontas e testadas. Setas, Home, End,
     PageUp/PageDown, arraste, toque e o anúncio de porcentagem vêm de graça,
     do navegador.

     O CLIP É NA DE CIMA, E ELA É ABSOLUTA. Recortar por clip-path em vez de
     encolher a largura é o que mantém a foto de cima PARADA enquanto o
     divisor anda: com width, a imagem seria remontada a cada quadro e
     pareceria esticar em vez de ser revelada.

     A FILEIRA DE PARES É OPCIONAL, e é a decisão de forma que 2026-09-21
     trouxe. Com UM par, apague o bloco .cmp-pares inteiro e o componente
     volta a ser exatamente o que era: o script procura a fileira, não a
     encontra e sai calado. Com dois a quatro, cada miniatura é um par, e
     trocar de par troca as duas fotos, os dois rótulos e o alt de uma vez.

     SÃO BOTÕES, NÃO ABAS. Aba (role="tab") promete um painel por aba e um
     laço de setas que anda entre elas; aqui há um painel só, que muda de
     conteúdo, e as setas do teclado já pertencem ao range — dar setas às
     miniaturas roubaria o gesto principal de quem navega por teclado. Um
     grupo de botões com aria-pressed diz a verdade sem prometer nada disso.

     A TROCA DEVOLVE O DIVISOR À POSIÇÃO INICIAL. A posição em que a pessoa
     largou o par anterior não significa nada no par novo: 38% ali era "quase
     tudo antes" de uma foto que já saiu de cena. Voltar ao value do HTML
     (50 no padrão) é o único estado honesto para começar uma comparação nova
     — e ler o value em vez de cravar 50 preserva a variação "começa fechado".

     NO PREVIEW, O "ANTES" É A MESMA FOTO COM FILTRO. A biblioteca não tem um
     par de fotos do mesmo enquadramento em showroom/demo/, e usar duas fotos
     diferentes faria o componente parecer quebrado em vez de comparar. No uso
     real são dois arquivos por par — ver a especificação de assets na ficha.

     A RESSALVA NÃO É ENFEITE. Antes/depois é exatamente onde um site de
     assessoria escorrega para promessa de resultado: a peça existe para
     mostrar ORGANIZAÇÃO, e o .nota abaixo é o que impede que a demonstração
     seja lida como rendimento. Ver a seção Compliance da ficha antes de
     trocar as fotos.
     ========================================================================== -->

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

    <div class="sec-head">
      <span class="eyebrow" data-rv>Antes e depois</span>
      <h2 class="sec-title ttl">Como a carteira fica <em>organizada</em></h2>
      <p class="sec-lead" data-rv data-d="1">Quatro frentes que a assessoria reorganiza. Escolha uma abaixo e arraste o divisor para comparar.</p>
    </div>

    <!-- data-rv no invólucro: o .cmp-quadro não gerencia transform próprio,
         mas o divisor e a alça sim, e manter a separação é o padrão da casa
         (ver efeitos/inclinacao-3d e efeitos/selo-circular).
         [data-cmp-conjunto] é o que amarra a fileira ao quadro: o componente
         pode repetir na página, e sem ele o segundo comparador obedeceria às
         miniaturas do primeiro. -->
    <div class="cmp-slot" data-rv data-d="2" data-cmp-conjunto>
      <div class="cmp-quadro" data-cmp>

        <!-- A de baixo: sempre inteira. É o "depois". -->
        <img class="cmp-img cmp-img-base" data-cmp-img-b src="../../../showroom/demo/ph-wide-2.webp"
             alt="Um relatório único reunindo as posições que antes estavam em cinco instituições"
             loading="lazy" decoding="async">

        <!-- A de cima: recortada pelo clip-path. É o "antes". -->
        <div class="cmp-topo" aria-hidden="true">
          <img class="cmp-img cmp-img-antes" data-cmp-img-a src="../../../showroom/demo/ph-wide-2.webp"
               alt="" loading="lazy" decoding="async">
        </div>

        <span class="grain" aria-hidden="true"></span>

        <span class="pill cmp-rotulo cmp-rotulo-a" aria-hidden="true">Espalhado</span>
        <span class="pill cmp-rotulo cmp-rotulo-b" aria-hidden="true">Consolidado</span>

        <!-- A linha e a alça são desenho. Quem recebe o gesto é o range. -->
        <span class="cmp-linha" aria-hidden="true">
          <span class="cmp-alca">
            <svg viewBox="0 0 24 24" focusable="false" aria-hidden="true">
              <path d="M9 6l-5 6 5 6M15 6l5 6-5 6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
        </span>

        <input class="cmp-range" type="range" min="0" max="100" step="0.1" value="50"
               data-cmp-range aria-label="Revelar espalhado e consolidado: 0 mostra espalhado, 100 mostra consolidado">

      </div>

      <!-- A FILEIRA DE PARES — apague este bloco inteiro para voltar ao
           comparador de um par só. Cada miniatura carrega o par que ela
           representa nos data-*, e é daí que o script tira as duas fotos
           grandes, os dois rótulos e o alt.
           As miniaturas são loading="lazy": elas moram abaixo do quadro e
           quase sempre nascem fora da tela. As fotos GRANDES dos pares que
           não estão em cena não estão no DOM — o script as busca na primeira
           intenção (ponteiro em cima ou foco na miniatura) e só troca o src
           quando a nova já está decodificada, que é o que impede o pisca
           branco na primeira troca. -->
      <div class="cmp-pares" role="group" aria-label="Escolher o par a comparar" data-cmp-pares>

        <button class="cmp-par" type="button" data-cmp-par aria-pressed="true"
                data-cmp-src-a="../../../showroom/demo/ph-wide-2.webp"
                data-cmp-src-b="../../../showroom/demo/ph-wide-2.webp"
                data-cmp-rot-a="Espalhado" data-cmp-rot-b="Consolidado"
                data-cmp-alt="Um relatório único reunindo as posições que antes estavam em cinco instituições">
          <img class="cmp-par-img" src="../../../showroom/demo/ph-wide-2.webp" alt="" loading="lazy" decoding="async">
          <span class="cmp-par-nome">Consolidação</span>
        </button>

        <button class="cmp-par" type="button" data-cmp-par aria-pressed="false"
                data-cmp-src-a="../../../showroom/demo/ph-wide-1.webp"
                data-cmp-src-b="../../../showroom/demo/ph-wide-1.webp"
                data-cmp-rot-a="Antes" data-cmp-rot-b="Depois"
                data-cmp-alt="O relatório mensal redesenhado, com as posições agrupadas por objetivo">
          <img class="cmp-par-img" src="../../../showroom/demo/ph-wide-1.webp" alt="" loading="lazy" decoding="async">
          <span class="cmp-par-nome">Relatório</span>
        </button>

        <button class="cmp-par" type="button" data-cmp-par aria-pressed="false"
                data-cmp-src-a="../../../showroom/demo/ph-wide-3.webp"
                data-cmp-src-b="../../../showroom/demo/ph-wide-3.webp"
                data-cmp-rot-a="Sem plano" data-cmp-rot-b="Com plano"
                data-cmp-alt="A documentação da sucessão reunida em um plano único, com os titulares nomeados">
          <img class="cmp-par-img" src="../../../showroom/demo/ph-wide-3.webp" alt="" loading="lazy" decoding="async">
          <span class="cmp-par-nome">Sucessão</span>
        </button>

        <button class="cmp-par" type="button" data-cmp-par aria-pressed="false"
                data-cmp-src-a="../../../showroom/demo/ph-wide-5.webp"
                data-cmp-src-b="../../../showroom/demo/ph-wide-5.webp"
                data-cmp-rot-a="Sem agenda" data-cmp-rot-b="Com agenda"
                data-cmp-alt="O calendário de revisões da carteira, com as datas e os responsáveis definidos">
          <img class="cmp-par-img" src="../../../showroom/demo/ph-wide-5.webp" alt="" loading="lazy" decoding="async">
          <span class="cmp-par-nome">Governança</span>
        </button>

      </div>

      <!-- .nota é a primitiva do core (só tipografia); .cmp-nota é a posição.
           Este texto é EXIGIDO: ver a seção Compliance da ficha. -->
      <p class="nota cmp-nota">Imagens ilustrativas, criadas para demonstrar a peça — não são telas de cliente. A comparação é de organização e de clareza do relatório: nada aqui representa rentabilidade, resultado de carteira ou evolução de patrimônio.</p>

      <!-- A troca de par é silenciosa para quem vê a tela e invisível para
           quem não vê. Esta região anuncia o par novo, e nasce vazia. -->
      <p class="visually-hidden" role="status" aria-live="polite" data-cmp-vivo></p>

    </div>

  </div>
</section>

estilo.css · 267 linhas · 8,0 KB

/* ==========================================================================
   COMPARADOR ANTES / DEPOIS — cmp-
   Duas fotos do mesmo enquadramento e um divisor arrastavel, mais a fileira
   OPCIONAL de miniaturas que troca o par comparado.
   O script escreve --cmp-k (0 a 100) lendo o <input type="range">.
   ========================================================================== */

.cmp-quadro{
  --cmp-k:50;
  position:relative;
  overflow:hidden;
  border-radius:var(--radius-media);
  aspect-ratio:16 / 9;
  /* O cursor avisa o gesto antes de a pessoa tentar. Nao substitui a alca:
     em toque nao ha cursor, e por isso a alca e' desenhada sempre. */
  cursor:ew-resize;
  /* Sem isto, arrastar sobre a imagem no celular rola a pagina em vez de
     mover o divisor. pan-y mantem a rolagem VERTICAL funcionando -- tirar
     o toque inteiro prenderia a pagina em quem so' queria passar por ela. */
  touch-action:pan-y;
}

.cmp-img{
  display:block;
  inline-size:100%;
  block-size:100%;
  object-fit:cover;
  /* A de cima tem de cobrir exatamente a de baixo: qualquer diferenca de
     posicao aparece como fantasma na linha do divisor. */
  user-select:none;
  -webkit-user-drag:none;
}

.cmp-topo{
  position:absolute;
  inset:0;
  /* clip-path, nao width: a foto de cima fica PARADA e o recorte anda por
     cima dela. Com width, a imagem seria remontada a cada quadro e
     pareceria esticar em vez de ser revelada. */
  clip-path:inset(0 calc(100% - var(--cmp-k) * 1%) 0 0);
  transition:clip-path 90ms linear;
}

/* No preview, o "antes" e' a mesma foto com filtro, porque a biblioteca nao
   tem um par de fotos do mesmo enquadramento em showroom/demo/. No uso real
   este filtro sai e entra o segundo arquivo -- ver a ficha. Ele vale para
   TODOS os pares, porque e' regra do elemento e nao do par: trocar de par
   troca o src, nao a classe. */
.cmp-img-antes{
  filter:grayscale(1) contrast(.86) brightness(.78);
}

.cmp-linha{
  position:absolute;
  inset-block:0;
  inset-inline-start:calc(var(--cmp-k) * 1%);
  inline-size:2px;
  margin-inline-start:-1px;
  background:var(--light);
  /* A linha e a alca sao desenho: quem recebe o gesto e' o range que esta'
     por cima. Sem isto, elas roubariam o arraste justamente no ponto onde
     a pessoa mira. */
  pointer-events:none;
  z-index:2;
  transition:inset-inline-start 90ms linear;
}

.cmp-alca{
  position:absolute;
  inset-block-start:50%;
  inset-inline-start:50%;
  transform:translate(-50%, -50%);
  display:grid;
  place-items:center;
  inline-size:var(--cmp-alca-size, 46px);
  block-size:var(--cmp-alca-size, 46px);
  border-radius:var(--radius-pill);
  border:1px solid color-mix(in srgb, var(--light) 55%, transparent);
  background:color-mix(in srgb, var(--dark) 34%, transparent);
  backdrop-filter:blur(8px);
  color:var(--light);
}

.cmp-alca svg{
  inline-size:52%;
  stroke:currentColor;
  stroke-width:1.7;
}

.cmp-rotulo{
  position:absolute;
  inset-block-start:16px;
  z-index:2;
  pointer-events:none;
}

.cmp-rotulo-a{inset-inline-start:16px}
.cmp-rotulo-b{inset-inline-end:16px}

/* O controle de verdade: um range nativo esticado sobre a imagem inteira.
   Invisivel, mas presente -- e' dele que vem teclado, toque e o anuncio de
   porcentagem para o leitor de tela. */
.cmp-range{
  position:absolute;
  inset:0;
  inline-size:100%;
  block-size:100%;
  margin:0;
  opacity:0;
  cursor:ew-resize;
  z-index:3;
  -webkit-appearance:none;
  appearance:none;
  background:transparent;
}

/* A area de agarre do polegar cobre a altura toda, para o arraste comecar
   de qualquer ponto da foto e nao so' da faixa central. */
.cmp-range::-webkit-slider-thumb{
  -webkit-appearance:none;
  appearance:none;
  inline-size:var(--cmp-alca-size, 46px);
  block-size:100vh;
}

.cmp-range::-moz-range-thumb{
  inline-size:var(--cmp-alca-size, 46px);
  block-size:100vh;
  border:0;
  border-radius:0;
}

/* O foco tem de aparecer em algum lugar: o range e' invisivel, entao quem
   mostra o anel e' a alca desenhada. Sem isto, navegar por teclado moveria
   o divisor sem nenhuma pista de onde esta' o foco. */
.cmp-range:focus-visible ~ .cmp-linha .cmp-alca{
  outline:2px solid var(--accent);
  outline-offset:3px;
}

/* ==========================================================================
   A FILEIRA DE PARES — opcional
   De um a quatro pares. Com um so', o bloco .cmp-pares nao existe no markup
   e nada aqui e' usado: o script sai calado e o comparador e' o de sempre.

   FLEX QUE EMBRULHA, e nao grade de N colunas: o numero de pares varia de 1
   a 4 e a peca nao pode saber quantos sao. Com flex-basis de 140px a fileira
   faz sozinha quatro numa linha no desktop e duas por linha no celular, sem
   uma media query por contagem.
   ========================================================================== */
.cmp-pares{
  display:flex;
  flex-wrap:wrap;
  gap:10px;
  margin-block-start:14px;
}

/* §6.5: quem da' display a algo que as vezes e' [hidden] desliga a mao. */
.cmp-pares[hidden]{display:none}

/* Sem JavaScript as miniaturas nao trocam nada, e botao morto e' pior que
   botao ausente -- o comparador do primeiro par continua inteiro e legivel.
   Mesma consulta que o core/motion.css usa para devolver os [data-rv]. */
@media (scripting: none){
  .cmp-pares{display:none}
}

.cmp-par{
  position:relative;
  overflow:hidden;
  /* cresce para ocupar a linha, mas nao vira um segundo comparador: acima de
     220px a miniatura passa a competir com a foto grande. */
  flex:1 1 140px;
  max-inline-size:220px;
  padding:0;
  border:1px solid var(--rule-2);
  border-radius:var(--radius-media);
  background:none;
  color:var(--light);
  font:inherit;
  cursor:pointer;
  transition:border-color .25s var(--ease);
}

.cmp-par[aria-pressed="true"]{
  border-color:var(--accent);
}

.cmp-par:focus-visible{
  outline:2px solid var(--accent);
  outline-offset:3px;
}

.cmp-par-img{
  display:block;
  inline-size:100%;
  aspect-ratio:16 / 9;
  object-fit:cover;
  /* O par que nao esta' em cena fica apagado: e' o que faz a miniatura ativa
     se destacar sem precisar de marca, seta ou segundo contorno. O estado
     verdadeiro continua sendo o aria-pressed do botao -- isto aqui e' a
     traducao dele para o olho. */
  opacity:.45;
  filter:grayscale(1);
  transition:opacity .25s var(--ease), filter .25s var(--ease);
}

.cmp-par[aria-pressed="false"]:hover .cmp-par-img{opacity:.8}

.cmp-par[aria-pressed="true"] .cmp-par-img{
  opacity:1;
  filter:none;
}

/* Legenda de card de midia vai DENTRO da imagem, ancorada embaixo (§6). O
   degrade e' o que a mantem legivel sobre qualquer foto: sem ele a palavra
   some na primeira miniatura clara. */
.cmp-par-nome{
  position:absolute;
  inset-inline:0;
  inset-block-end:0;
  padding:20px 10px 8px;
  font-family:var(--sans);
  font-size:12px;
  line-height:1.2;
  letter-spacing:.04em;
  text-align:start;
  color:var(--light);
  background:linear-gradient(to top,
             color-mix(in srgb, var(--dark) 86%, transparent),
             transparent);
  pointer-events:none;
}

/* A ressalva. A tipografia inteira vem da primitiva .nota do core; aqui fica
   so' a POSICAO -- margem e o filete que amarra a nota ao quadro de cima.
   Ver a secao Compliance da ficha: neste componente ela e' exigida. */
.cmp-nota{
  margin-block-start:16px;
  padding-block-start:12px;
  border-block-start:1px solid var(--rule);
}

@media (prefers-reduced-motion: reduce){
  /* O divisor continua andando: ele responde ao gesto, e gesto e' comando,
     nao enfeite. Sai so' a suavizacao entre um passo de teclado e o
     seguinte, que e' o unico movimento que a pessoa nao pediu -- e, pela
     mesma razao, o acender e apagar das miniaturas. */
  .cmp-topo,
  .cmp-linha,
  .cmp-par,
  .cmp-par-img{transition:none}
}

@media (max-width: 760px){
  .cmp-quadro{aspect-ratio:4 / 3}
  .cmp-alca{--cmp-alca-size:40px}
  .cmp-rotulo{inset-block-start:12px}
  .cmp-rotulo-a{inset-inline-start:12px}
  .cmp-rotulo-b{inset-inline-end:12px}
  .cmp-par{max-inline-size:none}
}

@media (max-width: 460px){
  .cmp-quadro{aspect-ratio:3 / 4}
  .cmp-par-nome{padding:16px 8px 6px;font-size:11.5px}
}

script.js · 181 linhas · 7,3 KB

/* ==========================================================================
   COMPARADOR ANTES / DEPOIS — cmp-
   Duas responsabilidades, e so' a primeira e' obrigatoria:

     1. copiar o valor do <input type="range"> para a custom property
        --cmp-k, que o CSS le no clip-path e na posicao da linha;
     2. se houver fileira de miniaturas, trocar o PAR comparado.

   Arraste, toque, setas, Home, End, PageUp/PageDown e o anuncio de
   porcentagem para leitor de tela sao do RANGE NATIVO, nao deste arquivo --
   e' o motivo de a parte 1 ter cinco linhas em vez de duzentas. A parte 2
   nao encosta em nenhum deles: ela troca src, rotulo e alt, e devolve o
   range a' posicao inicial. Nada do teclado passa por aqui.

   Nao ha movimento reduzido a tratar: o script nao anima nada. As unicas
   animacoes sao transicoes do estilo.css, que o @media do proprio
   componente desliga.
   ========================================================================== */
(function () {
  var quadros = document.querySelectorAll('[data-cmp]');
  if (!quadros.length) return;              // guard: saida silenciosa

  /* CACHE DE IMAGEM, compartilhado pelos comparadores da pagina.
     As fotos grandes dos pares que nao estao em cena NAO estao no DOM: por
     um lado isso e' o que impede a dobra de baixar oito arquivos grandes
     para quem talvez nunca clique; por outro, trocar o src de uma foto que
     ainda nao chegou pinta o quadro de branco por alguns quadros -- o pisca
     que a peca nao pode ter.
     A saida e' pedir a foto ANTES do clique (ponteiro em cima ou foco, que
     e' intencao declarada) e so' trocar o src quando ela ja' esta' decodada.
     Quem clica direto, sem passar o ponteiro, tambem nao ve pisca: nesse
     caso a troca espera o carregamento em vez de acontecer em branco. */
  var cache = {};

  function precarregar(src) {
    if (!src) return null;
    if (cache[src]) return cache[src];
    var im = new Image();
    im.decoding = 'async';
    im.src = src;
    cache[src] = im;
    return im;
  }

  function quandoPronta(srcs, feito) {
    var faltam = srcs.length, chamou = false;
    if (!faltam) { feito(); return; }
    function passo() {
      faltam -= 1;
      if (faltam > 0 || chamou) return;
      chamou = true;
      feito();
    }
    for (var i = 0; i < srcs.length; i++) {
      var im = precarregar(srcs[i]);
      // 'error' tambem libera: uma foto que nao existe tem de trocar assim
      // mesmo e mostrar o alt. Travar o botao esconderia o defeito.
      if (!im || im.complete) { passo(); continue; }
      im.addEventListener('load', passo);
      im.addEventListener('error', passo);
    }
  }

  // Parte da raiz de CADA quadro, nunca do document: o componente pode
  // aparecer duas vezes na mesma pagina, e uma busca global faria os dois
  // divisores obedecerem ao primeiro range.
  Array.prototype.forEach.call(quadros, function (quadro) {
    var range = quadro.querySelector('[data-cmp-range]');
    if (!range) return;

    function aplicar() {
      quadro.style.setProperty('--cmp-k', range.value);
    }

    // 'input' dispara durante o arraste e a cada tecla; 'change' so' no
    // fim. Escutar os dois seria redundante -- input ja' cobre tudo.
    range.addEventListener('input', aplicar);

    // O valor inicial vem do HTML (value="50"), e o CSS tem o mesmo numero
    // como padrao. Esta chamada existe para o caso de o markup nascer com
    // outro valor: sem ela, o divisor comecaria em 50 e pularia para o
    // valor certo no primeiro toque.
    aplicar();

    /* ---------------------------------------------------------------- pares
       Daqui para baixo tudo e' OPCIONAL. Sem a fileira no markup o script
       sai aqui e o componente e' exatamente o de um par so'. */
    var conjunto = quadro.closest ? quadro.closest('[data-cmp-conjunto]') : null;
    var fileira = conjunto ? conjunto.querySelector('[data-cmp-pares]') : null;
    if (!fileira) return;

    var botoes = fileira.querySelectorAll('[data-cmp-par]');
    var imgA = quadro.querySelector('[data-cmp-img-a]');
    var imgB = quadro.querySelector('[data-cmp-img-b]');
    if (!botoes.length || !imgA || !imgB) return;

    var rotA = quadro.querySelector('.cmp-rotulo-a');
    var rotB = quadro.querySelector('.cmp-rotulo-b');
    var vivo = conjunto.querySelector('[data-cmp-vivo]');

    // Contador de pedido: quem clica em tres miniaturas em sequencia nao
    // pode ver o par do primeiro clique chegar por ultimo e vencer.
    var pedido = 0;

    function marcar(ativo) {
      Array.prototype.forEach.call(botoes, function (b) {
        b.setAttribute('aria-pressed', b === ativo ? 'true' : 'false');
      });
    }

    function pintar(botao, i) {
      var a = botao.getAttribute('data-cmp-src-a');
      var b = botao.getAttribute('data-cmp-src-b');
      var ra = botao.getAttribute('data-cmp-rot-a');
      var rb = botao.getAttribute('data-cmp-rot-b');
      var alt = botao.getAttribute('data-cmp-alt');
      var nome = botao.querySelector('.cmp-par-nome');

      if (a) imgA.src = a;
      if (b) imgB.src = b;
      // O alt vive na foto de BAIXO: a de cima e' aria-hidden e tem alt
      // vazio de proposito, porque as duas mostram a mesma cena.
      if (alt !== null) imgB.alt = alt;
      if (ra && rotA) rotA.textContent = ra;
      if (rb && rotB) rotB.textContent = rb;

      // O rotulo do range acompanha o par: um slider que continuasse
      // dizendo "antes e depois" depois de a peca virar "sem plano / com
      // plano" mentiria para quem so' ouve.
      if (ra && rb) {
        range.setAttribute('aria-label',
          'Revelar ' + ra.toLowerCase() + ' e ' + rb.toLowerCase() +
          ': 0 mostra ' + ra.toLowerCase() + ', 100 mostra ' + rb.toLowerCase());
      }

      // O DIVISOR VOLTA A' POSICAO INICIAL. A posicao em que a pessoa largou
      // o par anterior nao significa nada no par novo. Le o defaultValue (o
      // atributo value do HTML, 50 no padrao) em vez de cravar 50, e com
      // isso a variacao "comeca fechado" continua valendo depois da troca.
      range.value = range.defaultValue;
      aplicar();

      quadro.removeAttribute('aria-busy');

      if (vivo) {
        vivo.textContent = (nome ? nome.textContent : 'Par ' + (i + 1)) +
          ' — par ' + (i + 1) + ' de ' + botoes.length + '.';
      }
    }

    Array.prototype.forEach.call(botoes, function (botao, i) {
      function adiantar() {
        precarregar(botao.getAttribute('data-cmp-src-a'));
        precarregar(botao.getAttribute('data-cmp-src-b'));
      }

      // Intencao declarada, e nao carregamento de pagina: quem passa o
      // ponteiro ou chega de Tab quase sempre vai clicar.
      botao.addEventListener('pointerenter', adiantar);
      botao.addEventListener('focus', adiantar);

      botao.addEventListener('click', function () {
        if (botao.getAttribute('aria-pressed') === 'true') return;

        var meu = ++pedido;

        // O estado responde NA HORA -- o contorno anda para a miniatura
        // clicada antes de a foto chegar. Quem clicou tem resposta imediata
        // mesmo quando a rede esta' ruim; o que espera e' a imagem.
        marcar(botao);
        quadro.setAttribute('aria-busy', 'true');

        quandoPronta([botao.getAttribute('data-cmp-src-a'),
                      botao.getAttribute('data-cmp-src-b')], function () {
          if (meu !== pedido) return;
          pintar(botao, i);
        });
      });
    });
  });
})();

Duas fotos do mesmo enquadramento, uma sobre a outra, e um divisor vertical que a pessoa arrasta para revelar uma ou outra. A de cima é recortada por clip-path; a de baixo está sempre inteira ali atrás.

Uma fileira opcional de miniaturas embaixo troca o par comparado — de um a quatro pares na mesma dobra. Com um par só, a fileira não existe no markup e o componente é exatamente o que sempre foi.

O que ele resolve: mostrar transformação sem pedir que a pessoa compare duas imagens lado a lado na cabeça. Colocadas uma sobre a outra, a diferença aparece no mesmo pixel — e o gesto de revelar é o que faz a pessoa parar na dobra.


Compliance

Antes e depois é exatamente onde um site de assessoria escorrega para promessa de resultado. A peça é convincente justamente porque parece prova: duas imagens, o mesmo enquadramento, a diferença no mesmo pixel. Se o que está de um lado e do outro for dinheiro, a peça inteira vira insinuação de rentabilidade — e ela insinua melhor do que qualquer frase, porque não diz nada e mostra tudo.

Pode comparar:

Não pode comparar:

A regra é a mesma que conteudo/trio-cards-ui-simulada e conteudo/lista-beneficios-palco declaram: estoque e estrutura, nunca resultado. Aqui ela é mais dura, porque a forma da peça é comparativa por construção.

A ressalva é slot exigido, não letra miúda opcional. O .cmp-nota embaixo do quadro veste a primitiva .nota do core e diz três coisas: o que está sendo comparado, o que não está, e a natureza das imagens. Se as fotos forem geradas por IA, ilustrativas ou telas simuladas, isso se declara ali — uma tela de relatório inventada é a forma mais convincente de parecer um extrato de cliente.

O texto do markup é um ponto de partida real, não um lorem: adapte, não apague.


Quando usar

Quando não usar

O irmão mais próximo, e quando ele ganha: dados/comparador-cenarios. Ele compara números em cenários nomeados, com eixo e legenda; este compara imagens. Quando o argumento é quanto muda, é o irmão; quando é como fica, é este. E há um caso em que o irmão ganha mesmo sendo sobre imagem: quando os dois estados precisam ser vistos ao mesmo tempo para valer — o divisor sempre esconde metade.


Padrão editorial

Slot Onde Medida Regra
olho .eyebrow 2–4 palavras
título da seção h2.sec-title 3–6 palavras uma palavra em <em>
apoio .sec-lead 1–2 frases a legenda real — o que aconteceu entre uma foto e a outra. Com fileira, diga que há mais de um par e que a pessoa escolhe
rótulo de cima .cmp-rotulo-a 1–2 palavras o estado que se deve ver primeiro
rótulo de baixo .cmp-rotulo-b 1–2 palavras o par do de cima
ressalva .cmp-nota 1–2 frases exigido — ver Compliance
nome do par .cmp-par-nome 1–2 palavras por par, dentro da miniatura
rótulo de cima do par data-cmp-rot-a 1–2 palavras por par
rótulo de baixo do par data-cmp-rot-b 1–2 palavras por par
descrição da foto do par data-cmp-alt 4–16 palavras por par — o alt da foto de baixo

Os rótulos são duas palavras, e só. Antes e Depois é o padrão; 2019 e Hoje funciona; Espalhado e Consolidado funciona. Frase não cabe — a pílula fica sobre a foto e compete com ela.

A ordem importa. O que está por cima (recortado) é o estado que a pessoa deve ver primeiro, porque o divisor começa no meio e o olho vai para a esquerda. Se o argumento é o depois, considere a variação começa fechado: a dobra abre mostrando quase só o antes, e o gesto vira convite.

Com fileira, os rótulos visíveis são escritos pelo script. A copy de cada par vive em data-cmp-rot-a e data-cmp-rot-b na miniatura; quem escrever só dentro de .cmp-rotulo-a vê o texto sumir na primeira troca. Mesmo precedente de midia/roda-de-cards.

O nome da miniatura nomeia o CASO, não a ordem. É ele que o leitor de tela anuncia na troca: Relatório — par 2 de 4. Par 2 não diz nada a quem não vê a foto.


Especificação dos assets

Duas fotos por par, 1600×900 ou maior, e a exigência que define o componente: mesmo enquadramento. Mesma posição de câmera, mesma distância focal, mesmo recorte. Um grau de diferença já aparece como fantasma na linha do divisor.

Na prática isso quer dizer tripé, ou pelo menos uma marca no chão. Se as fotos já existem e não batem, o alinhamento se resolve no recorte — mas se resolve antes de chegar aqui, não com CSS.

A miniatura pode ser a própria foto "depois" do par: o CSS recorta em 16:9 e escurece as inativas. Um arquivo menor e dedicado é melhor para o peso da página, mas não é obrigatório.

No preview, o "antes" é a mesma foto com filtro (grayscale, contraste e brilho reduzidos), porque a biblioteca não tem um par de fotos do mesmo enquadramento em showroom/demo/ e usar duas fotos diferentes faria o componente parecer quebrado em vez de comparar. O filtro é regra do elemento, não do par: ele vale para todos, porque trocar de par troca o src e não a classe. No uso real, esse filtro sai e entram os dois arquivos de cada par.


Como usar

Cole a seção inteira. Troque os src, os rótulos, os nomes das miniaturas, os alt — e a ressalva.

Para um par só, apague o bloco .cmp-pares inteiro. O script procura a fileira, não encontra e sai calado; divisor, teclado e arraste continuam idênticos. A .nota fica: ela é exigida com um par ou com quatro.

Para dois ou três pares, apague as miniaturas que sobram. A fileira é flex com base de 140px e se reacomoda sozinha — não há media query por contagem.

O alt da foto de cima é vazio de propósito, e o .cmp-topo inteiro é aria-hidden: as duas imagens mostram a mesma cena, e descrever as duas faria o leitor de tela anunciar a mesma coisa duas vezes. A descrição vai na de baixo, e é ela que o data-cmp-alt de cada par substitui.

Ganchos

Gancho O quê
[data-cmp] a raiz de cada quadro. Pode repetir na página
[data-cmp-range] o <input type="range"> que comanda, dentro do quadro
[data-cmp-conjunto] o invólucro que amarra a fileira ao quadro. Sem ele, o segundo comparador da página obedeceria às miniaturas do primeiro
[data-cmp-img-a] / [data-cmp-img-b] as duas fotos grandes — a de cima (antes) e a de baixo (depois)
[data-cmp-pares] a fileira de miniaturas. Opcional
[data-cmp-par] cada miniatura, com data-cmp-src-a, -src-b, -rot-a, -rot-b, -alt
[data-cmp-vivo] a região viva (role="status") que anuncia o par novo

Parafusos

Parafuso O quê
--cmp-k 0 a 100, escrita pelo script no quadro. O CSS lê no clip-path e na posição da linha
--cmp-alca-size diâmetro da alça. 46px, 40px no mobile
--nota-size, --nota-max do core, na .nota. Só mexa se a coluna do cliente for muito estreita

Variações

Variação Como
Um par só apagar o bloco .cmp-pares. O componente volta a ser o de antes de 2026-09-21
Dois ou três pares apagar as miniaturas que sobram
Divisor horizontal clip-path: inset(0 0 calc(100% - var(--cmp-k)*1%) 0) e a linha em inset-block-start. Bom para foto aérea ou fachada
Sem rótulos apagar as duas .cmp-rotulo. Com fileira, apagar também os data-cmp-rot-* — o script só escreve no rótulo que existe
Começa fechado value="8" no range. A troca de par devolve o divisor a esse valor, não a 50
Alça maior --cmp-alca-size: 58px. Em dobra de tela cheia a alça de 46px some
Fileira ao lado (experimental) grade de duas colunas com o quadro numa e as miniaturas empilhadas na outra. Cabe quando a dobra é larga e o quadro é retrato

Gotchas


Movimento reduzido / toque

Com prefers-reduced-motion: reduce, o divisor continua andando: ele responde ao gesto, e gesto é comando, não enfeite. Saem a suavização de 90ms entre um passo de teclado e o seguinte e o acender/apagar das miniaturas — os dois únicos movimentos que a pessoa não pediu. Medido: transition-duration 0s no .cmp-topo e no .cmp-par, com o divisor ainda respondendo à seta (50 → 50,1).

No toque, o arraste vem do range nativo, e touch-action: pan-y garante que a rolagem vertical da página continue funcionando. A alça é desenhada sempre, e não só no hover, justamente porque em toque não há cursor ew-resize para avisar do gesto. No celular a fileira faz duas miniaturas por linha (170×97 medidos a 390px) — alvo de toque folgado, sem overflow horizontal.

Por teclado: Tab chega ao range, setas movem de 0,1 em 0,1, PageUp/PageDown movem em passos maiores, Home e End vão às pontas. Depois do range, Tab percorre as miniaturas, e Enter ou Espaço troca o par. Nada disso foi escrito à mão.


Histórico

Origem Quando O quê
autoral 2026-09-04 Construído do zero. Padrão de domínio comum, sem referência única
referência: skinboost-design-review.vercel.app 2026-09-21 A fileira de pares, a ressalva .nota exigida e a voz de assessoria. estavel

Origem. O comparador com divisor arrastável aparece em página de reforma, de retoque, de antes e depois de produto e em toda ferramenta de comparação de imagem — padrão de domínio comum, sem dono. Nada foi copiado: reescrito do zero dentro do contrato desta biblioteca. O registro consolidado vai em projetos/autorais.md; a evolução de 2026-09-21 em projetos/referencias-externas.md.

Decisões da construção. A principal está no primeiro gotcha: o range nativo no lugar do arraste escrito à mão. Além dela: clip-path em vez de width, para a foto de cima ficar parada; touch-action: pan-y; linha e alça em pointer-events:none; e o anel de foco na alça desenhada, por seletor irmão a partir do range invisível.

O que 2026-09-21 trouxe. A dobra #evolucao da referência põe uma fileira de rostos sobre o comparador: cada rosto é um caso, e escolher um troca o par sem sair da dobra. Atravessou o que o olho faz — a fileira, a troca do par inteiro de uma vez, o divisor recomeçando do meio. Não atravessou nada mais: o original move a troca com GSAP, monta as miniaturas com componente de terceiro e usa retratos de pessoas reais. Aqui a troca é uma atribuição de src e três textContent. Entraram por decisão nossa: os <button> com aria-pressed em vez de abas, a volta ao defaultValue, o pré-carregamento por intenção (medido: 0 quadros em branco em 211), a fileira que some sem JavaScript, o aria-label do range que acompanha o par — e a ressalva .nota como slot exigido, porque a peça mudou de assunto e antes/depois de dinheiro é promessa de resultado.

Calibragem no primeiro uso. Espere ajustar o value inicial do range — meio-a-meio é o padrão, mas quando o depois é o argumento, começar fechado costuma converter melhor. Espere ajustar o aspect-ratio: 16:9 serve a paisagem, e uma fachada quase sempre pede 4:3 ou 3:4. E espere escrever a ressalva antes das fotos: é ela que decide se o par que você escolheu pode ou não estar nesta peça.