Pestañas
Un componente para cambiar entre diferentes paneles de contenido en la misma vista, manteniendo el contenido relacionado organizado sin navegación de página.
Busca en todas las páginas de la documentación
Un componente para cambiar entre diferentes paneles de contenido en la misma vista, manteniendo el contenido relacionado organizado sin navegación 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>
);
}Un conmutador de pestañas mínimo usando estado basado en índice. La pestaña activa obtiene un borde inferior azul. Solo el panel activo se renderiza, manteniendo el DOM pequeño.
"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 cadena en lugar de índices para que las pestañas puedan reordenarse sin romper el estado. El indicador activo es un span posicionado absolutamente, lo que facilita animarlo después con una transición de diseño.
"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>
);
}Un aspecto de control segmentado donde la pestaña activa tiene un fondo blanco con sombra. El contenedor externo usa bg-gray-100 con p-1 para crear el efecto de inserción.
"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>
);
}Apila las pestañas verticalmente en la izquierda con un indicador de borde derecho. El w-48 shrink-0 mantiene la lista de pestañas con un ancho fijo mientras el panel de contenido llena el espacio 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: "Descripción general",
// 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>Contenido de descripción general</p>,
// },
// ]} />Cada pestaña acepta un icon ReactNode mostrado antes de la etiqueta. El contenedor fijo h-4 w-4 mantiene los iconos de tamaño consistente independientemente de las dimensiones nativas del 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");
//
// // Sincroniza con parámetros de búsqueda de URL
// const searchParams = useSearchParams();
// useEffect(() => {
// const t = searchParams.get("tab");
// if (t) setTab(t);
// }, [searchParams]);
//
// <ControlledTabs tabs={tabs} activeTab={tab} onTabChange={setTab} />El padre posee el estado activo, lo que facilita sincronizarlo con parámetros de búsqueda de URL, persistir a localStorage o coordinar con otros componentes. Sin useState interno significa que el componente es una entrada controlada pura.
"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("Los componentes Tabs deben usarse dentro de Tabs.Root");
return ctx;
}
// --- Raíz ---
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 Pestañas ---
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>
);
}
// --- Pestaña ---
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>
);
}
// --- Panel ---
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>
);
}
// --- Ejemplo de Uso ---
// <TabsRoot defaultTab="general" orientation="horizontal" onChange={(id) => console.log(id)}>
// <TabList variant="underline">
// <Tab id="general">General</Tab>
// <Tab id="security">Seguridad</Tab>
// <Tab id="billing">Facturación</Tab>
// <Tab id="advanced" disabled>Avanzado</Tab>
// </TabList>
// <TabPanel id="general">Contenido de configuración general</TabPanel>
// <TabPanel id="security">Contenido de configuración de seguridad</TabPanel>
// <TabPanel id="billing">Contenido de configuración de facturación</TabPanel>
// <TabPanel id="advanced">Contenido de configuración avanzada</TabPanel>
// </TabsRoot>Aspectos clave:
TabsRoot, TabList, Tab y TabPanel se componen libremente. El estado fluye a través del contexto para que los componentes hijo no necesiten prop drilling.tabIndex={0}. Todos los demás son tabIndex={-1}, así que la tecla Tab mueve el enfoque dentro y fuera de la lista de pestañas en lugar de a través de cada pestaña.role="tablist", role="tab", role="tabpanel", aria-selected, aria-controls y aria-labelledby coinciden exactamente con el patrón de pestañas WAI-ARIA.useId de React 19 genera IDs seguros para SSR para vincular pestañas a sus paneles. Sin riesgo de colisión con múltiples instancias de pestañas en la misma página.aria-orientation y la dirección del teclado se adaptan a "horizontal" o "vertical", por lo que el mismo componente funciona tanto para diseños de navegación superior como de barra lateral.aria-disabled para la capacidad de descubrimiento.Todas las pestañas con tabIndex={0} - Esto obliga a los usuarios del teclado a tabular a través de cada botón de pestaña antes de llegar al panel. Usa roving tabindex: solo la pestaña activa obtiene tabIndex={0}, otros obtienen tabIndex={-1}.
Roles faltantes role="tablist" / role="tab" / role="tabpanel" - Sin los roles ARIA apropiados, los lectores de pantalla tratan las pestañas como botones normales. Siempre aplica el conjunto completo de roles y atributos relacionados con pestañas.
El contenido del panel se desmonta en el cambio de pestaña - La renderización condicional de paneles destruye el estado del componente (entradas de formulario, posición de desplazamiento). Si la preservación del estado es importante, renderiza todos los paneles y oculta los inactivos con hidden o display: none.
El estado de la pestaña no sincronizado con la URL - Los usuarios no pueden compartir ni marcar una pestaña específica. Usa useSearchParams para sincronizar la pestaña activa con un parámetro de consulta de URL para enlaces profundos.
Desbordamiento horizontal en móvil - Demasiadas pestañas se desbordan en el contenedor en pantallas pequeñas. Agrega overflow-x-auto y scrollbar-hide a la lista de pestañas, o cambia a un menú desplegable/seleccionar en móvil.
Indicador animado saltando - Un borde inferior CSS salta entre pestañas. Para un indicador deslizante, mide offsetLeft y offsetWidth de la pestaña activa con una ref y anima un elemento posicionado.
Revisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥