← Biblioteca componentes/conversao/select-custom/ficha.md CódigoTela cheia
conversao/select-custom

Select customizado acessível

🟢 estavel complexidade média prefixo csel- formulario select listbox acessibilidade teclado

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

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

markup.html · 35 linhas · 2,1 KB

<!-- ==========================================================================
     SELECT CUSTOM — substituto acessível do <select>
     Prefixo: csel-
     Requer: core (tokens, base) + estilo.css e script.js

     Existe porque o <select> nativo não aceita estilo na lista de opções em
     nenhum navegador. Este reproduz o comportamento: teclado, ARIA, foco,
     fecha ao clicar fora, e escreve num <input type="hidden"> para o
     formulário enviar normalmente.

     O wrapper é a primitiva .field do core, a mesma que qualquer formulário
     da biblioteca põe em volta dos campos dele. Então não há nada a trocar
     para levar o widget para dentro de um: o .field que já está lá serve, e
     [data-csel] pode ir no mesmo elemento.
     ========================================================================== -->

<div class="field" data-csel data-csel-required>
  <label id="csel-label-1">Capital para investir</label>

  <div class="csel">
    <button type="button" class="csel-btn" aria-haspopup="listbox" aria-expanded="false" aria-labelledby="csel-label-1">
      <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="csel-label-1" hidden>
      <li class="csel-opt" role="option" aria-selected="false" tabindex="-1" data-value="R$ 3 milhões a R$ 5 milhões">R$ 3 milhões a R$ 5 milhões</li>
      <li class="csel-opt" role="option" aria-selected="false" tabindex="-1" data-value="R$ 5 milhões a R$ 10 milhões">R$ 5 milhões a R$ 10 milhões</li>
      <li class="csel-opt" role="option" aria-selected="false" tabindex="-1" data-value="R$ 10 milhões a R$ 50 milhões">R$ 10 milhões a R$ 50 milhões</li>
      <li class="csel-opt" role="option" aria-selected="false" tabindex="-1" data-value="Acima de R$ 50 milhões">Acima de R$ 50 milhões</li>
    </ul>

    <input type="hidden" name="capital">
  </div>
</div>

estilo.css · 58 linhas · 2,5 KB

/* ==========================================================================
   SELECT CUSTOM — substituto acessível do <select>
   Prefixo: csel-
   Tokens usados: --fg --fg-2 --fg-muted --rule-2 --accent --dark-2 --sans --ease
   Primitiva usada: .field (core/base.css). É ela o wrapper de campo, e por
   isso não existe wrapper local: o mesmo `.field` serve ao componente sozinho
   e dentro de um formulário, que já veste os campos dele com essa classe.
   ========================================================================== */

/* ---- o widget ---- */
.csel{position:relative}

/* A tipografia repete a de `.field input`, e é de propósito: o controle aqui
   é um <button>, e o seletor do core cobre input e textarea. Ele não PODE
   cobrir button — todo .btn dentro de um campo perderia a caixa. O preço de
   reproduzir o <select> nativo com um botão é esta duplicação; se ela sair de
   sincronia com o core, o widget deixa de parecer um campo do formulário. */
.csel-btn{
  width:100%;background:none;border:none;outline:none;
  font-family:var(--sans);font-weight:300;font-size:15px;line-height:1.4;
  color:var(--fg);padding:0;text-align:left;
  display:flex;align-items:center;justify-content:space-between;gap:10px;
}
/* Placeholder e valor escolhido são o mesmo elemento — muda só a cor. */
.csel-val{color:var(--fg-muted)}
.csel-val.is-set{color:var(--fg)}

.csel-arrow{flex-shrink:0;color:var(--fg-muted);transition:transform .25s}
.csel.is-open .csel-arrow{transform:rotate(180deg)}

.csel-list{
  position:absolute;left:0;right:0;top:calc(100% + 10px);z-index:20;margin:0;
  background:var(--dark-2);border:1px solid var(--rule-2);border-radius:10px;padding:6px;
  max-height:270px;overflow-y:auto;
}
.csel-list[hidden]{display:none}

.csel-opt{
  padding:12px 14px;border-radius:6px;font-size:14px;line-height:1.4;
  color:var(--fg-2);cursor:pointer;outline:none;
}
.csel-opt:hover,
.csel-opt.is-active{background:color-mix(in srgb,var(--fg) 7%,transparent);color:var(--fg)}
.csel-opt[aria-selected="true"]{color:var(--accent)}

/* Erro de validação: o campo treme uma vez. Não é decoração — é o único
   retorno visual de "faltou isto" num campo que não tem :invalid nativo. */
@keyframes cselShake{
  0%,100%{transform:translateX(0)}
  20%{transform:translateX(-4px)}
  40%{transform:translateX(4px)}
  60%{transform:translateX(-3px)}
  80%{transform:translateX(3px)}
}
.csel.has-error{animation:cselShake .4s ease}
@media(prefers-reduced-motion:reduce){
  .csel.has-error{animation:none;outline:1px solid var(--accent);outline-offset:6px}
}

script.js · 85 linhas · 3,1 KB

/* ==========================================================================
   SELECT CUSTOM — substituto acessível do <select>
   Abre por clique ou teclado, navega com setas, escolhe com Enter ou Espaço,
   fecha com Esc ou clique fora. Escreve no <input type="hidden"> para o
   formulário enviar como qualquer campo.
   ========================================================================== */

(function () {
  var abertos = [];

  document.querySelectorAll('[data-csel]').forEach(function (campo) {
    var raiz  = campo.querySelector('.csel');
    var btn   = raiz.querySelector('.csel-btn');
    var val   = raiz.querySelector('.csel-val');
    var lista = raiz.querySelector('.csel-list');
    var hidden= raiz.querySelector('input[type=hidden]');
    var opcoes= [].slice.call(lista.querySelectorAll('.csel-opt'));
    var vazio = val.textContent;      // guarda o placeholder original
    var ativo = -1;

    function fechar() {
      lista.hidden = true;
      raiz.classList.remove('is-open');
      btn.setAttribute('aria-expanded', 'false');
      marcarAtivo(-1);
    }

    function abrir() {
      abertos.forEach(function (f) { if (f !== fechar) f(); });
      lista.hidden = false;
      raiz.classList.add('is-open');
      btn.setAttribute('aria-expanded', 'true');
      /* Abre já com o item escolhido em foco; sem escolha, no primeiro. */
      var i = opcoes.findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; });
      marcarAtivo(i < 0 ? 0 : i);
    }

    function marcarAtivo(i) {
      ativo = i;
      opcoes.forEach(function (o, n) { o.classList.toggle('is-active', n === i); });
      if (i >= 0) opcoes[i].scrollIntoView({ block: 'nearest' });
    }

    function escolher(opt) {
      hidden.value = opt.dataset.value;
      val.textContent = opt.dataset.value || vazio;
      val.classList.add('is-set');
      opcoes.forEach(function (o) {
        o.setAttribute('aria-selected', o === opt ? 'true' : 'false');
      });
      fechar();
      btn.focus();
    }

    btn.addEventListener('click', function (e) {
      e.stopPropagation();
      lista.hidden ? abrir() : fechar();
    });
    opcoes.forEach(function (opt) {
      opt.addEventListener('click', function () { escolher(opt); });
    });

    raiz.addEventListener('keydown', function (e) {
      if (e.key === 'Escape') { fechar(); btn.focus(); return; }
      if (lista.hidden) {
        if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); abrir(); }
        return;
      }
      if (e.key === 'ArrowDown') { e.preventDefault(); marcarAtivo(Math.min(ativo + 1, opcoes.length - 1)); }
      if (e.key === 'ArrowUp')   { e.preventDefault(); marcarAtivo(Math.max(ativo - 1, 0)); }
      if (e.key === 'Home')      { e.preventDefault(); marcarAtivo(0); }
      if (e.key === 'End')       { e.preventDefault(); marcarAtivo(opcoes.length - 1); }
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        if (ativo >= 0) escolher(opcoes[ativo]);
      }
    });

    document.addEventListener('click', function (e) {
      if (!raiz.contains(e.target)) fechar();
    });

    abertos.push(fechar);
  });
})();

Substituto do <select> nativo. Botão com a tipografia do campo, seta que gira, lista flutuante estilizável, navegação completa por teclado e ARIA. Escreve num <input type="hidden">, então o formulário envia como qualquer outro campo.

Existe por um motivo só: nenhum navegador deixa estilizar a lista de opções de um <select> nativo. Num formulário escuro e com tipografia de marca, o dropdown do sistema destoa de tudo.


Quando usar

Quando não usar


Padrão editorial

Slot Regra
Label 2 a 4 palavras, caixa alta por CSS: "Capital para investir".
Placeholder "Selecione" mais o substantivo: "Selecione uma faixa". Nunca uma opção real como placeholder.
Opções 3 a 8. Texto curto o bastante para caber em uma linha. Ordem crescente, ou alfabética — nunca "a mais escolhida primeiro".

Faixas de valor são melhores que campo aberto em formulário de qualificação: o visitante não precisa revelar o número exato, e você segmenta igual.


Como usar

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

O script varre [data-csel], então vários selects na mesma página funcionam — e abrir um fecha os outros.

Ganchos

Gancho Papel
[data-csel] o campo
[data-csel-required] marca como obrigatório, lido pela validação do formulário
.csel.is-open lista aberta
.csel.has-error dispara o tremor de validação
.csel-val.is-set valor escolhido (muda a cor do placeholder)
input[type=hidden] onde o valor é escrito; o name dele é o que o backend recebe

Teclado

Tecla Fechado Aberto
↓ / Enter / Espaço abre desce / escolhe
↑ — sobe
Home / End — primeira / última
Esc — fecha e devolve o foco ao botão

Gotchas


Histórico

Projeto Onde Data Diferenças
Liberta Wealth index.html, campo "Capital para investir" do #diagnostico 2026-08 Origem. Prefixo cselect-, estado .open, classe de erro .shake, cores #f8f6f2 / #66645e / #1a1a1a / #d4d1ca cruas, sem navegação por teclado dentro da lista (só Esc), sem tabindex nas opções, sem aria-labelledby na lista.

Normalizações aplicadas na extração: prefixo cselect- → csel-; .open → .is-open, .shake → .has-error; cores cruas → --fg / --fg-muted / --dark-2 / --rule-2; adicionada navegação completa por teclado (setas, Home, End, Enter, Espaço) e o estado .is-active que ela precisa; adicionado tabindex="-1" nas opções e aria-labelledby na lista; scrollIntoView({block:'nearest'}) para a lista acompanhar a seta; adicionado fallback de prefers-reduced-motion no erro; wrapper de campo separado do widget, para o select poder viver dentro de qualquer formulário — hoje esse wrapper é a primitiva .field do core, e o componente não traz nenhum wrapper próprio.