Ir al contenido
Evaluar mi proyecto
Componentes · NavegaciónDisponible

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.

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

navegacion-tabs.tsx129 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/navegacion-tabs

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
items?TabItem[]Opcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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.

VariableValor 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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)