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.
Casos de Uso
- Cambiar entre diferentes secciones de una página de configuración
- Alternar entre ejemplos de código en diferentes lenguajes
- Organizar detalles de productos (descripción, especificaciones, reseñas)
- Filtrar vistas de panel de control (descripción general, análisis, registros)
- Mostrar diferentes rangos de tiempo para gráficos o datos
- Navegar entre pasos en un formulario de múltiples partes
- Mostrar diferentes categorías de contenido en una página de perfil
Implementación Más Simple
"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.
Variaciones
Estilo Subrayado
"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.
Estilo Píldora
"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.
Pestañas Verticales
"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.
Pestañas con Iconos
"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.
Pestañas Controladas
"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.
Implementación Compleja
"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:
- Patrón de componente compuesto —
TabsRoot,TabList,TabyTabPanelse componen libremente. El estado fluye a través del contexto para que los componentes hijo no necesiten prop drilling. - Navegación completa de teclado — Las teclas de flecha se mueven entre pestañas, Inicio/Fin saltan a la primera/última, y el enfoque sigue la selección. La dirección se invierte para la orientación vertical.
- Roving tabindex — solo la pestaña activa tiene
tabIndex={0}. Todos los demás sontabIndex={-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. - Roles ARIA —
role="tablist",role="tab",role="tabpanel",aria-selected,aria-controlsyaria-labelledbycoinciden exactamente con el patrón de pestañas WAI-ARIA. - useId para IDs estables — El
useIdde 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. - Soporte de orientación —
aria-orientationy 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. - Pestañas deshabilitadas — las pestañas deshabilitadas se ven atenuadas, se excluyen del manejo de clics, pero permanecen en el orden de navegación del teclado con
aria-disabledpara la capacidad de descubrimiento.
Escollos
-
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 obtienetabIndex={0}, otros obtienentabIndex={-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
hiddenodisplay: 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
useSearchParamspara 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-autoyscrollbar-hidea 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
offsetLeftyoffsetWidthde la pestaña activa con una ref y anima un elemento posicionado.