Ir al contenido
Evaluar mi proyecto
Componentes · NavegaciónDisponible

Índice con scroll-spy

Documento con secciones y un índice cuyo marcador sigue la sección visible; autocontenido en su panel.

Alcance

Cada proyecto se define por escrito antes de empezar: qué entra, qué no y cómo se mide el resultado. Los cambios fuera de alcance se cotizan aparte para que nadie se lleve sorpresas.

Proceso

Trabajamos en sprints de una a dos semanas con entregas visibles. Cada sprint cierra con una demostración y una lista de lo que sigue, para que puedas decidir con información.

Pagos

El esquema habitual es 50% al inicio y 50% al terminar. En proyectos grandes los pagos se dividen en hitos acordados desde la propuesta.

Garantía

Después del lanzamiento hay 30 días de estabilización en los que corregimos cualquier falla sin costo. Después puedes contratar mantenimiento mensual.

Propiedad

Todo el código y los recursos del proyecto son tuyos al entregarse. Podemos firmar acuerdos de confidencialidad cuando lo necesites.

Código completo

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

nav-indice.tsx94 líneas
Descargar
"use client";

import { useEffect, useRef, useState, type CSSProperties } from "react";

/** Índice de contenidos con scroll-spy: documento con secciones a la derecha e índice fijo a la izquierda cuyo marcador sigue la sección visible. Autocontenido en su propio panel de desplazamiento. React + TypeScript, sin dependencias. */
export type NavIndiceSeccion = { id: string; title: string; text: string };

export type NavIndiceProps = {
  sections?: NavIndiceSeccion[];
  height?: number;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcnix-root { box-sizing: border-box; display: grid; grid-template-columns: 200px minmax(0, 1fr); width: 100%; max-width: 760px; height: var(--h, 420px); overflow: hidden; border: 1px solid #e4e4e7; border-radius: 16px; background: #fff; color: #18181b; font: 400 15px/1.65 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcnix-root *, .gcnix-root *::before, .gcnix-root *::after { box-sizing: border-box; }
.gcnix-root :focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcnix-root * { transition-duration: .01ms !important; scroll-behavior: auto !important; } }
.gcnix-toc { position: relative; padding: 24px 12px 24px 24px; border-right: 1px solid #f0f0f2; }
.gcnix-toc h2 { margin: 0 0 12px; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: #a1a1aa; }
.gcnix-list { position: relative; margin: 0; padding: 0 0 0 14px; list-style: none; }
.gcnix-list::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2px; border-radius: 2px; background: #e4e4e7; }
.gcnix-mark { position: absolute; left: 0; top: 0; width: 2px; height: 32px; border-radius: 2px; background: #18181b; transform: translateY(calc(var(--i) * 34px)); transition: transform .35s cubic-bezier(.16, 1, .3, 1); }
.gcnix-link { display: block; height: 34px; padding: 5px 4px; border: 0; background: none; color: #71717a; font: inherit; font-size: 14px; text-align: left; cursor: pointer; transition: color .15s; }
.gcnix-link:hover { color: #18181b; }
.gcnix-link[aria-current="true"] { color: #18181b; font-weight: 600; }
.gcnix-doc { overflow-y: auto; padding: 24px 28px 120px; scroll-behavior: smooth; scrollbar-width: thin; }
.gcnix-doc section { padding-bottom: 28px; }
.gcnix-doc h3 { margin: 0 0 8px; font: 700 22px/1.2 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.02em; }
.gcnix-doc p { margin: 0; color: #52525b; max-width: 62ch; }
@media (max-width: 560px) { .gcnix-root { grid-template-columns: 1fr; } .gcnix-toc { display: none; } }`;

const DEFAULT: NavIndiceSeccion[] = [
  { id: "alcance", title: "Alcance", text: "Cada proyecto se define por escrito antes de empezar: qué entra, qué no y cómo se mide el resultado. Los cambios fuera de alcance se cotizan aparte para que nadie se lleve sorpresas." },
  { id: "proceso", title: "Proceso", text: "Trabajamos en sprints de una a dos semanas con entregas visibles. Cada sprint cierra con una demostración y una lista de lo que sigue, para que puedas decidir con información." },
  { id: "pagos", title: "Pagos", text: "El esquema habitual es 50% al inicio y 50% al terminar. En proyectos grandes los pagos se dividen en hitos acordados desde la propuesta." },
  { id: "garantia", title: "Garantía", text: "Después del lanzamiento hay 30 días de estabilización en los que corregimos cualquier falla sin costo. Después puedes contratar mantenimiento mensual." },
  { id: "propiedad", title: "Propiedad", text: "Todo el código y los recursos del proyecto son tuyos al entregarse. Podemos firmar acuerdos de confidencialidad cuando lo necesites." },
];

export function NavIndice({ sections = DEFAULT, height = 420, className = "", style }: NavIndiceProps) {
  const doc = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState(0);

  useEffect(() => {
    const root = doc.current;
    if (!root) return;
    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) setActive(sections.findIndex((section) => section.id === (entry.target as HTMLElement).dataset.id));
        });
      },
      { root, rootMargin: "0px 0px -65% 0px", threshold: 0 },
    );
    root.querySelectorAll("section").forEach((el) => io.observe(el));
    return () => io.disconnect();
  }, [sections]);

  function jump(index: number) {
    const el = doc.current?.querySelectorAll("section")[index] as HTMLElement | undefined;
    if (el && doc.current) doc.current.scrollTo({ top: el.offsetTop - 16 });
    setActive(index);
  }

  return (
    <div className={`gcnix-root ${className}`} style={{ "--h": `${height}px`, ...style } as CSSProperties}>
      <style>{CSS}</style>
      <nav className="gcnix-toc" aria-label="En esta página">
        <h2>En esta página</h2>
        <ul className="gcnix-list">
          <span className="gcnix-mark" style={{ "--i": active } as CSSProperties} aria-hidden="true" />
          {sections.map((section, index) => (
            <li key={section.id}>
              <button className="gcnix-link" type="button" aria-current={active === index} onClick={() => jump(index)}>
                {section.title}
              </button>
            </li>
          ))}
        </ul>
      </nav>
      <div ref={doc} className="gcnix-doc" tabIndex={0} role="region" aria-label="Contenido">
        {sections.map((section) => (
          <section key={section.id} data-id={section.id} id={`gcnix-${section.id}`}>
            <h3>{section.title}</h3>
            <p>{section.text}</p>
          </section>
        ))}
      </div>
    </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/nav-indice

Ejemplo de uso de Índice con scroll-spy

Después de copiar nav-indice.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { NavIndice } from "./nav-indice";

export default function Ejemplo() {
  return <NavIndice />;
}

Propiedades de NavIndice

NavIndice acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
sections?NavIndiceSeccion[]Opcional.
height?numberOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Índice con scroll-spy 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
--h420px

Accesibilidad y rendimiento

El archivo tiene 94 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Índice con scroll-spy 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: region.
  • Se pausa cuando sale de la pantalla para no gastar batería ni CPU.
  • 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)