Pestañas con indicador compartido
Pestañas con indicador deslizante, paneles vinculados y navegación con flechas, Inicio y Fin.
Todo tu proyecto en un solo lugar. Consulta los próximos pasos y el estado del equipo.
Aún no hay actividad. Los cambios de tu equipo aparecerán aquí.
Organiza documentos, versiones y entregables.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import {
useState,
useRef,
useId,
type CSSProperties,
type ReactNode,
type KeyboardEvent,
} from "react";
/** Pestañas con indicador compartido. React + TypeScript, sin dependencias. Personaliza con variables --gcxn-*. */
export type TabItem = { id: string; label: string; content: ReactNode };
export type NavegacionTabsProps = {
items?: TabItem[];
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcxn-root { box-sizing: border-box; min-width: 0; max-width: 100%; color: var(--gcxn-fg, #202421); font: 400 15px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.gcxn-root *, .gcxn-root *::before, .gcxn-root *::after { box-sizing: border-box; }
.gcxn-root button, .gcxn-root input, .gcxn-root textarea, .gcxn-root select { font: inherit; }
.gcxn-root button, .gcxn-root a { -webkit-tap-highlight-color: transparent; }
.gcxn-root :focus-visible { outline: 2px solid var(--gcxn-accent, #25724b); outline-offset: 4px; }
.gcxn-root ::selection { background: var(--gcxn-accent, #25724b); color: #fff; }
.gcxn-root button:disabled { opacity: .5; cursor: not-allowed; }
.gcxn-root svg { flex-shrink: 0; }
@media (prefers-reduced-motion: reduce) { .gcxn-root *, .gcxn-root *::before, .gcxn-root *::after { animation: none !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
.gcxn-root { width:480px; }
.gcxn-tabs { position:relative; display:flex; border-bottom:1px solid #d9ddd7; }
.gcxn-tabs button { flex:1; min-width:0; min-height:48px; padding:10px 8px; background:transparent; border:0; color:#606c61; cursor:pointer; font-size:13px; overflow-wrap:anywhere; }
.gcxn-tabs button[aria-selected="true"] { color:var(--gcxn-accent,#255739); font-weight:600; }
.gcxn-tabs i { position:absolute; left:0; bottom:-1px; height:2px; background:var(--gcxn-accent,#255739); transition:transform .3s cubic-bezier(.16,1,.3,1); }
.gcxn-panel { padding:28px 8px; color:#425043; line-height:1.7; min-height:130px; }
.gcxn-panel[hidden] { display:none; }`;
export function NavegacionTabs({
items = [
{
id: "resumen",
label: "Resumen",
content:
"Todo tu proyecto en un solo lugar. Consulta los próximos pasos y el estado del equipo.",
},
{
id: "actividad",
label: "Actividad",
content:
"Aún no hay actividad. Los cambios de tu equipo aparecerán aquí.",
},
{
id: "archivos",
label: "Archivos",
content: "Organiza documentos, versiones y entregables.",
},
],
className = "",
style,
}: NavegacionTabsProps) {
const [current, setCurrent] = useState(0);
const id = useId();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const index = Math.min(current, Math.max(0, items.length - 1));
function keys(event: KeyboardEvent<HTMLButtonElement>, i: number) {
let next = i;
if (event.key === "ArrowRight") next = (i + 1) % items.length;
else if (event.key === "ArrowLeft")
next = (i + items.length - 1) % items.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = items.length - 1;
else return;
event.preventDefault();
setCurrent(next);
refs.current[next]?.focus();
}
return (
<div className={`gcxn-root ${className}`} style={style}>
<style>{CSS}</style>
{items.length ? (
<>
<div role="tablist" aria-label="Secciones" className="gcxn-tabs">
{items.map((item, i) => (
<button
ref={(node) => {
refs.current[i] = node;
}}
type="button"
role="tab"
id={`${id}-tab-${item.id}`}
aria-controls={`${id}-panel-${item.id}`}
aria-selected={i === index}
tabIndex={i === index ? 0 : -1}
key={item.id}
onClick={() => setCurrent(i)}
onKeyDown={(event) => keys(event, i)}
>
{item.label}
</button>
))}
<i
aria-hidden="true"
style={{
width: `${100 / items.length}%`,
transform: `translateX(${index * 100}%)`,
}}
/>
</div>
{items.map((item, i) => (
<div
className="gcxn-panel"
role="tabpanel"
id={`${id}-panel-${item.id}`}
aria-labelledby={`${id}-tab-${item.id}`}
hidden={i !== index}
tabIndex={0}
key={item.id}
>
{item.content}
</div>
))}
</>
) : (
<p>No hay secciones disponibles.</p>
)}
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/navegacion-tabsEjemplo de uso de Pestañas con indicador compartido
Después de copiar navegacion-tabs.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { NavegacionTabs } from "./navegacion-tabs";
export default function Ejemplo() {
return <NavegacionTabs />;
}Propiedades de NavegacionTabs
NavegacionTabs acepta 3 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
items? | TabItem[] | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Pestañas con indicador compartido con variables CSS
Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.
| Variable | Valor por defecto |
|---|---|
--gcxn-fg | #202421 |
--gcxn-accent | #25724b |
Accesibilidad y rendimiento
El archivo tiene 129 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Pestañas con indicador compartido por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: tablist, tab, tabpanel.
- Responde al teclado, no solo al ratón.
- Es un componente de cliente de Next.js: incluye la directiva "use client".