← Biblioteca componentes/efeitos/inclinacao-3d/ficha.md CódigoTela cheia
efeitos/inclinacao-3d

Inclinação 3D

🟢 estavel complexidade média prefixo tilt- tilt 3d inclinacao cursor hover glare brilho perspectiva interativo primitiva camada cards pointer-events glass

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

<link rel="stylesheet" href="componentes/efeitos/inclinacao-3d/estilo.css">
<script src="componentes/efeitos/inclinacao-3d/script.js"></script>

markup.html · 94 linhas · 4,9 KB

<!-- ==========================================================================
     INCLINAÇÃO 3D — card que segue o ponteiro
     Prefixo: tilt-
     Requer: core (tokens, base, motion.css, motion.js) + este estilo.css e
             este script.js

     O QUE SE CATALOGA AQUI NÃO É A SEÇÃO. É o atributo [data-tilt]: qualquer
     elemento que o receba passa a inclinar em 3D na direção do ponteiro, com
     um brilho (glare) que desliza pela superfície e uma mola suave de volta
     ao plano quando o ponteiro sai. A <section> abaixo — cabeçalho e grade
     de três cards — é só o PALCO de demonstração. O mesmo [data-tilt] cola
     sem adaptação num card de solução, de equipe ou de plano em qualquer
     outra dobra da biblioteca: basta o atributo no elemento e os dois
     arquivos deste componente na página.

     A GRADE É PALCO, NÃO PRODUTO. .tilt-grid, .tilt-card-num, .tilt-card-title
     e .tilt-card-text existem só para os três cards terem conteúdo — troque
     à vontade, mude a contagem, mude o texto. O que não muda de projeto para
     projeto é o [data-tilt] no card e o par estilo.css/script.js.

     [data-tilt-demo] NA SEÇÃO é só um marcador de documentação — indica
     "aqui começa o palco" para quem olha o HTML. O script NÃO lê este
     atributo: ele varre o document inteiro atrás de [data-tilt] (ver
     script.js e o gotcha correspondente na ficha), porque a primitiva
     precisa funcionar longe desta seção, sem depender de nenhuma raiz local.

     SEM HOVER, SEM MOVIMENTO. Em toque e com "reduzir movimento" ligado, o
     script não liga um único listener (ver guard em script.js) e a própria
     regra de CSS que dá o transform fica fora da media query — o card fica
     exatamente como o .glass sozinho já deixa: uma superfície de vidro
     legível, sem inclinação. Não há fallback visual porque não precisa —
     o card já é bonito parado.

     data-rv NUNCA NO MESMO ELEMENTO QUE data-tilt — isto é medido, não
     estilo. [data-rv].in (a entrada já concluída) tem especificidade
     (0,2,0) contra a (0,1,0) de [data-tilt]: se os dois atributos
     morassem no mesmo nó, o "transform:none" da entrada venceria para
     sempre e a inclinação nunca mais teria efeito nenhum depois da
     revelação — nem is-tilting, nem --tilt-x, nada, porque a REGRA que os
     lê perde a guerra de cascata antes de o script entrar em cena. Por
     isso cada card mora dentro de um .tilt-card-slot que leva o data-rv
     (a entrada da dobra) e deixa o [data-tilt] livre por dentro (o gesto
     do dia a dia). Mesma separação que midia/carrossel-perfis e
     midia/galeria-scroll-horizontal já fazem entre o item da lista
     (data-rv) e o card interativo (hover próprio) — aqui só ficou crítico
     porque os dois lados brigam pela MESMA propriedade, transform.
     ========================================================================== -->

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

    <div class="sec-head">
      <span class="eyebrow" data-rv>Como funciona</span>
      <h2 class="sec-title ttl">O método em três <em>etapas</em></h2>
      <p class="sec-lead" data-rv data-d="1">Da primeira conversa ao relatório do mês seguinte. Passe o ponteiro sobre cada card.</p>
    </div>

    <!-- A grade é só palco (ver comentário de cabeçalho). O que se cataloga
         é o [data-tilt] em cada card; .glass é a primitiva do core que dá
         corpo de vidro à superfície que ele inclina. .tilt-card-slot é só
         o wrapper que separa data-rv (entrada) de data-tilt (gesto) — ver
         o gotcha no comentário de cabeçalho. -->
    <div class="tilt-grid">

      <div class="tilt-card-slot" data-rv>
        <div class="glass tilt-card" data-tilt>
          <span class="tilt-card-num" aria-hidden="true">01</span>
          <h3 class="tilt-card-title">Diagnóstico</h3>
          <p class="tilt-card-text">Mapeamos patrimônio, estrutura familiar e objetivos, em uma reunião e um relatório.</p>
          <span class="pill">Etapa 1</span>
        </div>
      </div>

      <div class="tilt-card-slot" data-rv data-d="1">
        <div class="glass tilt-card" data-tilt>
          <span class="tilt-card-num" aria-hidden="true">02</span>
          <h3 class="tilt-card-title">Política</h3>
          <p class="tilt-card-text">Um documento que diz onde cada real fica e por quê. Assinado pelos dois lados.</p>
          <span class="pill">Etapa 2</span>
        </div>
      </div>

      <div class="tilt-card-slot" data-rv data-d="2">
        <div class="glass tilt-card" data-tilt>
          <span class="tilt-card-num" aria-hidden="true">03</span>
          <h3 class="tilt-card-title">Acompanhamento</h3>
          <p class="tilt-card-text">Comitê trimestral, relatório no dia 5 e uma pessoa que liga para explicar.</p>
          <span class="pill">Etapa 3</span>
        </div>
      </div>

    </div>
  </div>
</section>

estilo.css · 159 linhas · 6,7 KB

/* ==========================================================================
   INCLINAÇÃO 3D — prefixo tilt-
   Efeito, não seção: um par estilo.css + script.js que se liga em qualquer
   elemento marcado com [data-tilt]. A dobra que acompanha este arquivo
   (.tilt-grid, .tilt-card...) é só o palco de demonstração — ver o
   comentário de cabeçalho do markup.html. O que se cataloga, o que cola
   sem adaptação em outra grade da biblioteca, é só o atributo.

   Tokens usados:
     --ease                   curva única do projeto — volta ao plano e glare
     --light                  cor do brilho (glare), sempre claro sobre o vidro
     --radius-media           raio do card de demonstração; o glare herda dele
     --display --sans         tipografia dos cards do palco
     --fg --fg-2 --fg-muted   texto do palco, herdado do escopo .sec-dark

   POR QUE SELETOR DE ATRIBUTO, E NÃO CLASSE, NA RAIZ. [data-tilt] precisa
   colar em qualquer card já pronto — de solução, de equipe, de plano — sem
   entrar em guerra de especificidade com o prefixo de outro componente e
   sem herdar um nome que não é dele. Tudo que É classe aqui embaixo carrega
   tilt-, como o §6.1 do contrato pede; o atributo é a única exceção
   declarada na ficha, e é também a raiz do efeito.
   ========================================================================== */

/* ==========================================================================
   O EFEITO
   Só existe em ponteiro fino com hover de verdade. Fora disso — toque,
   trackpad em modo touch, tablet — nem esta regra nem o script.js pagam
   custo algum: sem a media query batendo, [data-tilt] não ganha nenhum
   transform (getComputedStyle devolve "none", medido), e o script (que
   confere a MESMA expressão) nem chega a varrer o document. As duas fontes
   usam a expressão idêntica de propósito, para nunca divergir uma da outra.
   ========================================================================== */
@media(hover:hover) and (pointer:fine){

  [data-tilt]{
    --tilt-max:7;      /* graus no pico do gesto. Lido por card via getComputedStyle */
    --tilt-lift:1.02;  /* escala no pico, só enquanto .is-tilting. 1 desliga o "levantar" */

    /* Ancora o glare (::after), que é absolute. SEM overflow:hidden aqui:
       cortaria o anel de :focus-visible de um card que também seja link ou
       botão em algum uso futuro (o core desenha o anel 3px para fora da
       caixa). border-radius:inherit no glare, logo abaixo, resolve o canto
       arredondado sem precisar cortar nada. */
    position:relative;

    /* scale mora numa variável à parte (--tilt-scale) e não faz parte fixa
       desta lista por escrito — é o que permite a .is-tilting, abaixo, MUDAR
       só um número em vez de reescrever perspective/rotateX/rotateY de novo.
       As duas regras compartilham a mesma lista de funções sempre na mesma
       ordem, e isso importa: transform com a MESMA lista de funções nos dois
       lados interpola função a função; listas diferentes forçam o navegador
       a cair para interpolação de matriz, que também funciona mas é menos
       previsível num ganho pequeno (2%) como este. */
    transform:perspective(var(--tilt-persp,900px))
              rotateX(var(--tilt-x,0deg))
              rotateY(var(--tilt-y,0deg))
              scale(var(--tilt-scale,1));
    transform-style:preserve-3d;
    transition:transform .55s var(--ease);
    will-change:transform;
  }

  /* Presente do pointerenter em diante — mas só a partir da SEGUNDA escrita
     do script. Ver script.js e o gotcha do "pulo" na ficha: a primeira
     escrita entra ainda com a transition acima ligada, para o card não
     saltar seco de 0° direto ao ângulo do canto onde o ponteiro entrou. */
  [data-tilt].is-tilting{
    transition:none;
    --tilt-scale:var(--tilt-lift);
  }

  /* O brilho. ::after do próprio [data-tilt] — nenhum elemento a mais no
     markup. border-radius:inherit e NÃO overflow:hidden no elemento: um
     background (mesmo em gradiente) já respeita o raio da própria caixa
     sozinho, sem precisar cortar nada por cima. */
  [data-tilt]::after{
    content:"";
    position:absolute;inset:0;
    pointer-events:none;
    border-radius:inherit;
    background:radial-gradient(circle at var(--tilt-gx,50%) var(--tilt-gy,50%),
                color-mix(in srgb,var(--light) 18%,transparent),transparent 60%);
    opacity:0;
    transition:opacity .4s var(--ease);
  }
  [data-tilt].is-tilting::after{opacity:1}

  /* Reduzir movimento desliga por CSS, sem depender de o script.js ter
     rodado (uso colado num Elementor sem o script, por exemplo). Como o
     resto da casa (pfl-strip-rail, depo-track, cur-note), esta regra NÃO
     reage a ?motion=on: nenhuma outra transição pura-CSS da biblioteca
     reage a esse parâmetro, e aqui o efeito também depende de um ponteiro
     de verdade se movendo — forçar por URL não teria o que mostrar sem
     alguém de fato mexendo o mouse do outro lado. */
  @media(prefers-reduced-motion:reduce){
    [data-tilt]{transition:none;transform:none}
    [data-tilt]::after{transition:none;opacity:0}
  }
}

/* ==========================================================================
   O PALCO — grade de demonstração
   Existe só para os três cards terem onde morar. Troque como quiser: nada
   daqui para baixo é parte do que se cataloga (ver markup.html).
   ========================================================================== */

.tilt-grid{
  display:grid;
  grid-template-columns:repeat(3,1fr);
  gap:clamp(20px,3vw,32px);
  margin-top:clamp(40px,6vh,64px);
}

/* O item de grade de verdade é o slot, não o card — ver o gotcha de
   especificidade no cabeçalho do markup.html. [data-rv] mora aqui; sem
   regra própria além de repassar a altura esticada pelo grid (align-items
   é stretch por padrão) para o card, via height:100%, porque um <div> não
   herda altura do pai sozinho. */
.tilt-card-slot{height:100%}

.tilt-card{
  position:relative;
  height:100%;
  display:flex;
  flex-direction:column;
  gap:14px;
  padding:clamp(28px,3.4vw,40px) clamp(24px,3vw,32px);
  border-radius:var(--radius-media);
}

.tilt-card-num{
  font-family:var(--display);
  font-weight:700;
  font-size:13px;
  letter-spacing:.16em;
  color:var(--fg-muted);
}

.tilt-card-title{
  font-family:var(--sans);
  font-weight:400;
  font-size:clamp(19px,1.8vw,22px);
  line-height:1.3;
  color:var(--fg);
}

.tilt-card-text{
  flex:1 0 auto;
  font-size:15px;
  line-height:1.55;
  color:var(--fg-2);
}

.tilt-card .pill{align-self:flex-start;margin-top:4px}

/* ---------------------------------------------------------------- mobile */
@media(max-width:860px){
  .tilt-grid{grid-template-columns:1fr}
}

script.js · 115 linhas · 4,9 KB

/* ==========================================================================
   INCLINAÇÃO 3D — o gesto: pointermove vira rotação + brilho

   BUSCA A PARTIR DO DOCUMENT, DE PROPÓSITO. Todo outro componente da
   biblioteca parte de [data-<pref>] local (§7.6 do contrato). Este é a
   exceção declarada: [data-tilt] é uma PRIMITIVA GLOBAL, do mesmo jeito que
   [data-rv] e [data-split] são globais em core/motion.js — o efeito precisa
   pegar qualquer card marcado em qualquer dobra da página, não só dentro de
   uma raiz que este arquivo controla. Não há raiz para partir: a própria
   ausência dela é o ponto.

   GUARD DUPLO. Só liga em ponteiro fino com hover de verdade, e só sem
   movimento reduzido. Fora disso o script nem varre o document — a mesma
   condição de mídia está espelhada no estilo.css, para as duas fontes nunca
   divergirem (um bater e o outro não já foi causa de bug em outra lib).

   POR QUE POINTERENTER/POINTERMOVE/POINTERLEAVE E NÃO MOUSE*. O guard já
   garante ponteiro fino (exclui toque), então mouse e pointer events fariam
   o mesmo aqui — a escolha é por consistência de família de evento, não por
   necessidade. E pointerleave, como mouseleave, NÃO borbulha por filhos:
   mover o ponteiro do padding do card para dentro do título ou da pílula
   não dispara um "saiu e entrou de novo" a cada cruzamento de borda interna
   — o que pointerout/mouseout, que borbulham, fariam.

   RAF POR CARD, SÓ ENQUANTO HÁ GESTO. Não é um laço contínuo: cada
   pointermove agenda no máximo UM quadro pendente (agendar/escrever),
   várias posições do mouse dentro do mesmo quadro colapsam numa escrita só.
   Sem esse coalescimento, um mouse de alta taxa de amostragem escreveria
   quatro ou cinco vezes por quadro à toa.
   ========================================================================== */

(function () {

  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;
  }

  // Mesma expressão do estilo.css. Fora dela o script não registra nada.
  var podeInclinar = matchMedia('(hover: hover) and (pointer: fine)').matches;
  if (!podeInclinar || reduzido()) return;

  var cartas = document.querySelectorAll('[data-tilt]');
  if (!cartas.length) return;

  function iniciar(card) {
    // Lido uma vez: trocar --tilt-max depois de carregada a página não
    // reajusta um card já em uso, só o próximo pointerenter. É a mesma
    // concessão que --pfl-speed faz em midia/carrossel-perfis.
    var max = parseFloat(getComputedStyle(card).getPropertyValue('--tilt-max')) || 7;

    var rel = null;        // {x,y} 0–1 dentro do card, ou null fora do gesto
    var pedido = 0;        // handle do rAF pendente, ou 0
    var primeira = false;  // true entre o pointerenter e a 1ª escrita

    function medir(e) {
      var r = card.getBoundingClientRect();
      var x = (e.clientX - r.left) / r.width;
      var y = (e.clientY - r.top) / r.height;
      if (x < 0) x = 0; else if (x > 1) x = 1;
      if (y < 0) y = 0; else if (y > 1) y = 1;
      rel = { x: x, y: y };
    }

    function escrever() {
      pedido = 0;
      if (!rel) return;   // pointerleave chegou antes deste quadro rodar

      var tiltX = (0.5 - rel.y) * 2 * max;
      var tiltY = (rel.x - 0.5) * 2 * max;

      card.style.setProperty('--tilt-x', tiltX.toFixed(2) + 'deg');
      card.style.setProperty('--tilt-y', tiltY.toFixed(2) + 'deg');
      card.style.setProperty('--tilt-gx', (rel.x * 100).toFixed(1) + '%');
      card.style.setProperty('--tilt-gy', (rel.y * 100).toFixed(1) + '%');

      /* A PRIMEIRA escrita depois de entrar fica SEM .is-tilting: a
         transition de repouso (.55s) ainda está ligada, e é ela quem leva
         o card de 0° até o ângulo do canto onde o ponteiro entrou. Só a
         escrita seguinte liga .is-tilting (transition:none) — a partir daí
         o card segue o ponteiro quadro a quadro, sem atraso. Sem este
         adiamento de uma escrita, entrar pelo canto salta ~6-7° num quadro
         só, seco; medido e documentado na ficha. */
      if (primeira) primeira = false;
      else card.classList.add('is-tilting');
    }

    function agendar() {
      if (pedido) return;
      pedido = requestAnimationFrame(escrever);
    }

    card.addEventListener('pointerenter', function (e) {
      primeira = true;
      card.classList.remove('is-tilting');
      medir(e);
      agendar();
    });

    card.addEventListener('pointermove', function (e) {
      medir(e);
      agendar();
    }, { passive: true });

    card.addEventListener('pointerleave', function () {
      rel = null;
      card.classList.remove('is-tilting');
      card.style.setProperty('--tilt-x', '0deg');
      card.style.setProperty('--tilt-y', '0deg');
    });
  }

  for (var i = 0; i < cartas.length; i++) iniciar(cartas[i]);
})();

Uma primitiva de efeito, não uma seção: qualquer elemento que receba o atributo [data-tilt] passa a se inclinar em 3D na direção do ponteiro — até um teto configurável de graus — com um brilho (glare) que desliza pela superfície acompanhando a mão, e volta ao plano com uma mola suave quando o ponteiro sai. É o que faz um card de vidro parecer um objeto que reage à presença de alguém, em vez de uma imagem parada.

Resolve um problema de acabamento, não de conteúdo: uma grade de cards já pronta (soluções, equipe, planos) muitas vezes está correta na informação e ainda assim parece "chapada" — sem peso, sem a sensação de superfície. Uma lista comum resolveria a informação; o que falta não é organização, é presença. A inclinação 3D é o "uau" barato que se aplica por cima de qualquer grade da biblioteca sem mexer no componente de baixo: cola o atributo, os dois arquivos, e pronto. Nenhum componente de card precisa ser reescrito para ganhar o efeito, e nenhum precisa perdê-lo para voltar a ser só .glass — basta tirar o atributo.


Quando usar

Quando não usar


Padrão editorial

Esta tabela descreve o palco desta ficha (a dobra "como funciona" que demonstra o efeito) — não o card de quem for só copiar [data-tilt] para uma grade já existente, que segue o padrão editorial do componente de origem daquele card.

Slot Regra
Numeral Dois dígitos, aria-hidden (decorativo — a ordem real vem da posição no DOM).
Título Uma palavra ou substantivo curto. Nomeia a etapa, não a explica.
Texto Uma frase, 70 a 130 caracteres. É o card inteiro que precisa continuar legível em repouso, sem o efeito.
Pílula Etapa N. Reaproveita .pill do core, sem variação de cor.

O que mantém a grade coesa é o paralelismo entre os três textos: mesmo comprimento aproximado, mesma estrutura de frase (sujeito oculto + o que acontece). Card com texto muito mais longo que os vizinhos cresce em altura e desalinha a fileira, já que os três dividem a mesma grade.

Especificação dos assets

Nenhum. [data-tilt] não usa imagem, ícone nem fonte própria — atua inteiramente sobre o que já existir dentro do elemento marcado, com CSS puro (radial-gradient + color-mix) para o brilho. Convive sem conflito com .glass-hover do core no mesmo elemento: um muda border-color e background no :hover, o outro muda transform e a opacidade do ::after — propriedades diferentes, sem disputa de cascata.


Como usar

<link rel="stylesheet" href="componentes/efeitos/inclinacao-3d/estilo.css">
<script src="componentes/efeitos/inclinacao-3d/script.js"></script>

O uso real não é copiar a dobra de demonstração — é marcar um card que já existe em outro componente:

<!-- um card de qualquer outra grade da biblioteca -->
<div class="glass minha-grade-card" data-tilt>
  ...
</div>

Nada mais muda no componente de baixo: nem a folha de estilo dele, nem o script dele, se tiver um. Confira sempre onde o data-rv mora antes de colar o atributo. Em conteudo/grid-entidades, por exemplo, o data-rv do card está no mesmo <article class="glass glass-hover ent-card" data-rv> que receberia o data-tilt — colar direto ali reproduziria o primeiro Gotcha desta ficha. O card certo para acompanhar precisa primeiro separar as duas coisas (o data-rv sobe para um wrapper, como este componente faz com .tilt-card-slot), e só depois recebe data-tilt livre por dentro.

Ganchos

Gancho Papel
[data-tilt] A raiz do efeito. Qualquer elemento com o atributo passa a inclinar; o script varre o document inteiro atrás dele (primitiva global — ver Gotchas).
[data-tilt-demo] Marca só a seção de demonstração desta ficha. O script não lê este atributo.
--tilt-max Graus no pico do gesto, lido por card via getComputedStyle. Padrão 7, sem unidade.
--tilt-lift Escala no pico da inclinação, só enquanto .is-tilting. Padrão 1.02; 1 desliga o "levantar".
--tilt-persp Distância da perspectiva, dentro do próprio transform (sem pai controlado — ver Gotchas). Padrão 900px.
--tilt-x / --tilt-y Ângulos que o script escreve a cada quadro em movimento. Estado interno; não se ajusta à mão.
--tilt-gx / --tilt-gy Posição do brilho, em % da caixa do card. Escrito junto com tilt-x/tilt-y.
--tilt-scale Ponte interna entre .is-tilting e --tilt-lift, para o transform ter uma única lista de funções (ver Gotchas). Não é API pública.
.is-tilting Presente do pointerenter em diante, mas só a partir da segunda escrita do quadro. Desliga a transição e acende o brilho.

Variações


Gotchas

O efeito continuaria "ligado" por dentro — JS escrevendo --tilt-x, classe .is-tilting alternando — e completamente invisível por fora, porque a regra que lê essas variáveis simplesmente perde a guerra antes do script entrar em cena. Medido: com os dois atributos no mesmo <div>, getComputedStyle(card).transform fica em "none" mesmo com --tilt-x e --tilt-y diferentes de zero — um card clonado à mão, fora da página, com as mesmas classes e os mesmos atributos, mostra a matriz correta, o que descarta hipótese de erro de seletor e aponta direto para a especificidade do .in herdado do card real. A correção é a mesma separação que midia/carrossel-perfis e midia/galeria-scroll-horizontal já fazem entre o item da lista e o card interativo: aqui, um .tilt-card-slot sem estilo próprio carrega o data-rv, e o [data-tilt] fica livre por dentro, um nível abaixo.

Medido entrando por um canto (max 7°, alvo 6.30°/6.30°): nos quadros 1 e 2 o transform ainda é o de repouso (0% do caminho — o motor de transição leva um quadro para "perceber" a mudança); no quadro 3, 4,1%; quadro 5, 30,4%; quadro 8, 40%; quadro 13, 53,6%; quadro 20, 67,6%; quadro 33 (~550ms, a duração declarada), 83,1%; quadro 45, 90,9% — a curva cubic-bezier(.16,.84,.24,1) tem uma cauda longa, então "83% na duração nominal" é esperado, não bug. A partir da segunda escrita, com .is-tilting já presente, o rastreio é instantâneo: medido movendo o ponteiro para o canto oposto, o transform do quadro seguinte já é idêntico ao dos quadros 2 e 5 depois dele — nenhuma interpolação, puro acompanhamento quadro a quadro.

A comparação que isola a causa: o canto do mesmo card em repouso, no mesmo recorte e no mesmo zoom, sai perfeitamente antialiased — então não é o border-radius em si, nem o zoom da captura. A diferença é só a inclinação. A explicação mais provável é a combinação de backdrop-filter (que força uma camada de composição própria, separada do resto da página) com um transform 3D aplicado a essa mesma camada: o recorte arredondado dela não é re-antialiased com a mesma qualidade quando a camada inteira gira. Não há correção de CSS para isto — é o preço de vidro fosco girando, e em tema com --radius-media grande vale saber que ele existe antes de aprovar a dobra. A variação "sem o levantar" (--tilt-lift:1) reduz um pouco o efeito (menos escala, menos recorte se movendo), mas não elimina.

Um selo ou botão position:fixed dentro de um [data-tilt] vira relativo ao card, não à viewport — e cola errado assim que a página rola, porque o elemento "fixo" passa a rolar junto com o card em vez de ficar parado na tela. Fora do guard de hover (mobile, reduzir movimento), a regra inteira nem existe, então esta pegadinha é só em desktop com hover fino — mas é justamente ali que alguém prepararia a página olhando para a tela e aprovaria o layout sem notar o problema, porque um selo fixed dentro de um contêiner sem scroll próprio se comporta como esperado até a página rolar de verdade.


Movimento reduzido / toque

Nada acontece — o card fica plano. Não existe fallback visual porque não precisa: o card já é .glass (ou equivalente) por baixo, e isso já é bonito parado. Medido: em --mobile (390×844, toque) e em --reduce (prefers-reduced-motion:reduce), os três cards do palco mostram getComputedStyle(card).transform === "none" e nunca ganham .is-tilting, mesmo disparando um pointerenter sintético do tipo touch sobre eles — porque o script, no guard de topo, nunca chega a anexar um único listener. Em --mobile especificamente, --tilt-max sai vazio de getComputedStyle: a regra inteira, incluindo o valor-padrão da variável, está atrás da mesma media query do script, então nem a declaração existe fora de ponteiro fino com hover.

Isso é a definição de "uau" que o contrato pede (§4): não é quantidade de efeito, é uma ideia clara que desiste completamente quando as condições não a sustentam, em vez de degradar mal. Um "tilt" que tentasse simular alguma coisa em toque — seguir o giroscópio do aparelho, por exemplo — seria outro componente, com outro orçamento de risco (giroscópio pede permissão em iOS, drena bateria, e o gesto de "inclinar o celular para ver o brilho" não é descoberto por ninguém sem instrução na tela).


Histórico

Projeto Onde Data Diferenças
autoral construído do zero na biblioteca 2026-09-03 —

Origem. Padrão de card que segue o ponteiro em 3D ("tilt card"), recorrente em sites premium e de produto: aparece em páginas vendidas como template no Awwwards, no marketplace de componentes da Framer, e a biblioteca open-source vanilla-tilt.js populariza a mesma matemática (rotação a partir da posição relativa do ponteiro dentro do elemento) desde 2017. Nenhuma dessas foi consultada linha a linha — o que se conhecia era o resultado visual do padrão, não o código de nenhuma implementação específica. Nada foi copiado: reescrito do zero dentro do contrato desta biblioteca.

Decisões de tradução para o contrato. Os ajustes de comportamento — teto de graus, escala no pico, distância de perspectiva — viraram variáveis do próprio atributo (--tilt-max, --tilt-lift, --tilt-persp) em vez de opções de configuração passadas para uma função de inicialização, como a maioria das implementações de mercado faz; isso segue a mesma regra dos demais componentes da casa (ajuste público é variável CSS na raiz, não parâmetro de JS). O brilho é CSS puro — radial-gradient lendo --light do escopo de tom, mais color-mix — em vez de canvas ou uma imagem de brilho, então funciona em qualquer tema sem asset.

A entrada ganhou uma suavização que o padrão comum não tem (a primeira escrita fica com a transição de repouso ligada, só a partir da segunda é que o rastreio vira instantâneo — ver Gotchas), e a ativação ganhou dupla trava de acessibilidade: CSS e JS checam a mesma condição de mídia (hover:hover e pointer:fine) para nunca acordar em toque, e o JS ainda confere movimento reduzido antes de anexar um único listener — a maior parte das implementações de mercado só checa 'ontouchstart' in window, sem checar prefers-reduced-motion nenhuma vez. Também não veio do padrão comum a separação entre data-rv e data-tilt em elementos diferentes: foi uma correção descoberta construindo este componente (ver primeiro Gotcha), não uma prevenção — o palco original desta própria ficha tinha os dois atributos juntos até a medição expor o conflito de especificidade.