Pular para o conteúdo
Biblioteca BTN-01

Botão origem

Botão inundado por um círculo que nasce no ponto exato do toque, do cursor ou no centro quando o foco vem do teclado.

usa o BTN-01
abrir sozinho ↗
Código
BTN-01 · alias botao-origem
Função
Botão
Fonte
21st.dev · origin-button.tsx
Recebido
14/09/2026
Status
adotado
Licença
não verificada

O código

botao-origem.css + botao-origem.js + markup.html 177 linhas · 3 arquivo(s) numa caixa só
/* ===== botao-origem.css ============================================================ */

/* BTN-01 · Botão origem — HTML/CSS/JS puro, sem React nem Motion.
   O círculo nasce no ponto do toque ou do cursor (no centro, quando o foco vem do teclado) e
   cobre o botão inteiro. Diâmetro = o dobro da distância até o canto mais longe, medido no JS.

   Tokens: redeclare no seu próprio escopo. Os valores abaixo são o padrão da biblioteca (tema preto).
   Diferença consciente em relação à variante que rodou no trento: ali o CTA tem degradê fixo e o
   texto não troca de cor; aqui o texto troca, como no original do 21st.dev. */
.c-botao-origem {
  --c-btn-fundo: #141414;
  --c-btn-tinta: #ebebeb;
  --c-btn-circulo: #ebebeb;
  --c-btn-tinta-sobre-circulo: #0a0a0a;
  --c-btn-linha: rgba(235, 235, 235, .14);
  --c-btn-raio: 12px;
  --c-btn-altura: 48px;
  --c-btn-ease: cubic-bezier(.16, 1, .3, 1);

  position: relative;
  isolation: isolate;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: var(--c-btn-altura);
  padding: 0 32px;
  border: .5px solid var(--c-btn-linha);
  border-radius: var(--c-btn-raio);
  overflow: hidden;
  background: var(--c-btn-fundo);
  color: var(--c-btn-tinta);
  font: 500 15px/1 inherit;
  letter-spacing: -.02em;
  text-decoration: none;
  cursor: pointer;
  touch-action: manipulation;          /* tira o atraso do toque duplo no celular */
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition: transform .15s ease, color .3s var(--c-btn-ease);
}
.c-botao-origem:active { transform: scale(.985); }
.c-botao-origem[disabled], .c-botao-origem[aria-disabled="true"] { opacity: .5; pointer-events: none; }
.c-botao-origem.is-cheio { color: var(--c-btn-tinta-sobre-circulo); }

/* o círculo: z-index -1 com isolation no pai, para não vazar para fora da pilha do botão */
.c-botao-origem__circulo {
  position: absolute;
  z-index: -1;
  left: var(--x, 50%);
  top: var(--y, 50%);
  width: var(--d, 0px);
  height: var(--d, 0px);
  border-radius: 50%;
  background: var(--c-btn-circulo);
  transform: translate(-50%, -50%) scale(0);
  transition: transform .5s var(--c-btn-ease);
  pointer-events: none;
}
.c-botao-origem.is-cheio .c-botao-origem__circulo { transform: translate(-50%, -50%) scale(1); }
.c-botao-origem__rotulo { position: relative; }

/* variante preenchida: o inverso — fundo claro, círculo escuro */
.c-botao-origem--solido {
  --c-btn-fundo: #ebebeb;
  --c-btn-tinta: #0a0a0a;
  --c-btn-circulo: #0a0a0a;
  --c-btn-tinta-sobre-circulo: #ebebeb;
  --c-btn-linha: transparent;
}
/* variante de destaque: usa o âmbar como fundo */
.c-botao-origem--destaque {
  --c-btn-fundo: #e8ad32;
  --c-btn-tinta: #171717;
  --c-btn-circulo: #171717;
  --c-btn-tinta-sobre-circulo: #e8ad32;
  --c-btn-linha: transparent;
}

@media (prefers-reduced-motion: reduce) {
  .c-botao-origem, .c-botao-origem__circulo { transition: none; }
}


/* ===== botao-origem.js ============================================================= */

/* BTN-01 · Botão origem — o círculo nasce no ponto do toque (ou do cursor) e cobre o botão inteiro.
   Reescrito do original do 21st.dev sem React nem Motion.
   Também impede que link sem destino (href="#") role a página para o topo. */
(function () {
  'use strict';

  var TOQUE_MINIMO = 320; // ms: um toque rápido ainda deixa ver o círculo crescer

  function diametro(w, h, x, y) {
    return Math.ceil(2 * Math.max(Math.hypot(x, y), Math.hypot(w - x, y),
                                  Math.hypot(x, h - y), Math.hypot(w - x, h - y)));
  }

  function origem(botao, x, y) {
    var r = botao.getBoundingClientRect();
    botao.style.setProperty('--x', x + 'px');
    botao.style.setProperty('--y', y + 'px');
    botao.style.setProperty('--d', diametro(r.width, r.height, x, y) + 'px');
  }

  function preparar(botao) {
    if (botao.__cBtnOrigem) { return; }
    botao.__cBtnOrigem = true;
    if (!botao.querySelector('.c-botao-origem__circulo')) {
      var c = document.createElement('span');
      c.className = 'c-botao-origem__circulo';
      c.setAttribute('aria-hidden', 'true');
      botao.insertBefore(c, botao.firstChild);
    }
    var desde = 0;
    var timer = null;

    function encher(x, y) {
      if (timer) { clearTimeout(timer); timer = null; }
      origem(botao, x, y);
      desde = Date.now();
      botao.classList.add('is-cheio');
    }
    function esvaziar(imediato) {
      var falta = imediato ? 0 : Math.max(0, TOQUE_MINIMO - (Date.now() - desde));
      if (timer) { clearTimeout(timer); }
      timer = setTimeout(function () { botao.classList.remove('is-cheio'); timer = null; }, falta);
    }
    function doPonteiro(e) {
      var r = botao.getBoundingClientRect();
      encher(e.clientX - r.left, e.clientY - r.top);
    }
    function doCentro() {
      var r = botao.getBoundingClientRect();
      encher(r.width / 2, r.height / 2);
    }

    botao.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { doPonteiro(e); } });
    botao.addEventListener('pointerdown', function (e) { if (e.button === 0) { doPonteiro(e); } });
    botao.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') { esvaziar(false); } });
    botao.addEventListener('pointerleave', function (e) { esvaziar(e.pointerType === 'mouse'); });
    botao.addEventListener('pointercancel', function () { esvaziar(true); });
    botao.addEventListener('focus', function () { if (botao.matches(':focus-visible')) { doCentro(); } });
    botao.addEventListener('blur', function () { esvaziar(true); });
    botao.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { doCentro(); } });
    botao.addEventListener('keyup', function (e) {
      if ((e.key === 'Enter' || e.key === ' ') && !botao.matches(':focus-visible')) { esvaziar(false); }
    });
  }

  function iniciar() {
    Array.prototype.forEach.call(document.querySelectorAll('.c-botao-origem'), preparar);
    document.addEventListener('click', function (e) {
      var a = e.target.closest ? e.target.closest('a[href="#"]') : null;
      if (a) { e.preventDefault(); }
    });
  }

  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); } else { iniciar(); }
})();


/* ===== markup.html ================================================================= */

<!-- BTN-01 · Botão origem. O <span class="c-botao-origem__circulo"> é criado pelo JS se faltar;
     está aqui para o botão nascer completo mesmo antes do script rodar.
     Use <button> para ação e <a> para navegação — o original é só <button>. -->
<button type="button" class="c-botao-origem">
  <span class="c-botao-origem__circulo" aria-hidden="true"></span>
  <span class="c-botao-origem__rotulo">Ver valores</span>
</button>

<a href="/espaco" class="c-botao-origem c-botao-origem--solido">
  <span class="c-botao-origem__circulo" aria-hidden="true"></span>
  <span class="c-botao-origem__rotulo">Explore o espaço</span>
</a>

Já rodou em

  • trento-pedro · home v3 — CTAs “Ver valores”, “Reservar”, “Agendar” (degradê âmbar). Em 15/09 a navegação saiu daqui e virou vidro.

Exige

  • react
  • motion (motion/react)
  • cn (clsx + tailwind-merge)
  • Tailwind com propriedade arbitrária

Como se configura

  • --c-btn-fundo
  • --c-btn-tinta
  • --c-btn-circulo
  • --c-btn-tinta-sobre-circulo
  • --c-btn-linha
  • --c-btn-raio
  • --c-btn-altura

A ficha

O que o 21st.dev entregou

Veio como: prompt de integração do 21st.dev com o origin-button.tsx e um demo.tsx dentro, mandado pelo Rafa em 14/09 como "o prompt do botão de CTA e os botões que levam para outra página, como o de galeria".

Decisão (Rafa, 14/09):

  • CTA ("Ver valores", "Reservar", "Agendar"): preenchido com o degradê amarelo → laranja.
  • Botões que levam a outra página ("Explore o espaço", "Ver ensaios"): em tons de cinza escuro.
  • Como o degradê se combina com o círculo de preenchimento é pergunta aberta no documento da v3.

Dependências:

  • react, cn, e motion (motion/react, o nome novo do Framer Motion).
  • Sintaxe de Tailwind com propriedade arbitrária ([--ic-background:#ffffff]) e ease-[cubic-bezier(...)].
  • "use client" é do Next.js. process.env.NODE_ENV só existe com bundler.

Tema embutido: a constante componentThemeClassName declara, no próprio botão, dezenas de variáveis --ic- (fundo, texto, borda, cartão, anel de foco, marca, gráficos) com variantes dark:, e as liga às --color- do Tailwind. O botão só usa uma parte: card, card-foreground, border, muted, foreground, background, ring. As de marca e de gráfico sobram. Há duas entradas de --color-accent repetidas no meio do bloco dark:, sem o prefixo — inofensivas.

Visual em repouso:

PropriedadeValor
Alturah-12 (48 px)
Cantosrounded-xl (12 px)
Espaço lateralpx-8 (32 px)
Texto15 px, peso médio, tracking -0.02em
Borda0,5 px na cor border (#e3e7ec)
Fundo e texto (claro)card branco com texto #111111
Fundo e texto (escuro)muted #171716 com texto #f6f3ec
Focoanel de 2 px com recuo
Desabilitado50% de opacidade, sem clique

Comportamento — o que dá nome ao componente:

  • O círculo: ao entrar com o cursor, tocar (pointerdown) ou focar pelo teclado, nasce um círculo (bg-foreground, escuro no tema claro) no ponto exato do toque ou do cursor — no centro, se for teclado. Ele cresce de escala 0 a 1 até cobrir o botão inteiro. O diâmetro é o dobro da distância até o canto mais longe (getCoverDiameter).
  • Tempo: 0,5 s, easing cubic-bezier(0.16, 1, 0.3, 1) (sai rápido e desacelera).
  • Texto: troca para a cor de fundo em 0,3 s, para ficar legível sobre o círculo.
  • Saída: ao sair ou soltar, o círculo encolhe de volta para o mesmo ponto.
  • Aperto: whileTap reduz o botão a 98,5%.
  • Remedição: um ResizeObserver e o document.fonts.ready refazem a conta enquanto o círculo está visível, para ele não ficar curto se o botão mudar de tamanho ou a fonte carregar depois.
  • Celular: touch-manipulation tira o atraso do toque duplo e select-none impede seleção de texto. O preenchimento aparece enquanto o dedo está no botão e recolhe ao soltar.
  • Acessibilidade: Enter e Espaço disparam o preenchimento pelo centro. Em desenvolvimento, avisa no console se o botão não tiver nome acessível. aria-busy quando loading.

Para a Trento:

  • O componente é <button>. Botão que leva a outra página ("Explore o espaço", "Ver ensaios") vira <a> com o mesmo visual e comportamento — é o elemento certo para navegação e é o que o Odoo espera num link.
  • O tema --ic-* inteiro sai; as cores vêm dos tokens da Trento e das decisões de 14/09 (CTA em degradê amarelo → laranja, navegação em cinza escuro).
  • Nesta fase os botões não levam a lugar nenhum; o endereço entra na importação para o Odoo.

O que a adaptação da OCTA mudou

  • Botão que leva a outra página virou <a> com o mesmo visual — é o elemento certo para navegação.
  • O tema --ic-* inteiro (dezenas de variáveis, a maioria sem uso) saiu e virou sete tokens.

Lacunas do original

Nenhuma apurada.

É o mais cuidadoso do acervo em acessibilidade: Enter e Espaço disparam o preenchimento pelo centro, aria-busy quando loading, e em desenvolvimento avisa no console se o botão não tiver nome acessível.