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
O código
/* ===== 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>
/* ===== 02-faq.jsx ================================================================== */
import React, { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
// Main reusable FAQ component
export const FAQ = ({
title = "FAQs",
subtitle = "Frequently Asked Questions",
categories,
faqData,
className,
...props
}) => {
const categoryKeys = Object.keys(categories);
const [selectedCategory, setSelectedCategory] = useState(categoryKeys[0]);
return (
<section
className={cn(
"relative overflow-hidden bg-background px-4 py-12 text-foreground",
className
)}
{...props}
>
<FAQHeader title={title} subtitle={subtitle} />
<FAQTabs
categories={categories}
selected={selectedCategory}
setSelected={setSelectedCategory}
/>
<FAQList
faqData={faqData}
selected={selectedCategory}
/>
</section>
);
};
const FAQHeader = ({ title, subtitle }) => (
<div className="relative z-10 flex flex-col items-center justify-center">
<span className="mb-8 bg-gradient-to-r from-primary to-primary/60 bg-clip-text font-medium text-transparent">
{subtitle}
</span>
<h2 className="mb-8 text-5xl font-bold">{title}</h2>
<span className="absolute -top-[350px] left-[50%] z-0 h-[500px] w-[600px] -translate-x-[50%] rounded-full bg-gradient-to-r from-primary/10 to-primary/5 blur-3xl" />
</div>
);
const FAQTabs = ({ categories, selected, setSelected }) => (
<div className="relative z-10 flex flex-wrap items-center justify-center gap-4">
{Object.entries(categories).map(([key, label]) => (
<button
key={key}
onClick={() => setSelected(key)}
className={cn(
"relative overflow-hidden whitespace-nowrap rounded-md border px-3 py-1.5 text-sm font-medium transition-colors duration-500",
selected === key
? "border-primary text-background"
: "border-border bg-transparent text-muted-foreground hover:text-foreground"
)}
>
<span className="relative z-10">{label}</span>
<AnimatePresence>
{selected === key && (
<motion.span
initial={{ y: "100%" }}
animate={{ y: "0%" }}
exit={{ y: "100%" }}
transition={{ duration: 0.5, ease: "backIn" }}
className="absolute inset-0 z-0 bg-gradient-to-r from-primary to-primary/80"
/>
)}
</AnimatePresence>
</button>
))}
</div>
);
const FAQList = ({ faqData, selected }) => (
<div className="mx-auto mt-12 max-w-3xl">
<AnimatePresence mode="wait">
{Object.entries(faqData).map(([category, questions]) => {
if (selected === category) {
return (
<motion.div
key={category}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.5, ease: "backIn" }}
className="space-y-4"
>
{questions.map((faq, index) => (
<FAQItem key={index} {...faq} />
))}
</motion.div>
);
}
return null;
})}
</AnimatePresence>
</div>
);
const FAQItem = ({ question, answer }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<motion.div
animate={isOpen ? "open" : "closed"}
className={cn(
"rounded-xl border transition-colors",
isOpen ? "bg-muted/50" : "bg-card"
)}
>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex w-full items-center justify-between gap-4 p-4 text-left"
>
<span
className={cn(
"text-lg font-medium transition-colors",
isOpen ? "text-foreground" : "text-muted-foreground"
)}
>
{question}
</span>
<motion.span
variants={{
open: { rotate: "45deg" },
closed: { rotate: "0deg" },
}}
transition={{ duration: 0.2 }}
>
<Plus
className={cn(
"h-5 w-5 transition-colors",
isOpen ? "text-foreground" : "text-muted-foreground"
)}
/>
</motion.span>
</button>
<motion.div
initial={false}
animate={{
height: isOpen ? "auto" : "0px",
marginBottom: isOpen ? "16px" : "0px"
}}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="overflow-hidden px-4"
>
<p className="text-muted-foreground">{answer}</p>
</motion.div>
</motion.div>
);
};
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 comoverflow-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, easingbackIndo 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, perguntatext-lg(1,125rem).
Lacunas do original:
- Sem
aria-expanded/aria-controlsno acordeão. - Sem
role="tablist"/aria-selectednas 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-selectednas abas ehiddenno painel inativo- A altura do acordeão anima por
grid-template-rows0fr → 1fr, sem medir pixel - A
visibilityacompanha a altura, para o conteúdo fechado sair da ordem de tabulação
Lacunas do original
- Sem
aria-expanded/aria-controlsno acordeão - Sem
role="tablist"/aria-selectednas abas - Sem navegação por teclado entre abas