Artículo con barra de progreso de lectura
Barra de avance, minutos restantes, sección actual y botón de volver arriba que aparece al avanzar.
Cómo evitar que un proyecto se estire
Por qué importa el alcance
Cuando un proyecto empieza sin límites claros, cada reunión agrega algo nuevo y el calendario se estira sin que nadie lo decida.
Definir qué entra y qué no desde el inicio no es burocracia: es la forma más barata de evitar discusiones después.
Cómo se define
Se escribe en lenguaje llano, con ejemplos de cómo se usará el sistema y qué resultado se espera en cada entrega.
Cada punto debe poder responderse con un sí o un no al terminar el sprint.
Qué pasa con los cambios
Los cambios son normales. Lo que importa es que se hablen, se cotizen y se acepten por escrito antes de empezar.
Así el presupuesto acordado se mantiene y nadie se lleva sorpresas al final.
Para llevarte
Un buen alcance cabe en una página, se entiende sin glosario y se puede revisar en cada sprint.
Si no puedes explicarlo en pocas líneas, todavía no está listo para empezar.
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";
/** Artículo con barra de progreso de lectura, minutos restantes, sección actual y botón de volver arriba que aparece al avanzar. Autocontenido en su panel. Estilo editorial. React + TypeScript, sin dependencias. */
export type ScrollProgresoSeccion = { title: string; text: string[] };
export type ScrollProgresoProps = {
title?: string;
sections?: ScrollProgresoSeccion[];
height?: number;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcspg-root { box-sizing: border-box; position: relative; width: 100%; max-width: 640px; height: var(--h, 460px); overflow: hidden; border: 1px solid #e3ddd0; border-radius: 16px; background: #fffdf8; color: #1c1a17; font: 400 16px/1.7 "Newsreader", Georgia, ui-serif, serif; }
.gcspg-root *, .gcspg-root *::before, .gcspg-root *::after { box-sizing: border-box; }
.gcspg-root button { font: inherit; }
.gcspg-root :focus-visible { outline: 2px solid #1c1a17; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcspg-bar i, .gcspg-up { transition: none !important; } .gcspg-scroll { scroll-behavior: auto !important; } }
.gcspg-top { position: absolute; z-index: 3; left: 0; right: 0; top: 0; background: rgba(255, 253, 248, .92); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); border-bottom: 1px solid #ece6d8; }
.gcspg-meta { display: flex; justify-content: space-between; gap: 12px; padding: 9px 20px 8px; font: 600 12px/1 ui-sans-serif, system-ui, sans-serif; color: #6e685b; letter-spacing: .02em; }
.gcspg-meta strong { color: #1c1a17; font-weight: 600; }
.gcspg-bar { height: 3px; background: #ece6d8; }
.gcspg-bar i { display: block; height: 100%; background: #ff6a3d; transform-origin: left; transform: scaleX(var(--p, 0)); transition: transform .1s linear; }
.gcspg-scroll { height: 100%; overflow-y: auto; padding: 66px 28px 80px; scroll-behavior: smooth; scrollbar-width: thin; outline: 0; }
.gcspg-scroll:focus-visible { box-shadow: inset 0 0 0 2px #1c1a17; }
.gcspg-scroll h1 { margin: 0 0 18px; font: 500 clamp(30px, 5vw, 42px)/1.05 "Newsreader", Georgia, ui-serif, serif; letter-spacing: -.03em; }
.gcspg-scroll h2 { margin: 28px 0 8px; font: 700 20px/1.2 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.02em; }
.gcspg-scroll p { margin: 0 0 14px; max-width: 62ch; color: #3a372f; }
.gcspg-up { position: absolute; z-index: 3; right: 16px; bottom: 16px; display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 50%; background: #1c1a17; color: #fff; cursor: pointer; opacity: 0; transform: translateY(10px); pointer-events: none; transition: opacity .3s, transform .3s cubic-bezier(.16, 1, .3, 1); box-shadow: 0 8px 16px -6px rgba(0, 0, 0, .4); }
.gcspg-up[data-show="true"] { opacity: 1; transform: none; pointer-events: auto; }`;
const DEFAULT: ScrollProgresoSeccion[] = [
{ title: "Por qué importa el alcance", text: ["Cuando un proyecto empieza sin límites claros, cada reunión agrega algo nuevo y el calendario se estira sin que nadie lo decida.", "Definir qué entra y qué no desde el inicio no es burocracia: es la forma más barata de evitar discusiones después."] },
{ title: "Cómo se define", text: ["Se escribe en lenguaje llano, con ejemplos de cómo se usará el sistema y qué resultado se espera en cada entrega.", "Cada punto debe poder responderse con un sí o un no al terminar el sprint."] },
{ title: "Qué pasa con los cambios", text: ["Los cambios son normales. Lo que importa es que se hablen, se cotizen y se acepten por escrito antes de empezar.", "Así el presupuesto acordado se mantiene y nadie se lleva sorpresas al final."] },
{ title: "Para llevarte", text: ["Un buen alcance cabe en una página, se entiende sin glosario y se puede revisar en cada sprint.", "Si no puedes explicarlo en pocas líneas, todavía no está listo para empezar."] },
];
export function ScrollProgreso({ title = "Cómo evitar que un proyecto se estire", sections = DEFAULT, height = 460, className = "", style }: ScrollProgresoProps) {
const sc = useRef<HTMLDivElement>(null);
const [p, setP] = useState(0);
const [cur, setCur] = useState(0);
const total = sections.reduce((n, s) => n + s.text.join(" ").split(" ").length, 0) + title.split(" ").length;
const minutes = Math.max(1, Math.round((total / 200) * (1 - p)));
useEffect(() => {
const el = sc.current;
if (!el) return;
let raf = 0;
const update = () => {
raf = 0;
const max = el.scrollHeight - el.clientHeight;
setP(max > 0 ? el.scrollTop / max : 0);
const heads = el.querySelectorAll<HTMLElement>("h2");
let idx = 0;
heads.forEach((h, i) => { if (h.offsetTop - 80 <= el.scrollTop) idx = i; });
setCur(idx);
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
update();
el.addEventListener("scroll", onScroll, { passive: true });
return () => { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(raf); };
}, []);
return (
<div className={`gcspg-root ${className}`} style={{ "--h": `${height}px`, "--p": p, ...style } as CSSProperties}>
<style>{CSS}</style>
<div className="gcspg-top">
<div className="gcspg-meta">
<strong>{sections[cur]?.title}</strong>
<span aria-live="off">{p >= 0.99 ? "Lectura terminada" : `${minutes} min restantes`}</span>
</div>
<div className="gcspg-bar" role="progressbar" aria-label="Progreso de lectura" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(p * 100)}><i /></div>
</div>
<div ref={sc} className="gcspg-scroll" tabIndex={0} role="article" aria-label={title}>
<h1>{title}</h1>
{sections.map((section) => (
<section key={section.title}>
<h2>{section.title}</h2>
{section.text.map((paragraph, i) => (
<p key={i}>{paragraph}</p>
))}
</section>
))}
</div>
<button className="gcspg-up" type="button" data-show={p > 0.15} aria-label="Volver arriba" tabIndex={p > 0.15 ? 0 : -1} onClick={() => sc.current?.scrollTo({ top: 0 })}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
</button>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/scroll-progresoEjemplo de uso de Artículo con barra de progreso de lectura
Después de copiar scroll-progreso.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { ScrollProgreso } from "./scroll-progreso";
export default function Ejemplo() {
return <ScrollProgreso title="Ejemplo" />;
}Propiedades de ScrollProgreso
ScrollProgreso acepta 5 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
title? | string | Opcional. |
sections? | ScrollProgresoSeccion[] | Opcional. |
height? | number | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Artículo con barra de progreso de lectura 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 | 460px |
--p | 0 |
Accesibilidad y rendimiento
El archivo tiene 95 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Artículo con barra de progreso de lectura 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: progressbar, article.
- Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
- Usa backdrop-filter para el efecto de vidrio; en navegadores sin soporte el panel se ve sin desenfoque.
- Es un componente de cliente de Next.js: incluye la directiva "use client".