← Biblioteca componentes/conversao/cta-formulario-lead/ficha.md CódigoTela cheia
conversao/cta-formulario-lead

CTA com formulário de captura

🟢 estavel complexidade alta prefixo lead- cta formulario lead conversao vidro sticky

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

<link rel="stylesheet" href="componentes/conversao/cta-formulario-lead/estilo.css">
<script src="componentes/conversao/cta-formulario-lead/script.js"></script>

markup.html · 88 linhas · 5,1 KB

<!-- ==========================================================================
     CTA + FORMULÁRIO — foto de fundo, lista de benefícios e card de captura
     Prefixo: lead-
     Requer: core (tokens, base, motion) + estilo.css e script.js
     Compõe: conversao/select-custom (carregue o CSS e o JS dele também)
     Usa as primitivas .glass e .field do core.

     O ENVIO VAI PARA O `action` DO <form>, e o "Recebemos" só aparece com
     resposta de sucesso. SEM `action` — como aqui, no exemplo — o formulário
     não finge: avisa que é demonstração e que nada foi enviado. Antes de
     publicar, ponha action="https://…" method="post" com o endereço do CRM
     ou do backend. Três mensagens abaixo do botão, uma por desfecho; o
     script mostra só a que aconteceu.
     ========================================================================== -->

<section class="lead-sec sec-dark sec-pad" id="contato">
  <div class="lead-media"><img src="../../../showroom/demo/ph-wide-2.webp" alt=""></div>
  <div class="grain"></div>

  <div class="wrap">
    <div class="lead-grid">

      <div class="lead-intro">
        <span class="eyebrow" data-rv>Comece por aqui</span>
        <h2 class="sec-title ttl">Agende um diagnóstico <em>gratuito</em> da sua carteira</h2>
        <p class="sec-lead" data-rv data-d="1">Uma frase de apoio explicando o que a pessoa recebe ao enviar o formulário, e por que não há custo nem compromisso.</p>
        <ul class="lead-list" data-rv data-d="2">
          <li>Acompanhamento personalizado e estratégico</li>
          <li>Monitoramento contínuo da performance</li>
          <li>Reuniões periódicas para rebalanceamentos</li>
          <li>Estratégias no Brasil e no exterior</li>
          <li>Modelo fee-based, sem comissões ocultas</li>
        </ul>
      </div>

      <form class="glass lead-form" data-lead-form data-rv data-d="1" novalidate>
        <div class="grain"></div>
        <h3 class="lead-title">Fale com um especialista</h3>
        <p class="lead-req">Requisito de entrada, quando houver, nesta linha.</p>

        <div class="lead-fields">
          <div class="field lead-field is-full">
            <label for="lead-nome">Nome completo</label>
            <input type="text" id="lead-nome" name="nome" placeholder="Seu nome" required autocomplete="name">
          </div>
          <div class="field lead-field">
            <label for="lead-email">E-mail</label>
            <input type="email" id="lead-email" name="email" placeholder="voce@email.com" required autocomplete="email">
          </div>
          <div class="field lead-field">
            <label for="lead-fone">WhatsApp</label>
            <input type="tel" id="lead-fone" name="whatsapp" placeholder="(00) 00000-0000" required autocomplete="tel">
          </div>

          <div class="field lead-field is-full" data-csel data-csel-required>
            <label id="lead-cap-label">Capital para investir</label>
            <div class="csel">
              <button type="button" class="csel-btn" aria-haspopup="listbox" aria-expanded="false" aria-labelledby="lead-cap-label">
                <span class="csel-val">Selecione uma faixa</span>
                <svg class="csel-arrow" width="12" height="8" viewBox="0 0 12 8" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M1 1l5 5 5-5" stroke-linecap="round" stroke-linejoin="round"/></svg>
              </button>
              <ul class="csel-list" role="listbox" aria-labelledby="lead-cap-label" hidden>
                <li class="csel-opt" role="option" aria-selected="false" tabindex="-1" data-value="Primeira faixa">Primeira faixa</li>
                <li class="csel-opt" role="option" aria-selected="false" tabindex="-1" data-value="Segunda faixa">Segunda faixa</li>
                <li class="csel-opt" role="option" aria-selected="false" tabindex="-1" data-value="Terceira faixa">Terceira faixa</li>
                <li class="csel-opt" role="option" aria-selected="false" tabindex="-1" data-value="Quarta faixa">Quarta faixa</li>
              </ul>
              <input type="hidden" name="capital">
            </div>
          </div>
        </div>

        <div class="lead-submit">
          <button type="submit" class="btn btn-primary">
            Solicitar diagnóstico
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 7H12M8 3L12 7L8 11"/></svg>
          </button>
          <p class="lead-note">Ao enviar você concorda em ser contatado por um especialista.</p>
        </div>

        <p class="lead-ok" data-lead-ok role="status" tabindex="-1" hidden>Recebemos seus dados. Um especialista entrará em contato em breve.</p>
        <p class="lead-erro" data-lead-erro role="alert" hidden>Não foi possível enviar agora. Tente de novo em alguns minutos — ou fale com a gente pelo WhatsApp.</p>
        <p class="lead-demo" data-lead-demo role="status" hidden>Demonstração: este formulário ainda não tem endereço de envio, e nada foi enviado.</p>
      </form>

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

estilo.css · 102 linhas · 4,5 KB

/* ==========================================================================
   CTA + FORMULÁRIO — foto de fundo, benefícios e card de captura
   Prefixo: lead-
   Tokens usados: --fg --fg-2 --fg-muted --rule --accent
                  --dark-rgb --display --nav-h
   Primitivas usadas: .glass e .field (core/base.css)
   ========================================================================== */

.lead-sec{position:relative;overflow:hidden}

/* A foto entra em 20% de opacidade e ainda leva um gradiente por cima. Ela
   é textura, não assunto: o que precisa ser lido aqui é o formulário. */
.lead-media{position:absolute;inset:0;z-index:0}
.lead-media img{width:100%;height:100%;object-fit:cover;opacity:.2;filter:grayscale(.5)}
.lead-media::after{
  content:"";position:absolute;inset:0;
  background:linear-gradient(to bottom,
    rgba(var(--dark-rgb),.94) 0%,
    rgba(var(--dark-rgb),.82) 45%,
    rgba(var(--dark-rgb),.96) 100%);
}
.lead-sec > .wrap{position:relative;z-index:2}

/* O formulário é maior que o texto (1fr contra .85fr): ele é o objetivo da
   seção, não o acompanhamento. */
.lead-grid{
  display:grid;grid-template-columns:minmax(0,.85fr) minmax(0,1fr);
  gap:clamp(36px,6vw,84px);align-items:start;
}

/* O texto acompanha a rolagem do formulário: em telas baixas o formulário é
   mais alto que a tela, e sem o sticky o argumento sai de vista enquanto a
   pessoa preenche. */
.lead-intro{position:sticky;top:calc(var(--nav-h) + 24px)}
.lead-list{margin-top:30px}
.lead-list li{
  font-size:15px;line-height:1.6;color:var(--fg-2);padding:12px 0;
  border-top:1px solid var(--rule);display:flex;gap:11px;align-items:flex-start;
}
.lead-list li::before{
  content:"";flex:0 0 auto;width:5px;height:5px;margin-top:8px;
  border-radius:50%;background:var(--accent);
}

/* ---- o card ---- */
/* `overflow:visible` de proposito. O clip existia para o .grain respeitar o
   canto arredondado do card, e isso o proprio grain resolve herdando o raio
   (regra abaixo). Com `hidden` o card cortava a lista do select custom, que
   abre para baixo a partir do ultimo campo: a opcao de baixo sumia pela
   metade, e quem estava preenchendo nem sabia que ela existia. */
.lead-form{position:relative;overflow:visible;border-radius:16px;padding:clamp(24px,3.6vw,40px);--glass-blur:26px}
.lead-form > .grain{border-radius:inherit}
.lead-form > *{position:relative;z-index:1}
/* O grupo de campos sobe um degrau. Sem isto a lista do select custom abre
   ATRAS do botao de envio: a regra acima empata todos os filhos do card em
   z-index 1, e entre iguais ganha quem vem depois no HTML — que e' o
   .lead-submit. O z-index:20 da .csel-list nao resolve porque ele so' vale
   dentro do contexto do proprio .lead-fields. */
.lead-form > .lead-fields{z-index:2}
.lead-title{font-family:var(--display);font-weight:700;font-size:20px;letter-spacing:-.025em;color:var(--fg)}
.lead-req{font-size:12.5px;color:var(--fg-muted);margin-top:8px}

/* gap 0 na vertical: o respiro entre linhas vem do padding do próprio campo,
   senão a régua de baixo de um campo fica longe demais do seguinte. */
.lead-fields{display:grid;grid-template-columns:1fr 1fr;gap:0 26px;margin-top:12px}

/* ---- campo ----
   A receita — rótulo em caixa alta, controle sem caixa, régua embaixo que
   acende no foco — é a primitiva .field do core. Sobra o que é desta grade: o
   campo que ocupa as duas colunas. O select customizado entra neste MESMO
   wrapper, e é por isso que ele não traz wrapper próprio. */
.lead-field.is-full{grid-column:1/-1}

.lead-submit{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin-top:28px}
.lead-note{font-size:11.5px;line-height:1.7;color:var(--fg-muted);max-width:30ch}

/* Três desfechos, três mensagens — e o script mostra SÓ a que aconteceu,
   pelo atributo [hidden]. Sucesso na cor de marca; erro e demonstração na
   tinta do escopo, com o fio mais forte. O que distingue as três é o TEXTO,
   não a cor. */
.lead-ok,
.lead-erro,
.lead-demo{
  margin-top:20px;padding:16px 18px;border-radius:10px;
  border:1px solid var(--rule-2);
  font-size:14px;line-height:1.55;color:var(--fg);
}
.lead-ok{
  border-color:var(--accent);
  background:color-mix(in srgb,var(--accent) 10%,transparent);
  color:var(--accent);
}
.lead-ok:focus{outline:none}
.lead-erro{border-color:var(--fg-muted)}
.lead-ok[hidden],.lead-erro[hidden],.lead-demo[hidden]{display:none}

@media(max-width:900px){
  .lead-grid{grid-template-columns:1fr;gap:40px}
  .lead-intro{position:static}
  .lead-fields{grid-template-columns:1fr}
  .lead-field.is-full{grid-column:auto}
}

script.js · 86 linhas · 3,6 KB

/* ==========================================================================
   CTA + FORMULÁRIO — validação, envio de verdade e os três desfechos

   O envio vai para o `action` do <form>, por fetch, e o "Recebemos" só
   aparece com resposta de sucesso. Qualquer outra coisa — 4xx, 5xx, rede
   fora — mostra a mensagem de erro e devolve o formulário à pessoa.

   SEM `action` o formulário não finge. Até 2026-09-29 ele mostrava o
   sucesso sem mandar nada, e o aviso morava só num comentário deste
   arquivo: publicado assim, perdia todos os leads em silêncio, com o
   visitante achando que deu certo. Agora a tela diz "demonstração, nada foi
   enviado" — quem esquecer o `action` descobre na primeira tentativa.
   ========================================================================== */

(function () {
  document.querySelectorAll('[data-lead-form]').forEach(function (form) {
    var ok = form.querySelector('[data-lead-ok]');
    var erro = form.querySelector('[data-lead-erro]');
    var demo = form.querySelector('[data-lead-demo]');
    var botao = form.querySelector('button[type="submit"]');
    var enviando = false;

    /* Mostra UMA mensagem e esconde as outras. Reescrever o texto depois de
       mostrar é o que garante o anúncio: a região viva fala quando o
       conteúdo muda, e só tirar o [hidden] nem sempre conta como mudança. */
    function mostrar(qual) {
      [ok, erro, demo].forEach(function (m) {
        if (!m) return;
        m.hidden = m !== qual;
        if (m === qual) { var t = m.textContent; m.textContent = ''; m.textContent = t; }
      });
    }

    form.addEventListener('submit', function (e) {
      e.preventDefault();
      if (enviando) return;

      /* 1. campos de texto obrigatórios — para no primeiro vazio */
      var vazio = null;
      form.querySelectorAll('input[required]').forEach(function (i) {
        if (!i.value.trim() && !vazio) vazio = i;
      });
      if (vazio) { vazio.focus(); return; }

      /* 2. selects customizados obrigatórios — eles não têm :invalid nativo,
            então a validação é manual e o retorno visual é o tremor */
      var faltando = null;
      form.querySelectorAll('[data-csel-required] input[type=hidden]').forEach(function (h) {
        if (!h.value && !faltando) faltando = h;
      });
      if (faltando) {
        var raiz = faltando.closest('.csel');
        raiz.classList.add('has-error');
        raiz.querySelector('.csel-btn').focus();
        setTimeout(function () { raiz.classList.remove('has-error'); }, 500);
        return;
      }

      /* 3. sem destino, sem envio — e sem fingir */
      var destino = form.getAttribute('action');
      if (!destino) { mostrar(demo); return; }

      /* 4. envio. aria-disabled e não `disabled` no botão: desabilitar o
            botão que tem o foco joga o foco para o <body>. */
      enviando = true;
      if (botao) botao.setAttribute('aria-disabled', 'true');
      fetch(destino, {
        method: (form.getAttribute('method') || 'post').toUpperCase(),
        body: new FormData(form),
        headers: { Accept: 'application/json' }
      }).then(function (r) {
        if (!r.ok) throw new Error(r.status);
        mostrar(ok);
        form.querySelectorAll('input, button').forEach(function (el) { el.disabled = true; });
        /* Os controles acabaram de ser desligados, e o foco estava num
           deles: ele vai para a própria mensagem, que é o que há para ler. */
        if (ok) ok.focus();
      }).catch(function () {
        mostrar(erro);
      }).then(function () {
        enviando = false;
        if (botao) botao.removeAttribute('aria-disabled');
      });
    });
  });
})();

Seção de conversão em duas colunas sobre foto esmaecida. À esquerda, o argumento: olho, título, apoio e lista de benefícios — e ele acompanha a rolagem, para não sair de vista enquanto a pessoa preenche. À direita, um card de vidro com o formulário: três campos de texto, um select customizado, botão e nota de consentimento.

O envio vai para o action do formulário

O script valida, envia por fetch para o action do <form> e só mostra "Recebemos seus dados" com resposta de sucesso. Qualquer outra coisa mostra a mensagem de erro. Sem action — como no exemplo — ele não finge: diz na tela que é demonstração e que nada foi enviado. Antes de publicar, ponha action e method="post" com o endereço do CRM ou do backend.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Olho "Comece por aqui", "Próximo passo". Sinaliza que a página acabou.
Título A oferta, com o benefício em <em>: "Agende um diagnóstico gratuito da sua carteira".
Apoio 1 frase, 25 a 40 palavras. O que a pessoa recebe e quanto custa (mesmo que seja nada).
.lead-list 4 a 6 itens, 4 a 8 palavras cada. O que está incluso — não o que a empresa é.
.lead-title 3 a 5 palavras. Repete a ação: "Fale com um especialista".
.lead-req O filtro de qualificação, se houver. Dito na cara: "Requisito: carteira superior a R$ 3 milhões".
.lead-note Consentimento, 10 a 16 palavras. Obrigatório se há tratamento de dados.

O requisito de qualificação em cima do formulário economiza o tempo dos dois lados. Parece que afasta lead; na prática afasta lead errado.

Especificação da imagem


Como usar

<link rel="stylesheet" href="componentes/conversao/select-custom/estilo.css">
<link rel="stylesheet" href="componentes/conversao/cta-formulario-lead/estilo.css">
<script src="componentes/conversao/select-custom/script.js"></script>
<script src="componentes/conversao/cta-formulario-lead/script.js"></script>

Este componente compõe o conversao/select-custom — carregue os dois. Os dois leem a mesma primitiva .field do core, e é ela que dá a régua e o rótulo: o <div class="field lead-field is-full"> do capital serve de wrapper ao widget sem que o select precise trazer wrapper nenhum.

Ligando o envio

Não há bloco para editar no script. O destino é o próprio formulário:

<form class="glass lead-form" data-lead-form action="https://seu-crm.exemplo/lead" method="post" novalidate>

O corpo vai como FormData (o mesmo que o navegador mandaria sem script), com Accept: application/json. Se o CRM pede JSON, é ali, no fetch do script.js, que se troca o body — e só ali.

Os três desfechos têm mensagem própria no markup, e o script mostra só a que aconteceu:

Mensagem Quando
.lead-ok resposta com status de sucesso (r.ok). O foco vai para ela, porque os campos acabaram de ser desligados
.lead-erro 4xx, 5xx ou rede fora. O formulário continua preenchido e ativo
.lead-demo o <form> não tem action. Nada é enviado

Variações


Gotchas


Histórico

Projeto Onde Data Diferenças
Liberta Wealth index.html e quem-somos.html, #diagnostico 2026-08 Origem. Prefixos .cta- e .cform- misturados, .cfield/.req/.full sem prefixo, ids #leadForm e #formOk no script, vidro escrito inline, cores #f8f6f2/#9b988f/#66645e/#6f6d66 cruas, rgba(198,206,170,.1) no aviso de sucesso, sem role="status".

Normalizações aplicadas na extração: prefixo único lead-; .cfield → .lead-field, .full → .is-full, .on → .is-on; ids → [data-lead-form] / [data-lead-ok], permitindo mais de um formulário na página; o vidro virou a primitiva .glass; cores cruas → tokens, com color-mix no fundo do aviso; adicionado role="status"; o bloco de envio virou um ponto de extensão marcado, em vez de um comentário solto no fim.

Divergências e pendências: nenhuma aberta. As duas que o mantinham em revisar desde a extração — o sucesso sem envio e a falta de estado de erro — foram fechadas em 2026-09-29, com o envio honesto que a conversao/faixa-newsletter estreou: sucesso só com resposta, erro visível, demonstração dita como demonstração. O status passou a estavel.