Í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.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/nav-indiceEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
sections? | NavIndiceSeccion[] | Opcional. |
height? | number | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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.
| Variable | Valor por defecto |
|---|---|
--h | 420px |
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".