Línea de tiempo con scroll
Línea vertical con tarjetas alternadas que se dibuja al avanzar la lectura; el contenido es visible aunque falle el script.
Del primer contacto al lanzamiento
Semana 1 Diagnóstico
Entendemos tu operación, tus procesos actuales y qué duele más.
Semana 2 Propuesta
Alcance, calendario y precio por escrito. Sin letra pequeña.
Semanas 3 a 8 Desarrollo por sprints
Entregas visibles cada una o dos semanas y ajustes sobre la marcha.
Semana 9 Lanzamiento
Puesta en producción acompañada, con capacitación para tu equipo.
Semana 13 Estabilización
Treinta días de corrección sin costo y plan de mantenimiento opcional.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
/** Línea de tiempo vertical con tarjetas alternadas: la línea se dibuja al avanzar la lectura y cada hito aparece al cruzar el punto de lectura. Contenido visible aunque falle el script. React + TypeScript, sin dependencias. */
export type SeccionTimelineItem = { when: string; title: string; text: string };
export type SeccionTimelineProps = {
title?: string;
items?: SeccionTimelineItem[];
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcsti-root { box-sizing: border-box; container-type: inline-size; width: 100%; padding: clamp(32px, 6cqi, 80px) clamp(18px, 5cqi, 72px); background: #f7f5f0; color: #1c1a17; font: 400 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcsti-root *, .gcsti-root *::before, .gcsti-root *::after { box-sizing: border-box; }
@media (prefers-reduced-motion: reduce) { .gcsti-root * { transition-duration: .01ms !important; } }
.gcsti-title { margin: 0 auto 44px; max-width: 18ch; text-align: center; font: 500 clamp(30px, 5cqi, 56px)/1.05 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.03em; text-wrap: balance; }
.gcsti-list { position: relative; max-width: 860px; margin: 0 auto; padding: 0; list-style: none; }
.gcsti-list::before { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; width: 2px; margin-left: -1px; background: #ddd7c8; }
.gcsti-prog { position: absolute; left: 50%; top: 0; width: 2px; margin-left: -1px; background: #ff6a3d; height: var(--p, 0%); transition: height .2s linear; }
.gcsti-item { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 56px; margin-bottom: 36px; }
.gcsti-card { padding: 20px 22px; border: 1px solid #e3ddd0; border-radius: 14px; background: #fff; transition: opacity .6s cubic-bezier(.16, 1, .3, 1), transform .6s cubic-bezier(.16, 1, .3, 1); }
.gcsti-js .gcsti-item[data-in="false"] .gcsti-card { opacity: 0; transform: translateY(24px); }
.gcsti-item:nth-child(odd) .gcsti-card { grid-column: 1; text-align: right; }
.gcsti-item:nth-child(even) .gcsti-card { grid-column: 2; }
.gcsti-dot { position: absolute; left: 50%; top: 22px; width: 16px; height: 16px; margin-left: -8px; border: 3px solid #ddd7c8; border-radius: 50%; background: #f7f5f0; transition: border-color .4s, background .4s, transform .4s cubic-bezier(.34, 1.56, .64, 1); }
.gcsti-item[data-in="true"] .gcsti-dot { border-color: #ff6a3d; background: #ff6a3d; transform: scale(1.15); }
.gcsti-when { display: block; margin-bottom: 4px; color: #ff6a3d; font-size: 13px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
.gcsti-card h3 { margin: 0 0 6px; font: 500 22px/1.15 "Newsreader", Georgia, ui-serif, serif; }
.gcsti-card p { margin: 0; color: #5d584c; }
@container (max-width: 640px) { .gcsti-list::before, .gcsti-prog { left: 10px; } .gcsti-dot { left: 10px; } .gcsti-item { grid-template-columns: 1fr; padding-left: 36px; } .gcsti-item:nth-child(odd) .gcsti-card, .gcsti-item:nth-child(even) .gcsti-card { grid-column: 1; text-align: left; } }`;
const DEFAULT: SeccionTimelineItem[] = [
{ when: "Semana 1", title: "Diagnóstico", text: "Entendemos tu operación, tus procesos actuales y qué duele más." },
{ when: "Semana 2", title: "Propuesta", text: "Alcance, calendario y precio por escrito. Sin letra pequeña." },
{ when: "Semanas 3 a 8", title: "Desarrollo por sprints", text: "Entregas visibles cada una o dos semanas y ajustes sobre la marcha." },
{ when: "Semana 9", title: "Lanzamiento", text: "Puesta en producción acompañada, con capacitación para tu equipo." },
{ when: "Semana 13", title: "Estabilización", text: "Treinta días de corrección sin costo y plan de mantenimiento opcional." },
];
export function SeccionTimeline({ title = "Del primer contacto al lanzamiento", items = DEFAULT, className = "", style }: SeccionTimelineProps) {
const list = useRef<HTMLOListElement>(null);
const [seen, setSeen] = useState<boolean[]>(() => items.map(() => false));
const js = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
const [p, setP] = useState(0);
useEffect(() => {
const el = list.current;
if (!el) return;
let raf = 0;
const update = () => {
raf = 0;
const rect = el.getBoundingClientRect();
const line = window.innerHeight * 0.6;
setP(Math.max(0, Math.min(100, ((line - rect.top) / rect.height) * 100)));
const nodes = el.querySelectorAll<HTMLElement>("li");
setSeen(Array.from(nodes, (node) => node.getBoundingClientRect().top < line));
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
raf = requestAnimationFrame(update);
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
// El demo vive dentro de un contenedor con scroll propio: escuchamos también en fase de captura.
document.addEventListener("scroll", onScroll, { passive: true, capture: true });
return () => { window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onScroll); document.removeEventListener("scroll", onScroll, true); cancelAnimationFrame(raf); };
}, [items]);
return (
<section className={`gcsti-root ${js ? "gcsti-js" : ""} ${className}`} style={style}>
<style>{CSS}</style>
<h2 className="gcsti-title">{title}</h2>
<ol ref={list} className="gcsti-list">
<span className="gcsti-prog" style={{ "--p": `${p}%` } as CSSProperties} aria-hidden="true" />
{items.map((item, i) => (
<li key={item.title} className="gcsti-item" data-in={seen[i]}>
<span className="gcsti-dot" aria-hidden="true" />
<article className="gcsti-card">
<span className="gcsti-when">{item.when}</span>
<h3>{item.title}</h3>
<p>{item.text}</p>
</article>
</li>
))}
</ol>
</section>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/seccion-timelineEjemplo de uso de Línea de tiempo con scroll
Después de copiar seccion-timeline.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { SeccionTimeline } from "./seccion-timeline";
export default function Ejemplo() {
return <SeccionTimeline title="Ejemplo" />;
}Propiedades de SeccionTimeline
SeccionTimeline acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
title? | string | Opcional. |
items? | SeccionTimelineItem[] | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Línea de tiempo con scroll 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 |
|---|---|
--p | 0% |
Accesibilidad y rendimiento
El archivo tiene 94 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Línea de tiempo con scroll por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Expone su estado a tecnologías de apoyo con atributos aria-*.
- Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
- Lee preferencias del navegador con useSyncExternalStore, sin parpadeos en la hidratación.
- Es un componente de cliente de Next.js: incluye la directiva "use client".