← Biblioteca componentes/efeitos/cursor-disco/ficha.md CódigoTela cheia
efeitos/cursor-disco

Cursor disco

🟢 estavel complexidade média prefixo ptr- cursor ponteiro anel hover decoracao fixo pointer-fine acessibilidade

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

<link rel="stylesheet" href="componentes/efeitos/cursor-disco/estilo.css">
<script src="componentes/efeitos/cursor-disco/script.js"></script>

markup.html · 55 linhas · 2,8 KB

<!-- ==========================================================================
     CURSOR DISCO — anel que segue o ponteiro
     Prefixo: ptr-
     Requer: core (tokens, base, motion.css, motion.js) + este estilo.css e
             este script.js

     NÃO É UMA DOBRA. É um elemento de PÁGINA, como o
     navegacao/progresso-leitura: um anel que vive uma vez, como primeiro
     filho do <body>, e acompanha o ponteiro com um atraso curto. Sobre
     link, botão ou qualquer elemento marcado com [data-ptr-grow], ele
     cresce e muda de tom. A <section> abaixo é só o PALCO.

     O CURSOR DO SISTEMA CONTINUA VISÍVEL. Quase toda implementação deste
     padrão esconde o cursor nativo com `cursor:none` e desenha um
     substituto. Aqui não: o cursor do sistema é a única coisa na tela que
     a pessoa configurou — tamanho, cor, contraste — e há quem dependa
     dessa configuração para enxergar onde está. O anel é um ACRÉSCIMO que
     rasteja atrás, não um substituto. Ver a ficha para a variação que
     esconde, e o preço dela.

     FORA DE QUALQUER [data-rv]. Mesmo motivo do progresso-leitura: o core
     termina o reveal em `filter:blur(0)`, blur(0) ainda é filtro, e
     elemento com filtro vira bloco de contenção para descendente
     `position:fixed`. Dentro de uma dobra revelada, o anel passaria a
     seguir o ponteiro em relação à DOBRA, e o erro só apareceria depois
     que ela entrasse na tela.

     SÓ ONDE HÁ PONTEIRO FINO. O CSS não desenha nada fora de
     (hover:hover) and (pointer:fine), e o script não liga um único
     listener nessas condições nem com movimento reduzido. Em toque, o
     componente não existe — não há cursor a acompanhar.
     ========================================================================== -->

<div class="ptr-disc" data-ptr aria-hidden="true"></div>

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

    <div class="sec-head">
      <span class="eyebrow" data-rv>Ponteiro</span>
      <h2 class="sec-title ttl">Um anel que <em>segue a mão</em></h2>
      <p class="sec-lead" data-rv data-d="1">Mova o ponteiro pela dobra. Sobre os links e o botão, o anel cresce.</p>
    </div>

    <div class="ptr-linhas" data-rv data-d="2">
      <p class="ptr-p">Um anel discreto atrás do ponteiro faz a página parecer viva sem pedir nada de quem lê. Ele cresce sobre <a class="ptr-link" href="#0">um link no meio do texto</a>, sobre <a class="ptr-link" href="#0">outro logo adiante</a>, e sobre qualquer bloco marcado como alvo.</p>
      <p class="ptr-p">O cursor do sistema continua onde sempre esteve — o anel só o acompanha.</p>
      <p class="ptr-acoes">
        <a class="btn btn-primary" href="#0">Falar com um assessor</a>
        <span class="ptr-alvo" data-ptr-grow>bloco marcado como alvo</span>
      </p>
    </div>

  </div>
</section>

estilo.css · 110 linhas · 3,5 KB

/* ==========================================================================
   CURSOR DISCO — ptr-
   Anel que acompanha o ponteiro com atraso curto e cresce sobre alvo.
   O script escreve --ptr-x, --ptr-y e a classe .is-grande.
   ========================================================================== */

.ptr-disc{
  /* Nao desenha nada por padrao. So' existe onde ha ponteiro fino de
     verdade -- ver a media query no fim. Nascer invisivel e' o que garante
     que toque e leitor de tela nunca recebam este no'. */
  display:none;
}

/* ---- Palco de demonstracao (nao e' o componente) ---- */
.ptr-linhas{
  max-width:62ch;
  margin-inline:auto;
  display:grid;
  gap:18px;
}

.ptr-p{
  color:var(--fg-2);
  font-size:1.02rem;
  line-height:1.66;
}

.ptr-link{
  color:var(--fg);
  text-decoration:underline;
  text-underline-offset:.22em;
  text-decoration-color:var(--rule-2);
  transition:text-decoration-color .3s var(--ease);
}

.ptr-link:hover,
.ptr-link:focus-visible{text-decoration-color:var(--accent)}

.ptr-acoes{
  display:flex;
  flex-wrap:wrap;
  align-items:center;
  gap:16px;
  margin-block-start:8px;
}

.ptr-alvo{
  padding:10px 18px;
  border:1px dashed var(--rule-2);
  border-radius:var(--radius-pill);
  color:var(--fg-muted);
  font-size:.86rem;
}

/* ==========================================================================
   O componente so' existe com ponteiro fino. Nao e' media query de largura
   -- e' de CAPACIDADE, e por isso nao vale a regra do "mobile no fim":
   ela nao adapta o layout, ela decide se o componente existe.
   ========================================================================== */
@media (hover: hover) and (pointer: fine){
  .ptr-disc{
    display:block;
    position:fixed;
    inset-block-start:0;
    inset-inline-start:0;
    inline-size:var(--ptr-size, 26px);
    block-size:var(--ptr-size, 26px);
    border-radius:var(--radius-pill);
    border:1px solid var(--accent);
    /* O anel e' vazado: o cursor do sistema aparece por dentro dele, o que
       e' o ponto -- ele acompanha, nao substitui. */
    background:transparent;
    /* Acima do conteudo, abaixo de overlay e menu: um anel decorativo nao
       pode cobrir um dialogo. */
    z-index:var(--ptr-z, 90);
    /* Sem isto o anel come cada clique da pagina, porque esta' sempre
       exatamente debaixo do ponteiro. */
    pointer-events:none;
    /* O -50% centra o anel no ponto; o translate3d joga a composicao para
       a GPU, e sem ele o anel arrasta em pagina longa. */
    transform:translate3d(calc(var(--ptr-x, -100px) - 50%), calc(var(--ptr-y, -100px) - 50%), 0)
              scale(var(--ptr-k, 1));
    transition:opacity .25s var(--ease),
               border-color .25s var(--ease),
               background-color .25s var(--ease);
    opacity:var(--ptr-o, 0);
    will-change:transform;
  }

  /* Sobre alvo: cresce e ganha corpo. A escala vem por --ptr-k, e nao por
     inline-size, porque transform nao remonta layout -- o mesmo motivo do
     scaleX no navegacao/progresso-leitura. */
  .ptr-disc.is-grande{
    --ptr-k:2.15;
    background:color-mix(in srgb, var(--accent) 16%, transparent);
    border-color:color-mix(in srgb, var(--accent) 55%, transparent);
  }
}

@media (prefers-reduced-motion: reduce){
  /* Anel que persegue o ponteiro e' movimento puro, sem funcao informativa:
     com movimento reduzido ele simplesmente nao existe. O script tambem nao
     liga listener nenhum nessa condicao. */
  .ptr-disc{display:none}
}

@media (max-width: 760px){
  .ptr-acoes{gap:12px}
  .ptr-p{font-size:.98rem}
}

script.js · 88 linhas · 3,3 KB

/* ==========================================================================
   CURSOR DISCO — ptr-
   Persegue o ponteiro por interpolacao, num requestAnimationFrame proprio
   que dorme quando nao ha nada a fazer.
   ========================================================================== */
(function () {
  var disc = document.querySelector('[data-ptr]');
  if (!disc) return;                        // guard: sem o anel, saida silenciosa

  // Duas travas antes de qualquer listener. A primeira e' a mesma condicao
  // que o CSS usa para decidir se desenha: sem ponteiro fino nao ha o que
  // perseguir, e ligar rAF em celular so' gastaria bateria por nada. A
  // segunda e' movimento reduzido -- anel que persegue e' movimento puro,
  // sem funcao informativa, entao nao ha versao degradada a oferecer.
  var pontoFino = matchMedia('(hover: hover) and (pointer: fine)').matches;
  if (!pontoFino || LIB.reduce) return;

  var alvoX = 0, alvoY = 0;                 // onde o ponteiro esta'
  var x = 0, y = 0;                         // onde o anel esta'
  var visivel = false, rodando = false, comecou = false;

  // Quanto do caminho o anel percorre por quadro. 0.18 da' o atraso curto
  // que se le como "acompanha"; acima de 0.3 ele gruda no ponteiro e o
  // efeito some, abaixo de 0.1 ele parece preso a um elastico.
  var PASSO = 0.18;

  function laco() {
    x += (alvoX - x) * PASSO;
    y += (alvoY - y) * PASSO;

    disc.style.setProperty('--ptr-x', x.toFixed(1) + 'px');
    disc.style.setProperty('--ptr-y', y.toFixed(1) + 'px');

    // Meio pixel de distancia ja' e' menos que um pixel de tela. Parar o
    // laco aqui e' o que faz o componente nao segurar um rAF vivo pela
    // pagina inteira enquanto o ponteiro esta' parado.
    if (Math.abs(alvoX - x) < 0.5 && Math.abs(alvoY - y) < 0.5) {
      x = alvoX; y = alvoY;
      disc.style.setProperty('--ptr-x', x.toFixed(1) + 'px');
      disc.style.setProperty('--ptr-y', y.toFixed(1) + 'px');
      rodando = false;
      return;
    }
    requestAnimationFrame(laco);
  }

  function acordar() {
    if (rodando) return;
    rodando = true;
    requestAnimationFrame(laco);
  }

  addEventListener('pointermove', function (e) {
    // Ponteiro grosso no meio do caminho (um toque numa tela hibrida) nao
    // move o anel: quem toca nao tem cursor para acompanhar.
    if (e.pointerType === 'touch') return;

    alvoX = e.clientX;
    alvoY = e.clientY;

    // Na primeira leitura o anel nasce EXATAMENTE onde o ponteiro esta',
    // em vez de vir voando do canto superior esquerdo -- que e' onde o
    // valor inicial o coloca.
    if (!comecou) { comecou = true; x = alvoX; y = alvoY; }

    if (!visivel) {
      visivel = true;
      disc.style.setProperty('--ptr-o', '1');
    }

    // closest sobe a arvore: pega o link mesmo quando o ponteiro esta' sobre
    // o <span> dentro dele. Sem isto o anel piscaria ao passar por cima de
    // um icone dentro do botao.
    var alvo = e.target && e.target.closest
      ? e.target.closest('a, button, [data-ptr-grow]')
      : null;
    disc.classList.toggle('is-grande', !!alvo);

    acordar();
  }, { passive: true });

  // Ponteiro saindo da janela: o anel some, em vez de ficar congelado na
  // borda como um resto de outra sessao.
  document.addEventListener('pointerleave', function () {
    visivel = false;
    disc.style.setProperty('--ptr-o', '0');
  });
})();

Um anel vazado que acompanha o ponteiro com atraso curto e cresce ao passar sobre link, botão ou bloco marcado. Não é uma dobra: é um elemento de página, que vive uma vez, como primeiro filho do <body>.

O que ele resolve: dar sensação de resposta a uma página que, fora os links, não reage a nada. É o efeito mais barato que existe para fazer um site parecer construído em vez de montado — e o mais fácil de fazer errado.


Quando usar

Quando não usar


Padrão editorial

Não há texto a escrever. O que há é uma decisão de onde o anel cresce, e ela é editorial: crescer é a forma de o componente dizer "isto aqui é clicável".

Por padrão ele cresce sobre todo <a> e todo <button>. Numa página onde isso é demais, marque os alvos à mão com data-ptr-grow e troque o seletor — o anel passa a apontar só o que importa, e o gesto ganha significado em vez de virar reflexo.


Especificação dos assets

Nenhum. Não há imagem, ícone nem SVG.


Como usar

Cole o anel logo depois da abertura do <body>, antes de qualquer seção:

<body>
  <div class="ptr-disc" data-ptr aria-hidden="true"></div>
  <!-- o resto da página -->

Para marcar um alvo que não é link nem botão:

<span data-ptr-grow>bloco que o anel reconhece</span>

Ganchos

Gancho O quê
[data-ptr] a raiz que o script procura. Um por página
[data-ptr-grow] marca um alvo que não é <a> nem <button>
--ptr-x / --ptr-y posição, escritas pelo script
--ptr-o opacidade, 0 até o primeiro movimento
--ptr-k escala. 1 em repouso, 2.15 sobre alvo
--ptr-size diâmetro em repouso. 26px
--ptr-z camada. 90 — acima do conteúdo, abaixo de menu e overlay
.is-grande estado sobre alvo, aplicado pelo script

Variações

Variação Como
Disco cheio background: var(--accent) e mix-blend-mode: difference. Mais chamativo, e some sobre fundo de tom médio
Esconde o cursor do sistema cursor: none no <body>, dentro da mesma media query. É o que a maioria faz — leia o gotcha antes
Alvo por marcação trocar o seletor do script por apenas [data-ptr-grow]
Mais lento PASSO para 0.10. Abaixo disso parece travamento

Gotchas


Movimento reduzido / toque

Com prefers-reduced-motion: reduce, o componente não existe: o CSS o mantém em display:none e o script sai antes de ligar qualquer listener. Não há versão degradada porque não há nada a degradar — anel que persegue o ponteiro é movimento puro, sem função informativa. Nada se perde.

No toque, idem: sem (pointer:fine) o anel nunca é desenhado, e o rAF nunca começa. É por isso que ele nasce em display:none e só aparece dentro da media query, em vez do contrário — assim celular não paga nem o custo de existir.


Histórico

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

Origem. O disco ou anel que persegue o ponteiro é quase assinatura de agência — aparece em portfólio de estúdio e em página premiada há mais de uma década, sem dono. Nada foi copiado: reescrito do zero dentro do contrato desta biblioteca. O registro consolidado vai em projetos/autorais.md.

Decisões da tradução para o contrato. O cursor do sistema não é escondido, pelo motivo do primeiro gotcha — é a diferença mais importante entre este componente e o padrão de mercado. O crescimento vem por escala em transform, não por inline-size, porque transform não remonta layout, mesma razão do scaleX no navegacao/progresso-leitura. O laço dorme quando o ponteiro para. O script não liga um único listener sem ponteiro fino nem com movimento reduzido, e o CSS nasce em display:none pelo mesmo motivo. E o elemento fica fora de qualquer [data-rv], pela regra de contenção do §5.

Calibragem no primeiro uso. Espere ajustar duas coisas: o PASSO do script, que decide o quanto o anel se atrasa, e o seletor de alvo — em página com muito link no corpo do texto, a variação alvo por marcação costuma ser a certa.