Tabs
Um componente para alternar entre diferentes painéis de conteúdo dentro da mesma visualização, mantendo o conteúdo relacionado organizado sem navegação de página.
Busque em todas as páginas da documentação
Um componente para alternar entre diferentes painéis de conteúdo dentro da mesma visualização, mantendo o conteúdo relacionado organizado sem navegação de página.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
import { useState } from "react";
interface Tab {
label: string;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
}
export function Tabs({ tabs }: TabsProps) {
const [activeIndex, setActiveIndex] = useState(0);
return (
<div>
<div className="flex border-b border-gray-200">
{tabs.map((tab, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeIndex === index
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-600 hover:text-gray-900"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="py-4">{tabs[activeIndex].content}</div>
</div>
);
}Um alternador de abas mínimo usando estado baseado em índice. A aba ativa recebe uma borda inferior azul. Apenas o painel ativo é renderizado, mantendo o DOM pequeno.
"use client";
import { useState } from "react";
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
}
export function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id);
return (
<div>
<div className="flex gap-8 border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={`relative pb-3 text-sm font-medium transition-colors ${
activeId === tab.id
? "text-gray-900"
: "text-gray-500 hover:text-gray-700"
}`}
>
{tab.label}
{activeId === tab.id && (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-gray-900" />
)}
</button>
))}
</div>
<div className="py-6">
{tabs.find((t) => t.id === activeId)?.content}
</div>
</div>
);
}Usa IDs de string em vez de índices para que as abas possam ser reordenadas sem quebrar o estado. O indicador ativo é um span posicionado absolutamente, facilitando a animação posterior com uma transição de layout.
"use client";
import { useState } from "react";
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
}
export function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id);
return (
<div>
<div className="inline-flex gap-1 rounded-lg bg-gray-100 p-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={`rounded-md px-4 py-2 text-sm font-medium transition-all ${
activeId === tab.id
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="py-6">
{tabs.find((t) => t.id === activeId)?.content}
</div>
</div>
);
}Um visual de controle segmentado onde a aba ativa tem um fundo branco com sombra. O contêiner externo usa bg-gray-100 com p-1 para criar o efeito de rebaixo.
"use client";
import { useState } from "react";
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface VerticalTabsProps {
tabs: Tab[];
defaultTab?: string;
}
export function VerticalTabs({ tabs, defaultTab }: VerticalTabsProps) {
const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id);
return (
<div className="flex gap-8">
<div className="flex w-48 shrink-0 flex-col border-r border-gray-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={`px-4 py-2 text-left text-sm font-medium transition-colors ${
activeId === tab.id
? "border-r-2 border-blue-600 bg-blue-50 text-blue-600"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex-1">{tabs.find((t) => t.id === activeId)?.content}</div>
</div>
);
}Empilha as abas verticalmente à esquerda com um indicador de borda direita. O w-48 shrink-0 mantém a lista de abas com uma largura fixa enquanto o painel de conteúdo preenche o espaço restante.
"use client";
import { useState } from "react";
interface Tab {
id: string;
label: string;
icon: React.ReactNode;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
}
export function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id);
return (
<div>
<div className="flex gap-1 border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={`inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
activeId === tab.id
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
<span className="h-4 w-4">{tab.icon}</span>
{tab.label}
</button>
))}
</div>
<div className="py-4">{tabs.find((t) => t.id === activeId)?.content}</div>
</div>
);
}
// Uso:
// <Tabs tabs={[
// {
// id: "overview",
// label: "Overview",
// icon: <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3" /></svg>,
// content: <p>Overview content</p>,
// },
// ]} />Cada aba aceita um ReactNode de icon exibido antes do rótulo. O wrapper fixo h-4 w-4 mantém os ícones com tamanho consistente, independentemente das dimensões nativas do SVG.
"use client";
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface ControlledTabsProps {
tabs: Tab[];
activeTab: string;
onTabChange: (id: string) => void;
}
export function ControlledTabs({ tabs, activeTab, onTabChange }: ControlledTabsProps) {
return (
<div>
<div className="flex border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? "border-b-2 border-blue-600 text-blue-600"
: "text-gray-600 hover:text-gray-900"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="py-4">{tabs.find((t) => t.id === activeTab)?.content}</div>
</div>
);
}
// Uso:
// const [tab, setTab] = useState("general");
//
// // Sincronizar com parâmetros de busca da URL
// const searchParams = useSearchParams();
// useEffect(() => {
// const t = searchParams.get("tab");
// if (t) setTab(t);
// }, [searchParams]);
//
// <ControlledTabs tabs={tabs} activeTab={tab} onTabChange={setTab} />O componente pai detém o estado ativo, facilitando a sincronização com parâmetros de busca da URL, persistência no localStorage ou coordenação com outros componentes. Nenhuma useState interna significa que o componente é uma entrada puramente controlada.
"use client";
import {
createContext,
useContext,
useState,
useCallback,
useRef,
useId,
useEffect,
type KeyboardEvent,
} from "react";
// --- Contexto ---
interface TabsContextValue {
activeId: string;
setActiveId: (id: string) => void;
registerTab: (id: string) => void;
tabIds: React.MutableRefObject<string[]>;
orientation: "horizontal" | "vertical";
baseId: string;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Componentes Tabs devem ser usados dentro de Tabs.Root");
return ctx;
}
// --- Raiz ---
interface TabsRootProps {
defaultTab: string;
orientation?: "horizontal" | "vertical";
onChange?: (id: string) => void;
children: React.ReactNode;
className?: string;
}
export function TabsRoot({
defaultTab,
orientation = "horizontal",
onChange,
children,
className,
}: TabsRootProps) {
const [activeId, setActiveIdState] = useState(defaultTab);
const tabIds = useRef<string[]>([]);
const baseId = useId();
const setActiveId = useCallback(
(id: string) => {
setActiveIdState(id);
onChange?.(id);
},
[onChange]
);
const registerTab = useCallback((id: string) => {
tabIds.current = [...new Set([...tabIds.current, id])];
}, []);
return (
<TabsContext.Provider value={{ activeId, setActiveId, registerTab, tabIds, orientation, baseId }}>
<div
className={`${orientation === "vertical" ? "flex gap-8" : ""} ${className ?? ""}`}
>
{children}
</div>
</TabsContext.Provider>
);
}
// --- Lista de Abas ---
interface TabListProps {
children: React.ReactNode;
className?: string;
variant?: "underline" | "pill";
}
export function TabList({ children, className, variant = "underline" }: TabListProps) {
const { orientation, tabIds, setActiveId, activeId } = useTabsContext();
const listRef = useRef<HTMLDivElement>(null);
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const ids = tabIds.current;
const currentIndex = ids.indexOf(activeId);
let nextIndex = currentIndex;
const isHorizontal = orientation === "horizontal";
const prev = isHorizontal ? "ArrowLeft" : "ArrowUp";
const next = isHorizontal ? "ArrowRight" : "ArrowDown";
if (e.key === next) {
e.preventDefault();
nextIndex = (currentIndex + 1) % ids.length;
} else if (e.key === prev) {
e.preventDefault();
nextIndex = (currentIndex - 1 + ids.length) % ids.length;
} else if (e.key === "Home") {
e.preventDefault();
nextIndex = 0;
} else if (e.key === "End") {
e.preventDefault();
nextIndex = ids.length - 1;
} else {
return;
}
setActiveId(ids[nextIndex]);
const nextButton = listRef.current?.querySelector(
`[data-tab-id="${ids[nextIndex]}"]`
) as HTMLElement | null;
nextButton?.focus();
}
const baseClasses =
variant === "pill"
? "inline-flex gap-1 rounded-lg bg-gray-100 p-1"
: orientation === "vertical"
? "flex w-48 shrink-0 flex-col border-r border-gray-200"
: "flex gap-1 border-b border-gray-200";
return (
<div
ref={listRef}
role="tablist"
aria-orientation={orientation}
onKeyDown={handleKeyDown}
className={`${baseClasses} ${className ?? ""}`}
>
{children}
</div>
);
}
// --- Aba ---
interface TabProps {
id: string;
children: React.ReactNode;
disabled?: boolean;
}
export function Tab({ id, children, disabled = false }: TabProps) {
const { activeId, setActiveId, registerTab, orientation, baseId } = useTabsContext();
const isActive = activeId === id;
useEffect(() => {
registerTab(id);
}, [id, registerTab]);
return (
<button
role="tab"
id={`${baseId}-tab-${id}`}
data-tab-id={id}
aria-selected={isActive}
aria-controls={`${baseId}-panel-${id}`}
aria-disabled={disabled}
tabIndex={isActive ? 0 : -1}
onClick={() => !disabled && setActiveId(id)}
className={`relative text-sm font-medium transition-colors ${
disabled
? "cursor-not-allowed text-gray-300"
: isActive
? orientation === "vertical"
? "border-r-2 border-blue-600 bg-blue-50 px-4 py-2 text-left text-blue-600"
: "border-b-2 border-blue-600 px-4 py-2.5 text-blue-600"
: orientation === "vertical"
? "px-4 py-2 text-left text-gray-600 hover:bg-gray-50 hover:text-gray-900"
: "px-4 py-2.5 text-gray-500 hover:text-gray-700"
}`}
>
{children}
</button>
);
}
// --- Painel ---
interface TabPanelProps {
id: string;
children: React.ReactNode;
}
export function TabPanel({ id, children }: TabPanelProps) {
const { activeId, baseId } = useTabsContext();
const isActive = activeId === id;
if (!isActive) return null;
return (
<div
role="tabpanel"
id={`${baseId}-panel-${id}`}
aria-labelledby={`${baseId}-tab-${id}`}
tabIndex={0}
className="flex-1 py-4 focus-visible:outline-none"
>
{children}
</div>
);
}
// --- Exemplo de Uso ---
// <TabsRoot defaultTab="general" orientation="horizontal" onChange={(id) => console.log(id)}>
// <TabList variant="underline">
// <Tab id="general">General</Tab>
// <Tab id="security">Security</Tab>
// <Tab id="billing">Billing</Tab>
// <Tab id="advanced" disabled>Advanced</Tab>
// </TabList>
// <TabPanel id="general">General settings content</TabPanel>
// <TabPanel id="security">Security settings content</TabPanel>
// <TabPanel id="billing">Billing settings content</TabPanel>
// <TabPanel id="advanced">Advanced settings content</TabPanel>
// </TabsRoot>Aspectos Chave:
TabsRoot, TabList, Tab e TabPanel compõem livremente. O estado flui através do contexto para que os componentes filhos não precisem de prop drilling.tabIndex={0}. Todas as outras são tabIndex={-1}, então a tecla Tab move o foco para dentro e para fora da lista de abas em vez de passar por todas as abas.role="tablist", role="tab", role="tabpanel", aria-selected, aria-controls e aria-labelledby correspondem exatamente ao padrão WAI-ARIA Tabs.useId para IDs estáveis - useId do React 19 gera IDs seguros para SSR para vincular abas aos seus painéis. Nenhum risco de colisão com múltiplas instâncias de abas na mesma página.aria-orientation e a direção do teclado se adaptam a "horizontal" ou "vertical", então o mesmo componente funciona para layouts de navegação superior e de barra lateral.aria-disabled para descoberta.Todas as abas com tabIndex={0} - Isso força os usuários de teclado a navegar por todos os botões de aba antes de chegar ao painel. Use roving tabindex: apenas a aba ativa recebe tabIndex={0}, as outras recebem tabIndex={-1}.
Falta de role="tablist" / role="tab" / role="tabpanel" - Sem as funções ARIA adequadas, os leitores de tela tratam as abas como botões comuns. Sempre aplique o conjunto completo de funções e atributos relacionados a abas.
Conteúdo do painel é desmontado na troca de abas - Renderizar painéis condicionalmente destrói o estado do componente (entradas de formulário, posição de rolagem). Se a preservação do estado for importante, renderize todos os painéis e oculte os inativos com hidden ou display: none.
Estado da aba não sincronizado com a URL - Os usuários não podem compartilhar ou marcar uma aba específica. Use useSearchParams para sincronizar a aba ativa com um parâmetro de consulta da URL para deep linking.
Estouro horizontal em dispositivos móveis - Muitas abas excedem o contêiner em telas pequenas. Adicione overflow-x-auto e scrollbar-hide à lista de abas, ou mude para um dropdown/select em dispositivos móveis.
Indicador animado pulando - Uma borda inferior CSS pula entre as abas. Para um indicador deslizante, meça o offsetLeft e offsetWidth da aba ativa com um ref e anime um elemento posicionado.
Revisado por Chris St. John·Última atualização: 19 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥