Acordeão
Lista de itens que abrem e fecham (um por vez ou vários), em cartões arredondados que seguem o raio do tema ou em linhas finas, com título, subtítulo e um lado direito livre para valores, selos ou botões. Altura animada sem medir e padrão de acessibilidade de acordeão.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<Acordeao>
Lista de itens que abrem e fecham (um de cada vez ou vários), com altura animada e o padrão de acessibilidade de acordeão: título num botão dentro de um heading, aria-expanded e aria-controls. Fechado, o conteúdo fica inerte (fora do Tab e dos leitores de tela). Com "reduzir movimento", abre sem animação.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| itensobrigatória | ItemDoAcordeao[] | — | |
| modo | "um" | "varios" | "varios" | "um": abrir um item fecha o que estava aberto. |
| abertosIniciais | number[] | [] | Índices dos itens que começam abertos (no modo "um", vale só o primeiro). |
| aparencia | "linhas" | "cartoes" | "cartoes" | "cartoes": cada item num cartão arredondado (segue o raio do tema); "linhas": itens separados por linhas finas. |
| nivelDoTitulo | 2 | 3 | 4 | 5 | 6 | 3 | Nível do heading que envolve cada título, para encaixar na hierarquia da página. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useId, useState, type ReactNode } from "react";
import { useSemMovimento } from "@/library/revelar/Movimento";
export type ItemDoAcordeao = {
titulo: ReactNode;
conteudo: ReactNode;
/** Linha menor abaixo do título (resumo, motivo, data...). */
subtitulo?: ReactNode;
/**
* Lado direito do cabeçalho: valores, selos ou botões. Clicar em texto aqui
* também abre o item; links e botões dentro dele continuam funcionando.
*/
extra?: ReactNode;
desativado?: boolean;
};
/**
* Lista de itens que abrem e fecham (um de cada vez ou vários), com altura
* animada e o padrão de acessibilidade de acordeão: título num botão dentro de um
* heading, aria-expanded e aria-controls. Fechado, o conteúdo fica inerte (fora do
* Tab e dos leitores de tela). Com "reduzir movimento", abre sem animação.
*/
export function Acordeao({
itens,
modo = "varios",
abertosIniciais = [],
aparencia = "cartoes",
nivelDoTitulo = 3,
}: {
itens: ItemDoAcordeao[];
/** "um": abrir um item fecha o que estava aberto. */
modo?: "um" | "varios";
/** Índices dos itens que começam abertos (no modo "um", vale só o primeiro). */
abertosIniciais?: number[];
/** "cartoes": cada item num cartão arredondado (segue o raio do tema); "linhas": itens separados por linhas finas. */
aparencia?: "linhas" | "cartoes";
/** Nível do heading que envolve cada título, para encaixar na hierarquia da página. */
nivelDoTitulo?: 2 | 3 | 4 | 5 | 6;
}) {
const id = useId();
const semMovimento = useSemMovimento();
const [abertos, setAbertos] = useState<number[]>(() =>
modo === "um" ? abertosIniciais.slice(0, 1) : abertosIniciais,
);
const Titulo = `h${nivelDoTitulo}` as const;
const cartoes = aparencia === "cartoes";
const alternar = (indice: number) =>
setAbertos((atual) => {
if (atual.includes(indice)) return atual.filter((i) => i !== indice);
return modo === "um" ? [indice] : [...atual, indice];
});
return (
<div className={cartoes ? "flex w-full flex-col gap-2" : "w-full divide-y divide-rule border-y border-rule"}>
{itens.map((item, indice) => {
const aberto = abertos.includes(indice);
const idBotao = `${id}-botao-${indice}`;
const idPainel = `${id}-painel-${indice}`;
return (
<div
key={indice}
// O foco aparece no cartão inteiro (o botão cobre a linha toda); o aberto ganha a cor de destaque na borda
className={
cartoes
? `overflow-hidden rounded-2xl border bg-page outline-accent transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 ${
aberto ? "border-accent/40 bg-surface/40" : "border-rule"
}`
: ""
}
>
{/* O botão cobre a linha inteira (after:inset-0); o extra fica por cima só nos elementos clicáveis */}
<div
className={`relative flex items-center gap-3 transition-colors ${cartoes ? "px-4 py-4" : "px-2 py-3"} ${
cartoes ? "hover:bg-surface/60" : aberto ? "bg-surface" : "hover:bg-surface/60"
}`}
>
<Titulo className="min-w-0 flex-1 text-base font-normal">
<button
id={idBotao}
type="button"
aria-expanded={aberto}
aria-controls={idPainel}
disabled={item.desativado}
onClick={() => alternar(indice)}
className={`flex w-full cursor-pointer items-center gap-3 rounded-sm text-left outline-accent after:absolute after:inset-0 after:content-[''] disabled:cursor-not-allowed disabled:opacity-50 ${
cartoes ? "focus-visible:outline-none" : "focus-visible:outline-2 focus-visible:outline-offset-2"
}`}
>
{/* Nos cartões a seta fica num círculo, que muda de cor ao abrir */}
<span
aria-hidden="true"
className={`grid shrink-0 place-items-center ${
cartoes
? `size-8 rounded-[max(var(--radius-sm),calc(var(--radius-2xl)_-_var(--spacing)*4))] transition-colors ${aberto ? "bg-accent/15 text-accent" : "bg-surface text-muted"}`
: "size-4 text-muted"
}`}
>
<svg
viewBox="0 0 24 24"
className={`size-4 ${semMovimento ? "" : "transition-transform duration-200"} ${aberto ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 6 6 6-6 6" />
</svg>
</span>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-ink">{item.titulo}</span>
{item.subtitulo && <span className="mt-0.5 block text-sm text-ink-2">{item.subtitulo}</span>}
</span>
</button>
</Titulo>
{item.extra && (
<div className="pointer-events-none relative flex shrink-0 items-center gap-2 text-sm text-ink-2 [&_a]:pointer-events-auto [&_button]:pointer-events-auto [&_input]:pointer-events-auto [&_label]:pointer-events-auto [&_select]:pointer-events-auto">
{item.extra}
</div>
)}
</div>
{/* Altura animada sem medir: a linha da grade vai de 0fr a 1fr */}
<div
id={idPainel}
role="region"
aria-labelledby={idBotao}
inert={!aberto}
className={`grid ${semMovimento ? "" : "transition-[grid-template-rows] duration-300 ease-out"} ${
aberto ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
}`}
>
<div className="min-h-0 overflow-hidden">
<div className={`text-sm text-ink ${cartoes ? "border-t border-rule px-4 py-4 pl-[4.25rem]" : "bg-surface pt-1 pr-2 pb-4 pl-9"}`}>
{item.conteudo}
</div>
</div>
</div>
</div>
);
})}
</div>
);
}
import { defineStories } from "@/registry/stories";
import { Acordeao, type ItemDoAcordeao } from "./Acordeao";
const SELO = {
pago: "bg-success/15 text-success",
pendente: "bg-warning/15 text-warning",
atrasado: "bg-critical/10 text-critical",
} as const;
function Selo({ tipo, children }: { tipo: keyof typeof SELO; children: string }) {
return <span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${SELO[tipo]}`}>{children}</span>;
}
const PEDIDOS: ItemDoAcordeao[] = [
{
titulo: "Pedido #1042 · Café Aurora",
subtitulo: "12 itens · entregue em 14/09",
extra: (
<>
<span className="hidden tabular-nums sm:inline">R$ 1.284,00</span>
<Selo tipo="pago">Pago</Selo>
</>
),
conteudo: (
<ul className="flex flex-col gap-1.5">
<li>6 × Café especial 250 g — R$ 534,00</li>
<li>4 × Filtro de papel (100 un.) — R$ 36,00</li>
<li>2 × Prensa francesa — R$ 714,00</li>
</ul>
),
},
{
titulo: "Pedido #1043 · Padaria Ponto Doce",
subtitulo: "Aguardando o comprovante",
extra: (
<>
<span className="hidden tabular-nums sm:inline">R$ 486,50</span>
<Selo tipo="pendente">Pendente</Selo>
</>
),
conteudo: <p>Boleto emitido em 10/09, vence em 20/09. O cliente já foi lembrado uma vez.</p>,
},
{
titulo: "Pedido #1044 · Mercado Bom Preço",
subtitulo: "Vencido há 5 dias",
extra: (
<>
<span className="hidden tabular-nums sm:inline">R$ 2.910,00</span>
<Selo tipo="atrasado">Atrasado</Selo>
</>
),
conteudo: <p>Sem resposta aos dois últimos contatos. Próximo passo: ligar para o financeiro.</p>,
},
];
// Botão no lado direito do cartão (rounded-2xl, a 4 unidades da borda): o raio acompanha o do cartão
const BOTAO =
"inline-flex min-h-9 cursor-pointer items-center rounded-[max(var(--radius-sm),calc(var(--radius-2xl)_-_var(--spacing)*4))] border border-rule bg-page px-3 text-xs font-medium text-ink outline-accent hover:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2";
const VERSOES: ItemDoAcordeao[] = [
{
titulo: "21/10/2026, 09:42",
subtitulo: "por Marina Lopes · ajuste do frete",
extra: (
<button type="button" className={BOTAO}>
Restaurar
</button>
),
conteudo: <p>Frete por região: Sul passa de 8% para 9,5% do subtotal.</p>,
},
{
titulo: "02/10/2026, 17:15",
subtitulo: "por Rafael Dias",
extra: (
<button type="button" className={BOTAO}>
Restaurar
</button>
),
conteudo: <p>Desconto progressivo a partir de 50 unidades.</p>,
},
{
titulo: "18/09/2026, 11:03",
subtitulo: "por Marina Lopes · versão inicial",
conteudo: <p>Primeira versão da tabela de preços.</p>,
desativado: true,
},
];
const PERGUNTAS: ItemDoAcordeao[] = [
{ titulo: "Qual o prazo de entrega?", conteudo: <p>De 2 a 5 dias úteis nas capitais e até 10 dias no interior.</p> },
{ titulo: "Posso trocar um produto?", conteudo: <p>Sim, em até 30 dias, com a embalagem original.</p> },
{ titulo: "Quais as formas de pagamento?", conteudo: <p>Pix, boleto e cartão em até 6 vezes sem juros.</p> },
];
export default defineStories({
args: { modo: "varios", aparencia: "cartoes", primeiroAberto: true },
controls: {
modo: { type: "select", options: ["um", "varios"] },
aparencia: { type: "select", options: ["cartoes", "linhas"] },
primeiroAberto: { type: "boolean", label: "primeiro item aberto" },
},
render: (a) => (
<div className="w-full max-w-2xl">
<Acordeao
key={`${a.modo}-${a.primeiroAberto}`}
itens={PEDIDOS}
modo={a.modo as "um" | "varios"}
aparencia={a.aparencia as "linhas" | "cartoes"}
abertosIniciais={a.primeiroAberto ? [0] : []}
/>
</div>
),
stories: [
{ name: "Cartões com valores" },
{ name: "Linhas", args: { aparencia: "linhas" } },
{
name: "Histórico com ações",
render: () => (
<div className="w-full max-w-2xl">
<Acordeao itens={VERSOES} modo="um" />
</div>
),
},
{
name: "Perguntas frequentes (um por vez)",
render: () => (
<div className="w-full max-w-2xl">
<Acordeao itens={PERGUNTAS} modo="um" abertosIniciais={[0]} nivelDoTitulo={2} aparencia="linhas" />
</div>
),
},
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Componentes da biblioteca (diretos e indiretos)
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}