Pular para o conteúdo
Biblioteca FAQ-01

FAQ com abas e acordeão

FAQ com abas por categoria e itens em acordeão: a aba ativa ganha um fundo que sobe de baixo, o painel entra subindo 20 px e o + gira 45°.

usa o FAQ-01
abrir sozinho ↗
Código
FAQ-01 · alias faq-abas
Função
FAQ e acordeão
Fonte
21st.dev · FAQ com abas por categoria
Recebido
14/09/2026
Status
adotado
Licença
não verificada

O código

faq.css + faq.js + markup.html 184 linhas · 3 arquivo(s) numa caixa só
/* ===== faq.css ===================================================================== */

/* FAQ-01 · FAQ com abas e acordeão — reescrito do original do 21st.dev sem React nem Framer Motion.
   Do componente vêm: a aba ativa com o fundo que sobe de baixo (0,5 s, easing "backIn" — recua antes
   de avançar), o painel que entra subindo 20 px, o acordeão que anima a altura e o + que gira 45°.
   A animação é toda do CSS; o JS só cuida de estado e acessibilidade. */
.c-faq {
  --c-faq-fundo: #0a0a0a;
  --c-faq-superficie: #141414;
  --c-faq-linha: rgba(235, 235, 235, .12);
  --c-faq-tinta: #ebebeb;
  --c-faq-tinta-2: #a8a8a8;
  --c-faq-aba-ativa: #e8ad32;
  --c-faq-aba-tinta-ativa: #171717;
  --c-faq-ease: cubic-bezier(.16, 1, .3, 1);

  padding: 20px 16px;
  border: 1px solid var(--c-faq-linha);
  border-radius: 20px;
  background: var(--c-faq-fundo);
  color: var(--c-faq-tinta);
}
.c-faq__abas { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.c-faq__aba {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  padding: 6px 12px;
  border: 1px solid var(--c-faq-linha);
  border-radius: 6px;
  background: transparent;
  color: var(--c-faq-tinta-2);
  font: 500 .8rem/1.2 inherit;
  white-space: nowrap;
  cursor: pointer;
  transition: color .5s ease, border-color .5s ease;
}
/* o fundo sobe de baixo com "backIn": recua 56% antes de avançar */
.c-faq__aba-fundo {
  position: absolute;
  z-index: -1;
  inset: 0;
  background: var(--c-faq-aba-ativa);
  transform: translateY(100%);
  transition: transform .5s cubic-bezier(.36, 0, .66, -.56);
}
.c-faq__aba[aria-selected="true"] { color: var(--c-faq-aba-tinta-ativa); border-color: var(--c-faq-aba-ativa); }
.c-faq__aba[aria-selected="true"] .c-faq__aba-fundo { transform: translateY(0); }

.c-faq__painel { display: grid; gap: 10px; animation: c-faq-entra .5s var(--c-faq-ease); }
.c-faq__painel[hidden] { display: none; }
@keyframes c-faq-entra { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: none; } }

.c-faq__item { border: 1px solid var(--c-faq-linha); border-radius: 12px; background: var(--c-faq-fundo); transition: background-color .2s ease; }
.c-faq__item.is-aberto { background: var(--c-faq-superficie); }
.c-faq__pergunta { margin: 0; font: inherit; }
.c-faq__botao {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 14px 16px;
  border: 0;
  background: transparent;
  color: var(--c-faq-tinta-2);
  font: 500 .95rem/1.35 inherit;
  text-align: left;
  cursor: pointer;
}
.c-faq__item.is-aberto .c-faq__botao { color: var(--c-faq-tinta); }
.c-faq__mais { flex: none; width: 20px; height: 20px; transition: transform .2s ease; }
.c-faq__item.is-aberto .c-faq__mais { transform: rotate(45deg); }

/* a altura anima por grid-template-rows 0fr → 1fr: não precisa medir pixel nem saber o conteúdo */
.c-faq__resposta { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .3s ease-in-out; }
.c-faq__item.is-aberto .c-faq__resposta { grid-template-rows: 1fr; }
/* a visibilidade acompanha a altura, para o conteúdo fechado sair da ordem de tabulação */
.c-faq__resposta-dentro { overflow: hidden; padding: 0 16px; visibility: hidden; transition: visibility 0s linear .3s; }
.c-faq__item.is-aberto .c-faq__resposta-dentro { visibility: visible; transition-delay: 0s; }
.c-faq__resposta-dentro p { margin: 0; padding-bottom: 16px; color: var(--c-faq-tinta-2); font-size: .9rem; }

@media (prefers-reduced-motion: reduce) {
  .c-faq__painel { animation: none; }
  .c-faq__aba-fundo, .c-faq__resposta, .c-faq__mais { transition: none; }
}


/* ===== faq.js ====================================================================== */

/* FAQ-01 · abas por assunto (com setas do teclado) e perguntas em acordeão.
   A animação é toda do CSS; aqui só estado e acessibilidade — que é o que o original não tinha. */
(function () {
  'use strict';

  function preparar(faq) {
    var abas = Array.prototype.slice.call(faq.querySelectorAll('[role="tab"]'));

    function selecionar(aba, focar) {
      abas.forEach(function (a) {
        var ativa = a === aba;
        a.setAttribute('aria-selected', ativa ? 'true' : 'false');
        a.tabIndex = ativa ? 0 : -1;
        var painel = document.getElementById(a.getAttribute('aria-controls'));
        if (painel) { painel.hidden = !ativa; }
      });
      if (focar) { aba.focus(); }
    }

    abas.forEach(function (aba, i) {
      aba.addEventListener('click', function () { selecionar(aba, false); });
      aba.addEventListener('keydown', function (e) {
        var alvo = null;
        if (e.key === 'ArrowRight') { alvo = abas[(i + 1) % abas.length]; }
        if (e.key === 'ArrowLeft') { alvo = abas[(i - 1 + abas.length) % abas.length]; }
        if (e.key === 'Home') { alvo = abas[0]; }
        if (e.key === 'End') { alvo = abas[abas.length - 1]; }
        if (alvo) { e.preventDefault(); selecionar(alvo, true); }
      });
    });

    Array.prototype.forEach.call(faq.querySelectorAll('.c-faq__botao'), function (botao) {
      botao.addEventListener('click', function () {
        var aberto = botao.getAttribute('aria-expanded') !== 'true';
        botao.setAttribute('aria-expanded', aberto ? 'true' : 'false');
        botao.closest('.c-faq__item').classList.toggle('is-aberto', aberto);
      });
    });
  }

  function iniciar() { Array.prototype.forEach.call(document.querySelectorAll('.c-faq'), preparar); }

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


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

<!-- FAQ-01 · abas + acordeão. O id de cada painel casa com o aria-controls da sua aba.
     O painel inativo nasce com [hidden]; o aria-selected="true" vem no markup para o estado
     anunciado bater com a tela mesmo se o JS não rodar. -->
<section class="c-faq">
  <div class="c-faq__abas" role="tablist" aria-label="Assuntos">
    <button type="button" class="c-faq__aba" role="tab" id="aba-reserva" aria-controls="painel-reserva" aria-selected="true" tabindex="0">
      <span class="c-faq__aba-fundo" aria-hidden="true"></span>Reserva
    </button>
    <button type="button" class="c-faq__aba" role="tab" id="aba-espaco" aria-controls="painel-espaco" aria-selected="false" tabindex="-1">
      <span class="c-faq__aba-fundo" aria-hidden="true"></span>O espaço
    </button>
  </div>

  <div class="c-faq__painel" role="tabpanel" id="painel-reserva" aria-labelledby="aba-reserva">
    <div class="c-faq__item">
      <h3 class="c-faq__pergunta">
        <button type="button" class="c-faq__botao" aria-expanded="false">
          Como funciona o pagamento?
          <svg class="c-faq__mais" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
        </button>
      </h3>
      <div class="c-faq__resposta"><div class="c-faq__resposta-dentro"><p>Pix na confirmação da reserva.</p></div></div>
    </div>
    <div class="c-faq__item">
      <h3 class="c-faq__pergunta">
        <button type="button" class="c-faq__botao" aria-expanded="false">
          Posso remarcar?
          <svg class="c-faq__mais" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
        </button>
      </h3>
      <div class="c-faq__resposta"><div class="c-faq__resposta-dentro"><p>Até 24 horas antes, sem custo.</p></div></div>
    </div>
  </div>

  <div class="c-faq__painel" role="tabpanel" id="painel-espaco" aria-labelledby="aba-espaco" hidden>
    <div class="c-faq__item">
      <h3 class="c-faq__pergunta">
        <button type="button" class="c-faq__botao" aria-expanded="false">
          Tem estacionamento?
          <svg class="c-faq__mais" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
        </button>
      </h3>
      <div class="c-faq__resposta"><div class="c-faq__resposta-dentro"><p>Vaga na rua, sem reserva.</p></div></div>
    </div>
  </div>
</section>

Já rodou em

  • trento-pedro · home v3 — seção 8

Exige

  • react
  • framer-motion (motion, AnimatePresence)
  • lucide-react (Plus)
  • cn
  • tokens do shadcn

Como se configura

  • --c-faq-fundo
  • --c-faq-superficie
  • --c-faq-linha
  • --c-faq-tinta
  • --c-faq-tinta-2
  • --c-faq-aba-ativa
  • --c-faq-aba-tinta-ativa

A ficha

O que o 21st.dev entregou

Dependências: react (useState), framer-motion (motion, AnimatePresence), lucide-react (ícone Plus), cn, tokens do shadcn (primary, background, muted, card, border).

Props: title (padrão "FAQs"), subtitle (padrão "Frequently Asked Questions"), categories ({ chave: rótulo }), faqData ({ chave: [{ question, answer }] }), className.

Comportamento:

  • Cabeçalho centralizado: subtítulo em texto com degradê, título text-5xl (3rem) em negrito e um brilho desfocado gigante atrás (600 × 500 px, blur-3xl), que a seção corta com overflow-hidden.
  • Abas: uma por categoria, com quebra de linha. A aba ativa ganha um fundo em degradê que sobe de baixo (y: 100% → 0, 0,5 s, easing backIn do Framer — recua antes de avançar).
  • Lista: ao trocar de aba, a lista sai e a nova entra com fade + deslize de 20 px (0,5 s, AnimatePresence mode="wait").
  • Item em acordeão: clique abre e fecha, e a resposta anima a altura de 0 a auto (0,3 s). O + gira 45° e vira ×. Item aberto ganha fundo apagado. Cada item guarda o próprio estado, então vários podem ficar abertos.
  • Cartão do item: rounded-xl (0,75rem), borda, p-4, pergunta text-lg (1,125rem).

Lacunas do original:

  • Sem aria-expanded/aria-controls no acordeão.
  • Sem role="tablist"/aria-selected nas abas.
  • Sem navegação por teclado entre abas.

Diferença para o Canva: no Canva o FAQ tem título "FAQ" à esquerda, sem subtítulo, e as abas e os itens ficam dentro de um cartão branco arredondado. O componente centraliza o cabeçalho e não tem cartão. As abas do Canva são do placeholder (Web Development, Mobile Development…).


O que a adaptação da OCTA mudou

  • aria-selected nas abas e hidden no painel inativo
  • A altura do acordeão anima por grid-template-rows 0fr → 1fr, sem medir pixel
  • A visibility acompanha a altura, para o conteúdo fechado sair da ordem de tabulação

Lacunas do original

  • Sem aria-expanded / aria-controls no acordeão
  • Sem role="tablist" / aria-selected nas abas
  • Sem navegação por teclado entre abas