Accordion
Um componente de painel de conteúdo recolhível para mostrar e ocultar seções de conteúdo relacionado, reduzindo a desordem visual enquanto mantém a informação acessível.
Busque em todas as páginas da documentação
Um componente de painel de conteúdo recolhível para mostrar e ocultar seções de conteúdo relacionado, reduzindo a desordem visual enquanto mantém a informação acessível.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
import { useState } from "react";
interface AccordionProps {
title: string;
children: React.ReactNode;
}
export function Accordion({ title, children }: AccordionProps) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-gray-200">
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && <div className="pb-4 text-sm text-gray-600">{children}</div>}
</div>
);
}Um toggle mínimo usando useState. O chevron gira com uma transição CSS. O conteúdo é renderizado condicionalmente, então não afeta o DOM quando recolhido.
"use client";
import { useState } from "react";
interface AccordionItem {
id: string;
title: string;
content: React.ReactNode;
}
interface AccordionGroupProps {
items: AccordionItem[];
}
export function AccordionGroup({ items }: AccordionGroupProps) {
const [openId, setOpenId] = useState<string | null>(null);
return (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
{items.map((item) => {
const isOpen = openId === item.id;
return (
<div key={item.id}>
<button
onClick={() => setOpenId(isOpen ? null : item.id)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between px-6 py-4 text-left text-sm font-medium text-gray-900 hover:bg-gray-50"
>
{item.title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && <div className="px-6 pb-4 text-sm text-gray-600">{item.content}</div>}
</div>
);
})}
</div>
);
}Armazena um único openId para que apenas um painel fique aberto por vez. Clicar no mesmo item novamente o fecha, definindo o estado para null.
"use client";
import { useState } from "react";
interface AccordionItem {
id: string;
title: string;
content: React.ReactNode;
}
interface AccordionGroupProps {
items: AccordionItem[];
defaultOpen?: string[];
}
export function AccordionGroup({ items, defaultOpen = [] }: AccordionGroupProps) {
const [openIds, setOpenIds] = useState<Set<string>>(new Set(defaultOpen));
function toggle(id: string) {
setOpenIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
return (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
{items.map((item) => {
const isOpen = openIds.has(item.id);
return (
<div key={item.id}>
<button
onClick={() => toggle(item.id)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between px-6 py-4 text-left text-sm font-medium text-gray-900 hover:bg-gray-50"
>
{item.title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && <div className="px-6 pb-4 text-sm text-gray-600">{item.content}</div>}
</div>
);
})}
</div>
);
}Usa um Set em vez de um único ID, para que vários painéis possam ficar abertos simultaneamente. A prop defaultOpen aceita um array de IDs para pré-expandir na montagem.
"use client";
import { useState } from "react";
interface AccordionProps {
title: string;
children: React.ReactNode;
}
export function Accordion({ title, children }: AccordionProps) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-gray-200">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600">
{open ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
)}
</span>
</button>
{open && <div className="pb-4 text-sm text-gray-600">{children}</div>}
</div>
);
}Alterna entre ícones SVG de mais e menos em vez de girar um chevron. O ícone fica em um fundo circular para peso visual adicional.
"use client";
import { useState, useRef, useEffect } from "react";
interface AccordionProps {
title: string;
children: React.ReactNode;
}
export function Accordion({ title, children }: AccordionProps) {
const [open, setOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (contentRef.current) {
setHeight(contentRef.current.scrollHeight);
}
}, [children]);
return (
<div className="border-b border-gray-200">
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-300 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: open ? `${height}px` : "0px" }}
>
<div ref={contentRef} className="pb-4 text-sm text-gray-600">
{children}
</div>
</div>
</div>
);
}Anima o painel abrindo/fechando, transicionando maxHeight entre 0px e o scrollHeight medido. O conteúdo está sempre no DOM (não renderizado condicionalmente) para que a altura possa ser medida.
"use client";
interface AccordionProps {
title: string;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}
export function Accordion({ title, open, onToggle, children }: AccordionProps) {
return (
<div className="border-b border-gray-200">
<button
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center justify-between py-4 text-left text-sm font-medium text-gray-900 hover:text-blue-600"
>
{title}
<svg
className={`h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && <div className="pb-4 text-sm text-gray-600">{children}</div>}
</div>
);
}
// Uso:
// const [openIndex, setOpenIndex] = useState<number | null>(null);
// {items.map((item, i) => (
// <Accordion
// key={i}
// title={item.title}
// open={openIndex === i}
// onToggle={() => setOpenIndex(openIndex === i ? null : i)}
// >
// {item.content}
// </Accordion>
// ))}Uma variante totalmente controlada onde o componente pai detém o estado de abertura/fechamento. Isso é útil quando ações externas (como um botão "recolher tudo" ou alterações de hash de URL) precisam controlar quais painéis estão abertos.
"use client";
import { useState } from "react";
interface FAQItem {
question: string;
answer: string;
}
interface FAQAccordionProps {
items: FAQItem[];
}
export function FAQAccordion({ items }: FAQAccordionProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<div className="mx-auto max-w-2xl">
<dl className="divide-y divide-gray-200">
{items.map((item, index) => {
const isOpen = openIndex === index;
return (
<div key={index} className="py-4">
<dt>
<button
onClick={() => setOpenIndex(isOpen ? null : index)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between text-left text-base font-medium text-gray-900 hover:text-blue-600"
>
{item.question}
<svg
className={`ml-4 h-5 w-5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</dt>
{isOpen && (
<dd className="mt-3 text-sm leading-relaxed text-gray-600">
{item.answer}
</dd>
)}
</div>
);
})}
</dl>
</div>
);
}Usa elementos semânticos <dl>, <dt> e <dd> que são apropriados para pares de pergunta/resposta. O leading-relaxed nas respostas melhora a legibilidade para blocos de texto mais longos.
"use client";
import {
createContext,
useContext,
useState,
useRef,
useEffect,
useCallback,
useId,
} from "react";
// --- Contexto ---
interface AccordionContextValue {
openIds: Set<string>;
toggle: (id: string) => void;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordionContext() {
const ctx = useContext(AccordionContext);
if (!ctx) throw new Error("Accordion.Item deve ser usado dentro de Accordion.Root");
return ctx;
}
// --- Root ---
interface AccordionRootProps {
type?: "single" | "multiple";
defaultOpen?: string[];
children: React.ReactNode;
className?: string;
}
export function AccordionRoot({
type = "single",
defaultOpen = [],
children,
className,
}: AccordionRootProps) {
const [openIds, setOpenIds] = useState<Set<string>>(new Set(defaultOpen));
const toggle = useCallback(
(id: string) => {
setOpenIds((prev) => {
if (type === "single") {
return prev.has(id) ? new Set() : new Set([id]);
}
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
},
[type]
);
return (
<AccordionContext.Provider value={{ openIds, toggle }}>
<div className={`divide-y divide-gray-200 ${className ?? ""}`} role="presentation">
{children}
</div>
</AccordionContext.Provider>
);
}
// --- Item ---
interface AccordionItemProps {
value: string;
children: React.ReactNode;
disabled?: boolean;
}
interface ItemContextValue {
value: string;
isOpen: boolean;
isDisabled: boolean;
triggerId: string;
contentId: string;
}
const ItemContext = createContext<ItemContextValue | null>(null);
function useItemContext() {
const ctx = useContext(ItemContext);
if (!ctx) throw new Error("Deve ser usado dentro de Accordion.Item");
return ctx;
}
export function AccordionItem({ value, children, disabled = false }: AccordionItemProps) {
const { openIds } = useAccordionContext();
const uid = useId();
const triggerId = `accordion-trigger-${uid}`;
const contentId = `accordion-content-${uid}`;
return (
<ItemContext.Provider
value={{
value,
isOpen: openIds.has(value),
isDisabled: disabled,
triggerId,
contentId,
}}
>
<div data-state={openIds.has(value) ? "open" : "closed"}>{children}</div>
</ItemContext.Provider>
);
}
// --- Trigger ---
export function AccordionTrigger({ children }: { children: React.ReactNode }) {
const { value, isOpen, isDisabled, triggerId, contentId } = useItemContext();
const { toggle } = useAccordionContext();
return (
<button
id={triggerId}
onClick={() => !isDisabled && toggle(value)}
aria-expanded={isOpen}
aria-controls={contentId}
aria-disabled={isDisabled}
className={`flex w-full items-center justify-between px-6 py-4 text-left text-sm font-medium transition-colors ${
isDisabled
? "cursor-not-allowed text-gray-400"
: "text-gray-900 hover:bg-gray-50 hover:text-blue-600"
}`}
>
{children}
<svg
className={`h-5 w-5 shrink-0 transition-transform duration-300 ${
isOpen ? "rotate-180" : ""
} ${isDisabled ? "text-gray-300" : "text-gray-500"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
);
}
// --- Content ---
export function AccordionContent({ children }: { children: React.ReactNode }) {
const { isOpen, triggerId, contentId } = useItemContext();
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (contentRef.current) {
setHeight(contentRef.current.scrollHeight);
}
}, [children, isOpen]);
return (
<div
id={contentId}
role="region"
aria-labelledby={triggerId}
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: isOpen ? `${height}px` : "0px", opacity: isOpen ? 1 : 0 }}
>
<div ref={contentRef} className="px-6 pb-4 text-sm leading-relaxed text-gray-600">
{children}
</div>
</div>
);
}
// --- Exemplo de Uso ---
// <AccordionRoot type="single" defaultOpen={["item-1"]}>
// <AccordionItem value="item-1">
// <AccordionTrigger>Qual é a sua política de reembolso?</AccordionTrigger>
// <AccordionContent>Oferecemos uma garantia de devolução do dinheiro em 30 dias.</AccordionContent>
// </AccordionItem>
// <AccordionItem value="item-2">
// <AccordionTrigger>Como faço para cancelar?</AccordionTrigger>
// <AccordionContent>Vá para Configurações e clique em Cancelar Assinatura.</AccordionContent>
// </AccordionItem>
// <AccordionItem value="item-3" disabled>
// <AccordionTrigger>Preços corporativos (em breve)</AccordionTrigger>
// <AccordionContent>Entre em contato com vendas para preços corporativos.</AccordionContent>
// </AccordionItem>
// </AccordionRoot>Aspectos Chave:
AccordionRoot, AccordionItem, AccordionTrigger e AccordionContent compõem livremente. Cada subcomponente lê seu estado do contexto.type controla se apenas um painel ou muitos podem ser abertos. A lógica de toggle ramifica com base nesse valor dentro de um único useCallback.aria-expanded, aria-controls, aria-labelledby e role="region" correspondem ao padrão WAI-ARIA Accordion para suporte a leitores de tela.useId para IDs únicos - useId do React 19 gera IDs estáveis e seguros para SSR para vincular triggers aos seus painéis de conteúdo.maxHeight transiciona de 0px para o scrollHeight medido. Combinado com opacity, o efeito parece suave sem travamentos de layout.disabled em AccordionItem desabilita o trigger e impede o toggle, útil para seções "em breve" ou bloqueadas.Mudanças na altura do conteúdo após a montagem - Se o conteúdo do accordion incluir imagens ou dados assíncronos, o scrollHeight medido pode estar incorreto. Reavalie a altura quando o conteúdo mudar ou use um ResizeObserver.
overflow-hidden cortando elementos filhos - Tooltips, dropdowns ou popovers dentro de um painel de accordion são cortados por overflow-hidden. Use um portal para essas sobreposições aninhadas.
aria-expanded ausente no trigger - Leitores de tela dependem de aria-expanded para anunciar se uma seção está aberta ou fechada. Sempre defina-o no elemento button.
Usando <div> como trigger em vez de <button> - Uma <div> com onClick não é acessível por teclado por padrão. Sempre use um <button> para triggers de accordion para obter foco integrado e tratamento de Enter/Espaço.
Flicker de animação na primeira abertura - Se o scrollHeight for medido antes que o conteúdo seja renderizado, a animação começa de 0px para 0px. Meça em um useEffect que é executado após a montagem do conteúdo.
Reinício de estado na re-renderização - Se o array de itens do accordion for recriado a cada renderização (literal de array inline), as chaves value do item podem não corresponder e o estado aberto é reiniciado. Estabilize o array de itens com useMemo ou defina-o fora do componente.
Revisado por Chris St. John·Última atualização: 10 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥